feat(theming)!: FIRMA B' - la cascada de paleta es una ESCALERA sin empates

suelo :where([data-{c}]) (0,0,0) < forward [data-{c}]:where([data-color],
[data-color-custom]) (0,1,0) < tono [data-{c}][data-color='X'] (0,2,0).
El orden de emision deja de decidir: la especificidad ES el contrato,
legible en el artefacto (doctrina hermana de la FIRMA 12.9).

- Emisor (render-css.ts): isPaletteSlotToken PARTE el bucket host - solo
  las ranuras de paleta bajan al suelo, el chasis del componente queda a
  (0,1,0). 49 suelos + 49 forwards reescritos + 0 pares viejos; bloques
  por tono (112), capa compartida y gradient finish byte-identicos.
- Neutra en pixel, MEDIDO: ~297.000 valores computados antes/despues
  (105.248 planos lotes A+B, 57.720 planos lote C, 134.464 con los tonos
  ESTAMPADOS en las 49 unidades, incluidos 5 @active), 0 diffs reales.
  Los 17 crudos, probados ruido por reproduccion sobre codigo identico.
- Guarda por MUTACION (active-eidos-config.test.ts): suelo con contenido,
  forward de un peldano, par viejo ausente, tono sin envolver, particion
  del host, cierre estructural (toda declaracion de paleta vive en un
  peldano) - 6 mutaciones inyectadas, 6 mordidas. El contrato del forward
  pasa de vigilar 2 recetas a 49 (privadas incluidas, multi-parte bien).
- Ledger: PALETTE_SUPERSEDED muere (describia el mundo viejo); 207 de sus
  353 claves pasan a VIVAS y las 146 restantes quedan bajo PALETTE_FLOOR
  (el tono-default estampado resuelve por el forward: quitar el atributo
  ES hablar en silencio) o TONE_UNREACHED (ningun paso del guard estampa
  tono Y hoverea), con razon medida. stepper y tag-group, barridos por
  primera vez. El detector STALE es CIEGO a patrones: la retirada fue
  manual y por estrechamiento, guiada por medida.
- Docs: changelog 53 - reference THM-2 - next-features 13 (RESUELTA) -
  CONTINUE-theming.

Supervision: 4 agentes Opus en 5 fases (baseline plano + baseline
estampado, emisor, guarda, verificacion de parque completo, docs), con
refutacion del supervisor sobre censo, emisor y guarda.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 5d71d830e9
commit 8244cb4a62

@ -1257,8 +1257,42 @@ Lo que `chart` destapó (2026-08-22):
congelar animaciones en general (Presence necesita la suya para montar), así
que es una medida A MANO cuando el nodo pulsa.
- **⚠⚠ LA CASCADA DE PALETA ANULA LOS TONOS DEL COMPONENTE — 419 claves
públicas del catálogo** (medido 2026-08-23 en `button`, `badge` y `callout`).
- **✅ ~~⚠⚠ LA CASCADA DE PALETA ANULA LOS TONOS DEL COMPONENTE — 419 claves
públicas del catálogo~~ — FIRMADA Y ARREGLADA 2026-08-24 (firma B′).** Todo lo
que sigue en esta entrada queda RESUELTO; se conserva porque es el expediente
de cómo se midió, y porque **la resolución NO fue la que la entrada proponía**.
La doctrina vive en [`theming/changelog.md`](./theming/changelog.md) §53, el
emisor en `render-css.ts` (`isPaletteSlotToken` + `renderRecipePaletteForward`)
y la guarda en `active-eidos-config.test.ts` («palette cascade is a ladder»).
**Por qué B′ (escalera) y no A (reordenar la emisión)**, que es lo que el
párrafo «Arreglo propuesto» de abajo pedía: reordenar habría dejado el
resultado dependiendo de **quién se emite antes**, que es un contrato
invisible — no se lee en el artefacto, no lo verifica un guard sin recorrer la
hoja entera, y se rompe con mover una línea. La especificidad, en cambio, **es
contrato EN el artefacto**: `:where([data-{c}])` `(0,0,0)` < `[data-{c}]:where([data-color], …)`
`(0,1,0)` < `[data-{c}][data-color='X']` `(0,2,0)`, y cualquiera que lea el CSS
ve qué gana. Es además la doctrina que §12.9 acababa de firmar para el plano:
una sola ley aplicada dos veces, no dos arreglos parecidos.
**La enmienda de `switch` de abajo se resolvió SIN caso especial.** No hizo
falta distinguir «componentes cuyo proveedor estampa siempre `data-color`»: la
escalera los cubre igual, y lo que queda de ellos es una sola frase general —
un tono por defecto ESTAMPADO resuelve por el forward, y quitar el atributo es
hablar en silencio (`PALETTE_FLOOR` en el ledger del centinela).
**Medido**: neutra en píxel sobre ~297.000 valores computados (0 diffs reales,
los 17 crudos probados ruido por reproducción sobre código idéntico) y **207
de las 353 claves adjudicadas volvieron a mover un valor computado**. Las 146
restantes ya no son supersesión: son el suelo (`PALETTE_FLOOR`) o el punto
ciego del guard (`TONE_UNREACHED`), y el ledger pasó de 17 a 28 patrones para
no volver a mezclarlas.
⚠ **Y el silencio del guard NO fue señal**: `theming-sentinel.ts` sólo detecta
STALE contra el ledger EXACTO, nunca contra `SENTINEL_PATTERN_EXCEPTIONS`. Las
207 claves resucitadas no habrían disparado ni un aviso — el estrechamiento se
hizo comparando a mano contra las 13 entradas. Vale para cualquier futura
adjudicación por patrón.
El forward emite, por componente, un bloque por tono
(`[data-button][data-color='risk'] { --button-palette-solid: var(--button-risk-solid) }`)
@ -1299,6 +1333,37 @@ Lo que `chart` destapó (2026-08-22):
`--palette-solid` sobre el nodo repinta. Amplía el alcance de la firma: no es
«los tonos no canónicos», es **los tonos**.
**Verdades residuales que dejó la firma (2026-08-24): tres, nuevas y
medidas.** Ninguna es la cascada de paleta; salieron al barrer los 15
componentes con bloques por tono, y ninguna bloquea:
1. **`stepper.neutral-text` no miente: miente el VALOR CENTINELA.** `sentinelFor`
sólo elige un color cuando la clave casa `/color|bg$|fg$|border$|ring$|…|track$/`,
y `neutral-text` no casa ninguna — así que el guard escribe `1234px`, que
es inválido para `color`, y la propiedad cae a la tinta HEREDADA. El tono
neutral de texto es prácticamente esa misma tinta, así que el fallback
aterriza en el mismo valor computado y nada se mueve. Con un color real
sobre el indicador `current`, con el tono estampado, la clave repinta.
**Es una clase, no un caso**: afectaría a cualquier `{tono}-text` cuyo tono
quede cerca de la tinta heredada. O `sentinelFor` aprende que `text$` es
una ranura de color, o cada uno paga su adjudicación escrita.
2. **Las celdas `fontSize` / `lineHeight` de `month-select` y `year-select` de
`calendar` no son medibles con la sonda.** Medido: el nodo **no responde a
`data-size`**, y la lectura resultó **99,912 % estable en cinco corridas** —
es decir, la inestabilidad no explica nada y el nodo simplemente no es el
que la sonda cree. Queda registrado como límite del instrumento, no como
token muerto: nadie retire esas claves apoyándose en esta lectura.
3. **`tag-group.hover-item-fg` lo gana el ARQUETIPO, y es un token que SOBRA.**
`archetypes.css` pinta `[data-archetype='item']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on'])`
a `(0,4,0)` y la receta escribe `[data-tag-group-item]:hover:not([data-disabled])`
a `(0,3,0)`: la tinta al puntero es la del SISTEMA, nunca la de la receta.
Medido con un puntero real sobre una etiqueta real (`:hover` confirmado): el
color se queda en `oklch(0.2435 0 0)` = `--color-content-primary`, con y sin
`aria-selected`. **Misma clase que `tags-input.item-fg`.** Y por doctrina
—el hover es del sistema, nunca una invención por componente— esto no es un
token que mienta sino uno que no debería existir: **queda para su eje**, no
se toca desde aquí.
- **⚠ `combobox.content-font-family` es el TERCER token de tipografía que el
plano `overlay` anula** (medido 2026-08-23). Su panel lleva
`data-depth='overlay'`, así que la clave no mueve nada, igual que las de

@ -3,13 +3,68 @@
## EMPIEZA AQUÍ
**El eje ya no tiene cola.** Los 16 componentes que nunca habían pasado están
cerrados o adjudicados, la deuda de artefactos está saldada, y **§12.9 quedó
FIRMADA Y EJECUTADA** — que era la decisión más cara pendiente. Global **68 %**,
**45 componentes al 100 %**.
cerrados o adjudicados, la deuda de artefactos está saldada, y **§12.9 y la
firma B′ quedaron FIRMADAS Y EJECUTADAS** — las dos decisiones más caras
pendientes. Global **68 %**, **45 componentes al 100 %**.
Lo que queda NO es ejecución: son **seis firmas** y una lista de deuda acotada.
Lo que queda NO es ejecución: son **cinco firmas** y una lista de deuda acotada.
No abras nada nuevo sin leer §«Lo que espera TU FIRMA» de aquí abajo.
### FIRMA B′ EJECUTADA (2026-08-24) — la cascada de paleta es una ESCALERA
**Qué se firmó**: los tres escritores de `--{c}-palette-*` pasan a tres peldaños
que **no empatan** — suelo `:where([data-{c}])` `(0,0,0)` < forward
`[data-{c}]:where([data-color], [data-color-custom])` `(0,1,0)` < tono
`[data-{c}][data-color='X']` `(0,2,0)`. Antes el forward genérico se emitía el
ÚLTIMO empatado a `(0,2,0)` con los bloques por tono, así que **ganaba por orden
de emisión** y 419 claves públicas de tono en 49 recetas no pintaban nunca.
**Por qué B′ y no reordenar la emisión** (que es lo que §13 proponía): el orden
es **contrato invisible** —no se lee en el artefacto, no lo verifica un guard sin
recorrer la hoja entera, se rompe moviendo una línea—. La especificidad es
**contrato EN el artefacto**. Y es la misma ley que §12.9 acababa de firmar para
el plano de profundidad: **una doctrina aplicada dos veces**, no dos parches.
**Los números**: neutra en píxel sobre **~297.000 valores computados**
comparados antes/después (70.912 + 34.336 planos en dos lotes, 57.720 en el
tercero, 134.464 con los tonos ESTAMPADOS en las 49 unidades) — **0 diffs
reales**, y los 17 crudos probados ruido reproduciéndolos sobre código idéntico.
**207 de 353 claves** de tono volvieron a mover un valor computado. Guarda por
**mutación** en `active-eidos-config.test.ts:1761` (6 mutaciones, 6 mordidas;
cierre estructural: toda declaración de paleta vive en uno de los tres peldaños;
anti-vacío `> 40`). Contrato del forward **ensanchado de 2 a 49 recetas** —
vigilaba sólo las de paleta pública y era ciego a las 47 privadas. Suite 35/36
(el rojo `skin-media-player` es AJENO y conocido).
**Dónde vive todo**: doctrina en [`theming/changelog.md`](../theming/changelog.md) §53 ·
contrato en [`theming/reference.md`](../theming/reference.md) (bloque THM-2) ·
emisor `src/uix/eidos/lib/render-css.ts` (`isPaletteSlotToken` parte el bucket
host — **sólo las ranuras de paleta bajan**, el chasis se queda a `(0,1,0)`) ·
guarda `active-eidos-config.test.ts` · ledger `scripts/theming-sentinel-exceptions.ts`.
**Tres leyes que deja escritas, y las tres costaron una medida**:
1. **El silencio del guard NO es señal cuando la adjudicación es por PATRÓN.**
`theming-sentinel.ts` detecta STALE sólo contra el ledger EXACTO
(`k in ledger`), nunca contra `SENTINEL_PATTERN_EXCEPTIONS`. Las 207 claves
resucitadas no dispararon ni un aviso: el estrechamiento hubo que hacerlo
comparando a mano contra las 13 entradas. La única que el guard sí vio fue
`editable.risk-border`, que era exacta.
2. **Un tono por defecto ESTAMPADO resuelve por el forward, no por el suelo.**
El envoltorio estampa `data-color` en toda instancia, la default incluida, así
que `<Button color="primary">` lee el forward y `--button-primary-solid` no
pinta. Quitar el atributo ES hablar en silencio, y entonces sí pinta (medido:
`oklch(0.5556 0.1829 305.86)` → `rgb(1, 2, 3)`). Eso es `PALETTE_FLOOR`; el
resto de supervivientes son `TONE_UNREACHED` (el guard no sabe combinar el
estampado del tono con el hover ni con la variante). Ledger 17 → **28 patrones**.
3. **Un `0/N` se investiga, no se reporta.** `switch` dio **0 de 12** claves de
tono vivas y `button` sólo 14 de 49 — y ninguna de las dos era la cascada: la
ficha de `switch` monta UN switch y arranca `unchecked` (y todas sus ranuras
cuelgan de `[data-state=checked]`), y `button` no declara `COMPONENT_OVERRIDES`,
así que no se barre `data-variant` y su ficha monta UN botón sólido. Forzando
el estado, las dos alcanzan. Es la ley 2 del §«Lo primero que debes saber»,
confirmada una vez más.
### Lo primero que debes saber mañana
1. **⚠⚠ EL ÍNDICE DE GIT ES COMPARTIDO.** Un solo `.git/index` para todas las
@ -43,11 +98,10 @@ No abras nada nuevo sin leer §«Lo que espera TU FIRMA» de aquí abajo.
vitest sí tiene; la columna `size` del censo se apaga cuando el eje sube al
TSC. Las tres piden re-verificar el ledger entero, así que son firma pequeña.
- **Lo que espera TU FIRMA** (todo medido, ninguno bloquea):
1. **La cascada de paleta** — el bloque genérico `[data-{c}][data-color]` se
emite el ÚLTIMO y anula 419 claves de tono en 49 recetas; **y donde soma
estampa `data-color` siempre, se lleva también el tono por DEFECTO**.
Ahora que §12.9 se resolvió con `:where()`, la misma herramienta puede
valer aquí — es la pregunta hermana.
1. ~~**La cascada de paleta**~~ — **✅ FIRMADA Y EJECUTADA 2026-08-24 como
firma B′**: era la pregunta hermana de §12.9 y se resolvió con la misma
herramienta, `:where()`. Todo (doctrina, contrato, emisor, guarda, ledger)
está en §«FIRMA B′ EJECUTADA» de aquí arriba. **No queda nada que firmar.**
2. **El componente COMPUESTO contra la receta que lo compone** — `form` (23),
`gradient-picker` (36), `emoji-picker` (3). **Salida barata ya probada**:
`toolbar` cede el cromo al `Button` y no pierde ni una clave, porque su

