feat(eidos): phantom-theme-token guard (STUMBLES #7, option C) + 3 real phantoms fixed

Closes the recipe→theme direction of the soma→eidos CSS-var drift stumble. New
check in recipe-css-contract.test.ts: every NO-FALLBACK `var(--x)` in a
component recipe must resolve to a token declared somewhere in the eidos CSS
tree (foundation + shared layers + the component's own self-declarations). A
`var(--x, default)` stays exempt — those are runtime-optional by construction
(provider/floating vars whose fallback IS the default), matching the existing
`collectOwnPublicVariables` rule.

This is the exact class that silently broke the Knob (`--color-neutral-content`,
`--state-hover`, `--color-neutral-bg` resolved to nothing, only visible in the
render). The guard immediately caught 3 MORE real phantoms — fixed, not silenced
(silencing would be the anti-pattern of a guard exempting the gap it exists to
catch):

- card-group: `--color-content-default` → `--color-content-primary`
- link-preview: `--leading-body` → `--leading-normal`
- textarea: `--font-family-body` → `--font-family-primary`

Full eidos suite 290/290. The provider→recipe direction (renaming a
provider-written `--knob-angle` breaking the recipe) remains open as option A
(morfo `cssVars` declaration or a grep cross-check) — noted in STUMBLES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 9e48b80b10
commit 2ef0fd58c8

@ -9,8 +9,9 @@ por impacto.
El Knob del ejercicio **ya está construido de verdad** en las 5 capas El Knob del ejercicio **ya está construido de verdad** en las 5 capas
(morfo/langs/sema/soma/eidos, `c13cad74`) con demo (`8dfd3505`) — lo que valida (morfo/langs/sema/soma/eidos, `c13cad74`) con demo (`8dfd3505`) — lo que valida
el ejercicio: 8 de los 9 tropiezos se cerraron; solo queda **#7** (contrato el ejercicio: 8 de 9 cerrados; de **#7** (contrato CSS-vars soma→eidos) se cerró
CSS-vars soma→eidos), que es diseño abierto. la dirección recipe→tema con un guard (opción C, cazó 3 fantasmas reales), y
queda solo el sub-contrato provider→recipe (opción A, opcional).
| # | Tropiezo | Estado | Cómo | | # | Tropiezo | Estado | Cómo |
|---|---|---|---| |---|---|---|---|
@ -20,7 +21,7 @@ CSS-vars soma→eidos), que es diseño abierto.
| 4 | Doctrina `trigger()` continuo sin cerrar | ✅ RESUELTO | sección `## Continuous components` en `sema.md` (`1da36ca6`), fact-check adversarial | | 4 | Doctrina `trigger()` continuo sin cerrar | ✅ RESUELTO | sección `## Continuous components` en `sema.md` (`1da36ca6`), fact-check adversarial |
| 5 | Falta condición `part-absent` | ✅ RESUELTO | `{ when: 'part-absent', part }` en morfo (`11504043`); el morfo del Knob la usa | | 5 | Falta condición `part-absent` | ✅ RESUELTO | `{ when: 'part-absent', part }` en morfo (`11504043`); el morfo del Knob la usa |
| 6 | Formato de `langs/components/{kebab}.ts` | ✅ RESUELTO | forma `LangNode` (`{ key: { es, en } }`, anidable, `satisfies`) documentada en morfo/soma/checklist | | 6 | Formato de `langs/components/{kebab}.ts` | ✅ RESUELTO | forma `LangNode` (`{ key: { es, en } }`, anidable, `satisfies`) documentada en morfo/soma/checklist |
| 7 | CSS-vars del provider sin contrato | ⏳ PENDIENTE | **diseño abierto** — ver abajo | | 7 | CSS-vars del provider sin contrato | 🟡 CASI | dirección **recipe→tema** guardada (opción C: guard de fantasmas de tema en `recipe-css-contract` — pilló 3 bugs reales); queda solo el sub-contrato **provider→recipe** (opción A, opcional) |
| 8 | Docs imprescindibles fuera del paquete | ✅ RESUELTO | `component-audit.md §0` lista el paquete mínimo como archivos exactos | | 8 | Docs imprescindibles fuera del paquete | ✅ RESUELTO | `component-audit.md §0` lista el paquete mínimo como archivos exactos |
| 9 | Fricciones menores (docs) | ✅ RESUELTO | `### Authoring notes` en `soma.md` §6: `state<T>()` vs `$state`, `role` opcional en Provider, `Without<>`/`PrimitiveDivAttributes`, ownership de pointermove/up del gesture | | 9 | Fricciones menores (docs) | ✅ RESUELTO | `### Authoring notes` en `soma.md` §6: `state<T>()` vs `$state`, `role` opcional en Provider, `Without<>`/`PrimitiveDivAttributes`, ownership de pointermove/up del gesture |
@ -118,6 +119,17 @@ selectors vía `style` no, pero soma+eidos ya son 2).
> en el morfo validada contra escrituras del provider + lecturas del recipe, o > en el morfo validada contra escrituras del provider + lecturas del recipe, o
> un cross-check de `eidos-lint` de vars escritas-por-provider vs consumidas. > un cross-check de `eidos-lint` de vars escritas-por-provider vs consumidas.
> Decidir antes de implementar. > Decidir antes de implementar.
>
> **Cerrada la dirección recipe→tema (opción C, 2026-07-03).** Guard nuevo en
> `recipe-css-contract.test.ts`: toda `var(--x)` **sin fallback** de un recipe
> debe resolver a un token declarado en algún sitio del árbol CSS de eidos
> (foundation + capas + auto-declaraciones del componente); las `var(--x,
> default)` son runtime-opcionales (vars provider/floating con default) y quedan
> exentas por construcción. El guard cazó **3 fantasmas reales** además de los
> del Knob — `card-group --color-content-default`→`-primary`, `link-preview
> --leading-body`→`-normal`, `textarea --font-family-body`→`-primary` —
> arreglados. Queda solo la dirección **provider→recipe** (opción A): declarar
> las vars del provider en el morfo (`cssVars`) o un grep-cross-check.
## 8. Dos docs imprescindibles no estaban en el paquete ## 8. Dos docs imprescindibles no estaban en el paquete

