Three fixes from the motion-ecosystem audit:
- `spring()` honours `ctx.reduced`: snap to the target and settle instead of running
the physics. The engine only short-circuits `reduce: 'instant'`, the driver ignored
`ctx.reduced`, and Presence never applied the declared CSS `fallback` — so spring-pop
played the full bounce under `prefers-reduced-motion`.
- `<Motion>` leave(): a loop preset is infinite — detect `animation-iteration-count:
infinite` and unmount promptly instead of holding the node for one loop iteration
(1–2s, still looping) on removal.
- Loop durations are now canonical `:root` tokens (`--motion-loop-{name}`), declared
like the `--duration-*` scale instead of living only as a `var()` fallback.
Tests: engine-motion (spring reduced) + motion (loop tokens); check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the content domain of the universal `motion` prop (RFC §D.12/§D.13):
- State domain: a dedicated `data-motion-state` attribute lets a component with
its OWN data-state machine (Card selected/idle) drive a preset on its transition
without clobbering its semantic `data-state`. Enter-only (animate-on, snap-off).
New emphasis preset `select-pop` (no opacity-from-0, for still-visible elements).
- Loops (content domain, infinite): Tailwind's canonical `spin`/`pulse`/`ping`/
`bounce`, emitted as un-gated `[data-animation-style='X'] { animation: … infinite }`
rules. Themeable per loop via `--motion-loop-{name}`; reduced motion stops them.
- Staggered-exit bridge: the container-driven cascade now covers exit too —
`[data-stagger][data-state='closed'] > …` cascades children OUT in reverse
(`--motion-stagger-index-rev`, last leaves first). The container is retained for
the stagger window then unmounts (pragmatic CSS bridge for bounded lists; the
parent-waits-for-children version still needs the retired PresenceGroup).
- `[data-debug-stagger]`: opt-in dev affordance — a CSS counter badge per child
showing its `:nth-child - 1` index (the analog of UIX_DEBUG_MOTION).
- Fix `<Motion>` exit: a `{ duration }`-only Svelte transition never completes (the
node sticks at the exit frame, a re-show reuses the dead node); add a `tick` no-op
so it times the unmount.
Generated base.css regenerated; motion.test 27/27; docs (eidos-motion + RFC) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- focus ring: adopt canonical `--focus-ring-*` (outline, HCM-safe, primary-tint) — drops the per-color fork
- press archetype: new `STATIC_PRESS` primitive (`--press-scale: 0.985` / `--press-duration: 80ms`) emitted from foundation. Button switches from hardcoded `translateY(1px)` to canonical `scale(var(--press-scale))`. Card recipe repointed to the shared token — single press signature across the system
- intrinsic box: `inline-size`/`block-size: fit-content` defends the Button against flex/grid stretch (was filling 400×160 in a grid; now 94×36). `data-block` still opts into width-stretch, `data-icon-only` still square
- variant=emphasis only: `plain` no longer mutates layout (`min-block-size: auto` + `padding-inline: 0` removed) — same control box as the other 5 variants. Inline link-like actions belong to <Link>, not here
- spinner composition: Button drops its bespoke SVG ring + `@keyframes button-spinner-spin` + `--button-spinner-duration: 720ms` and composes the canonical <Spinner variant="ring"> via two new modes: `presentation` (no role=status/aria-live, marks aria-hidden — Button owns the a11y) + `color="inherit"` (currentColor + 25% track). One spinner across the system, unified duration (0.9s) and reduced-motion behaviour
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Productizes the event-driven cascade on a real component (not the explicit
`<Cascade>` orchestrator). When a DropdownMenu opens, its items fade in
staggered by structural order — the appearance flows from the `open` (emerge)
event, nothing in the app wires it.
Architecture: a +1 GENERIC selector branch in the motion generator, NOT a
per-item `data-state`. A `menuitem` isn't open/closed — the menu is — and eidos
writing a state attr is a smell. The new container-driven form
`[data-stagger][data-state='open'] > [data-animation-style='X']` fires each
child's ENTER off the container's `data-state` (which soma already writes via
the morfo `commits`); items carry only `data-animation-style`. ENTER only: the
coordinated EXIT cascade (retain the container until children finish) is the
deferred soma PresenceGroup work, so a closing container can't drive a child
exit that would be cut off on unmount.
- render-css.ts: `renderCssPresetRules` adds the container-driven enter branch
(same declarations, shared comma selector) — reuses the existing presets +
the foundation `:nth-child` stagger index; zero new system.
- DropdownMenu: Content marks itself `[data-stagger]` +
`--motion-stagger-each: var(--motion-stagger)` (20ms, themeable, no magic
number); item/checkbox-item/radio-item carry `data-animation-style="fade"`
(overridable). `fade` so item opacity doesn't fight the panel's own scale.
- regen generated/base.css (8 css presets gain the container-driven enter).
Verified in browser (frozen frame): index from `:nth-child`, `delay = idx ×
each`, opacity gradient in flight (Ruler 0.98 → Grid 0.89 → Guides 0.69, Log
out 0.00). Honest limit (documented): only DIRECT-child items cascade — items in
a <Group>/<RadioGroup> DOM wrapper aren't `:nth-child` of the panel, so they
appear instantly (the "transparent intermediate").
Docs: dropdown README §Motion, cascade README §2 (concrete consumer), RFC §D.11.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cascade is no longer a separate system — it's the existing state-presets + the
Material stagger + one foundation rule. Retires the --cascade-* reinvention.
- render-css: foundation structural-index writer — `[data-stagger] > *:nth-child` →
`--motion-stagger-index` (forward) / `:nth-last-child` → `--motion-stagger-index-rev`
(reverse). Nobody writes the index; soma never writes a visual var. Every preset's
enter/exit rule consumes it (parallel = `--motion-stagger-each` 0, cascade = N).
- Cascade rewritten as a thin EXPLICIT orchestrator: marks `[data-stagger]`, reflects
open→data-state, passes the preset to items via context; the `out:` retention flips
to data-state=closed (reusing the exit preset), reading the duration via dom.getWindow.
Drops the whole `--cascade-*` namespace + bespoke keyframes.
Docs: MOTION_SERVICE_RFC §D.11 — the final model (three orthogonal axes sema/motion/
eidos, the full lifecycle, the realization ladder snap->transition->animation->JS, the
firma-vs-realization seam, the cascade = existing system + one rule, the motion service
as the engine). eidos-motion aligned.
Verified: browser (enter scale-in/fade-in + nth-child index, exit reverse, zero inline
writes), npm run check 0 mine, motion.test 22/22, eidos-lint 0 invalid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the parallel motion-coordination service per the redesign: animation is a
channel of the EVENT's firma (sema owns it across all channels), not a parallel axis.
Code is gone; the RFC stays as historical record with a retirement banner.
- morfo: drop MorfoPart.animation + its schema/compile/types/exports
- arts/motion: drop CoordinatedPreset / MotionConfig.coordinated
- eidos: drop BUILTIN_COORDINATED_PRESETS / renderCoordinatedPresetRules / the
animation:none neutralization + registry block; regen generated/base.css
- soma: Presence reduced to a single-surface island; delete presence-group /
dom-cascade / coordination; dropdown-menu loses the cascade wiring
- delete Reveal / Rail (morfo + soma + demos)
Docs: MOTION_SERVICE_RFC gains the retirement banner + §D.8 (retirada) + §D.9
(verified model: morfo->soma->sema->eidos pipeline + the two hard rules — soma
never writes a visual --var; data-event-* is a single-target stamp, not a bus) +
§D.10 (Fase 1 prototype). Fix stale "5 canales" claim in GUIA §11; eidos-motion +
dropdown README aligned.
Fase 1 prototype (web/routes/temas/animations/panel-cascade): validates the model
end to end — panel->cards cascade (enter/exit), dynamic removal with Svelte out:
retention, nested cascade — all via :nth-child + custom-property inheritance, with
zero JS visual writes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coherence audit (ARCHETYPE_COHERENCE_AUDIT_2026-06-19): 8 archetypes + 8 closed
decisions, the §13 construction contract, per-component deviation matrix (88/117
deviate). Master guides (soma/COMPONENT_GUIDE, eidos/components/README) point to it.
A1 — elevation bundle via `data-depth`: the plane paints surface·border·shadow·halo
(added `border` to the depth bundle). z-index is NOT painted — stacking is a
positioning concern owned by the floating-wrapper. Popover migrated to the overlay
plane (panel + arrow consume it); recipe orphans pruned.
Popover fixes: arrow un-clipped via an inner [data-popover-viewport] (Radix-style;
the absolute arrow escapes the scroll clip), sized via density tokens (not soma's
hardcoded 10×5), border-seam overlap keyed on data-side, hover-bounce guard in
scheduleHoverOpen (ignore hover-open when already open). Dialog.Close composes
IconButton/Button (neutral) — closes the Lote ② debt.
Option lists: density-driven padding/radius via --space-* + concentric radius,
ScrollArea adoption, emerge open/close events (combobox/select/command), and the
canonical option-highlight in archetypes.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidate the per-field / per-picker duplication the audit surfaced:
- Shared control trigger — new field/field-control-trigger.css selected by a
`data-field-control-trigger` marker: flush (no border, no shadow), UNIFORM
neutral hover tint (not the field accent), font-relative size. Migrated
date-picker + the password visibility toggle; deletes their per-picker chrome.
(date-range / time / time-range pickers still to migrate.)
- Shared segment state layer — new field/field-segment-state.css selected by a
`data-field-segment` marker (date / time / color fields): one hover (editable)
+ active (focus) rule. Fixes a specificity bug where the hover selector
out-ranked :focus, so hovering the active segment downgraded its highlight.
- Segment fixed width — min-inline-size: calc(2ch + 2*segment-px) +
font-variant-numeric: tabular-nums so a segment never reflows when its value
changes 1<->2 digits or between glyphs (no horizontal jitter). date-field done.
- date-range-picker trigger uses the same calendar icon as date-picker (was a
distinct calendar-range glyph).
- Generated element ids use the framework-neutral `uix-` prefix instead of the
layer name `soma-` (createId / useId + announce / menubar inline ids) — the
doctrine that bans data-soma-* / --soma-* keeps a layer name out of ids too.
- field.css: label + helper text scale one step below the control (font-relative
calc); optional-indicator weight to regular.
Foundation tokens added: --field-segment-hover-bg, --field-control-trigger-hover-bg.
Verified in-browser by measurement (date-field segments: states + no-jitter;
date-picker trigger: flush + uniform hover). check = 58 baseline (0 new);
recipe-css-contract clean (only pre-existing palabras WIP).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Field font scale: input 1:1 (size N -> --font-size-N, md now 16px), label one
step below (10/12/14/16/18). Recipes: field, spin-field, date/time/color-field,
search-field, password-field, select, editable, tags-input. Segmented-field
labels via CSS calc; generic Field label via label-font-size tokens.
- Picker triggers (date/time/range): box now scales off the local
--_X-field-height + font:inherit so the 1em icon follows the field font; added
the missing xs size variant to time-picker + time-range-picker.
- color-field/time-field commit-set fires + sounds on increment (compare by
ColorValue.hex; time-field passes fallbackTarget for the double-registered
'input' part).
- color-field format-select: reverted the segment-active tint back to its focus ring.
- Docs: THEMING.md field font rule; COMPONENT_COHERENCE_AUDIT.md.
words / palabras / chronos untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Un componente coordinado puede componer la cascada (motion) con sonido/háptico
(sema) en el mismo evento. El cabo: el meta-canal visual de sema estampa
data-event-* siempre que el evento tenga algún canal (channels != []), así que
channels:['sound'] arrastraba la firma visual genérica (present-rise), que pelea
con la cascada.
Solución EN MOTION (sema intacto): eidos neutraliza su PROPIA firma visual sobre
las superficies coordinadas. render-css.ts > renderCoordinatedPresetRules emite
`[data-animation-style='cascade-X'][data-event-phase='active'] { animation: none
!important }`. La firma usa `animation` -> muere; la cascada es `transition` ->
sobrevive. Aditivo: inerte hasta que un coordinado dispare un evento sema.
Ejemplo: Reveal pasa a modo (c) — open y close declaran channels:['sound']
(emerge suena, pitch 600) + expression:'family-default'; suena al abrir y al
cerrar a la vez que la cascada, sin pelea visual. Rail queda como modo (b) puro.
Layout de /temas/animations con events:{ sound: true }; la demo /reveal explica
el modo (c).
Honestidad: la versión inicial del RFC Apéndice B afirmaba que el modo (c)
componía "gratis" — falso (verificado en engine.ts/visual.ts). Corregido: el
Apéndice B documenta ahora el acoplamiento real y el opt-out en motion.
Tests: eidos/motion 25/25 (neutralización) · morfo 70/70 · morfo:vocabulary
limpio. Audio verificado en navegador (suena al abrir y cerrar).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Servicio de motion cross-layer: morfo declara superficies animables +
coordinación; soma coordina presencia/lifecycle; eidos posee lo visual;
arts/motion ejecuta por-nodo. RFC en eidos/MOTION_SERVICE_RFC.md.
- M1 — contrato MorfoPart.animation (types/schema/compile); children
como { enter?, exit? }.
- M2 — PresenceGroup (soma/layers/presence-group.ts), rune-free;
Presence.group descubre el coordinador por context.
- M3 — exit con retención de DOM (§8.1).
- M4 — interrupción/reversa (§8.3): token de generación + motion.cancel
en flip + toHandle resuelve finished en cancel (sin AbortError suelto).
- M5 — prop `animation` enrutada a las parts surface:true del morfo
compilado (routeAnimation); Panel/Item emiten data-animation-style.
- M6 — stagger auto-derivado del orden de registro (--motion-stagger-*,
inversa en exit) + presets coordinados en la librería de eidos
(MotionConfig.coordinated; cascade-slide/-fade/-scale) que reaccionan
a data-starting/ending-style, NO a data-state.
Helper Coordination (soma/layers/coordination.ts) extraído y validado
por DOS consumidores reales: Reveal (raíz virtual + Panel owner) y Rail
(raíz=owner, together). Demos en /temas/animations/{reveal,rail,
presence-group}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each input drew its focus ring differently — the foundation [data-archetype]
:focus-visible ring landing on the <input>, ad-hoc per-component input rings
(css-field/number-field), the textarea's own color-mix — so editing showed a
DOUBLE frame (inner + outer), inconsistent across components.
Unify into one theme-parameterised two-ring model:
- New --focus-ring-inner-width (0 by default), folded into --focus-ring and every
field *-focus-shadow: inner inset ring + outer ring. 0 = single outer frame;
raise it in a theme to show the inner line everywhere at once.
- Foundation [data-archetype]:focus-visible now excludes form-internal elements
(:not(input):not(textarea):not(select):not([data-archetype='segment'])) — their
focus is shown by the wrapping control.
- Removed the ad-hoc input rings (css-field/number-field); textarea uses
var(--focus-ring).
- Cleaned the now-dead per-data-color _accent-* cascade in css-field/number-field
(recorded as backlog in THEMING.md §32).
Verified with real keyboard on field/css-field/number-field/date-field/textarea:
single outer frame. Also includes the earlier textarea `resize` fix (morfo
data-resize values + soma stops inlining resize + eidos materialises it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
base.css shipped all 31 Radix donor scales, but the theme's roles only
reference 9 (gray green indigo orange plum purple red slate teal). The
other 22 were dead weight on every page: no component references raw
--scale-* (they use --color-{role}-*), and runtime theming builds from JS
scale data and writes RESOLVED values (build-scheme: variables[k]=hex),
so it never reads the CSS scale tokens.
renderThemeCss now emits only role-referenced scales by default; the full
31-scale donor palette ships opt-in in generated/palette.css (new
renderColorPaletteCss). The CSS contract stays the full vocabulary
(apps can still override any scale; load palette.css to use the extra 22).
base.css 53.7→35.4 KB gz. Monolith (every page) 54.4→36.2 KB gz —
combined with Phase 3, 113.5→36.2 KB gz total (−68%). Verified: roles
resolve (primary=purple, button bg correct), pruned scales empty on
regular pages, restored on /temas/color via palette.css. Updated the
contract test to verify coverage against the full palette. check clean
(13 errors are untracked heroscrolling demo + gitignored tmp/lexical);
eidos suite at baseline (192/195, 3 pre-existing words failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shape becomes a two-moment channel: resting form (Fase 1) + the morph (event).
Folded cross-modally into the existing `press-squeeze` contact firma — one press
now drives scale (motion) + shadow-flatten (depth) + corner-firm (shape): the
`continuous` corner squares up at the press peak (--shape-smoothing 2 -> 3 -> 2).
- `@property --shape-smoothing { syntax: <number>; inherits: true }` so the number
interpolates smoothly (mirrors the motion @property pattern).
- Set only at the keyframe peak -> interpolates from/to the element underlying
smoothing (respects a theme override). No-op for non-continuous corners (they do
not read --shape-smoothing). Degrades with reduced-motion via the firma system.
Material 3 Expressive is the only other system with shape morph (bespoke, closed);
this rides the unified firma — one event, many modalities.
Verified: check 0 errors; eidos config+motion+generated 78/78. Regenerated base.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`--shape-nest-gap` token + `[data-shape-nest]` rule: a nested element derives
`border-radius: max(0px, var(--shape-outer-radius) - var(--shape-nest-gap))`, so
its corners stay parallel to the parent (concentric). The parent exposes its
radius via `--shape-outer-radius` (inherited); gap defaults to the nest token.
nestGap is optional on ShapePrimitiveSet (a shape system can omit nesting); the
token + rule are gated on its presence. Only SwiftUI (recent, platform) has
concentric corners — none on the web as a token rule.
Verified: check 0 errors; eidos config+generated 55/55. Regenerated base.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 8th book channel (forma) — the one no web design system has elevated. Fase 1
lays the foundation: corner continuity + perceptual families as portable tokens.
- ShapePrimitiveSet (config-driven): `smoothing` (superellipse exponent, 1=arc ->
2=squircle) + `families` map. STATIC_SHAPE ships rounded/continuous/cut/scoop.
- Emission: `--shape-smoothing` token + `[data-shape=`{family}`]` rules setting
`corner-shape` (round / superellipse(var(--shape-smoothing)) / bevel / scoop).
Opt-in: magnitude stays in `--radius-*` (untouched), so corners degrade to the
plain border-radius arc where `corner-shape` is unsupported (progressive, like
the wide-gamut oklch of color). var() works inside superellipse() (Chrome 146).
- Validation (validateShapePrimitives) + 2 tests + regen.
Why it matters: the whole web field (Tailwind/shadcn/Chakra/Mantine/Ant/Radix
Themes/Carbon/Fluent/Spectrum/Polaris/Primer) is "radius scale + circular arc +
static". Continuity exists only in Apple (platform-locked); none on the web ships
squircle as a token. This is the first.
Verified: npm run check 0 errors; eidos config+generated 54/54. data-shape applies
in the live runtime (continuous->squircle, cut->bevel, scoop->scoop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the depth channel. Atmosphere as the channel's materials layer:
- Per-plane `blur` cue (overlay 10px, modal 16px) + an opt-in frost rule
`[data-depth=`{plane}`][data-frost]` -> translucent surface (color-mix 80%) +
backdrop-filter blur. Gated on data-frost so it never turns an opaque overlay
translucent by default; specificity 0,2,0 reliably overrides the component bg.
- buildDepth(planes) (pure) + ActiveEidos.applyDepth/clearDepth — retune any
plane cue (surface/shadow/halo/blur/scrim/z) at runtime, the depth sibling of
applyColorScheme / applyTypeScale. Exported from $uix/eidos.
Showcase: /temas/profundidad section Materiales — a frosted-glass panel over a
vivid color mesh (frost blur + shadow + halo).
The `scrim` cue stays an available token without a wired rule — modal backdrops
are component-managed.
Verified: npm run check 0 errors; eidos 185/188 (3 pre-existing `words` failures,
unrelated). New tests: frost emission + applyDepth. Regenerated generated/base.css.
Docs: DEPTH_ENGINE_RFC Fase 4 + token contract, THEMING 29.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Arranca el sprint de profundidad: depth como un plano unificado, config-driven y
semántico — la base del canal "la profundidad es algo que OCURRE" del libro (el
disparo eventful por sema llega en Fase 3).
- DEPTH_ENGINE_RFC.md — la guía de diseño: estudio de los límites de los
referentes, la tesis novel, y la doctrina "default fuerte, jaula abierta".
- EidosConfig.depth.planes (DepthPlane + DepthPrimitiveSet). Set canónico:
flush · raised · overlay · modal · recessed — cada uno COMPONE los primitivos
existentes (surface/shadow/z), sin matemática nueva → cero rotura.
- emite tokens --depth-{plane}-{cue} + reglas [data-depth='{plane}'] que aplican
las señales aditivas seguras (box-shadow + z-index); surface + blur/scrim
quedan como tokens opt-in (no pisan fondos de componente).
- validado; config-driven (un tema añade/retunea planos — jaula abierta).
- arregla un punto y coma latente en la emisión de variable-fonts, cazado aquí.
Verificado: check 0 errores; tests nuevos (emisión canónica + plano custom);
generated/base.css regenerado; navegador — data-depth='overlay' aplica la sombra
overlay + z 400, 'recessed' aplica una sombra inset. (3 fallos de words pre-existentes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2 Semantic leading/tracking config-driven: the per-role `--leading-{role}`
(ui/prose/text/heading/display) + `--tracking-{role}` (badge/label/ui/prose/
heading/display) tokens were hardcoded in render-css; moved to
`typography.semanticLeading` / `semanticTracking` (STATIC_TYPOGRAPHY), emitted
config-driven + validated, so a theme can retune them. Byte-identical output
(generated/base.css unchanged — same values, same order).
#3 Font preloads: `collectFontPreloads(typography)` (pure) + `eidos.fontPreloads()`
surface `<link rel=preload>` descriptors for families flagged `preload: true`
(the engine emits CSS, not head markup), for the app `<svelte:head>`. Inert
until a theme opts in.
Docs: TYPOGRAPHY_ENGINE_RFC fases marked closed + Fase 4 (the two-zone scale is
intentional — documented, not rewired); THEMING §10 — applyColorScheme +
applyTypeScale system builders.
`npm run check` 0 errors; new font-preload tests + config/generated tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`FontFamily.axes` ({ wght, opsz }) was declared but never consumed — the
TYPOGRAPHY_ENGINE_RFC §5 promise was unmaterialized. Wire it:
- axes.wght -> when a face omits `weight`, @font-face emits the range
(`font-weight: 100 900`) so one variable face spans the whole axis
- axes.opsz -> `:root { font-optical-sizing: auto }` so the optical-size
axis tracks the rendered font-size
Engine-only, inert until a theme declares axes (shipped fonts are static
TTFs — same posture as wide-gamut color: ready, not yet exercised by
assets). Zero change to generated/base.css (no family declares axes).
`npm run check` 0 errors; new test asserts the emission; the generated-css
test confirms base output unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the Phase 3 scales into <Text> and <Heading> + fixes a token collision the scales
introduced.
- Text/Heading props (additive): `tracking` / `leading` (reuse the existing
--_x-letter-spacing / --_x-line-height vars → override the size-derived values),
`wrap` (text-wrap: balance/pretty/nowrap), `numeric` (tabular/oldstyle →
font-feature-settings), `measure` (max-inline-size). Heading defaults to
`text-wrap: balance` (reference-grade titles); Text defaults to the CSS initial so the
axes are no-ops until a prop is set. Heading reuses Text's scale unions.
- COLLISION FIX: Phase 3a's config-driven --tracking-{tight,normal,wide,wider} collided
with a pre-existing HARDCODED tracking scale in render-css (semantic badge/ui/… +
scale tight/normal/wide/wider, all 0) emitted later → it won (everything resolved to
0, so the tracking prop did nothing). Removed the hardcoded scale lines; the config
(typography.tracking, real optical values) now owns tighter/tight/normal/wide/wider.
The semantic tracking tokens (badge/label/ui/prose/heading/display) stay (recipes use
them, e.g. card-title --tracking-tight now picks up the real -0.02em). leading/features
/measure don't collide (distinct keys).
Verified in browser: tracking-wide 0.02em -> 0.32px; tracking-tight -0.02em -> -0.32px;
heading default text-wrap balance; wrap=pretty, numeric=tabular, measure, leading all
apply. check 0 errors; eidos suite green (3 pre-existing words failures only).
Note: the pre-existing hardcoded semantic typography block (leading-ui/prose/… +
tracking-badge/…) is still hardcoded, not config-driven — a separate cleanup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TYPOGRAPHY_ENGINE_RFC Phase 3 (engine tokens). Additive scales, pure CSS, no binaries.
- config-types: TypographyPrimitiveSet += tracking / leading / features / measure
(optional Record<string,string>).
- render-css: emits --tracking-{k} / --leading-{k} / --font-feature-{k} / --measure-{k}.
- typography.ts defaults: tracking (tighter…wider), leading (none…loose), features
(tabular = "tnum"+"lnum" for data, oldstyle/smallcaps/ligatures), measure (54/66/78ch).
+ optical tracking baked into the size tokens: small text slightly looser
(xxs +0.01em), display tighter (xxxl -0.02em) — was all 0.
- config.ts: validates the 4 scales (CSS-value maps).
- generated/base.css regenerated. test: scales + optical tracking emission.
Additive tokens (recipes/components consume var(--tracking-tight) etc.) so the scales
are zero-risk; the only rendered change is the gentle optical tracking on headings/small
text. check 0 errors; eidos suite green (3 pre-existing words failures only).
Next (Phase 3b): wire the component props (wrap: balance/pretty, numeric: tabular,
tracking/leading/measure) on <Text>/<Heading>.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen
token contract (--font-size-X keeps its name; only the value formula changes, like
color --scale-* hex -> oklch()).
- type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp /
resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom).
- config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}).
Plain length strings still valid -> backward-compatible.
- render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling)
-> a fixed rem or a fluid clamp; the --scaling axis composes on top.
- config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so
the base config validates (was the cascade root — FluidSize objects failed the
string-only CSS-value check).
- typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max
@1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2
drop the manual { base, md } responsive sizes — the clamp covers the viewport.
- generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions
updated to the new rem/clamp values.
Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20;
md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only).
canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes the THEMING_AUDIT P3 backlog.
P3-5 (fixed) · appendScaledMetricDeclarations: the `parseFloat(raw) === 0` guard let
non-numeric values (auto / var() / calc()) fall into `calc(x * …)` = invalid CSS. Now
only finite, non-zero numbers are scaled; zero + non-numeric emit verbatim. No change
to the base config output (all values numeric) — pure robustness.
P3-8 (fixed) · index.ts no longer re-exports the raw render-* fns. The public render
API is the ActiveEidos class (gated by assertValid() + active config); ./lib/render-css
stays reachable for internal/tooling use. Redirected the one internal consumer
(active-eidos-config.test.ts) to import renderThemeCss from the module.
Triaged the rest with rationale (audit updated):
- P3-4 deferred · density wins by deterministic source order (stable); the :where(:root)
restructure to also support scoped density is high-cost for a theoretical nit.
- P3-6 already resolved · dispose() routes documentElement via dom (no direct access).
- P3-7 deferred · ActiveEidos reactivity is callback-driven (apply() on pref change) by
design; a full runes conversion is a risky refactor with no bug to justify it.
- P3-9 deferred · orphan _accent forwarders — low-value recipe surgery with cascade risk.
All P3 now fixed-or-decided; only the P2 secondary halves (contract pruning + bare
identifier color validation, both edge-case) remain, deferred as low-value.
check 0 errors · eidos suite green (3 pre-existing words-track failures unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the forced-colors a11y work. Users who request more contrast (macOS
"Increase contrast", Windows, etc.) now get strengthened neutral chrome: borders
bumped to neutral 7/8/9 (subtle/default/strong) and de-emphasized text to 12/11
(secondary/muted). Solid fills + primary text are already high-contrast, so they stay.
renderPrefersContrastBlock emits a @media (prefers-contrast: more) block using
`:root:root` (specificity 0,2,0) so it wins over the theme's :root regardless of
stylesheet order. Values reference --primitive-neutral-* (resolve from the cascade; a
theme omitting them just no-ops the declaration — graceful). Strictly additive (gated
by the media query) and strictly STRONGER, so it can't regress the default look.
Also marks audit P3-6 resolved: dispose() already routes documentElement access via
dom (#lastAttrs.target + dom.apply), no direct document access remains.
- generated/base.css regenerated. test: renderStaticCss emits the prefers-contrast
block. docs: THEMING §28 + audit P3-2/P3-6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the two color-quality items from THEMING_AUDIT P3.
P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the
browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so
the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The
foundation now always emits a system-colored outline fallback:
@media (forced-colors: active) {
:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
}
Components that already focus via outline (e.g. Button) keep theirs by specificity; this
is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts.
P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's
functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read
washed-out on real element borders (outline/surface/controls). element/hover/active
(3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS.
- generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7).
- Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80
0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken.
- test: renderStaticCss emits the forced-colors outline block.
- docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each palette step is now emitted twice: the hex as a universal fallback, then an
oklch() sibling that wins where supported (Chrome 111+/Safari 15.4+/Firefox 113+).
The token layer is now OKLCH-native and wide-gamut-ready, with NO @media and NO
config flag (it is the default behaviour).
- render-css `appendColorScaleDeclarations`: hex line + `oklch()` sibling per
`--scale-{name}-{step}`. Only opaque, parseable steps get the sibling; empty/
non-color values keep just the fallback. `--primitive-*`/`--color-*` are var()
refs (untouched); alpha scales stay color-mix/rgba.
- generated/base.css regenerated (+744 oklch sibling lines: 31 scales x 12 x 2 modes).
Honest scope: the shipped Radix palette is authored in sRGB hex, so its oklch()
siblings are sRGB-equivalent (verified: --scale-purple-9 -> oklch(0.5556 0.1829
305.86) paints #8e4ec6) -- identical today. The win is the OKLCH-native foundation:
an OKLCH-authored theme or a vivid generated scheme now renders wider on P3 with no
extra work. Making the SHIPPED palette visibly wide-gamut is Phase 3.
Tests: full eidos suite green except 3 pre-existing words-track failures (confirmed
unrelated via baseline). Docs: RFC §7 (status) + THEMING §27 + README ref row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First consumer of uix.color. The theme generator's on-solid text pick
(white vs dark) now decides by APCA (|Lc| >= 60) instead of WCAG 2
(< 3:1), with a WCAG 2 ratio kept as a conservative cross-check — white
must clear BOTH or the contrast slot flips to onSolidContrast. APCA is
accurate in the mid-tones where WCAG 2 mis-estimates (the risk=orange
case). Reproduces the documented base behavior (only risk flips) via a
better metric; generated/base.css unchanged (the pick lives in the
runtime theme block).
- render-css: import apcaLc / oklchToGammaRgb / safeParseColor /
wcagContrastRatio from $color; replace the local WCAG pick; drop the
now-orphaned local wcagRelativeLuminance + wcagContrastRatio.
- color: add safeParseColor (null instead of throw for var()/color-mix
theme values the engine can't introspect).
- wire $color alias (vite.config.ts + svelte.config.js + CLAUDE.md).
Verified: color 20/20, eidos 162/165 (3 pre-existing words failures),
active-eidos-config contrast asserts pass, npm run check 0 new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate the motion runtime out of eidos into a runtime art (src/arts/motion,
$motion), exposed as uix.motion and consumed by BOTH soma (Presence.motion ->
motion.run) and eidos (delegates + registers presets) -- dissolving the
soma->eidos coupling. Remove DialogProps.runMotion / eidos.motionRunner; the
bridge is now EngineMotion.run (reads data-animation-style). Delete the 4
relocated dead files (lib/motion/{types,runtime,runner,presets/js}.ts); the
preset DATA (presets/css.ts) stays in eidos. Regenerate generated/base.css.
F6 - token rigor (Carbon): tokenize the raw firma durations (slower/deliberate/
emphatic/sustained holds, escalating by announce intent severity), add
--motion-distance-xl (30px shared-axis), --motion-scale-through (0.92), the
emphasized easing, and productive/expressive sets ([data-motion-set=expressive]).
F7 - extensibility + typegen: app-extensible, type-safe preset-name registry
(EidosMotionPresets, mirroring SemaChannelSignatures); MotionPresetName =
keyof EidosMotionPresets | none | (string & {}).
Also sweeps other in-progress working-tree edits (web/routes/temas/grafito).
Verify: npm run check -> 1 pre-existing error (grafito), 0 new; motion 22/22,
active-uix 25/25, Presence 2/2, Dialog 3/3. Pre-existing words-track failures
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the synthetic step-9-at-opacity alpha ramp with a real compositing inverse: each --scale-X-aN is the translucent color that, painted over the scale's background (white for light scales, black for dark, by step-1 luminance), reproduces the opaque solid step N (Radix model). Computed in render-css.ts for ANY theme (base + custom), so alpha is consistent-by-construction with the solid scale and cannot drift. alphaScales opt-in still wins verbatim; non-hex solids fall back to the legacy color-mix ramp. Verified: --scale-purple-a9 = rgb(92 0 173 / 0.6941) over white = #8e4ec6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alpha steps (--scale-*-aN) are unconsumed today (only a test + README ref). The step-9-at-opacity synthesis is a valid 'accent-at-opacity' ramp default, not a bug; it is not a Radix-style reproduction of the solid scale. Documented the model, its limitation, and the already-wired alphaScales opt-in in appendColorAlphaScaleDeclarations. A build-time compositing-inverse generator is deferred until alpha is actually consumed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single source of truth for typography values that the recipe layer
consumes. The foundation aliases `--font-ui` and `--leading-ui` (read
by ~30 recipe tokens in `lib/recipes/base.ts`) now derive from the
canonical `label` named style instead of carrying duplicate literals:
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
Chain: typography.ts styles → --style-{name}-* → --leading-ui / --font-ui
→ recipe tokens → component CSS. Editing
`STATIC_TYPOGRAPHY.styles.label.lineHeight` now propagates to every
recipe in one go.
Why not push recipes to consume `--style-{name}-*` directly:
- t-shirt sizes (xs/sm/md/lg/xl) don't map to four semantic buckets
- per-component matices (description/caption/hint) need their own
color / weight / letter-spacing
- ref libraries (Radix Themes, Mantine, MUI, Chakra) all keep
numerical scale for component internals; semantic layer is only for
user-facing typography primitives (`<Text variant="body2">`)
Audit rule R-2.7 (warn): detects literal font-size / font-weight /
line-height / letter-spacing in eidos component CSS. Escape valves:
var(...), numeric identities (0/0px/1), keywords (inherit/initial/
unset), or trailing `/* literal: <reason> */` comment. Current run
flags 6 components with letter-spacing/font-size literals (all
intentional micro-tracking and em-relative; can be annotated case by
case).
Documentation:
- src/uix/eidos/README.md § "Vertebración tipográfica" — two-layer
architecture rationale, alias chain diagram, comparison vs Radix
Themes / Chakra / Mantine / MUI, escape valves
- web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md § 4.11 — pointer to
R-2.7 + cross-link to the foundation doc
Verified end-to-end in browser at /uix/components/field:
--font-ui → 'Instrument Sans', system-ui, sans-serif
--style-label-font-family → 'Instrument Sans', system-ui, sans-serif
--leading-ui → 1.25
--style-label-line-height → 1.25
computed [data-field-label].line-height → 17.5px (= 14 × 1.25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>