Skip to content

Aesthetic token contract (ADR-0009 Slice 1)

The design knobs of the canonical block library (packages/components-v3/src), lifted out of hard-coded utility literals into CSS custom properties emitted by lib/assembler-fulldev/theme.js#emitGlobalCss. Both lanes inherit the same emission: the per-site assembler writes it via lib/assembler-fulldev/emit.js, the CMS via apps/cms/scripts/emit-theme-css.mjs — one emitter, two consumers (ADR-0004).

Slice-1 defaults reproduce the previous hard-coded values exactly — zero visual change. This document is the list the capture lane populates per site (design.jsonemitSiteCss). Per ADR-0009's reconciliation principle, captured values must land as bounded scales/enums mapped onto these tokens — never raw computed-style passthrough.

Slice 2a — the emit/consume half (shipped 2026-07-06)

lib/assembler-fulldev/theme.js#emitSiteCss(design) is the per-site override half. It takes a parsed design.json (bounded tokens from lib/design-derive.js) and returns an unlayered :root {} block appended after emitGlobalCss's output — source order lets the site block win over the Slice-1 defaults (an @layer override would lose, because an unlayered custom-property declaration beats any layer). Only measured dimensions are emitted; an absent/empty design.json emits '' → no override → the defaults above stand (zero-regression, the Slice-1 bar).

design.json shape ({ tokens: { … } }), each a bounded bucket → one CSS value:

design.json token overrides buckets (lib/design-derive.js)
btnRadius --btn-radius square 0px · slight 4px (default) · rounded 10px · pill 9999px
radius --radius none 0 · sm .25rem · md .5rem (default) · lg .875rem · xl 1.25rem
sectionPy --section-py tight 2rem · normal 3rem (default) · airy 4.5rem · spacious 6rem
shadow --site-shadow-{xs,sm,md,lg} flat → all none; present → omit (keep defaults)

Both lanes wired: per-site assembler reads builds/<domain>/design.json (emit.js#emitProject), CMS reads it via designPathFor in emit-theme-css.mjs. Derive with node scripts/emit-design-json.js <domain> (reads the stored theme.json _raw, no re-crawl). Slice 2b extends the Playwright capture() to actually record borderRadius/boxShadow/effective section rhythm so design.json carries real values (today's _raw has none → empty tokens).

Tokens

Token Default (== previous literal) Consumers
--ring-width 3px (was ring-3 / ring-[3px]) Focus/invalid rings via ring-[length:var(--ring-width)]: ui/button/button-variants.ts (also aria-invalid:), ui/input/input.astro, ui/textarea/textarea.astro, ui/checkbox/checkbox.astro, ui/radio-group/radio-group-item.astro, ui/native-select/native-select.astro, ui/accordion/accordion-trigger.astro, ui/navigation-menu/navigation-menu-link.astro, ui/navigation-menu/navigation-menu-trigger-style.ts, ui/badge/badge-variants.ts (+ .js twin), ui/tile/tile.astro, ui/item/item.astro
--btn-h 2.25rem (was h-9) Button default size only (ui/button/button-variants.ts, size.default) via h-[var(--btn-h)]
--btn-px 0.625rem (was px-2.5) same, via px-[var(--btn-px)]
--btn-gap 0.375rem (was gap-1.5) same, via gap-[var(--btn-gap)]
--btn-radius 4px (was literal 4px; ADR-0009 Slice 2a) Brand CTA corner: the [data-slot="button"][data-variant="default"\|secondary\|outline"] rules in theme.js#emitGlobalCss. The single highest-signal brand-shape knob (pill vs square CTA).
--site-shadow-xs 0 1px 2px 0 rgb(0 0 0 / 0.05) (Tailwind v4 built-in, verbatim) every shadow-xs use: ui/button (outline variant), ui/input, ui/textarea, ui/checkbox, ui/native-select
--site-shadow-sm 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1) every shadow-sm use: ui/banner, ui/tile (floating), ui/footer
--site-shadow-md 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) every shadow-md use: ui/section (card variant), blocks/header-wcp (dropdown)
--site-shadow-lg 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) every shadow-lg use: ui/sheet/sheet-content, ui/navigation-menu/navigation-menu-viewport

Already tokenized before this slice (unchanged): colours, fonts, --radius-* scale, --section-* spacing, captured --fs-*/--fw-* type scale, --service-*/topbar vars.

Shadow wiring — how it works, and the trap

Tailwind v4 inlines its built-in shadow values into the compiled shadow-* utilities at build time; a :root --shadow-* variable is never read by default (the commented --shadow-* block in apps/cms/src/styles/theme.css was always a silent no-op, per the data-slot silent-no-op pattern). To make the utilities runtime-themable with no markup change, emitGlobalCss registers the theme keys as pass-throughs:

@theme inline {
  --shadow-xs: var(--site-shadow-xs);   /* etc. */
}
:root {
  --site-shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);   /* TW default, verbatim */
}

which compiles every shadow-xs use to --tw-shadow: var(--site-shadow-xs).

  • The --site- prefix is load-bearing. --shadow-xs: var(--shadow-xs) inside @theme inline emits a self-referential (cyclic → invalid) custom property and all shadows silently disappear. Verified empirically against Tailwind 4.3.0.
  • Cost accepted: the pass-through loses Tailwind's per-shadow var(--tw-shadow-color, …) colour injection, so shadow-<color> utilities would no longer tint these four sizes. No shadow-<color> utility exists anywhere in packages/components-v3/src (verified); if one is ever added, extend the token instead.

Deferred (future slices — not tokenized yet)

  • Button sm / lg / xs / icon size variants — still literal (h-8, h-10, size-9, …) in button-variants.ts. Extension point: per-size tokens (--btn-h-lg, …) or a derived scale off --btn-h. Deferred to bound Slice 1; trigger: first captured site whose non-default button sizes diverge from the house scale.
  • Hover-darken (hover:bg-primary/80 etc.) — explicitly deferred by the slice brief.
  • Border widths — explicitly deferred by the slice brief.
  • Decorative (non-focus) ringsring-2 on blocks/cta-2.astro's avatar stack and [a]:hover:ring-12 on ui/tile/tile.astro are not focus rings; folding them into --ring-width (3px) would have changed pixels (2px/12px → 3px), violating the zero-visual-change bar. Left as literals; tokenize separately (e.g. --avatar-ring-width, --tile-hover-ring-width) if capture ever needs them.

Audit corrections (vs the ADR-0009 Slice-1 audit)

  • --hero-radius not created. The audit listed rounded-4xl in blocks/hero-1.astro / blocks/hero-4.astro; no such literal exists in the current tree (nor in their git history). Hero CTAs use rounded-full (intentional, kept); hero media uses rounded-none / SectionMedia's rounded-lg. The only rounded-4xl in the library is the badge pill (ui/badge/badge-variants.ts) — intentional badge shape, kept. No token was emitted so Slice 2 can't populate a knob that paints nothing.
  • Focus-ring surface was wider than the audit's ring-3 grep: badge, tile and item spell it ring-[3px]. Included (same knob, same 3px value, zero change).