@ -44,7 +44,7 @@
[data-card-group-title][data-static] { [data-card-group-title][data-static] {
display: block; display: block;
padding: var(--_cg-pad) var(--_cg-pad) var(--space-2); padding: var(--_cg-pad) var(--_cg-pad) var(--space-2);
color: var(--color-content-default); color: var(--color-content-primary);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--font-size-md); font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);

@ -67,7 +67,7 @@
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--_link-preview-font-size); font-size: var(--_link-preview-font-size);
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--leading-body); line-height: var(--leading-normal);
outline: none; outline: none;
} }

@ -101,7 +101,7 @@
border-radius: var(--_textarea-radius); border-radius: var(--_textarea-radius);
background: var(--_textarea-bg); background: var(--_textarea-bg);
color: var(--_textarea-color); color: var(--_textarea-color);
font-family: var(--font-family-body); font-family: var(--font-family-primary);
font-size: var(--_textarea-font-size); font-size: var(--_textarea-font-size);
line-height: var(--_textarea-line-height); line-height: var(--_textarea-line-height);
outline: none; outline: none;

@ -219,6 +219,55 @@ describe('Eidos recipe CSS contract', () => {
expect(violations).toEqual([]) expect(violations).toEqual([])
}) })
// Phantom-theme-token guard (STUMBLES #7, soma→eidos CSS-var drift). A recipe
// that consumes a theme token which the foundation never declares fails
// SILENTLY: the `var(--x)` resolves to nothing (empty / transparent), only
// visible in the render. Building the Knob hit exactly this
// (`--color-neutral-content`, `--state-hover`, `--color-neutral-bg` — all
// non-existent). Validate every theme-token reference against the tokens the
// foundation actually emits. Component-own vars (`--{c}-*` / `--_{c}-*`) and
// sibling-composed vars (date-range → `--calendar-*`) are NOT theme tokens —
// they're skipped by the component-prefix test.
it('resolves every no-fallback var() to a declared token (no phantom tokens)', () => {
// Every custom property DECLARED (`--x:`) anywhere in the eidos CSS tree —
// foundation, shared layers, and each component's own recipe. A recipe may
// legitimately consume its own private vars (any prefix), foundation
// tokens, shared-layer tokens, and a sibling's public tokens; all appear
// here as declarations.
const declared = new Set<string>()
const collectDeclarations = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name)
if (entry.isDirectory()) collectDeclarations(p)
else if (entry.name.endsWith('.css')) {
for (const m of stripCssComments(readFileSync(p, 'utf8')).matchAll(
/(--[a-z][a-z0-9-]+)\s*:/g
)) {
declared.add(m[1])
}
}
}
}
collectDeclarations('src/uix/eidos')
// Runtime-written vars (provider / floating engine) live in JS, not CSS,
// and are ALWAYS consumed with a fallback (the fallback IS the default —
// same rule as `collectOwnPublicVariables`). So only a NO-FALLBACK
// `var(--x)` — where the author asserts the token exists — must resolve to
// a declaration; a `var(--x, default)` is runtime-optional by construction.
const NO_FALLBACK = /var\(\s*(--[a-z][a-z0-9-]+)\s*\)/g
const phantoms: string[] = []
for (const { component, css } of readComponentCssFiles()) {
for (const match of stripCssComments(css).matchAll(NO_FALLBACK)) {
const token = match[1]
if (!declared.has(token)) phantoms.push(`${component}: ${token}`)
}
}
expect([...new Set(phantoms)].sort()).toEqual([])
})
// Coherence guard (Fase 7 — theming audit): the type/size canon is only worth // Coherence guard (Fase 7 — theming audit): the type/size canon is only worth
// having if components CONSUME it. Recipe `font-size-*` / `icon-size-*` tokens // having if components CONSUME it. Recipe `font-size-*` / `icon-size-*` tokens
// MUST reference the `--font-size-*` / `--icon-size-*` scale (or another token), // MUST reference the `--font-size-*` / `--icon-size-*` scale (or another token),

Loading…
Cancel
Save

Powered by TurnKey Linux.