ADRs
ADR 0083 — px-canon storage, rem-emit contract, and root-preview as a verifier
  • Date: 2026-08-08
  • Status: Accepted
  • Deciders: founder + co-admin (per Rules.md governance)
  • Builds on: ADR 0059 (Italian / Renaissance naming chord), ADR 0063 (Studio tools as reusable @arno/* packages), the origine token model + verifier (packages/origine), the spacing-scale model (packages/origine/src/tokens/spacing-scale.ts).

Context

The spacing-scale editor (right-sidebar Scale panel + full-width rows) shipped on feat/sorg-layout. It carries a unit: 'px' | 'rem' field on the spacing panel itself. That is architecturally wrong: unit and root-font-size are not a property of one token group — they are a project-wide output contract shared by every size-bearing group (Type, Spacing, Stroke weight, Radius).

Two forces pull against each other:

  1. Designers author in px. ~99% of designers reason in px; showing rem as a primary input loses them. px must stay the authoring unit everywhere.
  2. rem is the accessibility contract. If Arno emits rem by default, every product it produces passes resize-text (WCAG 1.4.4) — a competitive edge. But only if the designer never has to touch rem to get it.

The reconciliation: px is the authoring unit; rem is the output unit. Arno stores a px-canonical number and converts to rem only at the hand-off boundary (rem = px / rootPx). This also opens a proof: because values are already rem under the hood, a canvas-root slider can prove each screen survives 200% text scale (resize-text-200%-safe), folding into the origine verifier line rather than being a mere emit feature.

Decision

1. Tokens store a px-canonical number; unit is not a token property

A size token stores one number, in px. The pair (emitUnit, rootPx) lives at project / Foundation level, read by every size group. The unit field is removed from the spacing panel. Per-group holdout: 'px' marks groups that always emit px regardless of emitUnit.

  • rootPx — project setting, default 16. The single rem knob, expressed in px terms ("base font size: 16px").
  • emitUnit — project setting, rem | px, default rem for new projects.
  • Conversion is emit-time only: rem = round(px / rootPx) with the existing 3-decimal ceil round() contract. Storage never holds rem.

2. rem is shown to the designer only as a secondary readout

The designer never sees a rem input. rem appears in exactly three places:

  1. Secondary readout under a token value (Type + Spacing only): 16px large, 1rem faint below — collapsible / optional. Never the primary input.
  2. Hand-off boundary — Export to git / Code Connect / dev-spec, where rem is the language of code.
  3. One project control — "base font size: 16px" (= rootPx), in px terms.

Colors are unaffected. Type is rem-first (scales with text); Spacing is rem- secondary (grows with text).

3. px-holdouts attach to real entities, not an invented token

Some categories always emit px even in a rem project. The holdout mechanism attaches only to entities that actually exist in the model — it does not invent a "hairline border" token to hang a flag on:

  • Stroke weight (kind: 'stroke', shipped this session — the line-thickness scale, Figma "stroke weight" ↔ node strokeWeight): always px, whole group is holdout: 'px'. Hairlines must not scale with text.
  • Node-level strokeWeight (packages/ponte — a raw px number consumed by the box-shadow geometry in @arno/quadro): outside the token-unit pipeline entirely; always px by construction.
  • Radius: px by default (scaling radii distorts corners); overridable per project.
  • Icon sizes: rem by default (icons should scale with adjacent text for a11y); overridable.

Recorded defaults: hairline / stroke = px (not debatable); radius = px; icons = rem. These are per-project overridable via holdout; the alternative (all-non-type = px, i.e. icons px too) is safer visually but weakens a11y and is rejected as the default — see Alternatives.

strokeWeight is a raw number (not a CSS-length string like radius / fill / gap, which can already hold var(--token)), so it cannot carry a var() reference and is not part of the emit pipeline. Live token→node binding for it is a separate future change (numeric variable-binding — the first of its kind in the app), deliberately out of scope here.

4. root-preview is a verifier, not just a preview

The Workflow tab gets a Text scale slider: root 100% (16px) → 150 → 200%. Dragging re-renders the artboard at a larger root; everything in rem (text + spacing) grows proportionally, px-holdouts (hairlines, stroke) do not. Arno measures artboard overflow at 200% and emits a verdict survives 200%: yes/no per artboard.

This makes resize-text-200%-safe a machine-checkable origine behavior, auto-verified from the artboard — Arno proves resize-safety rather than merely emitting rem. Mechanically near-free: values are already rem; only the canvas root variable changes and overflow is measured.

Side effect worth surfacing in UI

At root=16 the 2/4/8/16 grid yields clean rem (0.125 / 0.25 / 0.5 / 1); an off-grid stop yields an ugly rem. The existing off-grid validator therefore also predicts ugly rem — grid discipline and rem-cleanliness are the same property. The Scale panel should say so on an off-grid warning.

Consequences

Positive

  • One unit contract, read by every size group — no per-panel drift.
  • rem-by-default → every Arno product passes WCAG 1.4.4 with no designer effort.
  • resize-text-200%-safe becomes a proof-carrying behavior in the verifier line.
  • The off-grid validator gains a second, free justification (rem-cleanliness).
  • The holdout list maps to entities that exist (Stroke weight group, node strokeWeight, Radius) — no phantom tokens.

Negative

  • Migration: any scale carrying unit must fold into project emitUnit + px-canon storage (Phase 1).
  • Non-16 base fonts: rem = px/rootPx still holds, but the readout reflects rootPx, not a fixed 16 — a subtlety designers must understand.

Neutral

  • Generate/Stops naming (space.<value>) is untouched by this ADR — a separate open question.
  • Live token→node binding for strokeWeight is deferred to a later, dedicated change (numeric variable-binding).

Phased plan (implementation follows separately)

  1. Relocate (emitUnit, rootPx) to project level; remove unit from the spacing panel; add per-group holdout; migrate stored scales.
  2. Secondary rem readout (Type + Spacing); off-grid warning gains the rem-clean note.
  3. Emitter px→rem on Export / Code Connect, honouring holdout.
  4. Workflow Text scale slider: canvas root variable + overflow measurement.
  5. resize-text-200%-safe origine behavior, auto-verified from the artboard.

Alternatives considered

  • Keep unit on the spacing panel. Rejected — unit is project-wide; per-panel copies drift and cannot back a project-level slider.
  • Store rem. Rejected — designers author in px; storing rem forces conversion on every read and loses the px canon.
  • Invent a hairline / border-width token just to hold the holdout flag. Rejected — the real entities (Stroke weight group + node strokeWeight) already carry the value; a phantom token is exactly the drift ADR 0059 warns against.
  • Radii + icons as px-holdouts too (all non-type = px). Live alternative for the founder: safest visually, but icons then ignore text scale (weaker a11y). The chosen default keeps icons rem; overridable per project.
  • Emit-only, no verifier. Rejected — the slider is near-free given rem storage and turns a feature into a proof; dropping it forfeits the origine-line payoff.

References

  • packages/origine/src/tokens/registry.ts — Foundation catalogue incl. the Stroke weight group (this session).
  • packages/origine/src/tokens/spacing-scale.ts — spacing model + validator (the unit field this ADR relocates).
  • packages/origine/src/studio/SpacingScalePanel.tsx — Scale panel (source of the misplaced unit field).
  • packages/ponte/src/types.ts — node strokeWeight; packages/quadro/src/features/canvas/box-style.ts — the box-shadow geometry that consumes it.
  • ADR 0063 — Studio tools as reusable packages.
  • WCAG 1.4.4 (Resize text, AA).