@ -2069,6 +2069,100 @@ llegaron a la hoja de estilo y no pintaron nada.
---
**Última revisión**: 2026-08-18 (§52 el contexto de tinta `data-ink`). Si algo en
este doc no coincide con el código, el código gana — pero abre un issue para que
actualicemos el doc.
## 53. La cascada de paleta es una ESCALERA — suelo `:where()` (0,0,0) < forward (0,1,0) < tono (0,2,0) (2026-08-24)
**FIRMA B′ — doctrina HERMANA de §12.9**: la misma ley, segunda aplicación. Allí
el plano de profundidad; aquí la paleta por instancia. Donde dos reglas que
significan cosas distintas empatan en especificidad, no hay decisión de diseño:
hay una moneda al aire que el orden de emisión resuelve en silencio.
**La premisa rota**. El forward de THM-2 emitía, por componente, un bloque por
tono (`[data-button][data-color='risk'] { --button-palette-solid: var(--button-risk-solid) }`)
y **al final** uno genérico
(`[data-button][data-color] { --button-palette-solid: var(--palette-solid, …) }`).
Ambos a `(0,2,0)`: ganaba el último, y el último era siempre el genérico. Toda
instancia con `data-color` resolvía por el `--palette-*` GLOBAL y **los bloques
por tono no pintaban jamás**. Medido 2026-08-23 en `button`, `badge` y `callout`:
sobre `data-color='risk'`, `--button-risk-solid` no movía nada —ni en `:root` ni
en el nodo— mientras `--palette-solid` sobre el mismo nodo repintaba. **419
claves públicas de tono** en 49 recetas: mudas, y no por deuda de nadie, sino
por el orden de emisión.
**La ley**. Tres peldaños, y **ninguno empata con otro**:
| peldaño | selector | especificidad | qué significa |
| --- | --- | --- | --- |
| **suelo** | `:where([data-{c}])` | `(0,0,0)` | lo que el componente CALLA |
| **forward** | `[data-{c}]:where([data-color], [data-color-custom])` | `(0,1,0)` | la instancia habla, y no con un tono propio del componente |
| **tono** | `[data-{c}][data-color='X']` | `(0,2,0)` | la instancia habla, y el componente tiene respuesta propia |
El suelo pinta lo que el componente calla; el tono gana donde la instancia
habla. **El orden de emisión deja de decidir** — que es exactamente lo que §12.9
firmó para el plano.
**El mecanismo** (`lib/render-css.ts`). Dos cambios, ninguno cosmético:
- `isPaletteSlotToken` **parte el bucket `host`**: sólo las declaraciones cuyo
token es una ranura de paleta bajan a `:where(...)`. El resto del chasis del
componente **se queda a `(0,1,0)`** — el censo previo contó **129
declaraciones no-paleta en los 71 bloques host** del artefacto, 23 de ellas
sólo en `[data-toggle]`, y comprobó que ninguna tenía rival a `≤ (0,1,0)` en
las 4.595 reglas del corpus CSS de eidos: bajarlas no habría movido un píxel
hoy, y se quedan igual porque ensanchar el contrato sin necesidad es deuda.
El cotejo es contra la constante de ranuras, nunca contra un regex laxo: una
ranura nueva en `PALETTE_SLOT_STEP` entra en la escalera gratis, y un token
que sólo se LLAME parecido (`palette-shadow`) no entra.
- El forward baja **un** peldaño: de la pareja `[data-{c}][data-color],
[data-{c}][data-color-custom]` a un solo compuesto
`[data-{c}]:where([data-color], [data-color-custom])`. Misma coincidencia,
`(0,1,0)` en vez de `(0,2,0)`. Eso es lo que deja a los bloques por tono
solos en `(0,2,0)` en vez de empatarlos.
Vale igual para el caso multi-parte (`select`, cuyo panel se portaliza):
suelo `:where([data-select-trigger], [data-select-content])`, forward
`[data-select-trigger]:where(…), [data-select-content]:where(…)`.
**Lo medido**. La firma es **NEUTRA EN PÍXEL**: ~297.000 valores computados
comparados antes/después —70.912 + 34.336 planos en dos lotes, 57.720 en el
tercero, y **134.464 con los tonos ESTAMPADOS en las 49 unidades**— con **0
diffs reales**. Los 17 crudos que aparecieron se probaron ruido reproduciéndolos
sobre código idéntico. Es neutra porque las filas de rol de la capa compartida
espejan `--color-{rol}-{ranura}` **1:1**, y las 373 declaraciones de tono del
artefacto resuelven a ese mismo valor: se comprobó una por una, 373 de 373.
Lo que cambia no es el píxel de hoy: es **quién puede moverlo mañana**.
**Lo que restauró**: **207 de las 353 claves** que el guard adjudicaba como
superseded pasaron a mover un valor computado en cuanto la escalera aterrizó
(`badge` 35, `card` 35, `toggle` 30, `callout`/`select`/`surface` 21 cada uno…).
Las 146 restantes NO son la vieja supersesión: son dos hechos distintos, y se
adjudican por separado (`PALETTE_FLOOR` y `TONE_UNREACHED`, ver abajo).
**La guarda**: `active-eidos-config.test.ts:1761` — *palette cascade is a ladder*.
No comprueba tres cadenas: recorre las recetas desde la config (nunca una lista
de nombres a mano, que se pudre), ancla cada peldaño en la cadena que ABRE el
bloque que el emisor escribe (nunca un `indexOf` sobre la hoja entera), y
**cierra estructuralmente**: toda declaración de paleta del artefacto vive en
uno de los tres peldaños, con anti-vacío `> 40` para que un guard que no
inspeccione nada no pase en verde. Probada por **mutación: 6 mutaciones, 6
mordidas**. El cierre se lee también contando: de las **653** declaraciones
`--{c}-palette-*` del artefacto, **140 en el suelo, 140 en el forward y 373 en
los bloques por tono** — ninguna fuera. Y el contrato del forward en `recipe-css-contract.test.ts` se
**ensanchó de 2 a 49 recetas** — vigilaba sólo las de paleta PÚBLICA (`button`,
`toggle`) y era ciego a las 47 privadas.
**El matiz nuevo, que sólo apareció al medir**: un tono por defecto **estampado
explícitamente** resuelve por el forward, no por el suelo. El envoltorio de eidos
resuelve `color` y estampa `data-color` en TODA instancia —la de por defecto
incluida—, así que en un `<Button color="primary">` el forward `(0,1,0)` tapa al
suelo `(0,0,0)` y `--button-primary-solid` no pinta. No es un defecto: **quitar
el atributo ES hablar en silencio**, y entonces el suelo pinta —medido, `--button-primary-solid`
repinta de `oklch(0.5556 0.1829 305.86)` a `rgb(1, 2, 3)`—. Esa es la razón
`PALETTE_FLOOR` del ledger del centinela, y afecta al tono default de cada
receta: `primary` en once componentes, `neutral` en `card`, `surface`, `switch`
y `toggle`.
---
**Última revisión**: 2026-08-24 (§53 la escalera de la cascada de paleta). Si algo
en este doc no coincide con el código, el código gana — pero abre un issue para
que actualicemos el doc.

@ -1495,15 +1495,28 @@ component-agnostic layer, not a per-component copy of the cascade.
per-component cascade.
- **The per-recipe forward** (`renderRecipePaletteForward`): a recipe that
declares `palette-{slot}` (public) or `_palette-{slot}` (private) tokens
gets `[data-{c}][data-color] { --{c}-palette-{slot}: var(--palette-{slot}, <host default>) }`.
gets `[data-{c}]:where([data-color], [data-color-custom]) { --{c}-palette-{slot}: var(--palette-{slot}, <host default>) }`.
It consumes its own `--{c}-palette-*` unchanged — a **private-palette recipe
gains all 33 scales with ZERO CSS change**.
- **Nesting safety**: the `[data-{c}][data-color]` **presence** guard means a
nested component with no color of its own matches only `[data-{c}]` (its
host default) and never reads a `--palette-*` a colored ancestor set — the
isolation the per-component prefix used to give, kept without the ~21 KB × N
it cost (the per-component rollout to all 17 surfaces would have been
≈ +315 KB; the shared layer is ~19 KB once).
- **The ladder** (firma B′, 2026-08-24 — changelog §53): the three writers of
`--{c}-palette-*` sit on three rungs and **no rung ties another**, so the
order of emission never decides:
| rung | selector | specificity |
| --- | --- | --- |
| floor | `:where([data-{c}])` | `(0,0,0)` |
| forward | `[data-{c}]:where([data-color], [data-color-custom])` | `(0,1,0)` |
| tone | `[data-{c}][data-color='X']` | `(0,2,0)` |
Only the palette SLOTS ride the floor (`isPaletteSlotToken`); every other
host-scoped token of the recipe stays at `(0,1,0)`. Guarded by
`active-eidos-config.test.ts` ("palette cascade is a ladder").
- **Nesting safety**: the `:where([data-color], [data-color-custom])`
**presence** guard means a nested component with no color of its own matches
only the floor (its host default) and never reads a `--palette-*` a colored
ancestor set — the isolation the per-component prefix used to give, kept
without the ~21 KB × N it cost (the per-component rollout to all 17 surfaces
would have been ≈ +315 KB; the shared layer is ~19 KB once).
Wiring a component to the scales = declare `palette-*` / `_palette-*` tokens
(host default) and consume `--{c}-palette-*` in its recipe CSS. Guarded by

