28 KiB
Barcode
Themeable, accessible 1D barcode — own zero-dependency encoder, framework 4-layer rigor, the anatomy no reference library ships.
Design proposal approved 2026-07-28, shipped 2026-07-29. This README is both the design record and the component doc. It is the sibling of
qr-code/README.md: same architecture, same doctrine, different physics.
Baseline
No Air baseline — new component (2026-07-28). Nothing in the legacy layers
rendered linear barcodes; the encoder is also new ($libs/barcode, zero-dep).
The internal baseline is QrCode (2026-06-12): eidos-native display
primitive, scope: ['eidos'], own encoder in $libs, DownloadTrigger that
composes <Button>. Every divergence from it is argued in ## Decisiones.
Comparativa — why a new one (vs the references)
No headless library ships a barcode: ark-ui, bits-ui, Radix and React Aria have a QR code (or nothing) and stop there. The field belongs to imperative encoders.
| Capability | JsBarcode | bwip-js | react-barcode | ark-ui / bits-ui / Radix | Barcode (ours) |
|---|---|---|---|---|---|
| Parts / anatomy | ❌ imperative | ❌ imperative | ❌ single comp | ❌ no barcode at all | ✅ Provider·Pattern·Text·DownloadTrigger |
| Encoder | own dep (~40 kB) | own dep (~500 kB) | wraps JsBarcode | — | own, zero-dep ($libs/barcode) |
| Retail symbologies (EAN/UPC) | ✅ | ✅ | ✅ | — | ✅ EAN-13/8 · UPC-A/E |
| Logistics (Code128 A/B/C auto, ITF-14) | ✅ | ✅ | ✅ | — | ✅ + bearer bars |
| Check digit computed | ✅ | ✅ | ✅ | — | ✅ (returned, not hidden) |
| Quiet zone per spec | ⚠️ single margin in px |
✅ | ⚠️ px | — | ✅ in modules, per symbology |
| Guard bars extend under HRI | ✅ EAN only | ✅ | ✅ | — | ✅ (the guards mask) |
| Themeable colour (tokens) | ⚠️ free strings | ⚠️ free strings | ⚠️ props | — | ✅ via tokens + color |
| Invalid input is a state, not a throw | ❌ throws / logs | ❌ throws | ❌ throws | — | ✅ data-invalid + onInvalid |
role=img + label a11y |
❌ | ❌ | ⚠️ partial | — | ✅ |
| Download PNG/SVG | ❌ manual | ❌ manual | ❌ manual | — | ✅ DownloadTrigger = <Button> |
| Framework morfo/recipe rigor | n/a | n/a | n/a | n/a | ✅ |
Takeaway: JsBarcode has the right symbology coverage and the wrong shape (imperative, mutates a DOM node you hand it, no a11y, no anatomy); bwip-js is encyclopaedic and enormous; react-barcode is a thin wrapper that inherits both problems. Ours keeps QrCode's clean anatomy + API, owns the encoder (every symbology here is a published ISO standard — no npm dep), and adds themeable colour, real a11y, a first-class invalid state and PNG/SVG export.
Encoder — $libs/barcode (own, zero-dep)
encode(value, { symbology?, quietZone? }) → BarcodeResult
A from-scratch linear-barcode encoder living in $libs alongside the other pure
helpers. Each symbology is a published open standard — ISO/IEC 15417
(Code 128), ISO/IEC 15420 (EAN/UPC), ISO/IEC 16388 (Code 39), ISO/IEC 16390
(ITF) — so the tables are transcribed from the specification and the layout is
written from scratch. JsBarcode / bwip-js are correctness references only,
never imported — the same doctrine $libs/qr used with Nayuki's QR reference.
Zero dependencies added to package.json, runtime or dev.
export type Symbology = 'code128' | 'ean13' | 'ean8' | 'upca' | 'upce' | 'code39' | 'itf' | 'itf14';
export interface BarcodeResult {
/** Module lattice, left→right. `true` = dark (bar). Excludes the quiet zone. */
modules: boolean[];
/** Symbol width in modules (= `modules.length`). */
size: number;
/** Quiet zone in modules per side — the symbology's spec minimum. */
quietZone: { start: number; end: number };
/**
* Module ranges whose bars extend below the baseline into the text band
* (EAN/UPC guard patterns). The renderer must honour them: they are what
* lets a scanner find the symbol's edges.
*/
guards: { start: number; end: number }[];
/**
* HRI groups anchored in module space (`start` may be < 0 — the quiet zone).
* EAN/UPC emit ONE GROUP PER DIGIT, each over the 7 modules that encode it;
* the other symbologies emit a single group over the whole symbol.
*/
text: { text: string; start: number; end: number }[];
/** The value as encoded (input + computed check digit). */
value: string;
symbology: Symbology;
/** The check digit computed, when the symbology carries one. */
checkDigit?: string;
/** ITF-14 only: the symbol must be framed by a bearer bar. */
bearerBar?: boolean;
}
export type BarcodeErrorReason =
| 'empty'
| 'invalid-characters'
| 'invalid-length'
| 'invalid-check-digit';
export class BarcodeError extends Error {
readonly reason: BarcodeErrorReason;
}
guards is the exact analogue of reserved in $libs/qr: the function
modules the renderer must treat differently. There they stay square so the
scanner locks on; here they extend below the baseline for the same reason.
v1 symbologies (Retail + Logistics)
| Symbology | Standard | Input | Check digit | Notes |
|---|---|---|---|---|
code128 |
ISO/IEC 15417 | ASCII 0–127 | mod-103, internal (not in the HRI) | auto A/B/C subset switching; subset C for digit runs ≥ 4 |
ean13 |
ISO/IEC 15420 | 12 digits | mod-10, appended | L/G parity per first digit; first digit sits outside the symbol |
ean8 |
ISO/IEC 15420 | 7 digits | mod-10, appended | no parity table |
upca |
ISO/IEC 15420 | 11 digits | mod-10, appended | EAN-13 with a leading 0; outer digits outside the symbol |
upce |
ISO/IEC 15420 | 6 digits (+ number system 0/1) | mod-10 over the expanded UPC-A | parity table selected by the check digit |
code39 |
ISO/IEC 16388 | 43-char alphabet | mod-43 optional | * start/stop; self-checking |
itf |
ISO/IEC 16390 | digits, even count | mod-10 optional | interleaved pairs |
itf14 |
ISO/IEC 16390 | 13 digits | mod-10 mandatory | + bearer bars |
ISBN — a profile, not a symbology
An ISBN barcode IS an EAN-13. ISO 2108 assigns books the Bookland prefixes
978 / 979 inside the same GTIN-13 space as everything else, which is why one
scanner reads a novel and a can of beans. So ISBN is not a ninth symbology
value: declaring one would make data-symbology claim a standard that does not
exist. It is an input profile over ean13 — the shape every serious
implementation ships.
<Barcode symbology="ean13" value="978-84-339-2042-3" />
<!-- ISBN-13, hyphenated -->
<Barcode symbology="ean13" value="0306406152" />
<!-- ISBN-10 → 9780306406157 -->
<Barcode symbology="ean13" value="080442957X" />
<!-- base-11 X check character -->
What the profile does:
- Tolerates separators (
-, whitespace) in every NUMERIC symbology — an ISBN travels hyphenated, a GTIN travels spaced. Code 128 and Code 39 are deliberately excluded: there a hyphen is encodable data, and stripping it would silently encode a different string. - Reads a 10-character
ean13value as an ISBN-10 and converts it: prefix978, keep the 9 significant digits, recompute the EAN mod-10 check. - Validates the ISBN-10's own check digit first (weights 10…1, mod 11, where
X= 10). This is the part that matters: without it a mistyped ISBN-10 becomes a perfectly valid EAN-13 pointing at a different book — the costliest failure mode in this domain. A bad check digit fails withinvalid-check-digit.
What it deliberately does NOT do:
- Hyphenate. The hyphen positions are not computable from the number: they
depend on the International ISBN Agency's prefix-range tables, which are
versioned data with an expiry date. Vendoring them would put a maintenance
clock inside a zero-dependency library. If the
ISBN 978-84-339-2042-3line above the bars is ever added, the hyphenated string comes from the consumer's catalogue — where it is already stored that way. - Print the upper ISBN line (the book-cover convention) or the EAN-5 price add-on — both registered as deferred gaps below.
Scannability (the doctrine)
The QR's scannability rules restated for a linear symbol — same principle, own physics:
- The X-dimension is the anchor.
moduleWidthis the rendered width of the narrowest bar, in px. It is what governs whether a scanner can resolve the symbol (GS1 nominal X = 0.33 mm; retail range 0.264–0.66 mm). Everything else derives from it. Rendering below the nominal X degrades decoding — the<svg>carriesmax-width: 100%so it never overflows a layout, with that caveat documented rather than silently accepted. - The bar height is free. A scanner reads a horizontal slice; vertical size
is redundancy, not information. That is why
heightis an independent prop and not a second dimension of a singlesizetoken. - Quiet zone in modules, not pixels — and per symbology. EAN-13 wants 11 X
left / 7 X right; Code 128 and ITF want 10 X. The encoder returns the spec
value;
quietZoneoverrides it. There is deliberately no pixel margin prop: for fixed spacing in a layout, pad the container in CSS using the barcode'sbackground. - Guard bars stay long. EAN/UPC guards extend through the HRI band down to
the digits' baseline (the
guardsmask), so the digit groups read between them. Flattening them for looks is the linear-barcode equivalent of rounding a QR's finder patterns — it breaks edge detection. - The HRI aligns digit-under-digit. Every EAN/UPC digit is centred over the 7 modules that encode it (the encoder emits one text group per cell), and the face is the system's mono anchor — the standard's HRI type is OCR-B, a fixed-width design. A proportional face with a compact centred string misaligns the digits from their bars and loses the family's silhouette.
- ITF-14 carries its bearer bar. The frame sits OUTSIDE the quiet zone (it must never eat into it), so the box grows by its thickness on every side.
- Fixed-tone default colours (dark-on-light). A barcode cannot invert in
dark mode and stay scannable, so the default fg/bg are theme-independent;
theming is opt-in via
color/background(you own the contrast). - Export bakes resolved styles. A serialized SVG loses the document's CSS
custom properties and its inherited font.
DownloadTriggerresolves the fills and the HRI'sfont-family/fillinto the clone before writing PNG / SVG — the same reason QrCode bakes its fills. - The HRI travels in both exports. The text is a real SVG
<text>, not a<foreignObject>— so unlike QrCode's logo (which PNG export drops, a Chromium rasterization limit) it survives PNG too.
Anatomy (parts)
<Barcode value="5901234123457" symbology="ean13"> ← Provider (svg, role="img", aria-label)
└ Pattern ← the <path>: bars + guards (decorative)
└ Text ← the HRI <text> (decorative, optional)
<Barcode.DownloadTrigger> ← <Button> → PNG / SVG (optional)
morfo scope: ['eidos']. No soma — a barcode is pure display, like
Avatar / Image / QrCode: value → modules in a $derived, rendered to SVG.
Download is imperative through the eidos context.
API (root)
| Prop | Type | Default | |
|---|---|---|---|
value |
string |
'' |
data to encode |
symbology |
Symbology |
'code128' |
which standard |
height |
number |
60 |
bar height in px |
moduleWidth |
number |
2 |
px per narrow module — the X-dimension |
quietZone |
number |
the symbology's spec minimum | in modules, never px |
color |
string |
fixed dark token | bar colour (you own the contrast) |
background |
string |
fixed light token | quiet-zone + symbol background |
showText |
boolean |
true |
render the human-readable interpretation |
text |
string |
the encoded value | override the HRI |
alt |
string |
value |
accessible name for the Provider |
onInvalid |
(reason: BarcodeErrorReason) => void |
— | fires once per distinct reason |
Two behaviours worth knowing: onInvalid is deduped by reason (encoded is
a fresh object per keystroke, so an un-deduped effect would spam the handler
while a value is typed), and with nothing encoded the placeholder frame carries
aria-hidden — a role="img" with an empty name would announce an unlabelled
image.
DownloadTrigger props: format: 'png' | 'svg' (default 'png'), filename
(default 'barcode') + all <Button> props (variant, size, color, …).
It renders the framework <Button> and calls the root's style-baking export via
context.
Compound: <Barcode> + .DownloadTrigger. The morfo's Pattern / Text parts are
render internals, not subcomponents.
Geometry
mw = moduleWidth // px per module (the X-dimension)
barH = height // px
hri = showText ? max(8, round(barH * 0.2)) : 0 // text band
desc = guards.length ? round(hri * 0.55) : 0 // guard descender into the band
W = (quiet.start + size + quiet.end) * mw
H = barH + hri
viewBox = "0 0 W H" // px, uniform scale → no glyph distortion
The <text>'s font-size travels as a computed SVG attribute (proportional
to the band), not as a CSS literal — the same pattern, and the same reason, as
QrCode's inline overlay padding: the viewBox owns geometry, the recipe owns
font-family / letter-spacing / fill.
Decisiones
- Own zero-dep encoder (
$libs/barcode) — every v1 symbology is a published ISO standard; JsBarcode / bwip-js are correctness references only. No npm dependency, runtime or dev. - No soma — pure display (like Avatar / Image / QrCode):
value→ modules in a$derived; download is imperative through the eidos context. - Absolute px geometry instead of QrCode's
sizetoken. A QR is square, so its box collapses into one token. A barcode has two physically independent axes: the X-dimension governs scannability, the bar height is free redundancy. Collapsing them into one t-shirt token would make the X-dimension a function of the height — exactly the property that must not float. The precedent is thechartfamily (chart/gauge.svelte: numericsize, px viewBox,<text>styled by the recipe), which is the catalog's home for SVG-drawn primitives. - HRI inside the SVG (
<text>, not an HTML sibling) — a sibling would be lost on export, and it is what lets guard bars interleave with the digits. - Invalid input is a state, not an exception. Unlike a QR (any UTF-8 string
encodes), EAN-13 needs 12/13 digits, ITF an even count, Code 39 a 43-char
alphabet — a half-typed value is a normal state.
encodethrows a typedBarcodeError; the wrapper catches it, stampsdata-invalidand callsonInvalid(reason)(the framework's canonical validation vocabulary). Letting it throw inside a$derivedwould take the page down. quietZonein modules with a per-symbology default (not QrCode's single4) — each standard fixes its own; pixel spacing belongs to the container.- The
*delimiters stay out of Code 39's value. They are structure, not data: ISO/IEC 16388 keeps the start/stop out of the HRI, and every scanner returns the bare string.result.valueis the (uppercased) input. - The HRI is one text group per cell, emitted by the encoder in module space. Placement belongs to whoever knows the cell geometry — the renderer only centres each group in its range, so it needs no per-symbology branch.
- Fixed-tone default colours — a barcode can't invert in dark mode and stay
scannable; theming is opt-in via
color/background. - Exports bake resolved styles (
getComputedStyleinto the clone) — a serialized SVG loses both the document's custom properties and its font. - One
<path>for the whole pattern — bars and extended guards are subpaths of different heights, mirroring QrCode's single-path module matrix.
v1 scope (proposed)
8 symbologies (Code 128 A/B/C auto · EAN-13 · EAN-8 · UPC-A · UPC-E · Code 39 ·
ITF · ITF-14) · computed check digits · spec quiet zones · guard bars · HRI with
override · themeable colour · invalid state · download PNG + SVG · role=img +
label.
Gaps
- Codabar · MSI · Pharmacode — the niche tail of JsBarcode's set — disposition: diferir (v2).
- GS1-128 (FNC1 + Application Identifier parsing) — shipped by bwip-js — disposition: diferir (v2); it is a Code 128 profile, not a new encoder.
- EAN-2 / EAN-5 add-ons — shipped by JsBarcode; the EAN-5 is the price supplement printed beside a book's ISBN — disposition: diferir (v2).
- The
ISBN 978-…line above the bars — the book-cover convention of the industry guidelines. The band is easy; the hyphenation is not ours (see## ISBN), so the consumer would pass the already-hyphenated string — disposition: diferir (v2), pairs with the EAN-5 add-on. - 2D symbologies (DataMatrix, PDF417, Aztec) — shipped by bwip-js — disposition: diferir; each is a whole different encoder (own Reed–Solomon, matrix layout), so they belong to their own component, not to this one's scope.
textPosition: 'top'— shipped by JsBarcode — disposition: diferir (v2).- Custom webfont in PNG export — a rasterized SVG does not load webfonts —
disposition: descartar as a defect; the HRI token resolves to a generic
(
ui-monospace, monospace) family precisely so PNG export stays faithful. Documented, not worked around.
Passive justification
Zero events by design: a barcode is pure display — value in, SVG out; no
interaction surface of its own. The one interactive part, DownloadTrigger, IS
a composed <Button> (its contact firma and states live there). The morfo may
later add an optional commit-download cue (scope would gain sema), the same
recorded exception path QrCode declares.
Layers
$libs/barcode— own encoder + tests.- morfo
barcode.ts— Provider / Pattern / Text / DownloadTrigger contract. - eidos
barcode/— wrapper + recipe (--barcode-*tokens) + SVG render from the module lattice. - langs
langs/components/barcode.ts— thedownloadcatalog entry. - demo
web/routes/uix/components/barcode/+ nav entry (Media group).
Recipe tokens (proposed)
| Token | Role |
|---|---|
--barcode-fg / --barcode-bg |
bar colour / quiet-zone (fixed-tone, annotated /* literal: … */) |
--barcode-radius |
frame radius |
--barcode-text-font-family |
HRI family — anchored to var(--style-label-font-family) |
--barcode-text-letter-spacing / --barcode-text-fg |
HRI treatment |
--barcode-invalid-border / --barcode-invalid-color |
the invalid placeholder |
No geometry tokens: geometry is computed inline (see Geometry), and a public
token with no real consumer fails recipe-css-contract.test.ts.
Build plan
The route of docs/building-a-component.md:
| Phase | Deliverable | Guard |
|---|---|---|
| 1 · Encoder | src/libs/barcode/{barcode,index,barcode.test}.ts |
npx vitest run src/libs/barcode |
| 2 · Morfo | morfo/components/barcode.ts + 'text' added to MorfoElement + langs/components/barcode.ts registered in the index |
npm run check · npm run translations:check |
| 3 · Sema | none — 0 events (see ## Passive justification) |
npm run morfo:vocabulary |
| 4 · Wrapper | root + download-trigger + context + types + index | component-api-contract.test.ts |
| 5 · Recipe | barcode block in lib/recipes/base.ts + barcode.css |
npm run generate:eidos-css · recipe-css-contract.test.ts |
| 6 · Demo | web/routes/uix/components/barcode/+page.svelte — v2 9-tab template + nav entry |
audit D-* · npm run smoke |
| 7 · README | this file, minus the "pending implementation" note | audit F-* |
| 8 · Acceptance | it passes | component:audit --only barcode · morfo:check · eidos-lint |
Morfo skeleton
Provider svg role='img' aria-label ← propRef('alt')
data-symbology (enum of 8) · data-invalid (optional)
Pattern path archetype 'indicator' aria-hidden (bars + guards)
Text text archetype 'indicator' aria-hidden optional (HRI)
DownloadTrigger button archetype 'trigger' type=button + aria-label optional
texts: { download: '#?components.barcode.download|Download barcode' }
data-symbology documents the closed set in the contract the same way
data-cell-shape does for QrCode (which likewise carries no CSS rule —
architecture/eidos.md §"the unused column" classifies that as legitimate).
data-invalid does get a recipe rule (R-1.4).
Verification
npx vitest run src/libs/barcode— published vectors per symbology (EAN-135901234123457, UPC-A036000291452, Code 39*ABC*, …), check digits, module counts, guard positions, quiet zones, plus an independently written test decoder (tables transcribed a second time) that re-derives the input frommodules— it catches layout / interleaving errors the shared tables would hide.npm run check— 0 errors in the new files.npm run generate:eidos-css+npx vitest run src/uix/eidos— TSC + recipe contract.node --import tsx/esm scripts/eidos-lint.ts barcode— 0invalid.npm run translations:check.- With
npm run devup:npm run morfo:check·SMOKE_SCOPE=/uix/components/barcode npm run smoke. npm run component:audit -- --only barcode— 0 errors; every remaining warning justified under## Audit exceptions.- Browser: open
/uix/components/barcode, look at it, exercise light/dark, RTL and density from the System tab, and check both exports.
Open item — real-world scanning. Chrome on Windows does not expose
BarcodeDetector (the Shape Detection API ships only on Android / ChromeOS /
macOS), and adding a decoder package would break the zero-dependency premise.
Two ways out, user's call: (a) scan the demo with a phone — the real proof;
(b) a one-off cross-check with npx @zxing/library in a throwaway script
outside the repo (npx never touches package.json), with exactly the status
Nayuki had for the QR: correctness reference, never an import. Without one of
the two, scannability rests on the standards' vectors alone.
Audit exceptions
R-1.5 exception:the only interactive part (DownloadTrigger) composes the canonical<Button>— the focus ring lives inbutton.css;PatternandTextare display indicators.D-1.5/D-4.3(warnings, shared with the canary): the audit greps the demo source for an inlinednew MutationObserverand a literaluix.events.emit. The v2 migration moved both into the shared harness — the observer lives inDemoTrace, the ▶ play inSemaPanel— sobutton, the canonical reference demo, reports the same two warnings. Verified by running the audit on it. The regexes lag the harness; this demo uses both correctly.- The Sema ▶ play surface is empty by construction: 0 events (see
## Passive justification), soSemaPanelrenders its justified empty state.