feat(eidos): C6 — universal palette cascade moves into the generator

palette-{slot} is now RESERVED recipe vocabulary: normalizeRecipeTokens
(render-css) appends the full per-scale color:{scale} cascade for every
donor scale in PALETTE_SCALES to any token named palette-track/element/
border/solid/solid-hover/text/contrast — author-declared color:*
declarations win, only absent scales are appended. This is the
structural end of the hand-maintained-subset era: a recipe opts into
the per-instance palette by naming the token, and the generator
guarantees all scales (the 31-vs-33 drift class cannot recur at this
layer). Applies to app-config recipes too, since the hook sits where
every RecipeTokenSet is normalized.

Button's seven manual spreads are gone from base.ts (the helpers moved
into the generator); its generated output is byte-identical. Toggle —
which already exposed palette-* tokens — universalized itself: +330
generated lines, and <Toggle color="teal"> / color="steel" verified
resolving in a live browser (steel and fuchsia are exactly the two
scales the hand-kept lists used to miss). eidos-lint now classifies
donor-scale values on data-color as sanctioned eidos-only vocabulary
(the TSC color:* axis extension) instead of invalid — the morfo enum
keeps declaring the semantic roles, per the pilot's deliberate
runtime-open design. Guard: recipe-css-contract pins the LAST scale of
PALETTE_SCALES for every palette-bearing recipe in the generated css.

Also: the stale '31 physical color scales' test TITLE says 33 (its body
already asserted 33). Recorded in the plan: the eidos suite carries 15
pre-existing failures in 6 files (baselined against HEAD before this
change) — their triage is its own batch. npm run check at the 61-error
baseline; lint toggle baseline green again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent bfcff0e3c0
commit 62815cb485