@ -64,8 +64,9 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
},
'tags-input': {
// 2026-08-24. Four reasons, all measured on the real nodes with a real
// pointer; the twenty-four tone keys are the palette cascade and ride the
// PATTERN entry below.
// pointer. Of the twenty-four tone keys the old palette defect buried,
// SEVEN came back alive with firma B′; the seventeen still dead ride the
// two PATTERN entries below, and neither is the old supersession.
'item-fg':
"the ARCHETYPE ink always wins: the morfo stamps aria-selected='true' as a LITERAL on EVERY tag, so archetypes.css's `[data-archetype='item'][aria-selected='true']:not([data-state='checked']):not([data-state='on'])` (0,4,0) out-ranks the recipe's `[data-tags-input-item]` (0,1,0) on every item, at rest and always. Measured 2026-08-24: with the attribute as shipped the token moves nothing (the ink stays oklch(0.2435 0 0) = --color-content-primary, the archetype's); set aria-selected=false on the real tag and the same write reaches (rgb(1, 2, 3)). Same class as table.selected-row-fg and the listbox ink (next-features §12); the aria literal is a morfo decision and is registered, not touched from this axis",
'placeholder-fg':
@ -91,19 +92,21 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'same xxl step, on AvatarGroup; forced data-size=xxl on the real group -> reaches (margin-inline-start -33.6px -> 37px)'
},
editable: {
// 2026-08-23. Only two reasons, both measured: the transition pair IS the
// transition the guard freezes, and the twelve tone keys are the palette
// cascade (adjudicated by PATTERN below).
// 2026-08-23, revised 2026-08-24. The transition pair IS the transition the
// guard freezes. Of the twelve tone keys the old palette defect buried, TEN
// came back alive the moment firma B′ landed; only the two `primary-*` are
// still covered, by PATTERN below, and for a different reason (the floor).
'submit-fg':
'OSCILLATES: read ALIVE in 3 of 4 runs on the same HEAD (42/56) and dead in the fourth (41/56). Leaving the edit mode is what a blur does, and the guard opens by focus + prepareWith, so the surface it measures is not deterministic. Measured alive with the editor open and transitions frozen -> reaches. Without this entry the fourth run fails R-5.4 with a token that does not lie (2026-08-23)',
'transition-duration':
'the guard freezes transitions and this token IS the transition; measured unfrozen -> reaches (0.12s -> 4.321s on the three transitioned properties of the preview)',
'transition-ease':
'same freeze; measured unfrozen -> reaches (cubic-bezier(0.4, 0, 0.2, 1) -> steps(3))',
// NOT the palette pattern: this one key is ALSO read straight by the invalid
// rules, so its silence has a second, different cause — the demo is valid.
'risk-border':
'the palette path is superseded like its eleven siblings, but this key is ALSO the invalid tint, read directly by three rules; forced data-invalid on the real root -> reaches on the input AND on the control (oklch(0.8059 0.1123 59.96) -> rgb(1, 2, 3))'
'same freeze; measured unfrozen -> reaches (cubic-bezier(0.4, 0, 0.2, 1) -> steps(3))'
// `risk-border` had an entry here until 2026-08-24. It was adjudicated as
// palette-superseded with a second cause on top (it is ALSO the invalid
// tint). Firma B′ removed the first cause and the guard now reads the key
// ALIVE, so the entry went — the sentinel reported it STALE, which is the
// one class of drift the exact ledger can see on its own.
},
background: {
// 2026-08-23. Its parts are independent OPT-IN layers and only ONE pattern
@ -1058,14 +1061,10 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'transition-duration': 'the guard freezes transitions to measure and this token IS the transition; measured unfrozen -> reaches (0.12s -> 11.5s, five properties)',
'transition-ease': 'same instrument limit; measured unfrozen -> reaches (cubic-bezier(0.4, 0, 0.2, 1) -> steps(7))',
'disabled-opacity': 'no disabled button in the demo; forced data-disabled -> reaches (0.4 -> 0.137)',
'primary-track': 'the FALLBACK of the palette forward: it acts only on an instance with NO data-color, and the demo stamps data-color=primary. Removed the attribute -> reaches. See the pattern entry below for the other seven tones',
'primary-element': 'same fallback, same condition',
'primary-border': 'same fallback, same condition',
'primary-solid': 'same fallback: without data-color it repaints (oklch(0.5556 0.1829 305.86) -> rgb(1,2,3)); with data-color=primary the generic forward rule supersedes it',
'primary-solid-hover': 'same fallback, same condition',
'primary-text': 'same fallback, same condition',
'primary-contrast': 'same fallback, same condition',
'palette-track': 'a RESOLVED name, declared on [data-button] by the forward, so a :root write loses BY DESIGN - a theme moves the tone or the scale. Measured on a soft button: from :root nothing, from the node oklch(0.9932 0.0034 325.6) -> rgb(4,5,6)',
// The seven `primary-*` keys moved to a PATTERN entry below when firma B′
// (2026-08-24) replaced the order-decides defect with the ladder: they are
// the FLOOR under the forward now, one fact, not seven accidents.
'palette-track': 'a RESOLVED name, declared on the node itself — by the floor :where([data-button]) at (0,0,0) and, on a coloured instance, by the forward at (0,1,0). Either way it is a declaration ON the element, so a :root write loses BY DESIGN (an inherited value never beats one declared on the node, whatever its specificity) - a theme moves the tone or the scale. Measured on a soft button: from :root nothing, from the node oklch(0.9932 0.0034 325.6) -> rgb(4,5,6)',
'palette-element': 'same resolved name; the theme moves its coordinate',
'palette-border': 'same resolved name; the theme moves its coordinate',
'palette-text': 'same resolved name; the theme moves its coordinate'
@ -1358,24 +1357,72 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'same focus ring; measured focused -> reaches (color(srgb 0.745076 0.576585 0.894185 / 0.48) -> rgb(1, 2, 3))',
'scrollable-fade':
'TWO reasons, and either alone would be enough: the edge fade only exists under `[data-scrollable]`, an opt-in switch the stage boots OFF, and it paints through `mask-image`, which is not in the guard read set. Measured 2026-08-24 with the demo switch on and transitions frozen -> reaches (the mask reads `linear-gradient(to right, transparent, black 16px, black calc(100% - 16px), transparent)` and the write moves both stops to 1234px)'
},
// ── First sweep 2026-08-24, riding the firma B′ run. Neither component had a
// ledger before: the palette ladder is what made them measurable at all. ──
stepper: {
'transition-duration':
'the guard freezes transitions and this token IS the transition; measured unfrozen 2026-08-24 -> reaches (0.12s -> 4.321s on all four transitioned properties: background, border-color, color, box-shadow)',
'transition-ease':
'same freeze; measured unfrozen -> reaches (cubic-bezier(0.4, 0, 0.2, 1) -> steps(3) on the same four)',
// NOT a tone problem: stepper emits a `neutral` block and the ladder gives
// it to the node. The instrument is what cannot see it.
'neutral-text':
"the SENTINEL VALUE, not the token: `sentinelFor` picks a colour only for keys matching /color|bg$|fg$|border$|ring$|…|track$/, and `neutral-text` matches none of them, so the guard writes `1234px` — invalid for `color`, which then falls back to the inherited ink. Neutral's text tone IS practically that ink, so the fallback lands on the same computed value and nothing moves. Measured 2026-08-24 with a real colour on the current indicator, tone stamped: --stepper-neutral-text repaints it (rgb(1, 2, 3)). The same instrument gap would hide any `{tone}-text` whose tone sits near the inherited ink"
},
'tag-group': {
'transition-duration':
'the guard freezes transitions and this token IS the transition; measured unfrozen 2026-08-24 -> reaches on the four transitioned properties of the tag',
'transition-ease': 'same freeze; measured unfrozen -> reaches',
'solid-fg':
'the ink of the SOLID variant, and the ficha mounts its tags SOFT (measured: 12 nodes, one data-variant=soft); the guard declares no sweep for tag-group. Forced data-variant=solid on the real selected tag 2026-08-24 -> reaches (rgb(255, 255, 255) -> rgb(1, 2, 3))',
'hover-item-fg':
"the ARCHETYPE ink wins, exactly as it does on tags-input's item-fg: archetypes.css paints `[data-archetype='item']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on'])` at (0,4,0) and the recipe's `[data-tag-group-item]:hover:not([data-disabled])` is (0,3,0), so the hover ink is the system's, never the recipe's. Measured 2026-08-24 with a real pointer on a real tag (`:hover` confirmed true): the colour stays oklch(0.2435 0 0) = --color-content-primary, the archetype's, and the write moves nothing — with or without aria-selected. The hover is the system's by doctrine, so this is a token that should not exist rather than a token that lies; registered, not touched from this axis"
}
};
/**
* The one architectural fact behind every tone key that reads dead. Measured
* 2026-08-23 on button, badge and callout: the palette forward emits a block per
* tone AND, LAST, a generic `[data-{c}][data-color]` that resolves the palette
* slots from the GLOBAL `--palette-*` (which `[data-color={tone}]` fills from
* `--color-{tone}-*`). Equal specificity (0,2,0), so the last one wins: with any
* tone attribute present the per-tone keys are bypassed. On data-color=risk,
* `--{c}-risk-solid` moves nothing at :root NOR on the node, while
* `--palette-solid` and `--color-risk-solid` on the node repaint. The primary-*
* keys are that rule's FALLBACK and do act on an instance with no data-color.
* 49 recipes emit the generic block and the contract holds 419 tone keys —
* registered in next-features §13, where the fix (emission order) is proposed.
* The tone keys used to read dead for ONE reason: the palette forward's generic
* block was emitted LAST at the same (0,2,0) as the per-tone blocks, so source
* order decided and the tone blocks were bypassed. Firma B′ (2026-08-24) turned
* that cascade into a ladder in which no rung ties another:
*
* floor `:where([data-{c}])` (0,0,0)
* forward `[data-{c}]:where([data-color], …)` (0,1,0)
* tone `[data-{c}][data-color='X']` (0,2,0)
*
* The tone blocks WIN now. Measured component by component 2026-08-24 across the
* fifteen recipes that emit them: of the 353 keys these patterns adjudicated,
* 207 moved a computed value the moment the ladder landed and 146 did not. What
* is left is NOT the old supersession — it is two smaller and quite different
* facts, written apart on purpose, because one is the design working and the
* other is this guard's blind spot.
*/
/**
* ARCHITECTURAL — and the ladder working as intended. The tone a recipe uses as
* its HOST DEFAULT has no `[data-{c}][data-color='X']` block: its values ARE the
* floor. And the eidos wrapper resolves `color` and stamps `data-color` on every
* instance, the default included, so the forward at (0,1,0) always covers the
* floor at (0,0,0) and resolves from the shared `--palette-*`. The key is not
* dead — it is what the component says when the instance says nothing.
*/
const PALETTE_SUPERSEDED =
'SUPERSEDED, not missing: the generic [data-{c}][data-color] block of the palette forward is emitted LAST and wins by source order at equal specificity, so the tone keys are bypassed and the paint comes from the global --palette-* (see next-features 13, measured on button, badge and callout 2026-08-23)';
const PALETTE_FLOOR =
'THE FLOOR UNDER THE FORWARD: this tone is the recipe HOST DEFAULT, so it has no [data-{c}][data-color=X] block — its values are the floor :where([data-{c}]) at (0,0,0). soma stamps data-color on every instance, so the forward [data-{c}]:where([data-color], [data-color-custom]) at (0,1,0) always covers it and resolves from the shared --palette-*. REMOVE data-color from the same node and the key repaints — measured 2026-08-24, firma B′';
/**
* INSTRUMENTAL. A tone slot that only paints under a variant or an interaction
* state cannot be measured here, because the two passes each hold one half of
* what the slot needs: the STATIC pass stamps `data-color` (and sweeps whatever
* axes COMPONENT_OVERRIDES declares) but never hovers, and the HOVER pass hovers
* but never stamps `data-color` — the tone stamp lives only in the static pass
* (theming-sentinel.ts). Toast's `-action-hover-bg` wrote this class down first.
* Every key adjudicated with this reason was measured BY HAND on the real node,
* tone stamped, with the state it needs held open; the per-entry tail says which
* state and what moved. A guard that learns to combine the two retires these.
*/
const TONE_UNREACHED =
'ALIVE, unmeasurable by THIS guard: the slot needs its tone AND a state the guard cannot hold at once (the static pass stamps data-color but never hovers; the hover pass hovers but never stamps data-color)';
/**
* The same adjudication, written ONCE for a whole family of keys. Born with
@ -1391,34 +1438,62 @@ export const SENTINEL_PATTERN_EXCEPTIONS: Array<{
pattern: RegExp;
reason: string;
}> = [
{
component: 'button',
pattern: /^primary-(track|element|border|solid|solid-hover|text|contrast)$/,
reason:
PALETTE_FLOOR +
' — button defaults to PRIMARY. Measured 2026-08-24 on the ficha button with data-color REMOVED: --button-primary-solid repaints the fill (oklch(0.5556 0.1829 305.86) -> rgb(1, 2, 3)) and, with a real pointer on it, --button-primary-solid-hover too (oklch(0.5246 0.1755 305.39) -> rgb(1, 2, 3)). These seven replace the seven exact entries the old order-decides defect needed'
},
{
component: 'button',
pattern:
/^(secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|element|border|solid|solid-hover|text|contrast)$/,
reason: PALETTE_SUPERSEDED + ' — the seven primary-* keys carry their own entry above'
/^(secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|element|border|solid-hover|text)$/,
reason:
TONE_UNREACHED +
" — button declares no COMPONENT_OVERRIDES, so no data-variant is swept, and its ficha mounts ONE button, solid: `solid` and `contrast` are the only slices that paint there at rest, and both read ALIVE for the seven tones (14 keys the ladder gave back). Measured by hand 2026-08-24 on that same button with data-color=secondary — forced soft: --button-secondary-track repaints the background (oklch(0.9913 0.0013 286.38) -> rgb(1, 2, 3)) and --button-secondary-text the ink; forced outline: --button-secondary-border repaints; hovered: --button-secondary-solid-hover (oklch(0.6108 0.0155 272.56) -> rgb(1, 2, 3)) and, on soft, --button-secondary-element (oklch(0.956 0.004 286.32) -> rgb(1, 2, 3)). Not one of the five is dead; the stage is"
},
{
component: 'callout',
pattern: /^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|text|solid)$/,
reason: PALETTE_SUPERSEDED
pattern: /^primary-(track|text|solid)$/,
reason:
PALETTE_FLOOR +
' — callout defaults to PRIMARY; its other seven tones x three slots (21 keys) read ALIVE on this run'
},
{
component: 'checkbox',
pattern: /^primary-solid$/,
reason:
PALETTE_FLOOR +
' — checkbox defaults to PRIMARY; the secondary / neutral / affirm `-solid` keys read ALIVE on this run'
},
{
component: 'checkbox',
pattern: /^(primary|secondary|neutral|affirm)-(solid|solid-hover)$/,
reason: PALETTE_SUPERSEDED
pattern: /^(primary|secondary|neutral|affirm)-solid-hover$/,
reason:
TONE_UNREACHED +
' — the slot lives at [data-checkbox][data-variant=solid][data-state=checked]:hover. Measured 2026-08-24 on the real CHECKED box with the tone stamped and a real pointer on it: --checkbox-neutral-solid-hover repaints background and border (oklch(0.61 0 0) -> rgb(1, 2, 3))'
},
{
component: 'radio-group',
pattern: /^primary-solid$/,
reason:
PALETTE_FLOOR +
' — same shape as its twin checkbox: radio-group defaults to PRIMARY, and the secondary / neutral / affirm `-solid` keys read ALIVE on this run (they were dead under the old order-decides defect). Measured 2026-08-24 with data-color stripped from the real group -> reaches'
},
{
component: 'radio-group',
pattern: /^(primary|secondary|neutral|affirm)-(solid|solid-hover)$/,
pattern: /^(primary|secondary|neutral|affirm)-solid-hover$/,
reason:
PALETTE_SUPERSEDED +
' — the same four tones x two slots as its twin checkbox, and PRIMARY falls with them: the eidos wrapper always resolves a colour and stamps data-color, so the generic block matches on every instance and the fallback that names the component key never applies. Measured 2026-08-23 on an affirm instance: --radio-group-affirm-solid moves nothing (oklch(0.6491 0.1136 181.96) stays), --palette-solid on the same node repaints (rgb(4, 5, 6))'
TONE_UNREACHED +
' — the slot lives at [data-radio-group][data-variant=solid] [data-radio-group-item][data-state=checked]:hover, the item twin of the checkbox entry above. Measured 2026-08-24 on the real CHECKED item with the tone stamped and a real pointer on it: --radio-group-neutral-solid-hover repaints background and border (oklch(0.61 0 0) -> rgb(1, 2, 3))'
},
{
component: 'badge',
pattern:
/^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|border|solid|text|contrast)$/,
reason: PALETTE_SUPERSEDED
pattern: /^primary-(track|border|solid|text|contrast)$/,
reason:
PALETTE_FLOOR +
' — badge defaults to PRIMARY; its other seven tones x five slots (35 keys) read ALIVE on this run'
},
{
component: 'form',
@ -1428,53 +1503,78 @@ export const SENTINEL_PATTERN_EXCEPTIONS: Array<{
},
{
component: 'editable',
pattern: /^(primary|secondary|neutral|affirm|risk|threat)-(solid|border)$/,
pattern: /^primary-(solid|border)$/,
reason:
PALETTE_SUPERSEDED +
' — measured here 2026-08-23 with the mechanism in the open: the node carries --palette-solid (oklch(0.5556 …)) from the shared per-instance layer, the generic block reads it, and the fallback that names the component key never applies; strip data-color from the same instance and --editable-primary-solid paints again (rgb(1, 2, 3)). soma always stamps data-color, so no instance escapes'
PALETTE_FLOOR +
' — editable defaults to PRIMARY; its other five tones x two slots (10 keys) read ALIVE on this run, `risk-border` among them — the exact entry that key used to carry went with the fix'
},
{
component: 'surface',
pattern: /^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|solid|contrast)$/,
reason: PALETTE_SUPERSEDED
pattern: /^neutral-(track|solid|contrast)$/,
reason:
PALETTE_FLOOR +
' — surface defaults to NEUTRAL, not primary; its other seven tones x three slots (21 keys) read ALIVE on this run'
},
{
component: 'file-upload',
pattern:
/^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(solid|solid-hover|track|border|text)$/,
pattern: /^primary-(solid|solid-hover|track|border|text)$/,
reason: PALETTE_FLOOR + ' — file-upload defaults to PRIMARY'
},
{
component: 'file-upload',
pattern: /^(secondary|neutral|affirm|fulfill|risk|threat|loss)-(solid-hover|track|border|text)$/,
reason:
TONE_UNREACHED +
' — track / border / text paint ONLY on [data-file-upload-dropzone]:hover (or [data-dragging]) and solid-hover only on [data-file-upload-trigger]:hover; none of the four key names contains "hover", so not even the hover pass runs for them. Measured 2026-08-24 with a real pointer and the tone stamped, all four repaint: --file-upload-secondary-track and -border and -text on the hovered dropzone, --file-upload-secondary-solid-hover on the hovered trigger (oklch(0.6108 0.0155 272.56) -> rgb(1, 2, 3)). The seven `-solid` keys, which paint at rest on the trigger, read ALIVE'
},
{
component: 'switch',
pattern: /^neutral-(solid|solid-hover)$/,
reason:
PALETTE_SUPERSEDED +
' — the eight tones x five slots, 40 of its 97 keys. Measured 2026-08-23 on the trigger (solid) and on the hovered dropzone (track) for the eight tones: not one moves, while --palette-solid on the node repaints (oklch(0.6908 …) -> rgb(4,5,6)). Its host default is `primary`, and the demo passes color explicitly, so the fallback never applies either'
PALETTE_FLOOR +
' — switch defaults to NEUTRAL. Measured 2026-08-24 on the real track forced checked with data-color removed: --switch-neutral-solid repaints it (oklch(0.6434 0 0) -> rgb(1, 2, 3))'
},
{
component: 'switch',
pattern: /^(primary|secondary|neutral|affirm|risk|threat)-(solid|solid-hover)$/,
pattern: /^(primary|secondary|affirm|risk|threat)-(solid|solid-hover)$/,
reason:
PALETTE_SUPERSEDED +
' — and here NEUTRAL falls with the rest: soma always stamps data-color (it is the resolved colour, neutral by default), so the generic block matches on every instance and the fallback the other recipes keep alive never applies. Measured checked 2026-08-23: --switch-neutral-solid moves nothing, --palette-solid on the node repaints (oklch(0.5556 …) -> rgb(4,5,6))'
TONE_UNREACHED +
' — every palette slot of switch hangs off [data-switch][data-state=checked], its ficha mounts ONE switch and it boots UNCHECKED (measured: 1 node, data-state=unchecked), and the guard declares no data-state sweep for switch. That is the whole reason all twelve read dead while the ladder works. Forced checked with the tone stamped 2026-08-24: --switch-secondary-solid repaints the track (oklch(0.6453 0.0165 277.7) -> rgb(1, 2, 3)) and --switch-primary-solid too (oklch(0.5556 0.1829 305.86) -> rgb(1, 2, 3))'
},
{
component: 'toggle',
pattern:
/^(primary|secondary|neutral|affirm|risk|threat)-(track|element|hover|border|solid|solid-hover|text|contrast)$/,
pattern: /^neutral-(track|element|hover|border|solid|solid-hover|text|contrast)$/,
reason:
PALETTE_FLOOR +
' — toggle defaults to NEUTRAL, and the guard STAMPS data-color=neutral to test these eight, which is exactly what turns the forward on. Measured 2026-08-24 on the real toggle forced solid/on with data-color removed: --toggle-neutral-solid repaints the fill (oklch(0.6434 0 0) -> rgb(1, 2, 3))'
},
{
component: 'toggle',
pattern: /^(primary|secondary|affirm|risk|threat)-(hover|solid-hover)$/,
reason:
PALETTE_SUPERSEDED +
' — the six tones toggle forwards x eight slots, 48 of its 126 keys (it excludes fulfill/loss by doctrine: a toggle is neither completion nor irreversible loss). Measured 2026-08-23 on the real pressed toggle with transitions frozen: with data-color=primary, --toggle-primary-solid moves NOTHING (the fill stays oklch(0.5556 0.1829 305.86)) while --palette-solid on the same node repaints it (rgb(1, 2, 3)); strip data-color and --toggle-neutral-solid paints again (oklch(0.6434 0 0) -> rgb(1, 2, 3)). The neutral eight fall with the rest for a second reason worth writing down: the guard STAMPS data-color=neutral to test them, which is exactly what turns the generic block on'
TONE_UNREACHED +
' — toggle DOES sweep data-variant x data-state, which is why 30 of its 48 tone keys read alive on this run; the two hover slots are the one thing no sweep can add. Measured 2026-08-24 with a real pointer and the tone stamped: --toggle-primary-solid-hover on solid+on repaints (oklch(0.5246 0.1755 305.39) -> rgb(1, 2, 3)), and --toggle-primary-hover on outline+on — the only slice that reads it, through --toggle-outline-on-hover-bg — repaints too (oklch(0.9333 0.0386 312.65) -> rgb(1, 2, 3)). It excludes fulfill/loss by doctrine: a toggle is neither completion nor irreversible loss'
},
{
component: 'card',
pattern:
/^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|border|solid|text|contrast)$/,
pattern: /^neutral-(track|border|solid|text|contrast)$/,
reason:
PALETTE_SUPERSEDED +
' — the eight tones x five slots, 40 of its 98 keys. Measured 2026-08-23 on the real stage card with transitions frozen: with data-color=primary, --card-primary-track moves NOTHING (the surface stays oklch(0.9932 0.0034 325.6)) while --palette-track on the same node repaints it (rgb(1, 2, 3)); strip data-color and --card-neutral-track paints again (oklch(0.9911 0 0) -> rgb(1, 2, 3)). The wrapper stamps data-color on every instance, so the named fallback never applies'
PALETTE_FLOOR +
' — card defaults to NEUTRAL, not primary; its other seven tones x five slots (35 keys) read ALIVE on this run, primary-track among them (it was the headline of the old defect: with data-color=primary the surface would not move)'
},
{
component: 'tags-input',
pattern: /^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|border|text)$/,
pattern: /^primary-(track|border|text)$/,
reason:
PALETTE_SUPERSEDED +
' — the eight tones x three slots, 24 of its 91 keys. Measured 2026-08-24 on the real tag forced into data-state=active with transitions frozen: with data-color=primary (the wrapper resolves `color` and stamps it on EVERY instance, default included) --tags-input-primary-border moves NOTHING (the tag border stays oklch(0.8043 0.0918 309.69)) while --palette-border on the same node repaints it (rgb(4, 5, 6)); strip data-color and --tags-input-primary-border paints again (rgb(1, 2, 3)). The generic `[data-tags-input][data-color], [data-tags-input][data-color-custom]` block is emitted LAST at the same (0,2,0) as the eight per-tone blocks. The three `*-text` slots carry a SECOND cause on top: even unsuperseded they cannot tint the item, because the archetype ink beats them (see the item-fg entry above)'
PALETTE_FLOOR +
' — tags-input defaults to PRIMARY; the seven other `-border` keys read ALIVE on this run'
},
{
component: 'tags-input',
pattern: /^(secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|text)$/,
reason:
TONE_UNREACHED +
' — the three slots share ONE rule, [data-tags-input-item][data-state=active], and the guard sweeps data-focus / data-invalid / data-disabled for this component, never data-state; `border` reads alive because the CONTROL paints it under data-focus, which IS swept. Measured 2026-08-24 forcing data-state=active on the real tag with the tone stamped: --tags-input-secondary-track repaints it (oklch(0.9913 0.0013 286.38) -> rgb(1, 2, 3)) and --tags-input-secondary-text the ink. The eight `-text` keys carry a SECOND cause on top: the archetype ink beats the recipe on every tag (see the item-fg entry above)'
},
{
component: 'toast',
@ -1497,9 +1597,39 @@ export const SENTINEL_PATTERN_EXCEPTIONS: Array<{
},
{
component: 'select',
pattern: /^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|border|text)$/,
pattern: /^primary-(track|border|text)$/,
reason:
PALETTE_FLOOR +
' — select defaults to PRIMARY, and here the ladder is the TSC v2.2 MULTI-PART one (parts trigger + content, because the panel portals out): floor `:where([data-select-trigger], [data-select-content])`, forward `[data-select-trigger]:where([data-color], [data-color-custom]), …`, tone `[data-select-trigger][data-color=X], …`. Its other seven tones x three slots (21 keys) read ALIVE on this run'
},
// ── stepper — first sweep (2026-08-24), so nothing here is a narrowing ──
{
component: 'stepper',
pattern: /^primary-(solid|track|border|text)$/,
reason:
PALETTE_FLOOR +
' — stepper defaults to PRIMARY. Measured 2026-08-24 with data-color stripped from the real indicator: --stepper-primary-solid repaints it. Its secondary / neutral / affirm / fulfill blocks read ALIVE on this run'
},
// ── tag-group — first sweep (2026-08-24) ───────────────────────────────
{
component: 'tag-group',
pattern: /^(hover-)?primary-(solid-bg|soft-bg|soft-fg|outline-border)$/,
reason:
PALETTE_FLOOR +
' — tag-group names its slots {tone}-{variant}-{bg|fg} and defaults to PRIMARY. Measured 2026-08-24 with data-color stripped from the real tag forced selected: --tag-group-primary-soft-bg repaints it (rgba(128, 0, 223, 0.03) -> rgb(1, 2, 3)) and --tag-group-primary-soft-fg the ink (oklch(0.5168 0.1733 305.88) -> rgb(1, 2, 3))'
},
{
component: 'tag-group',
pattern: /^(hover-)?(secondary|neutral|affirm|fulfill|risk|threat|loss)-(solid-bg|outline-border)$/,
reason:
TONE_UNREACHED +
' — every palette slot of tag-group hangs off [data-state=selected] AND a data-variant, its ficha mounts ONE selected tag in the SOFT variant (measured: 12 nodes, one data-variant=soft), and the guard declares no sweep for tag-group — which is why the soft slots of the seven tones are exactly what reads alive. Measured 2026-08-24 forcing selected + the variant with the tone stamped: --tag-group-secondary-solid-bg repaints under solid (oklch(0.6453 0.0165 277.7) -> rgb(1, 2, 3)) and --tag-group-secondary-outline-border under outline (oklch(0.853 0.0111 280.45) -> rgb(1, 2, 3))'
},
{
component: 'tag-group',
pattern: /^hover-(secondary|neutral|fulfill|risk|threat|loss)-soft-bg$/,
reason:
PALETTE_SUPERSEDED +
' — the eight tones x three slots, 24 of its 103 keys, and here the cascade is the TSC v2.2 MULTI-PART one (parts trigger + content, because the panel portals out): the last block emitted is `[data-select-trigger][data-color], [data-select-content][data-color], …` reading `var(--palette-track, var(--select-primary-track))`. Measured 2026-08-24 on the open trigger, which carries data-color=primary: --select-primary-border moves NOTHING (border stays oklch(0.8043 0.0918 309.69)) while --palette-border on the same node repaints it (rgb(4, 5, 6)). soma always stamps data-color, so the named fallback never applies on any instance'
TONE_UNREACHED +
' — the soft hover of a selected tag. `hover-affirm-soft-bg` is deliberately NOT in this pattern: the ficha tag is affirm+soft, so the hover pass reaches that one and it reads alive — if a future stage stops mounting it, the key must earn its own reason rather than hide in here. Measured 2026-08-24 with a real pointer, tone stamped, tag forced selected: --tag-group-hover-secondary-soft-bg repaints it (rgba(0, 0, 51, 0.06) -> rgb(1, 2, 3))'
}
];

@ -1738,6 +1738,168 @@ describe('ActiveEidos config', () => {
// infrastructure for a future per-color cross-recipe need.
})
/**
* §B′ — THE PALETTE CASCADE IS A LADDER (signed 2026-08-24; sister doctrine
* of §12.9 above, where the depth PLANE became the floor). Three rungs, one
* step apart, and never a tie:
*
* floor `:where([data-{c}])` (0,0,0)
* forward `[data-{c}]:where([data-color], …)` (0,1,0)
* tone `[data-{c}][data-color='X']` (0,2,0)
*
* The floor paints what the component leaves unsaid; the tone wins where the
* instance speaks; no rung ties another — so the ORDER OF EMISSION stops
* deciding, which is exactly what a runtime-injected foundation and
* code-split recipe chunks cannot guarantee. The old forward
* (`[data-{c}][data-color], [data-{c}][data-color-custom]`) sat at (0,2,0),
* TIED with the tone blocks, and the coin-flip landed on source order.
*
* The recipes are walked from the config — never a hand list of names that
* rots — and every rung is anchored on the block-opening string the emitter
* writes, never on an `indexOf` into the whole sheet.
*/
it('palette cascade is a ladder: floor (0,0,0) < forward (0,1,0) < tone (0,2,0)', () => {
const css = createThemeBaseEidos().renderStaticCss();
const PALETTE_SLOTS = [
'track',
'element',
'border',
'solid',
'solid-hover',
'text',
'contrast'
];
type PaletteToken = {
declarations?: readonly { value: string; scope?: string | readonly string[] }[];
parts?: readonly string[];
};
const recipes = THEME_BASE_RECIPE_TOKENS as unknown as Readonly<
Record<string, Readonly<Record<string, PaletteToken | undefined>>>
>;
// Rules as (selector, OWN declarations): nested blocks are stripped, so an
// at-rule wrapper never takes the credit — or the blame — for what it holds.
const rules: { selector: string; body: string }[] = [];
const open: { selector: string; start: number }[] = [];
let pending = '';
for (let i = 0; i < css.length; i++) {
const ch = css[i];
if (ch === '{') {
open.push({ selector: pending.trim(), start: i + 1 });
pending = '';
} else if (ch === '}') {
const frame = open.pop();
if (frame)
rules.push({
selector: frame.selector,
body: css.slice(frame.start, i).replace(/\{[^}]*\}/g, '')
});
pending = '';
} else pending += ch;
}
const declaresPalette = (component: string) =>
new RegExp(`--_?${component}-palette-(?:${PALETTE_SLOTS.join('|')})\\s*:`);
const violations: string[] = [];
let covered = 0;
for (const [component, tokens] of Object.entries(recipes)) {
let parts: readonly string[] | undefined;
const tones = new Set<string>();
let hasPalette = false;
for (const slot of PALETTE_SLOTS) {
// Public (`palette-{slot}` → `--{c}-palette-…`, the button/toggle pair)
// and private (`_palette-{slot}` → `--_{c}-palette-…`, the other 47)
// ride the SAME ladder: same floor, same forward, same tone rung.
const token = tokens[`palette-${slot}`] ?? tokens[`_palette-${slot}`];
if (!token) continue;
hasPalette = true;
if (token.parts) parts = token.parts;
for (const decl of token.declarations ?? []) {
const atoms = typeof decl.scope === 'string' ? [decl.scope] : (decl.scope ?? []);
for (const atom of atoms)
if (atom.startsWith('color:')) tones.add(atom.slice('color:'.length));
}
}
if (!hasPalette) continue;
covered++;
// Parts-aware: a recipe whose `data-color` lives on parts (Select →
// trigger + content) climbs the ladder on those parts, not on the root.
const hosts =
parts && parts.length > 0
? parts.map((part) => `[data-${component}-${part}]`)
: [`[data-${component}]`];
// (a) FLOOR — the host palette block wears `:where(...)`, and it is the
// block that actually carries the slots (an empty floor proves nothing).
const floorSelector = `:where(${hosts.join(', ')})`;
const floor = rules.find((rule) => rule.selector === floorSelector);
if (!floor) violations.push(`${component}: no floor block \`${floorSelector} {\``);
else if (!declaresPalette(component).test(floor.body))
violations.push(`${component}: floor \`${floorSelector} {\` declares no palette slot`);
// (b) FORWARD — ONE compound rung at (0,1,0), not the old pair.
const forwardSelector = hosts
.map((host) => `${host}:where([data-color], [data-color-custom])`)
.join(', ');
if (!rules.some((rule) => rule.selector === forwardSelector))
violations.push(`${component}: no forward block \`${forwardSelector} {\``);
// (c) …and the old pair is GONE. It matched the same elements, one rung
// too high, which is the whole bug this signature closes.
for (const host of hosts)
if (css.includes(`${host}[data-color], ${host}[data-color-custom] {`))
violations.push(`${component}: the legacy (0,2,0) forward pair for ${host} is back`);
// (d) the FLAT host block keeps the component's own chrome and NOTHING of
// the palette (the bucket partition). In most recipes the host was all
// palette and the flat block is no longer emitted at all — that is legal.
for (const host of hosts) {
const flat = rules.find((rule) => rule.selector === host);
if (flat && declaresPalette(component).test(flat.body))
violations.push(`${component}: flat host \`${host} {\` still declares a palette slot`);
}
// (e) the TONE blocks stay EXACTLY where they were — (0,2,0), unwrapped.
// Mirror of the frost assertion in §12.9: same attribute, opposite intent.
// A `:where()` here would drop the tone down onto the forward and re-tie
// the ladder from the other end.
for (const tone of tones) {
const toneSelectors = hosts.map((host) => `${host}[data-color='${tone}']`);
const painted = rules.some(
(rule) =>
!rule.selector.startsWith(':where(') &&
toneSelectors.every((selector) => rule.selector.includes(selector)) &&
declaresPalette(component).test(rule.body)
);
if (!painted)
violations.push(`${component}: tone '${tone}' has no (0,2,0) block painting its palette`);
for (const host of hosts)
if (css.includes(`:where(${host}[data-color='${tone}']`))
violations.push(`${component}: tone '${tone}' is wrapped in :where() — rung lost`);
}
}
// (f) closing, structural: EVERY palette-slot declaration in the sheet sits
// on one of the three rungs. A fourth place to declare a palette is a tie
// waiting to happen, whoever emitted it.
const anyPaletteSlot = new RegExp(`--_?[a-z0-9-]+-palette-(?:${PALETTE_SLOTS.join('|')})\\s*:`);
for (const rule of rules) {
if (!anyPaletteSlot.test(rule.body)) continue;
const onARung =
rule.selector.startsWith(':where(') ||
rule.selector.includes(':where([data-color], [data-color-custom])') ||
rule.selector.includes("[data-color='");
if (!onARung)
violations.push(`palette slot declared off the ladder, in \`${rule.selector} {\``);
}
expect(violations).toEqual([]);
// A guard that inspected NOTHING would pass green. The census is part of
// the assertion: 49 recipes carry a palette today, and the number only
// grows — what must never happen is the walk matching (almost) nobody.
expect(covered).toBeGreaterThan(40);
});
it('renders raw theme CSS with a selector derived only from the theme id', () => {
const css = renderThemeCss(THEME_BASE_OPTIONS, 'base-light')

@ -5622,12 +5622,15 @@
isolation: isolate;
}
[data-avatar] {
:where([data-avatar]) {
--_avatar-palette-solid: var(--avatar-neutral-solid-bg);
--_avatar-palette-surface: var(--avatar-neutral-soft-bg);
--_avatar-palette-contrast: var(--avatar-neutral-solid-fg);
--_avatar-palette-text: var(--avatar-neutral-soft-fg);
--_avatar-palette-border: var(--avatar-neutral-outline-border);
}
[data-avatar] {
--_avatar-bg: var(--_avatar-palette-surface);
--_avatar-fg: var(--_avatar-palette-text);
--_avatar-border: transparent;
@ -5783,7 +5786,7 @@
--_avatar-badge-border: var(--avatar-loss-outline-border);
}
[data-avatar][data-color], [data-avatar][data-color-custom] {
[data-avatar]:where([data-color], [data-color-custom]) {
--_avatar-palette-surface: var(--palette-surface, var(--avatar-neutral-soft-bg));
--_avatar-palette-border: var(--palette-border, var(--avatar-neutral-outline-border));
--_avatar-palette-solid: var(--palette-solid, var(--avatar-neutral-solid-bg));
@ -5791,8 +5794,7 @@
--_avatar-palette-contrast: var(--palette-contrast, var(--avatar-neutral-solid-fg));
}
[data-badge] {
--badge-min-height: var(--badge-min-height-md);
:where([data-badge]) {
--_badge-palette-track: var(--badge-primary-track);
--_badge-palette-border: var(--badge-primary-border);
--_badge-palette-solid: var(--badge-primary-solid);
@ -5800,6 +5802,10 @@
--_badge-palette-contrast: var(--badge-primary-contrast);
}
[data-badge] {
--badge-min-height: var(--badge-min-height-md);
}
[data-badge][data-size='xs'] {
--badge-min-height: var(--badge-min-height-xs);
}
@ -5868,7 +5874,7 @@
--_badge-palette-contrast: var(--badge-loss-contrast);
}
[data-badge][data-color], [data-badge][data-color-custom] {
[data-badge]:where([data-color], [data-color-custom]) {
--_badge-palette-track: var(--palette-track, var(--badge-primary-track));
--_badge-palette-border: var(--palette-border, var(--badge-primary-border));
--_badge-palette-solid: var(--palette-solid, var(--badge-primary-solid));
@ -5900,7 +5906,7 @@
--barcode-text-fg: var(--barcode-fg);
}
[data-card] {
:where([data-card]) {
--_card-palette-track: var(--card-neutral-track);
--_card-palette-border: var(--card-neutral-border);
--_card-palette-solid: var(--card-neutral-solid);
@ -5964,7 +5970,7 @@
--_card-palette-contrast: var(--card-loss-contrast);
}
[data-card][data-color], [data-card][data-color-custom] {
[data-card]:where([data-color], [data-color-custom]) {
--_card-palette-track: var(--palette-track, var(--card-neutral-track));
--_card-palette-border: var(--palette-border, var(--card-neutral-border));
--_card-palette-solid: var(--palette-solid, var(--card-neutral-solid));
@ -5972,7 +5978,7 @@
--_card-palette-contrast: var(--palette-contrast, var(--card-neutral-contrast));
}
[data-checkbox] {
:where([data-checkbox]) {
--_checkbox-palette-solid: var(--checkbox-primary-solid);
--_checkbox-palette-solid-hover: var(--checkbox-primary-solid-hover);
--_checkbox-palette-text: var(--checkbox-indicator-fg);
@ -5993,15 +5999,18 @@
--_checkbox-palette-solid-hover: var(--checkbox-affirm-solid-hover);
}
[data-checkbox][data-color], [data-checkbox][data-color-custom] {
[data-checkbox]:where([data-color], [data-color-custom]) {
--_checkbox-palette-solid: var(--palette-solid, var(--checkbox-primary-solid));
--_checkbox-palette-solid-hover: var(--palette-solid-hover, var(--checkbox-primary-solid-hover));
--_checkbox-palette-text: var(--palette-text, var(--checkbox-indicator-fg));
}
:where([data-field]) {
--_field-palette-text: var(--color-primary-text);
}
[data-field] {
--field-segment-height: calc(var(--field-control-height-md) - var(--space-2));
--_field-palette-text: var(--color-primary-text);
}
[data-field][data-size='xs'] {
@ -6020,49 +6029,49 @@
--field-segment-height: calc(var(--field-control-height-xl) - var(--space-2));
}
[data-field][data-color], [data-field][data-color-custom] {
[data-field]:where([data-color], [data-color-custom]) {
--_field-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-form-submit], [data-form-reset] {
:where([data-form-submit], [data-form-reset]) {
--_form-palette-solid: var(--color-primary-solid);
--_form-palette-solid-hover: var(--color-primary-solid-hover);
--_form-palette-border: var(--color-primary-border);
--_form-palette-contrast: var(--color-primary-contrast);
}
[data-form-submit][data-color], [data-form-submit][data-color-custom], [data-form-reset][data-color], [data-form-reset][data-color-custom] {
[data-form-submit]:where([data-color], [data-color-custom]), [data-form-reset]:where([data-color], [data-color-custom]) {
--_form-palette-border: var(--palette-border, var(--color-primary-border));
--_form-palette-solid: var(--palette-solid, var(--color-primary-solid));
--_form-palette-solid-hover: var(--palette-solid-hover, var(--color-primary-solid-hover));
--_form-palette-contrast: var(--palette-contrast, var(--color-primary-contrast));
}
[data-image] {
:where([data-image]) {
--_image-palette-track: var(--color-primary-track);
}
[data-image][data-color], [data-image][data-color-custom] {
[data-image]:where([data-color], [data-color-custom]) {
--_image-palette-track: var(--palette-track, var(--color-primary-track));
}
[data-float-panel-content] {
:where([data-float-panel-content]) {
--_float-panel-palette-border: var(--color-neutral-border);
}
[data-float-panel-content][data-color], [data-float-panel-content][data-color-custom] {
[data-float-panel-content]:where([data-color], [data-color-custom]) {
--_float-panel-palette-border: var(--palette-border, var(--color-neutral-border));
}
[data-color-picker-trigger], [data-color-picker-content] {
:where([data-color-picker-trigger], [data-color-picker-content]) {
--_color-picker-palette-border: var(--color-primary-border);
}
[data-color-picker-trigger][data-color], [data-color-picker-trigger][data-color-custom], [data-color-picker-content][data-color], [data-color-picker-content][data-color-custom] {
[data-color-picker-trigger]:where([data-color], [data-color-custom]), [data-color-picker-content]:where([data-color], [data-color-custom]) {
--_color-picker-palette-border: var(--palette-border, var(--color-primary-border));
}
[data-button] {
:where([data-button]) {
--button-palette-track: var(--button-primary-track);
--button-palette-element: var(--button-primary-element);
--button-palette-border: var(--button-primary-border);
@ -6142,7 +6151,7 @@
--button-palette-contrast: var(--button-loss-contrast);
}
[data-button][data-color], [data-button][data-color-custom] {
[data-button]:where([data-color], [data-color-custom]) {
--button-palette-track: var(--palette-track, var(--button-primary-track));
--button-palette-element: var(--palette-element, var(--button-primary-element));
--button-palette-border: var(--palette-border, var(--button-primary-border));
@ -6172,7 +6181,7 @@
--_button-finish-ink: var(--gradient-aurora-ink, var(--color-content-on-solid));
}
[data-month-grid] {
:where([data-month-grid]) {
--_month-grid-palette-solid: var(--color-primary-solid);
--_month-grid-palette-solid-hover: var(--color-primary-solid-hover);
--_month-grid-palette-track: var(--color-primary-track);
@ -6180,7 +6189,7 @@
--_month-grid-palette-text: var(--color-primary-text);
}
[data-month-grid][data-color], [data-month-grid][data-color-custom] {
[data-month-grid]:where([data-color], [data-color-custom]) {
--_month-grid-palette-track: var(--palette-track, var(--color-primary-track));
--_month-grid-palette-border: var(--palette-border, var(--color-primary-border));
--_month-grid-palette-solid: var(--palette-solid, var(--color-primary-solid));
@ -6188,7 +6197,7 @@
--_month-grid-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-year-grid] {
:where([data-year-grid]) {
--_year-grid-palette-solid: var(--color-primary-solid);
--_year-grid-palette-solid-hover: var(--color-primary-solid-hover);
--_year-grid-palette-track: var(--color-primary-track);
@ -6196,7 +6205,7 @@
--_year-grid-palette-text: var(--color-primary-text);
}
[data-year-grid][data-color], [data-year-grid][data-color-custom] {
[data-year-grid]:where([data-color], [data-color-custom]) {
--_year-grid-palette-track: var(--palette-track, var(--color-primary-track));
--_year-grid-palette-border: var(--palette-border, var(--color-primary-border));
--_year-grid-palette-solid: var(--palette-solid, var(--color-primary-solid));
@ -6204,7 +6213,7 @@
--_year-grid-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-range-calendar] {
:where([data-range-calendar]) {
--_range-calendar-palette-solid: var(--color-primary-solid);
--_range-calendar-palette-solid-hover: var(--color-primary-solid-hover);
--_range-calendar-palette-track: var(--color-primary-track);
@ -6212,7 +6221,7 @@
--_range-calendar-palette-text: var(--color-primary-text);
}
[data-range-calendar][data-color], [data-range-calendar][data-color-custom] {
[data-range-calendar]:where([data-color], [data-color-custom]) {
--_range-calendar-palette-track: var(--palette-track, var(--color-primary-track));
--_range-calendar-palette-border: var(--palette-border, var(--color-primary-border));
--_range-calendar-palette-solid: var(--palette-solid, var(--color-primary-solid));
@ -6220,13 +6229,16 @@
--_range-calendar-palette-text: var(--palette-text, var(--color-primary-text));
}
:where([data-table-root]) {
--_table-palette-solid: var(--color-primary-solid);
--_table-palette-element: var(--color-primary-element);
}
[data-table-root] {
--table-row-height: var(--table-row-height-md);
--table-cell-padding-inline: var(--table-cell-padding-inline-md);
--table-cell-padding-block: var(--table-cell-padding-block-md);
--table-font-size: var(--table-font-size-md);
--_table-palette-solid: var(--color-primary-solid);
--_table-palette-element: var(--color-primary-element);
}
[data-table-root][data-size='xs'] {
@ -6257,18 +6269,21 @@
--table-font-size: var(--table-font-size-xl);
}
[data-table-root][data-color], [data-table-root][data-color-custom] {
[data-table-root]:where([data-color], [data-color-custom]) {
--_table-palette-element: var(--palette-element, var(--color-primary-element));
--_table-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
:where([data-tree-grid-root]) {
--_tree-grid-palette-element: var(--color-primary-element);
}
[data-tree-grid-root] {
--tree-grid-row-height: var(--tree-grid-row-height-md);
--tree-grid-row-padding-block: var(--tree-grid-row-padding-block-md);
--tree-grid-row-padding-inline: var(--tree-grid-row-padding-inline-md);
--tree-grid-indent: var(--tree-grid-indent-md);
--tree-grid-font-size: var(--tree-grid-font-size-md);
--_tree-grid-palette-element: var(--color-primary-element);
}
[data-tree-grid-root][data-size='xs'] {
@ -6303,17 +6318,20 @@
--tree-grid-font-size: var(--tree-grid-font-size-xl);
}
[data-tree-grid-root][data-color], [data-tree-grid-root][data-color-custom] {
[data-tree-grid-root]:where([data-color], [data-color-custom]) {
--_tree-grid-palette-element: var(--palette-element, var(--color-primary-element));
}
:where([data-tree-view-root]) {
--_tree-view-palette-element: var(--color-primary-element);
}
[data-tree-view-root] {
--tree-view-row-height: var(--tree-view-row-height-md);
--tree-view-row-padding-block: var(--tree-view-row-padding-block-md);
--tree-view-row-padding-inline: var(--tree-view-row-padding-inline-md);
--tree-view-indent: var(--tree-view-indent-md);
--tree-view-font-size: var(--tree-view-font-size-md);
--_tree-view-palette-element: var(--color-primary-element);
}
[data-tree-view-root][data-size='xs'] {
@ -6348,17 +6366,20 @@
--tree-view-font-size: var(--tree-view-font-size-xl);
}
[data-tree-view-root][data-color], [data-tree-view-root][data-color-custom] {
[data-tree-view-root]:where([data-color], [data-color-custom]) {
--_tree-view-palette-element: var(--palette-element, var(--color-primary-element));
}
:where([data-feed-root]) {
--_feed-palette-solid: var(--color-primary-solid);
--_feed-palette-element: var(--color-primary-element);
}
[data-feed-root] {
--feed-font-size: var(--feed-font-size-md);
--feed-article-padding-block: var(--feed-article-padding-block-md);
--feed-article-padding-inline: var(--feed-article-padding-inline-md);
--feed-thread-indent: var(--feed-thread-indent-md);
--_feed-palette-solid: var(--color-primary-solid);
--_feed-palette-element: var(--color-primary-element);
}
[data-feed-root][data-size='xs'] {
@ -6389,16 +6410,19 @@
--feed-thread-indent: var(--feed-thread-indent-xl);
}
[data-feed-root][data-color], [data-feed-root][data-color-custom] {
[data-feed-root]:where([data-color], [data-color-custom]) {
--_feed-palette-element: var(--palette-element, var(--color-primary-element));
--_feed-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
:where([data-grid-list]) {
--_grid-list-palette-solid: var(--color-primary-solid);
}
[data-grid-list] {
--grid-list-row-height: var(--grid-list-row-height-md);
--grid-list-row-padding-inline: var(--grid-list-row-padding-inline-md);
--grid-list-font-size: var(--grid-list-font-size-md);
--_grid-list-palette-solid: var(--color-primary-solid);
}
[data-grid-list][data-size='xs'] {
@ -6425,7 +6449,7 @@
--grid-list-font-size: var(--grid-list-font-size-xl);
}
[data-grid-list][data-color], [data-grid-list][data-color-custom] {
[data-grid-list]:where([data-color], [data-color-custom]) {
--_grid-list-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
@ -6486,21 +6510,24 @@
--gradient-picker-chip-size: var(--gradient-picker-chip-size-lg);
}
[data-clipboard] {
:where([data-clipboard]) {
--_clipboard-palette-solid: var(--color-primary-solid);
--_clipboard-palette-element: var(--color-primary-element);
}
[data-clipboard][data-color], [data-clipboard][data-color-custom] {
[data-clipboard]:where([data-color], [data-color-custom]) {
--_clipboard-palette-element: var(--palette-element, var(--color-primary-element));
--_clipboard-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
:where([data-carousel]) {
--_carousel-palette-solid: var(--color-primary-solid);
--_carousel-palette-element: var(--color-primary-element);
}
[data-carousel] {
--carousel-indicator-size: var(--carousel-indicator-size-md);
--carousel-indicator-gap: var(--carousel-indicator-gap-md);
--_carousel-palette-solid: var(--color-primary-solid);
--_carousel-palette-element: var(--color-primary-element);
}
[data-carousel][data-size='xs'] {
@ -6523,87 +6550,90 @@
--carousel-indicator-gap: var(--carousel-indicator-gap-xl);
}
[data-carousel][data-color], [data-carousel][data-color-custom] {
[data-carousel]:where([data-color], [data-color-custom]) {
--_carousel-palette-element: var(--palette-element, var(--color-primary-element));
--_carousel-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
[data-listbox] {
:where([data-listbox]) {
--_listbox-palette-solid: var(--color-primary-solid);
--_listbox-palette-element: var(--color-primary-element);
}
[data-listbox][data-color], [data-listbox][data-color-custom] {
[data-listbox]:where([data-color], [data-color-custom]) {
--_listbox-palette-element: var(--palette-element, var(--color-primary-element));
--_listbox-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
[data-link] {
:where([data-link]) {
--_link-palette-text: var(--color-primary-text);
--_link-palette-solid-hover: var(--color-primary-solid-hover);
}
[data-link][data-color], [data-link][data-color-custom] {
[data-link]:where([data-color], [data-color-custom]) {
--_link-palette-solid-hover: var(--palette-solid-hover, var(--color-primary-solid-hover));
--_link-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-mark] {
:where([data-mark]) {
--_mark-palette-element: var(--color-fulfill-element);
--_mark-palette-text: var(--color-primary-text);
}
[data-mark][data-color], [data-mark][data-color-custom] {
[data-mark]:where([data-color], [data-color-custom]) {
--_mark-palette-element: var(--palette-element, var(--color-fulfill-element));
--_mark-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-text] {
:where([data-text]) {
--_text-palette-text: var(--color-primary-text);
}
[data-text][data-color], [data-text][data-color-custom] {
[data-text]:where([data-color], [data-color-custom]) {
--_text-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-heading] {
:where([data-heading]) {
--_heading-palette-text: var(--color-primary-text);
}
[data-heading][data-color], [data-heading][data-color-custom] {
[data-heading]:where([data-color], [data-color-custom]) {
--_heading-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-display] {
:where([data-display]) {
--_display-palette-text: var(--color-primary-text);
}
[data-display][data-color], [data-display][data-color-custom] {
[data-display]:where([data-color], [data-color-custom]) {
--_display-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-code] {
:where([data-code]) {
--_code-palette-text: var(--color-primary-text);
}
[data-code][data-color], [data-code][data-color-custom] {
[data-code]:where([data-color], [data-color-custom]) {
--_code-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-label] {
:where([data-label]) {
--_label-palette-text: var(--color-primary-text);
}
[data-label][data-color], [data-label][data-color-custom] {
[data-label]:where([data-color], [data-color-custom]) {
--_label-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-proof-of-human-root] {
--proof-of-human-min-height: var(--proof-of-human-min-height-md);
:where([data-proof-of-human-root]) {
--_proof-of-human-palette-solid: var(--color-primary-solid);
--_proof-of-human-palette-element: var(--color-primary-element);
}
[data-proof-of-human-root] {
--proof-of-human-min-height: var(--proof-of-human-min-height-md);
}
[data-proof-of-human-root][data-size='sm'] {
--proof-of-human-min-height: var(--proof-of-human-min-height-sm);
}
@ -6612,14 +6642,17 @@
--proof-of-human-min-height: var(--proof-of-human-min-height-lg);
}
[data-proof-of-human-root][data-color], [data-proof-of-human-root][data-color-custom] {
[data-proof-of-human-root]:where([data-color], [data-color-custom]) {
--_proof-of-human-palette-element: var(--palette-element, var(--color-primary-element));
--_proof-of-human-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
:where([data-skeleton]) {
--_skeleton-palette-track: var(--color-primary-track);
}
[data-skeleton] {
--skeleton-size: var(--skeleton-size-md);
--_skeleton-palette-track: var(--color-primary-track);
}
[data-skeleton][data-size='xs'] {
@ -6638,15 +6671,18 @@
--skeleton-size: var(--skeleton-size-xl);
}
[data-skeleton][data-color], [data-skeleton][data-color-custom] {
[data-skeleton]:where([data-color], [data-color-custom]) {
--_skeleton-palette-track: var(--palette-track, var(--color-primary-track));
}
:where([data-spinner]) {
--_spinner-palette-text: var(--color-primary-text);
--_spinner-palette-track: var(--color-primary-track);
}
[data-spinner] {
--spinner-size: var(--spinner-size-md);
--spinner-thickness: var(--spinner-thickness-md);
--_spinner-palette-text: var(--color-primary-text);
--_spinner-palette-track: var(--color-primary-track);
}
[data-spinner][data-size='xs'] {
@ -6669,17 +6705,20 @@
--spinner-thickness: var(--spinner-thickness-xl);
}
[data-spinner][data-color], [data-spinner][data-color-custom] {
[data-spinner]:where([data-color], [data-color-custom]) {
--_spinner-palette-track: var(--palette-track, var(--color-primary-track));
--_spinner-palette-text: var(--palette-text, var(--color-primary-text));
}
:where([data-textarea]) {
--_textarea-palette-solid: var(--color-primary-solid);
}
[data-textarea] {
--textarea-input-font-size: var(--textarea-input-font-size-md);
--textarea-input-padding-inline: var(--textarea-input-padding-inline-md);
--textarea-input-padding-block: var(--textarea-input-padding-block-md);
--textarea-count-font-size: calc(var(--textarea-input-font-size) * 0.85);
--_textarea-palette-solid: var(--color-primary-solid);
}
[data-textarea][data-size='xs'] {
@ -6706,11 +6745,11 @@
--textarea-input-padding-block: var(--textarea-input-padding-block-xl);
}
[data-textarea][data-color], [data-textarea][data-color-custom] {
[data-textarea]:where([data-color], [data-color-custom]) {
--_textarea-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
[data-calendar] {
:where([data-calendar]) {
--_calendar-palette-solid: var(--color-primary-solid);
--_calendar-palette-solid-hover: var(--color-primary-solid-hover);
--_calendar-palette-track: var(--color-primary-track);
@ -6718,7 +6757,7 @@
--_calendar-palette-text: var(--color-primary-text);
}
[data-calendar][data-color], [data-calendar][data-color-custom] {
[data-calendar]:where([data-color], [data-color-custom]) {
--_calendar-palette-track: var(--palette-track, var(--color-primary-track));
--_calendar-palette-border: var(--palette-border, var(--color-primary-border));
--_calendar-palette-solid: var(--palette-solid, var(--color-primary-solid));
@ -6726,13 +6765,16 @@
--_calendar-palette-text: var(--palette-text, var(--color-primary-text));
}
:where([data-radio-group]) {
--_radio-group-palette-solid: var(--radio-group-primary-solid);
--_radio-group-palette-solid-hover: var(--radio-group-primary-solid-hover);
--_radio-group-palette-text: var(--radio-group-dot-fg);
}
[data-radio-group] {
--radio-group-item-size: var(--radio-group-item-size-md);
--radio-group-dot-size: var(--radio-group-dot-size-md);
--radio-group-font-size: var(--radio-group-font-size-md);
--_radio-group-palette-solid: var(--radio-group-primary-solid);
--_radio-group-palette-solid-hover: var(--radio-group-primary-solid-hover);
--_radio-group-palette-text: var(--radio-group-dot-fg);
}
[data-radio-group][data-size='xs'] {
@ -6774,7 +6816,7 @@
--_radio-group-palette-solid-hover: var(--radio-group-affirm-solid-hover);
}
[data-radio-group][data-color], [data-radio-group][data-color-custom] {
[data-radio-group]:where([data-color], [data-color-custom]) {
--_radio-group-palette-solid: var(--palette-solid, var(--radio-group-primary-solid));
--_radio-group-palette-solid-hover: var(--palette-solid-hover, var(--radio-group-primary-solid-hover));
--_radio-group-palette-text: var(--palette-text, var(--radio-group-dot-fg));
@ -6810,7 +6852,7 @@
--rating-group-font-size: var(--rating-group-font-size-xl);
}
[data-select-trigger], [data-select-content] {
:where([data-select-trigger], [data-select-content]) {
--_select-palette-track: var(--select-primary-track);
--_select-palette-border: var(--select-primary-border);
--_select-palette-text: var(--select-primary-text);
@ -6858,13 +6900,13 @@
--_select-palette-text: var(--select-loss-text);
}
[data-select-trigger][data-color], [data-select-trigger][data-color-custom], [data-select-content][data-color], [data-select-content][data-color-custom] {
[data-select-trigger]:where([data-color], [data-color-custom]), [data-select-content]:where([data-color], [data-color-custom]) {
--_select-palette-track: var(--palette-track, var(--select-primary-track));
--_select-palette-border: var(--palette-border, var(--select-primary-border));
--_select-palette-text: var(--palette-text, var(--select-primary-text));
}
[data-file-upload] {
:where([data-file-upload]) {
--_file-upload-palette-solid: var(--file-upload-primary-solid);
--_file-upload-palette-solid-hover: var(--file-upload-primary-solid-hover);
--_file-upload-palette-track: var(--file-upload-primary-track);
@ -6928,7 +6970,7 @@
--_file-upload-palette-text: var(--file-upload-loss-text);
}
[data-file-upload][data-color], [data-file-upload][data-color-custom] {
[data-file-upload]:where([data-color], [data-color-custom]) {
--_file-upload-palette-track: var(--palette-track, var(--file-upload-primary-track));
--_file-upload-palette-border: var(--palette-border, var(--file-upload-primary-border));
--_file-upload-palette-solid: var(--palette-solid, var(--file-upload-primary-solid));
@ -6936,7 +6978,7 @@
--_file-upload-palette-text: var(--palette-text, var(--file-upload-primary-text));
}
[data-tag-group] {
:where([data-tag-group]) {
--_tag-group-palette-solid: var(--tag-group-primary-solid-bg);
--_tag-group-palette-solid-hover: var(--tag-group-hover-primary-solid-bg);
--_tag-group-palette-surface: var(--tag-group-primary-soft-bg);
@ -7008,7 +7050,7 @@
--_tag-group-palette-border: var(--tag-group-loss-outline-border);
}
[data-tag-group][data-color], [data-tag-group][data-color-custom] {
[data-tag-group]:where([data-color], [data-color-custom]) {
--_tag-group-palette-surface: var(--palette-surface, var(--tag-group-primary-soft-bg));
--_tag-group-palette-surface-hover: var(--palette-surface-hover, var(--tag-group-hover-primary-soft-bg));
--_tag-group-palette-border: var(--palette-border, var(--tag-group-primary-outline-border));
@ -7017,7 +7059,7 @@
--_tag-group-palette-text: var(--palette-text, var(--tag-group-primary-soft-fg));
}
[data-editable] {
:where([data-editable]) {
--_editable-palette-solid: var(--editable-primary-solid);
--_editable-palette-border: var(--editable-primary-border);
--_editable-palette-solid-hover: var(--color-primary-solid-hover);
@ -7053,13 +7095,13 @@
--_editable-palette-solid-hover: var(--color-threat-solid-hover);
}
[data-editable][data-color], [data-editable][data-color-custom] {
[data-editable]:where([data-color], [data-color-custom]) {
--_editable-palette-border: var(--palette-border, var(--editable-primary-border));
--_editable-palette-solid: var(--palette-solid, var(--editable-primary-solid));
--_editable-palette-solid-hover: var(--palette-solid-hover, var(--color-primary-solid-hover));
}
[data-tags-input] {
:where([data-tags-input]) {
--_tags-input-palette-track: var(--tags-input-primary-track);
--_tags-input-palette-border: var(--tags-input-primary-border);
--_tags-input-palette-text: var(--tags-input-primary-text);
@ -7107,13 +7149,13 @@
--_tags-input-palette-text: var(--tags-input-loss-text);
}
[data-tags-input][data-color], [data-tags-input][data-color-custom] {
[data-tags-input]:where([data-color], [data-color-custom]) {
--_tags-input-palette-track: var(--palette-track, var(--tags-input-primary-track));
--_tags-input-palette-border: var(--palette-border, var(--tags-input-primary-border));
--_tags-input-palette-text: var(--palette-text, var(--tags-input-primary-text));
}
[data-metrics-icon] {
:where([data-metrics-icon]) {
--_metrics-palette-track: var(--color-primary-track);
--_metrics-palette-solid: var(--color-primary-solid);
--_metrics-palette-text: var(--color-primary-text);
@ -7121,7 +7163,7 @@
--_metrics-palette-border: var(--color-primary-border);
}
[data-metrics-icon][data-color], [data-metrics-icon][data-color-custom] {
[data-metrics-icon]:where([data-color], [data-color-custom]) {
--_metrics-palette-track: var(--palette-track, var(--color-primary-track));
--_metrics-palette-border: var(--palette-border, var(--color-primary-border));
--_metrics-palette-solid: var(--palette-solid, var(--color-primary-solid));
@ -7129,7 +7171,7 @@
--_metrics-palette-contrast: var(--palette-contrast, var(--color-primary-contrast));
}
[data-stepper] {
:where([data-stepper]) {
--_stepper-palette-solid: var(--stepper-primary-solid);
--_stepper-palette-track: var(--stepper-primary-track);
--_stepper-palette-border: var(--stepper-primary-border);
@ -7164,14 +7206,14 @@
--_stepper-palette-text: var(--stepper-fulfill-text);
}
[data-stepper][data-color], [data-stepper][data-color-custom] {
[data-stepper]:where([data-color], [data-color-custom]) {
--_stepper-palette-track: var(--palette-track, var(--stepper-primary-track));
--_stepper-palette-border: var(--palette-border, var(--stepper-primary-border));
--_stepper-palette-solid: var(--palette-solid, var(--stepper-primary-solid));
--_stepper-palette-text: var(--palette-text, var(--stepper-primary-text));
}
[data-timeline] {
:where([data-timeline]) {
--_timeline-palette-solid: var(--color-primary-solid);
--_timeline-palette-track: var(--color-primary-track);
--_timeline-palette-border: var(--color-primary-border);
@ -7179,7 +7221,7 @@
--_timeline-palette-text: var(--color-primary-text);
}
[data-timeline][data-color], [data-timeline][data-color-custom] {
[data-timeline]:where([data-color], [data-color-custom]) {
--_timeline-palette-track: var(--palette-track, var(--color-primary-track));
--_timeline-palette-border: var(--palette-border, var(--color-primary-border));
--_timeline-palette-solid: var(--palette-solid, var(--color-primary-solid));
@ -7219,7 +7261,7 @@
--media-player-control-height: var(--media-player-control-height-xl);
}
[data-surface] {
:where([data-surface]) {
--_surface-palette-track: var(--surface-neutral-track);
--_surface-palette-solid: var(--surface-neutral-solid);
--_surface-palette-contrast: var(--surface-neutral-contrast);
@ -7267,7 +7309,7 @@
--_surface-palette-contrast: var(--surface-loss-contrast);
}
[data-surface][data-color], [data-surface][data-color-custom] {
[data-surface]:where([data-color], [data-color-custom]) {
--_surface-palette-track: var(--palette-track, var(--surface-neutral-track));
--_surface-palette-solid: var(--palette-solid, var(--surface-neutral-solid));
--_surface-palette-contrast: var(--palette-contrast, var(--surface-neutral-contrast));
@ -7293,7 +7335,7 @@
--_surface-finish-ink: var(--gradient-aurora-ink, var(--color-content-on-solid));
}
[data-switch] {
:where([data-switch]) {
--_switch-palette-solid: var(--switch-neutral-solid);
--_switch-palette-solid-hover: var(--switch-neutral-solid-hover);
}
@ -7323,20 +7365,20 @@
--_switch-palette-solid-hover: var(--switch-threat-solid-hover);
}
[data-switch][data-color], [data-switch][data-color-custom] {
[data-switch]:where([data-color], [data-color-custom]) {
--_switch-palette-solid: var(--palette-solid, var(--switch-neutral-solid));
--_switch-palette-solid-hover: var(--palette-solid-hover, var(--switch-neutral-solid-hover));
}
[data-s-text] {
:where([data-s-text]) {
--_s-text-palette-text: var(--color-primary-text);
}
[data-s-text][data-color], [data-s-text][data-color-custom] {
[data-s-text]:where([data-color], [data-color-custom]) {
--_s-text-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-toggle] {
:where([data-toggle]) {
--toggle-palette-track: var(--toggle-neutral-track);
--toggle-palette-element: var(--toggle-neutral-element);
--toggle-palette-hover: var(--toggle-neutral-hover);
@ -7345,6 +7387,9 @@
--toggle-palette-solid-hover: var(--toggle-neutral-solid-hover);
--toggle-palette-text: var(--toggle-neutral-text);
--toggle-palette-contrast: var(--toggle-neutral-contrast);
}
[data-toggle] {
--toggle-solid-hover-bg: color-mix( in srgb, var(--color-surface-overlay) 68%, var(--toggle-palette-track) );
--toggle-solid-hover-border: color-mix( in srgb, var(--toggle-palette-border) 45%, var(--color-border-strong) );
--toggle-solid-on-bg: var(--toggle-palette-solid);
@ -7425,7 +7470,7 @@
--toggle-palette-contrast: var(--toggle-threat-contrast);
}
[data-toggle][data-color], [data-toggle][data-color-custom] {
[data-toggle]:where([data-color], [data-color-custom]) {
--toggle-palette-track: var(--palette-track, var(--toggle-neutral-track));
--toggle-palette-element: var(--palette-element, var(--toggle-neutral-element));
--toggle-palette-hover: var(--palette-hover, var(--toggle-neutral-hover));
@ -7481,7 +7526,7 @@
--sidebar-label-font-size: var(--sidebar-label-font-size-lg);
}
[data-callout] {
:where([data-callout]) {
--_callout-palette-track: var(--callout-primary-track);
--_callout-palette-text: var(--callout-primary-text);
--_callout-palette-solid: var(--callout-primary-solid);
@ -7529,7 +7574,7 @@
--_callout-palette-solid: var(--callout-loss-solid);
}
[data-callout][data-color], [data-callout][data-color-custom] {
[data-callout]:where([data-color], [data-color-custom]) {
--_callout-palette-track: var(--palette-track, var(--callout-primary-track));
--_callout-palette-solid: var(--palette-solid, var(--callout-primary-solid));
--_callout-palette-text: var(--palette-text, var(--callout-primary-text));
@ -7718,13 +7763,13 @@
--combobox-content-font-size: var(--combobox-content-font-size-xl);
}
[data-combobox-control], [data-combobox-input], [data-combobox-trigger], [data-combobox-content] {
:where([data-combobox-control], [data-combobox-input], [data-combobox-trigger], [data-combobox-content]) {
--_combobox-palette-track: var(--color-primary-track);
--_combobox-palette-border: var(--color-primary-border);
--_combobox-palette-text: var(--color-primary-text);
}
[data-combobox-control][data-color], [data-combobox-control][data-color-custom], [data-combobox-input][data-color], [data-combobox-input][data-color-custom], [data-combobox-trigger][data-color], [data-combobox-trigger][data-color-custom], [data-combobox-content][data-color], [data-combobox-content][data-color-custom] {
[data-combobox-control]:where([data-color], [data-color-custom]), [data-combobox-input]:where([data-color], [data-color-custom]), [data-combobox-trigger]:where([data-color], [data-color-custom]), [data-combobox-content]:where([data-color], [data-color-custom]) {
--_combobox-palette-track: var(--palette-track, var(--color-primary-track));
--_combobox-palette-border: var(--palette-border, var(--color-primary-border));
--_combobox-palette-text: var(--palette-text, var(--color-primary-text));
@ -7751,12 +7796,12 @@
--command-item-font-size: var(--command-item-font-size-lg);
}
[data-drag-drop-root] {
:where([data-drag-drop-root]) {
--_drag-drop-palette-solid: var(--color-primary-solid);
--_drag-drop-palette-element: var(--color-primary-element);
}
[data-drag-drop-root][data-color], [data-drag-drop-root][data-color-custom] {
[data-drag-drop-root]:where([data-color], [data-color-custom]) {
--_drag-drop-palette-element: var(--palette-element, var(--color-primary-element));
--_drag-drop-palette-solid: var(--palette-solid, var(--color-primary-solid));
}

@ -1851,9 +1851,29 @@ function appendRecipeDeclarations(
// parts (Select cascades via Trigger + Content), not on the root.
// Bucket-key includes parts so two scope-identical declarations
// targeting different parts emit as separate blocks.
//
// PALETTE FLOOR (firma B′, 2026-08-24 — sister doctrine of §12.9, where
// the depth plane is the FLOOR and not the ceiling). The host-scope
// declarations whose token is a palette SLOT leave the flat host block
// for a `:where(...)` wrapper of the same selector, turning the palette
// cascade into a ladder in which no rung ties another:
//
// floor `:where([data-{c}])` (0,0,0)
// forward `[data-{c}]:where([data-color], …)` (0,1,0)
// tone `[data-{c}][data-color='X']` (0,2,0)
//
// The floor paints what the component leaves unsaid; the tone block wins
// where the instance speaks; no rung ties another — so the ORDER OF
// EMISSION stops deciding. Only the palette slots descend: every other
// host declaration is the component's own chrome and stays at (0,1,0).
const byScopeKey = new Map<
string,
{ scope: TokenScope; parts: readonly string[] | undefined; lines: string[] }
{
scope: TokenScope;
parts: readonly string[] | undefined;
lines: string[];
floor: string[];
}
>();
for (const [name, decls] of normalized) {
const cssVarName = name.startsWith('_')
@ -1864,9 +1884,12 @@ function appendRecipeDeclarations(
const bucket = byScopeKey.get(key) ?? {
scope: decl.scope,
parts: decl.parts,
lines: []
lines: [],
floor: []
};
bucket.lines.push(cssVar(cssVarName, decl.value));
const target =
decl.scope === 'host' && isPaletteSlotToken(name) ? bucket.floor : bucket.lines;
target.push(cssVar(cssVarName, decl.value));
byScopeKey.set(key, bucket);
}
}
@ -1875,9 +1898,14 @@ function appendRecipeDeclarations(
const rootBucket = byScopeKey.get(rootKey);
if (rootBucket) declarations.push(...rootBucket.lines);
for (const [key, { scope, parts, lines }] of byScopeKey) {
for (const [key, { scope, parts, lines, floor }] of byScopeKey) {
if (key === rootKey) continue;
blocks.push(renderBlock(recipeScopeSelector(component, scope, parts), lines));
const selector = recipeScopeSelector(component, scope, parts);
// The floor rides exactly where the host block already sat — nothing
// else is reordered (a guard downstream depends on the shared palette
// layer still preceding the recipe blocks).
if (floor.length > 0) blocks.push(renderBlock(`:where(${selector})`, floor));
if (lines.length > 0) blocks.push(renderBlock(selector, lines));
}
// Palette forward — presence-guarded routing to the shared `--palette-*`
@ -2072,6 +2100,25 @@ const PALETTE_SLOT_STEP: Readonly<Record<string, string>> = {
/** The palette slots eligible for the shared cascade + per-recipe forward. */
const PALETTE_FORWARD_SLOTS = [...Object.keys(PALETTE_SLOT_STEP), 'contrast'];
/**
* Is this recipe token key one of the palette SLOTS that ride the shared
* `--palette-*` layer (firma B′, 2026-08-24)? Both visibilities count: the
* public `palette-{slot}` (button / toggle pattern) and the private
* `_palette-{slot}` (badge / checkbox / switch pattern) forward to the same
* layer, so both need the same FLOOR — the `:where([data-{c}])` rung the
* host bucket splits off above. Matched against the slot const, never a loose
* regex: a slot added to `PALETTE_SLOT_STEP` joins the ladder for free, and a
* token merely NAMED like one ("palette-shadow") does not.
*/
function isPaletteSlotToken(name: string): boolean {
const slot = name.startsWith('_palette-')
? name.slice('_palette-'.length)
: name.startsWith('palette-')
? name.slice('palette-'.length)
: null;
return slot !== null && PALETTE_FORWARD_SLOTS.includes(slot);
}
/**
* Per-recipe palette forward (THM-2, 2026-07-12). For a recipe that declares
* `palette-{slot}` tokens, emit ONE presence-guarded block that forwards its
@ -2120,13 +2167,16 @@ function renderRecipePaletteForward(
// `[data-color-custom]` presence — the latter carries the color-mix ramp
// derived from `--color-custom` (`renderSharedPaletteLayer`), so a raw CSS
// value flows through the same `--{c}-palette-*` forward, zero extra CSS.
//
// The guard is ONE compound at (0,1,0) — `[data-{c}]:where(…)` — and not
// the old `[data-{c}][data-color], [data-{c}][data-color-custom]` pair at
// (0,2,0): same matching, one rung down (firma B′, 2026-08-24, sister of
// §12.9). That is what leaves the tone blocks `[data-{c}][data-color='X']`
// alone at (0,2,0) instead of tying them and letting source order decide.
const hosts =
parts && parts.length > 0
? parts.flatMap((p) => [
`[data-${component}-${p}][data-color]`,
`[data-${component}-${p}][data-color-custom]`
])
: [`[data-${component}][data-color]`, `[data-${component}][data-color-custom]`];
? parts.map((p) => `[data-${component}-${p}]:where([data-color], [data-color-custom])`)
: [`[data-${component}]:where([data-color], [data-color-custom])`];
blocks.push(renderBlock(hosts.join(', '), lines));
}
return blocks;

@ -699,14 +699,37 @@ describe('Eidos recipe CSS contract', () => {
)
}
// (b) every recipe with palette-* tokens forwards to it — on BOTH the
// canonical `[data-color]` and the custom `[data-color-custom]` presence.
// canonical `[data-color]` and the custom `[data-color-custom]` presence,
// as ONE (0,1,0) compound (firma B′, 2026-08-24, sister of §12.9): the
// forward sits BELOW the tone blocks `[data-{c}][data-color='X']` (0,2,0)
// and ABOVE the `:where([data-{c}])` floor (0,0,0) — a ladder with no
// tie, in which the order of emission no longer decides.
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)
// BOTH visibilities ride the shared layer: the PUBLIC `palette-{slot}`
// (`--{c}-palette-…`, the button / toggle pair) and the PRIVATE
// `_palette-{slot}` (`--_{c}-palette-…`, the other 47). Watching only the
// public form left 47 of the 49 forwards unguarded — a private-palette
// recipe could lose its wiring in silence (firma B′, 2026-08-24).
const paletteEntries = tokenEntries(tokens).filter(([key]) =>
/^_?palette-(track|element|border|solid|solid-hover|text|contrast)$/.test(key)
)
if (!hasPalette) continue
const forward = `[data-${component}][data-color], [data-${component}][data-color-custom] {`
if (paletteEntries.length === 0) continue
// Parts-aware, like the emitter: a recipe whose `data-color` lives on
// parts (Select → trigger + content) forwards on THOSE parts, and the
// block opens with the comma-joined list of them.
const parts = paletteEntries
.map(([, value]) =>
typeof value === 'object' && 'declarations' in value ? value.parts : undefined
)
.find((candidate) => candidate && candidate.length > 0)
const hosts =
parts && parts.length > 0
? parts.map((part) => `[data-${component}-${part}]`)
: [`[data-${component}]`]
const forward = `${hosts
.map((host) => `${host}:where([data-color], [data-color-custom])`)
.join(', ')} {`
if (!generated.includes(forward)) {
missing.push(
`${component}: generated/base.css lacks the ${forward} forward — ` +

Loading…
Cancel
Save

Powered by TurnKey Linux.