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.json → emitSiteCss). 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 inlineemits 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, soshadow-<color>utilities would no longer tint these four sizes. Noshadow-<color>utility exists anywhere inpackages/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, …) inbutton-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/80etc.) — explicitly deferred by the slice brief. - Border widths — explicitly deferred by the slice brief.
- Decorative (non-focus) rings —
ring-2onblocks/cta-2.astro's avatar stack and[a]:hover:ring-12onui/tile/tile.astroare 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-radiusnot created. The audit listedrounded-4xlinblocks/hero-1.astro/blocks/hero-4.astro; no such literal exists in the current tree (nor in their git history). Hero CTAs userounded-full(intentional, kept); hero media usesrounded-none/ SectionMedia'srounded-lg. The onlyrounded-4xlin 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-3grep: badge, tile and item spell itring-[3px]. Included (same knob, same 3px value, zero change).