- 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:
- 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.
- 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 ceilround()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:
- Secondary readout under a token value (Type + Spacing only):
16pxlarge,1remfaint below — collapsible / optional. Never the primary input. - Hand-off boundary — Export to git / Code Connect / dev-spec, where rem is the language of code.
- 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" ↔ nodestrokeWeight): always px, whole group isholdout: '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%-safebecomes 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
unitmust fold into projectemitUnit+ px-canon storage (Phase 1). - Non-16 base fonts:
rem = px/rootPxstill holds, but the readout reflectsrootPx, 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
strokeWeightis deferred to a later, dedicated change (numeric variable-binding).
Phased plan (implementation follows separately)
- Relocate
(emitUnit, rootPx)to project level; removeunitfrom the spacing panel; add per-groupholdout; migrate stored scales. - Secondary rem readout (Type + Spacing); off-grid warning gains the rem-clean note.
- Emitter px→rem on Export / Code Connect, honouring
holdout. - Workflow Text scale slider: canvas root variable + overflow measurement.
resize-text-200%-safeorigine behavior, auto-verified from the artboard.
Alternatives considered
- Keep
uniton 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-widthtoken just to hold the holdout flag. Rejected — the real entities (Stroke weight group + nodestrokeWeight) 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 (theunitfield this ADR relocates).packages/origine/src/studio/SpacingScalePanel.tsx— Scale panel (source of the misplacedunitfield).packages/ponte/src/types.ts— nodestrokeWeight;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).