@ -42,7 +42,7 @@ Por dimensión:
| **C3** | **R-2.7 tipografía literal HECHA 2026-07-03** — 16 recipes: 8 font-weights → `var(--font-weight-*)` (verificado resolve=500 en navegador); ~13 proporcionales/identidad anotados `/* literal: */` con razón (avatar ∝ diámetro; `calc(1em − (md−sm))` = label one-step-down con delta token-derived; `1em` identidad de contexto; captions ×0.85; optical −1px) | **HECHA** |
| **C4** | **A-1.4 APG HECHA 2026-07-03** — 20 morfos con patrón real (pickers/command/search→combobox · date-field→spinbutton espejo de time-field · rating→radio · nav-menu→disclosure · splitter→windowsplitter · tag-group/tags-input/virtual-grid→grid · file-upload/clipboard→button · announce→alert · form→landmark · image-adjustments→slider · table→table); válvula A-1.4 + notas en 5 READMEs (cropper·pagination·stepper·timeline·image-picker — sin patrón APG real). Restan 4 esperados: card/drag-drop/virtual-list (nota aterriza con su README en C8) + media-player (foráneo) | **HECHA** |
| **C5** | **Sema `expression:` HECHA 2026-07-03** — los 30 morfos eventful sin declaración TODOS tienen pack en `sema/components/` → `expression: 'pack'` (clasificación sin ambigüedad; D.4). `morfo:vocabulary` limpio (solo warn naming de media-player, foráneo). `persistence`/`a11ySemantic` adicionales NO añadidos de oficio — la tabla D.9 es referencia y los 6 consumidores reales ya declaran; ampliar solo con caso real | **HECHA** |
| **C6** | **Paleta universal** — `paletteScaleDecls` pasa de helper manual (7 usos) a paso del generador para toda recipe con tokens `palette-*` + regen + guard ("data-color sin cascade de paleta = error") | pendiente |
| **C6** | **Paleta universal HECHA 2026-07-03** — `universalPaletteDecls` en el generador (`normalizeRecipeTokens`): `palette-{slot}` = vocabulario reservado → cascade de PALETTE_SCALES completo automático (declaraciones de autor ganan; solo se añaden escalas ausentes). Spreads manuales de button retirados (base.ts); button regenera byte-idéntico; **toggle se universalizó solo** (+330 líneas generadas — ya exponía tokens palette-*). Lint: valores de escala en `data-color` = eidos-only sancionado (la enum del morfo sigue siendo los roles). Guard: test en recipe-css-contract fija la ÚLTIMA escala de la const. Verificado en navegador: toggle color=teal/steel resuelven | **HECHA** |
| **C7** | **Size-bundle sweep** (~90 recipes; patrón theming/reference §5, pilot toggle) + guard "recipe que re-declara size→font = warn" | pendiente |
| **C8** | **READMEs** — 28 nuevos + F-1.x incompletos (~18). Baseline/Comparativa (≥3 refs reales)/Decisiones/Gaps; passive → justificación. El más pesado: requiere investigación por componente | pendiente |
| **C9** | **Motion eventful gap** — decidir qué interactivos deben expresar firma de evento (hoy 10) y cablearla; criterio: alta frecuencia = sobrio (doctrina D.5) | pendiente |
@ -51,6 +51,16 @@ Por dimensión:
FASE SIGUIENTE (no aquí): ficha canónica generada por componente, demos v3
(D-*), web de docs. Ver conversación 2026-07-02.
## Hallazgo (fuera de fase, registrado 2026-07-03)
La suite eidos tiene **15 fallos preexistentes** en 6 archivos (verificado
contra HEAD sin C6): create-icon (1) · component-api-contract (3) ·
recipe-css-contract (3: "declares every public component variable" · "raw
colors" · "orphaned") · lint (2: morfo-file coverage · dialog drift-detector)
· active-eidos-config (4) · active-eidos (2). Necesita su propia tanda de
triage — NO se tocó aquí salvo el título stale "31 scales"→33 (el cuerpo ya
afirmaba 33; su fallo es otra causa).
## Verificación de cierre de fase
`npm run component:audit` → 0 BROKEN, NEEDS-WORK solo por reglas D-* (demos,

@ -122,7 +122,7 @@ describe('ActiveEidos config', () => {
])
})
it('ships a base theme pair with 31 physical color scales of 12 steps', () => {
it('ships a base theme pair with 33 physical color scales of 12 steps', () => {
const eidos = createThemeBaseEidos()
expect(eidos.validate()).toEqual({ ok: true, issues: [] })

@ -4746,6 +4746,336 @@
--toggle-palette-contrast: var(--toggle-threat-contrast);
}
[data-toggle][data-color='gray'] {
--toggle-palette-track: var(--scale-gray-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-gray-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-gray-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-gray-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-gray-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-gray-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='slate'] {
--toggle-palette-track: var(--scale-slate-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-slate-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-slate-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-slate-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-slate-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-slate-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='blue'] {
--toggle-palette-track: var(--scale-blue-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-blue-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-blue-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-blue-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-blue-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-blue-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='cyan'] {
--toggle-palette-track: var(--scale-cyan-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-cyan-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-cyan-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-cyan-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-cyan-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-cyan-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='teal'] {
--toggle-palette-track: var(--scale-teal-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-teal-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-teal-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-teal-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-teal-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-teal-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='green'] {
--toggle-palette-track: var(--scale-green-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-green-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-green-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-green-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-green-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-green-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='yellow'] {
--toggle-palette-track: var(--scale-yellow-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-yellow-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-yellow-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-yellow-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-yellow-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-yellow-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid-contrast);
}
[data-toggle][data-color='amber'] {
--toggle-palette-track: var(--scale-amber-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-amber-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-amber-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-amber-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-amber-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-amber-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid-contrast);
}
[data-toggle][data-color='orange'] {
--toggle-palette-track: var(--scale-orange-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-orange-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-orange-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-orange-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-orange-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-orange-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='red'] {
--toggle-palette-track: var(--scale-red-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-red-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-red-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-red-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-red-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-red-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='pink'] {
--toggle-palette-track: var(--scale-pink-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-pink-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-pink-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-pink-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-pink-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-pink-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='purple'] {
--toggle-palette-track: var(--scale-purple-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-purple-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-purple-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-purple-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-purple-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-purple-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='mauve'] {
--toggle-palette-track: var(--scale-mauve-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-mauve-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-mauve-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-mauve-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-mauve-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-mauve-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='sage'] {
--toggle-palette-track: var(--scale-sage-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-sage-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-sage-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-sage-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-sage-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-sage-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='olive'] {
--toggle-palette-track: var(--scale-olive-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-olive-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-olive-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-olive-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-olive-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-olive-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='sand'] {
--toggle-palette-track: var(--scale-sand-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-sand-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-sand-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-sand-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-sand-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-sand-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='tomato'] {
--toggle-palette-track: var(--scale-tomato-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-tomato-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-tomato-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-tomato-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-tomato-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-tomato-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='ruby'] {
--toggle-palette-track: var(--scale-ruby-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-ruby-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-ruby-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-ruby-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-ruby-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-ruby-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='crimson'] {
--toggle-palette-track: var(--scale-crimson-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-crimson-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-crimson-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-crimson-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-crimson-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-crimson-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='plum'] {
--toggle-palette-track: var(--scale-plum-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-plum-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-plum-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-plum-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-plum-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-plum-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='fuchsia'] {
--toggle-palette-track: var(--scale-fuchsia-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-fuchsia-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-fuchsia-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-fuchsia-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-fuchsia-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-fuchsia-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='violet'] {
--toggle-palette-track: var(--scale-violet-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-violet-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-violet-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-violet-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-violet-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-violet-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='iris'] {
--toggle-palette-track: var(--scale-iris-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-iris-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-iris-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-iris-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-iris-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-iris-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='indigo'] {
--toggle-palette-track: var(--scale-indigo-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-indigo-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-indigo-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-indigo-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-indigo-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-indigo-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='jade'] {
--toggle-palette-track: var(--scale-jade-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-jade-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-jade-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-jade-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-jade-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-jade-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='grass'] {
--toggle-palette-track: var(--scale-grass-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-grass-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-grass-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-grass-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-grass-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-grass-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='brown'] {
--toggle-palette-track: var(--scale-brown-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-brown-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-brown-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-brown-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-brown-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-brown-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='sky'] {
--toggle-palette-track: var(--scale-sky-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-sky-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-sky-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-sky-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-sky-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-sky-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid-contrast);
}
[data-toggle][data-color='mint'] {
--toggle-palette-track: var(--scale-mint-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-mint-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-mint-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-mint-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-mint-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-mint-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid-contrast);
}
[data-toggle][data-color='lime'] {
--toggle-palette-track: var(--scale-lime-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-lime-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-lime-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-lime-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-lime-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-lime-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid-contrast);
}
[data-toggle][data-color='gold'] {
--toggle-palette-track: var(--scale-gold-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-gold-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-gold-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-gold-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-gold-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-gold-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid-contrast);
}
[data-toggle][data-color='bronze'] {
--toggle-palette-track: var(--scale-bronze-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-bronze-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-bronze-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-bronze-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-bronze-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-bronze-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle][data-color='steel'] {
--toggle-palette-track: var(--scale-steel-1, var(--color-primary-track));
--toggle-palette-element: var(--scale-steel-3, var(--color-primary-element));
--toggle-palette-border: var(--scale-steel-7, var(--color-primary-border));
--toggle-palette-solid: var(--scale-steel-9, var(--color-primary-solid));
--toggle-palette-solid-hover: var(--scale-steel-10, var(--color-primary-solid-hover));
--toggle-palette-text: var(--scale-steel-11, var(--color-primary-text));
--toggle-palette-contrast: var(--color-content-on-solid);
}
[data-toggle-group][data-color='primary'] [data-toggle-group-item] {
--toggle-palette-track: var(--toggle-primary-track);
--toggle-palette-element: var(--toggle-primary-element);

@ -4,51 +4,13 @@ import type {
RecipeTokenMultiDeclaration,
RecipeTokenDeclaration
} from '../config-types';
import { PALETTE_SCALES } from '../types';
// ── Per-instance palette cascade (THEMING §25.5) ──────────────────────────
//
// A component whose recipe exposes the `palette-{slot}` indirection can let an
// instance borrow ANY donor scale via `color="teal"` — a per-instance color
// override (`<Button color="grass">`). Button is the pilot
// (`pendiente_color_demos.md`). For each of the 33 scales we map the slot to
// its `--scale-{name}-{step}` with a graceful fallback to the primary role, so
// an app that has NOT opted into the full `generated/palette.css` degrades to
// `primary` instead of rendering an empty (broken) chrome. The step map mirrors
// `DEFAULT_COLOR_ROLE_SLOT_STEPS` (render-css.ts).
const PALETTE_SLOT_STEP = {
track: '1',
element: '3',
border: '7',
solid: '9',
'solid-hover': '10',
text: '11'
} as const;
// Scales whose step-9 solid is light enough that white on-solid text fails
// the contrast floor → dark on-solid text (Radix's documented light-9 set +
// our custom light gold). The role loop computes this with APCA; for the raw
// scale cascade we use the known set.
const LIGHT_SOLID_SCALES = new Set<string>(['amber', 'yellow', 'lime', 'mint', 'sky', 'gold']);
/** Per-scale `color:{scale}` declarations for one palette slot. */
function paletteScaleDecls(slot: keyof typeof PALETTE_SLOT_STEP): RecipeTokenDeclaration[] {
const step = PALETTE_SLOT_STEP[slot];
return PALETTE_SCALES.map((scale) => ({
value: `var(--scale-${scale}-${step}, var(--color-primary-${slot}))`,
scope: `color:${scale}`
}));
}
/** Per-scale `color:{scale}` declarations for the on-solid text (contrast) slot. */
function paletteContrastDecls(): RecipeTokenDeclaration[] {
return PALETTE_SCALES.map((scale) => ({
value: LIGHT_SOLID_SCALES.has(scale)
? 'var(--color-content-on-solid-contrast)'
: 'var(--color-content-on-solid)',
scope: `color:${scale}`
}));
}
// `palette-{slot}` is RESERVED recipe vocabulary: the GENERATOR appends the
// full per-scale `color:{scale}` cascade to any token with that name
// (`universalPaletteDecls`, render-css.ts) — recipes only author the
// role/intent declarations. Button below is the pilot consumer.
export const THEME_BASE_RECIPE_TOKENS = {
accordion: {
@ -1586,8 +1548,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
{ value: 'var(--button-fulfill-track)', scope: 'color:fulfill' },
{ value: 'var(--button-risk-track)', scope: 'color:risk' },
{ value: 'var(--button-threat-track)', scope: 'color:threat' },
{ value: 'var(--button-loss-track)', scope: 'color:loss' },
...paletteScaleDecls('track')
{ value: 'var(--button-loss-track)', scope: 'color:loss' }
]
},
'palette-element': {
@ -1599,8 +1560,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
{ value: 'var(--button-fulfill-element)', scope: 'color:fulfill' },
{ value: 'var(--button-risk-element)', scope: 'color:risk' },
{ value: 'var(--button-threat-element)', scope: 'color:threat' },
{ value: 'var(--button-loss-element)', scope: 'color:loss' },
...paletteScaleDecls('element')
{ value: 'var(--button-loss-element)', scope: 'color:loss' }
]
},
'palette-border': {
@ -1612,8 +1572,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
{ value: 'var(--button-fulfill-border)', scope: 'color:fulfill' },
{ value: 'var(--button-risk-border)', scope: 'color:risk' },
{ value: 'var(--button-threat-border)', scope: 'color:threat' },
{ value: 'var(--button-loss-border)', scope: 'color:loss' },
...paletteScaleDecls('border')
{ value: 'var(--button-loss-border)', scope: 'color:loss' }
]
},
'palette-solid': {
@ -1625,8 +1584,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
{ value: 'var(--button-fulfill-solid)', scope: 'color:fulfill' },
{ value: 'var(--button-risk-solid)', scope: 'color:risk' },
{ value: 'var(--button-threat-solid)', scope: 'color:threat' },
{ value: 'var(--button-loss-solid)', scope: 'color:loss' },
...paletteScaleDecls('solid')
{ value: 'var(--button-loss-solid)', scope: 'color:loss' }
]
},
'palette-solid-hover': {
@ -1638,8 +1596,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
{ value: 'var(--button-fulfill-solid-hover)', scope: 'color:fulfill' },
{ value: 'var(--button-risk-solid-hover)', scope: 'color:risk' },
{ value: 'var(--button-threat-solid-hover)', scope: 'color:threat' },
{ value: 'var(--button-loss-solid-hover)', scope: 'color:loss' },
...paletteScaleDecls('solid-hover')
{ value: 'var(--button-loss-solid-hover)', scope: 'color:loss' }
]
},
'palette-text': {
@ -1651,8 +1608,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
{ value: 'var(--button-fulfill-text)', scope: 'color:fulfill' },
{ value: 'var(--button-risk-text)', scope: 'color:risk' },
{ value: 'var(--button-threat-text)', scope: 'color:threat' },
{ value: 'var(--button-loss-text)', scope: 'color:loss' },
...paletteScaleDecls('text')
{ value: 'var(--button-loss-text)', scope: 'color:loss' }
]
},
'palette-contrast': {
@ -1664,8 +1620,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
{ value: 'var(--button-fulfill-contrast)', scope: 'color:fulfill' },
{ value: 'var(--button-risk-contrast)', scope: 'color:risk' },
{ value: 'var(--button-threat-contrast)', scope: 'color:threat' },
{ value: 'var(--button-loss-contrast)', scope: 'color:loss' },
...paletteContrastDecls()
{ value: 'var(--button-loss-contrast)', scope: 'color:loss' }
]
}
},

@ -45,6 +45,7 @@ import {
type ThemeColorSet,
type TypographyPrimitiveSet
} from './config-types'
import { PALETTE_SCALES } from './types'
import { createEidosCssContract } from './contract'
import { toKebab } from './utils'
import { BUILTIN_LOOP_PRESETS } from './motion/presets/css'
@ -1731,6 +1732,62 @@ interface NormalizedDeclaration {
* Normalize a component's tokens: convert each entry to its list of
* declarations, infer var()-driven dependencies, merge explicit deps.
*/
// ── Universal per-instance palette cascade (THEMING §25.5) ────────────────
//
// `palette-{slot}` is RESERVED recipe vocabulary: any recipe token named
// `palette-track` / `-element` / `-border` / `-solid` / `-solid-hover` /
// `-text` / `-contrast` automatically receives the full `color:{scale}`
// cascade for every donor scale in `PALETTE_SCALES` — the generator appends
// the missing scales, so `<X color="teal">` resolves on EVERY recipe that
// exposes the indirection, never on a hand-maintained subset (the 31-vs-33
// drift class). Author-declared `color:*` declarations win (they are kept;
// only absent scales are appended). The step map mirrors
// `DEFAULT_COLOR_ROLE_SLOT_STEPS`; the fallback keeps a purged app on the
// primary role instead of broken chrome.
const PALETTE_SLOT_STEP: Readonly<Record<string, string>> = {
track: '1',
element: '3',
border: '7',
solid: '9',
'solid-hover': '10',
text: '11'
}
// Scales whose step-9 solid is light enough that white on-solid text fails
// the contrast floor → dark on-solid text (Radix's documented light-9 set +
// our custom light gold). The role loop computes this with APCA; for the raw
// scale cascade we use the known set.
const LIGHT_SOLID_SCALES = new Set<string>(['amber', 'yellow', 'lime', 'mint', 'sky', 'gold'])
/** The declarations the generator appends for one `palette-*` recipe token. */
function universalPaletteDecls(
name: string,
existing: readonly RecipeTokenDeclaration[]
): RecipeTokenDeclaration[] {
if (!name.startsWith('palette-')) return []
const slot = name.slice('palette-'.length)
if (slot !== 'contrast' && !(slot in PALETTE_SLOT_STEP)) return []
const declared = new Set<string>()
for (const decl of existing) {
const scope = normalizeScopeAlias(decl.scope ?? 'root')
if (typeof scope === 'string' && scope.startsWith('color:')) declared.add(scope)
}
const missing = PALETTE_SCALES.filter((scale) => !declared.has(`color:${scale}`))
if (slot === 'contrast') {
return missing.map((scale) => ({
value: LIGHT_SOLID_SCALES.has(scale)
? 'var(--color-content-on-solid-contrast)'
: 'var(--color-content-on-solid)',
scope: `color:${scale}` as AtomicScope
}))
}
const step = PALETTE_SLOT_STEP[slot]
return missing.map((scale) => ({
value: `var(--scale-${scale}-${step}, var(--color-primary-${slot}))`,
scope: `color:${scale}` as AtomicScope
}))
}
function normalizeRecipeTokens(
component: string,
tokens: Readonly<Record<string, RecipeTokenValue>>
@ -1742,7 +1799,8 @@ function normalizeRecipeTokens(
// belongs to the primitives layer (not to the icon recipe itself).
const declaredNames = new Set(Object.keys(tokens))
for (const [name, raw] of Object.entries(tokens)) {
const { declarations, parts } = expandToDeclarationsWithParts(raw)
const { declarations: authored, parts } = expandToDeclarationsWithParts(raw)
const declarations = authored.concat(universalPaletteDecls(name, authored))
const decls: NormalizedDeclaration[] = declarations.map((decl) => ({
value: decl.value,
scope: normalizeScopeAlias(decl.scope ?? 'root'),

@ -23,6 +23,7 @@
*/
import type { CompiledMorfo } from '../morfo/compile'
import { PALETTE_SCALES } from './lib/types'
export interface LintAttrRef {
readonly attr: string
@ -152,6 +153,15 @@ function classify(
}
const allowed = currentPartAttrs.get(ref.attr)!
if (ref.value !== undefined && allowed.size > 0 && !allowed.has(ref.value)) {
// The universal palette cascade (render-css `universalPaletteDecls`)
// extends `data-color` with the donor scales — an eidos-owned axis
// extension (TSC scope `color:*`). The morfo enum keeps declaring
// the SEMANTIC roles; the scale values are sanctioned eidos-only
// vocabulary, not drift.
if (ref.attr === 'data-color' && (PALETTE_SCALES as readonly string[]).includes(ref.value)) {
eidosOnlyAttrs.push(ref)
continue
}
invalidReason = `[${ref.attr}='${ref.value}'] is not in the morfo enum {${[...allowed].map((v) => `'${v}'`).join(', ')}} for part "${currentPart}".`
}
seenContract.add(`${currentPart}|${ref.attr}|${ref.value ?? ''}`)

@ -6,7 +6,7 @@ import { THEME_BASE_RECIPE_TOKENS } from './lib/recipes/base'
import type { EidosConfig, RecipeTokenSet, RecipeTokenValue } from './lib/config-types'
import { renderStaticCss } from './lib/render-css'
import { createThemeBaseEidosConfig } from './lib/themes/base'
import { EIDOS_VARIANTS, EIDOS_VARIANT_VALUES } from './lib/types'
import { EIDOS_VARIANTS, EIDOS_VARIANT_VALUES, PALETTE_SCALES } from './lib/types'
/**
* Build a minimal EidosConfig whose recipes are the given map. Reuses the
@ -319,6 +319,35 @@ describe('Eidos recipe CSS contract', () => {
expect(violations).toEqual([])
})
/**
* Universal palette cascade (`universalPaletteDecls`, render-css.ts).
*
* `palette-{slot}` is RESERVED recipe vocabulary: the generator appends
* the full per-scale `color:{scale}` cascade for every donor scale in
* `PALETTE_SCALES`. A hand-maintained subset is exactly how the 31-vs-33
* drift shipped (fuchsia/steel silently missing); pinning the LAST scale
* of the const means a grown palette that misses the generator fails here.
*/
it('every palette-{slot} recipe token resolves the FULL scale cascade in generated css', () => {
const generated = readFileSync('src/uix/eidos/generated/base.css', 'utf8')
const lastScale = PALETTE_SCALES[PALETTE_SCALES.length - 1]
const missing: string[] = []
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
if (WIP_TRACKS.has(component)) continue
const hasPalette = tokenKeys(tokens).some((k) =>
/^palette-(track|element|border|solid|solid-hover|text|contrast)$/.test(k)
)
if (!hasPalette) continue
if (!generated.includes(`[data-${component}][data-color='${lastScale}']`)) {
missing.push(
`${component}: generated/base.css lacks [data-${component}][data-color='${lastScale}'] — ` +
`the universal palette cascade did not run for its palette-* tokens`
)
}
}
expect(missing).toEqual([])
})
/**
* Variant canon enforcement (per-component).
*

Loading…
Cancel
Save

Powered by TurnKey Linux.