diff --git a/STUMBLES.md b/STUMBLES.md index c7cf39412..d4c9e1370 100644 --- a/STUMBLES.md +++ b/STUMBLES.md @@ -9,9 +9,10 @@ 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 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). +el ejercicio: **los 9 cerrados**. De **#7** (contrato CSS-vars soma→eidos): +recipe→tema con un guard (opción C, cazó 3 fantasmas reales) y provider→recipe +con la opción A1 fiel a la arquitectura — el morfo declara `cssVars` (fuente de +verdad) y un guard verifica que provider y recipe honran cada declaración. | # | Tropiezo | Estado | Cómo | |---|---|---|---| @@ -21,7 +22,7 @@ queda solo el sub-contrato provider→recipe (opción A, opcional). | 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 | 🟡 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) | +| 7 | CSS-vars del provider sin contrato | ✅ RESUELTO | **recipe→tema**: guard de fantasmas de tema (opción C — pilló 3 bugs reales); **provider→recipe**: opción A1 (fiel a la arquitectura) — campo `cssVars` en el morfo (fuente de verdad) + guard "provider escribe Y recipe lee cada var declarada" | | 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 | @@ -140,8 +141,17 @@ selectors vía `style` no, pero soma+eidos ya son 2). > cross-check da ~17 falsos positivos. **Solo A1 lo cierra**: declarar > `cssVars: [...]` en el morfo (fuente de verdad de qué vars SON el contrato) + > guard "el provider escribe cada `cssVar` declarada" (un rename deja de -> escribir la declarada → se detecta). Coste: morfo types+schema+compile + -> declarar en cada morfo con vars publicadas + guard. Pendiente de decisión. +> escribir la declarada → se detecta). +> +> **A1 implementado (2026-07-03), fiel a la arquitectura (el usuario: "la fuente +> de verdad por diseño es el morfo").** `MorfoCssVar` + campo `cssVars?` en el +> tipo `Morfo` (types) + schema + `compile` lo expone en `contracts.cssVars` +> (`--{kebab}-{name}`); el morfo del Knob declara `progress`/`angle`; guard nuevo +> en `recipe-css-contract.test.ts` verifica **ambos lados** — el provider escribe +> `--{kebab}-{name}` Y el recipe lo lee — enumerando morfos con `import.meta.glob`. +> Probado no-vacío (una cssVar bogus se caza en ambos lados). Resuelve la +> ambigüedad de A2: en el CSS una var escrita-por-provider y un alias-de-consumidor +> son idénticas; la declaración del morfo es lo que las distingue. ## 8. Dos docs imprescindibles no estaban en el paquete diff --git a/docs/architecture/morfo.md b/docs/architecture/morfo.md index bf7a52bbd..4c5255b20 100644 --- a/docs/architecture/morfo.md +++ b/docs/architecture/morfo.md @@ -528,6 +528,28 @@ string is not duplicated across Drawer, Dialog, Popover, Toast, etc. Use `v.langRef` for an app/system namespace that is deliberately not owned by the component. +### Step 4.6 — Optional: declare provider→eidos CSS vars (`cssVars`) + +When a soma provider publishes a **functional** CSS custom property at runtime +(writing it into an inline `style` for the eidos recipe to consume — e.g. Knob's +`--knob-progress` / `--knob-angle`, Drawer's `--drawer-progress`), declare it in +`cssVars`. The full property is `--{kebab}-{name}`. + +```ts +cssVars: [ + { name: 'progress', description: '0–1 value fraction; drives the conic value arc' }, + { name: 'angle', description: 'pointer rotation in degrees; drives the indicator' } +], +``` + +Declaring it makes the **soma→eidos functional-var surface part of the +contract**: a guard (`recipe-css-contract.test.ts`) verifies the provider writes +each declared var *and* the recipe reads it, so renaming one side without the +other fails loudly instead of degrading silently to a `var()` fallback (STUMBLES +#7). Declare only the vars that are the contract (written by the provider **and** +read by the recipe) — not public `--{kebab}-*` aliases the *consumer* may +override, nor recipe-internal `--_{kebab}-*` privates. + ### Step 5 — Optional: keyboard and focus ```ts diff --git a/src/uix/eidos/recipe-css-contract.test.ts b/src/uix/eidos/recipe-css-contract.test.ts index e3e329153..34f22e3c8 100644 --- a/src/uix/eidos/recipe-css-contract.test.ts +++ b/src/uix/eidos/recipe-css-contract.test.ts @@ -268,6 +268,54 @@ describe('Eidos recipe CSS contract', () => { expect([...new Set(phantoms)].sort()).toEqual([]) }) + // Provider→recipe CSS-var contract (STUMBLES #7, option A — the architecture- + // faithful close). The MORFO is the source of truth: `cssVars` declares the + // functional custom properties the soma provider writes and the eidos recipe + // reads. This guard verifies BOTH sides honour each declaration — the provider + // writes `--{kebab}-{name}` and the recipe reads it — so renaming one side + // without the other fails loudly instead of silently degrading to a fallback + // (exactly how the Knob's `--knob-angle` / `--knob-progress` would have + // drifted). A grep-only cross-check can't do this: in the CSS a provider- + // written var and a consumer-override alias are indistinguishable — the morfo + // declaration is what disambiguates. + it('honours the morfo cssVars contract on both sides (soma writes, eidos reads)', async () => { + const morfoLoaders = import.meta.glob('../morfo/components/*.ts') + const problems: string[] = [] + for (const [path, load] of Object.entries(morfoLoaders)) { + if (path.includes('.test.')) continue + const mod = (await load()) as Record + for (const exported of Object.values(mod)) { + const morfo = exported as { kebab?: unknown; cssVars?: unknown } + if (typeof morfo?.kebab !== 'string') continue + const cssVars = morfo.cssVars + if (!Array.isArray(cssVars) || cssVars.length === 0) continue + const kebab = morfo.kebab + if (WIP_TRACKS.has(kebab)) continue + + const providerDir = join('src/uix/soma/components', kebab) + const providerSrc = existsSync(providerDir) + ? stripCssComments(readTextFilesRecursive(providerDir).join('\n')) + : '' + const recipePath = join(COMPONENTS_DIR, kebab, `${kebab}.css`) + const recipe = existsSync(recipePath) ? readFileSync(recipePath, 'utf8') : '' + + for (const cssVar of cssVars as { name: string }[]) { + const full = `--${kebab}-${cssVar.name}` + // A write is `--{c}-{name}:` or `'--{c}-{name}':` (colon, maybe + // through a quote, follows the name); a `var(--{c}-{name})` read has + // `,`/`)` after it and does NOT match. + if (!new RegExp(`${full}['"]?\\s*:`).test(providerSrc)) { + problems.push(`${kebab}: morfo declares cssVar ${full} but the provider never writes it`) + } + if (!recipe.includes(`var(${full}`)) { + problems.push(`${kebab}: morfo declares cssVar ${full} but the recipe never reads it`) + } + } + } + } + expect(problems.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), diff --git a/src/uix/morfo/compile.ts b/src/uix/morfo/compile.ts index ae7afe56d..f822f54fc 100644 --- a/src/uix/morfo/compile.ts +++ b/src/uix/morfo/compile.ts @@ -249,6 +249,12 @@ export interface CompiledMorfo { readonly dataAttrsByPart: ReadonlyMap readonly cssSelectors: readonly CssSelectorContract[] readonly requiredSources: RequiredSourceContract + /** + * Full names (`--{kebab}-{name}`) of the CSS custom properties the soma + * provider writes and the eidos recipe reads. The soma→eidos functional-var + * contract; empty when the morfo declares no `cssVars`. + */ + readonly cssVars: readonly string[] } } @@ -338,7 +344,8 @@ function build(morfo: Morfo): CompiledMorfo { props: Object.freeze([...propSet].sort()), parts: Object.freeze([...partRefSet].sort()), translations: Object.freeze([...translationSet].sort()) - }) + }), + cssVars: Object.freeze((morfo.cssVars ?? []).map((v) => `--${morfo.kebab}-${v.name}`)) }) }) diff --git a/src/uix/morfo/components/knob.ts b/src/uix/morfo/components/knob.ts index 3eaee0640..35e8a6b00 100644 --- a/src/uix/morfo/components/knob.ts +++ b/src/uix/morfo/components/knob.ts @@ -26,6 +26,13 @@ export const knobMorfo = { 'control.roledescription': '#?components.knob.control.roledescription|rotary knob' }, + // Functional CSS vars the provider publishes (on the Control) and the eidos + // recipe consumes — the soma→eidos contract surface (STUMBLES #7). + cssVars: [ + { name: 'progress', description: '0–1 value fraction; drives the conic value arc' }, + { name: 'angle', description: 'pointer rotation in degrees; drives the indicator' } + ], + parts: [ { name: 'Provider', diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 57a08b111..1e0344f00 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -351,6 +351,11 @@ const semaExpressionModeSchema = union( literal('none') ); +const cssVarSchema = object({ + name: string(), + description: optional(string()) +}); + const morfoShallowSchema = object( { name: string(), @@ -360,6 +365,7 @@ const morfoShallowSchema = object( focus: optional(focusSchema), events: optional(array(eventSchema)), expression: optional(semaExpressionModeSchema), + cssVars: optional(array(cssVarSchema)), translations: optional(object({}, { unknownKeys: 'passthrough' })), parts: array(object({}, { unknownKeys: 'passthrough' })) // ^ parts are opaque here; walker recurses with `partShallowSchema` diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index e42d02978..a5d32b849 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -831,6 +831,20 @@ export type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none * Props live in the component's `types.ts` with JSDoc. Component-owned * text slots live in `texts`. */ +/** + * A CSS custom property the component's soma provider writes at runtime (into an + * inline `style`) and its eidos recipe consumes via `var(...)`. This is the + * soma→eidos functional-var contract — the surface that, before being declared + * here, drifted silently when either side renamed the property (STUMBLES #7). + * The full property is `--{kebab}-{name}` (public-token convention). + */ +export interface MorfoCssVar { + /** Custom-property suffix; the full name is `--{kebab}-{name}`. */ + name: string; + /** What the provider writes into it (documentation only). */ + description?: string; +} + export interface Morfo { /** Component display name, PascalCase. */ name: string; @@ -898,6 +912,14 @@ export interface Morfo { * text slots*, not the localized content. */ texts?: Record; + /** + * CSS custom properties the soma provider writes and the eidos recipe reads + * (`--{kebab}-{name}`). Declaring them makes the soma→eidos functional-var + * surface part of the contract: a guard verifies the provider writes each and + * the recipe reads each, so renaming one side without the other fails loudly + * instead of silently degrading to a fallback. + */ + cssVars?: readonly MorfoCssVar[]; /** The component's part tree. */ parts: readonly MorfoPart[]; }