From 2ef0fd58c8b778a76589e315404ee99b46ca22b0 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 3 Jul 2026 19:14:14 +0200 Subject: [PATCH] feat(eidos): phantom-theme-token guard (STUMBLES #7, option C) + 3 real phantoms fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- STUMBLES.md | 18 +++++-- .../components/card-group/card-group.css | 2 +- .../components/link-preview/link-preview.css | 2 +- .../eidos/components/textarea/textarea.css | 2 +- src/uix/eidos/recipe-css-contract.test.ts | 49 +++++++++++++++++++ 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/STUMBLES.md b/STUMBLES.md index a182b2616..65e6f1317 100644 --- a/STUMBLES.md +++ b/STUMBLES.md @@ -9,8 +9,9 @@ por impacto. 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 -el ejercicio: 8 de los 9 tropiezos se cerraron; solo queda **#7** (contrato -CSS-vars soma→eidos), que es diseño abierto. +el ejercicio: 8 de 9 cerrados; de **#7** (contrato CSS-vars soma→eidos) se cerró +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 | |---|---|---|---| @@ -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 | | 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 | -| 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 | | 9 | Fricciones menores (docs) | ✅ RESUELTO | `### Authoring notes` en `soma.md` §6: `state()` 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 > un cross-check de `eidos-lint` de vars escritas-por-provider vs consumidas. > 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 diff --git a/src/uix/eidos/components/card-group/card-group.css b/src/uix/eidos/components/card-group/card-group.css index 3909f25c2..864343c9c 100644 --- a/src/uix/eidos/components/card-group/card-group.css +++ b/src/uix/eidos/components/card-group/card-group.css @@ -44,7 +44,7 @@ [data-card-group-title][data-static] { display: block; 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-size: var(--font-size-md); font-weight: var(--font-weight-semibold); diff --git a/src/uix/eidos/components/link-preview/link-preview.css b/src/uix/eidos/components/link-preview/link-preview.css index 6615dacc8..a4cc38131 100644 --- a/src/uix/eidos/components/link-preview/link-preview.css +++ b/src/uix/eidos/components/link-preview/link-preview.css @@ -67,7 +67,7 @@ font-family: var(--font-ui); font-size: var(--_link-preview-font-size); font-weight: var(--font-weight-regular); - line-height: var(--leading-body); + line-height: var(--leading-normal); outline: none; } diff --git a/src/uix/eidos/components/textarea/textarea.css b/src/uix/eidos/components/textarea/textarea.css index 8121e32f1..95d7d4997 100644 --- a/src/uix/eidos/components/textarea/textarea.css +++ b/src/uix/eidos/components/textarea/textarea.css @@ -101,7 +101,7 @@ border-radius: var(--_textarea-radius); background: var(--_textarea-bg); color: var(--_textarea-color); - font-family: var(--font-family-body); + font-family: var(--font-family-primary); font-size: var(--_textarea-font-size); line-height: var(--_textarea-line-height); outline: none; diff --git a/src/uix/eidos/recipe-css-contract.test.ts b/src/uix/eidos/recipe-css-contract.test.ts index 99eb1d97f..e3e329153 100644 --- a/src/uix/eidos/recipe-css-contract.test.ts +++ b/src/uix/eidos/recipe-css-contract.test.ts @@ -219,6 +219,55 @@ describe('Eidos recipe CSS contract', () => { 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() + 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 // having if components CONSUME it. Recipe `font-size-*` / `icon-size-*` tokens // MUST reference the `--font-size-*` / `--icon-size-*` scale (or another token),