feat(morfo): `cssVars` contract for soma→eidos functional vars (STUMBLES #7 closed, option A1)

Closes the provider→recipe direction of the CSS-var drift stumble the
architecture-faithful way: the morfo is the source of truth. A component now
declares the functional custom properties its soma provider writes and its eidos
recipe reads:

  cssVars: [{ name: 'progress', … }, { name: 'angle', … }]   // → --knob-progress, --knob-angle

- types: `MorfoCssVar` + optional `cssVars` on `Morfo` (additive; the 133
  existing morfos are unaffected).
- schema: validates `cssVars` (array of `{ name, description? }`).
- compile: exposes `contracts.cssVars` as full names `--{kebab}-{name}`.
- knob morfo declares its two contract vars.
- guard (recipe-css-contract.test.ts): enumerates morfos via `import.meta.glob`
  and 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. Proven non-vacuous (a bogus cssVar is flagged on both
  sides).

Why A1 and not a grep cross-check (A2): in the CSS a provider-written functional
var and a consumer-override alias are indistinguishable, and providers publish
hook vars the recipe doesn't consume — a grep gives ~17 false positives (proven
earlier). The morfo declaration is what disambiguates.

npm run check 59 (baseline, 0 in touched files); eidos recipe-css-contract 24/24;
morfo suite green (only the pre-existing dialog-role test fails); docs:check 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 5e21fd1ef1
commit 36c7c4b26a

@ -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<T>()` 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

@ -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

@ -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<string, unknown>
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),

@ -249,6 +249,12 @@ export interface CompiledMorfo {
readonly dataAttrsByPart: ReadonlyMap<string, readonly DataAttrContract[]>
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}`))
})
})

@ -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',

@ -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`

@ -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<string, LangRef>;
/**
* 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[];
}

Loading…
Cancel
Save

Powered by TurnKey Linux.