ADRs
ADR 0045 — Composite typography: live refs, not frozen snapshots
  • Date: 2026-06-06
  • Status: Accepted
  • Phase / Feature: Foundation v1 · token semantics
  • Closes: ADR 0036 Q4 — "Composite typography frozen vs live refs"
  • Builds on: ADR 0036 §2 (3-tier tokens), ADR 0039 (Foundation upgrade path — bears on what happens when a primitive shifts under a composite), ADR 0035 §3 (rule-locked cascade)

Context

Foundation ships composite typography tokens (text.heading.h1, text.body.regular, text.code.inline, …). Each composite token is a bundle of references to primitive tokens:

"text.heading.h1": {
  "$type": "typography",
  "$value": {
    "fontFamily": "{font.family.display}",
    "fontSize":   "{font.size.700}",
    "fontWeight": "{font.weight.bold}",
    "lineHeight": "{font.lineheight.tight}",
    "letterSpacing": "{font.letterspacing.tight}"
  }
}

The unresolved question: when a designer changes font.size.700 from 48px to 52px, does text.heading.h1 follow the change automatically (live refs), or does it stay at 48px because it was bound at composite-token creation time (frozen snapshot)?

The same question applies to brand-import rebinds (ADR 0038) where font.family.display could be swapped out for a brand-uploaded font — every composite typography token built on it should either repaint or stay pinned.

This ADR settles the policy across all composite tokens, not just typography. Once the semantics are fixed, the editor surface (eventually) and the rule cascade (ADR 0035 §3) can rely on them.

Decision

§1 — Composite tokens are live refs, always

Every reference inside a composite token resolves at read time. Editing font.size.700 immediately changes every composite that references it. Editing font.family.display (brand-import) immediately rebrands every typography composite that uses it.

Rationale:

  • Cascade-faithful. ADR 0035 §3 promised "change a primitive, every dependent re-renders." Frozen snapshots break that contract — a primitive edit silently leaves composites stale.
  • Brand-import expectation. When a designer drops in a brand font (ADR 0038), the immediate visible payoff is "every heading + body row updates." If composites were frozen, the brand import would feel like it did nothing until each composite was manually re-bound.
  • Storage size. Live refs persist one row per composite (~33 typography composites × ~5 fields = 165 cells, mostly references). Frozen snapshots would persist the resolved value at every composite, multiplied by every theme / brand variant — order-of-magnitude more storage.
  • Foundation upgrade compatibility. ADR 0039 ships major-version migrations as deterministic transforms (from-to.ts). Live refs let a Foundation v2 rename font.size.700 → font.size.heading-1 and update composites in one rule; frozen snapshots would need a per-row migration of resolved values.

§2 — Edit-time UX implication

When a designer edits font.size.700 they see EVERY composite that references it repaint in the live preview. The editor surface (eventually) surfaces this with a side panel: "Editing font.size.700 — affects 4 composites: text.heading.h1, text.heading.h2, text.display.large, text.display.xlarge."

This is information, not friction. A designer who wanted to change h1 only without changing h2 will re-read the message and realize they want a per-composite override, not a primitive edit. The editor offers the "override at composite level" affordance inline:

Wait — only text.heading.h1 should change? Detach from font.size.700 and set the literal value here.

Detached composite tokens persist the resolved primitive value verbatim in their $value, no longer a {token.ref} string. They're the escape hatch from §1's cascade. ADR 0035 paradigm calls this "rule-locked cascade" — the rule (live refs) is the default, the override is explicit and tracked.

§3 — Detached composites are first-class state

The persistence shape from ADR 0038 §1 (Foundation tokens come from the package, brand overrides come from brand_seed etc.) extends: detached composites go in a token_override table (out of scope here; ADR-companion when the editor surface lands).

The key constraint: a detached composite is not a separate primitive. It does not pollute the primitive namespace. The editor surfaces it as "text.heading.h1 (overridden)" with a 'Re-attach' affordance that snaps back to the cascade.

§4 — Performance

Cost of live refs on every read: ~5 field lookups × ~33 typography composites = ~165 cell resolves on initial paint. Negligible. The Style Dictionary compile step at build time already resolves every reference once; runtime CSS variables (ADR 0036 §2.6) make the cascade implicit in the browser's existing variable resolution.

No caching layer needed. No memo needed. Resolution is reads of CSSStyleDeclaration.getPropertyValue('--font-size-700') which the browser already memoizes per stylesheet load.

§5 — Out-of-scope refs (graceful)

A composite token might reference a primitive that's been deleted (a designer removed font.size.700 after the composite was authored). The resolver returns the chain's NEAREST defined ancestor's value with a console warning. If the chain dead-ends with no defined value, the resolver returns 'inherit' (CSS) and surfaces a non-blocking editor warning ("composite text.heading.h1 references missing font.size.700").

Hard error would block paint over a token rename — too strict. Silent fallback would let bad references rot — too lax. Non-blocking warning + 'inherit' is the calibration.

Anti-patterns explicit

  • Do not introduce a hybrid mode (some composites frozen, some live). The rule must be uniform — a designer who has to ask "is this composite live or frozen" has lost the cascade promise.
  • Do not auto-detach a composite when its primitive is edited "by a lot." The amount of change isn't a useful threshold; intent is. The editor offers detach; it doesn't guess at it.
  • Do not persist resolved values for live composites. The whole point is the value isn't authored — it's derived.
  • Do not allow composite tokens to reference each other (a typography composite referencing another typography composite). Reference is from composite to primitive only. Two layers, not N.

Open questions / parking

  • Conditional composites (a text.body.regular.rtl variant whose letterSpacing differs from the LTR sibling). Defer until RTL coverage demands it; today the cascade handles LTR/RTL via CSS direction selectors at the primitive layer.
  • Composite token versioning per-project. When a designer detaches a composite, the editor needs a clear "this is yours, not Foundation's" marker. Lives with the token_override table when it ships.
  • Cross-tier composites (a composite that references both a primitive AND a semantic token). Allowed by the schema but discouraged by review — flag in lint at the token-edit surface.