You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/components/barcode/README.md

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 ean13 value as an ISBN-10 and converts it: prefix 978, 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 with invalid-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-3 line 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. moduleWidth is 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> carries max-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 height is an independent prop and not a second dimension of a single size token.
  • 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; quietZone overrides it. There is deliberately no pixel margin prop: for fixed spacing in a layout, pad the container in CSS using the barcode's background.
  • Guard bars stay long. EAN/UPC guards extend through the HRI band down to the digits' baseline (the guards mask), 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. DownloadTrigger resolves the fills and the HRI's font-family / fill into 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 size token. 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 the chart family (chart/gauge.svelte: numeric size, 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. encode throws a typed BarcodeError; the wrapper catches it, stamps data-invalid and calls onInvalid(reason) (the framework's canonical validation vocabulary). Letting it throw inside a $derived would take the page down.
  • quietZone in modules with a per-symbology default (not QrCode's single 4) — 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.value is 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 (getComputedStyle into 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 — the download catalog 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

  1. npx vitest run src/libs/barcode — published vectors per symbology (EAN-13 5901234123457, UPC-A 036000291452, 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 from modules — it catches layout / interleaving errors the shared tables would hide.
  2. npm run check — 0 errors in the new files.
  3. npm run generate:eidos-css + npx vitest run src/uix/eidos — TSC + recipe contract.
  4. node --import tsx/esm scripts/eidos-lint.ts barcode — 0 invalid.
  5. npm run translations:check.
  6. With npm run dev up: npm run morfo:check · SMOKE_SCOPE=/uix/components/barcode npm run smoke.
  7. npm run component:audit -- --only barcode — 0 errors; every remaining warning justified under ## Audit exceptions.
  8. 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 in button.css; Pattern and Text are display indicators.
  • D-1.5 / D-4.3 (warnings, shared with the canary): the audit greps the demo source for an inlined new MutationObserver and a literal uix.events.emit. The v2 migration moved both into the shared harness — the observer lives in DemoTrace, the ▶ play in SemaPanel — so button, 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), so SemaPanel renders its justified empty state.

Powered by TurnKey Linux.