feat(theming)!: FIRMA §12.9 — el plano de profundidad es el SUELO, no el techo

El plano `data-depth` pinta el bundle de elevación (superficie · borde · sombra
· tipografía on-surface) y hasta hoy lo hacía a (0,1,0), la misma especificidad
que la receta del componente. La Decisión 8 daba por bueno ese empate porque
«las recetas cargan después de la fundación y ganan por ORDEN DE CASCADA».

ESA PREMISA ERA FALSA

La fundación la inyecta ActiveEidos en runtime como <style> gestionado, y las
recetas llegan como chunks code-split de Vite: a igual peso ganaba quien
cargara el último, y se midió AL REVÉS en dev que en producción (§13). No era
una decisión de diseño, era una moneda al aire — y la moneda cayó del lado del
plano.

LO QUE COSTABA, MEDIDO

Un toast `risk` y uno `fulfill` vestían la MISMA TARJETA GRIS: los seis tonos
idénticos y la franja de acento —el rasgo que identifica la intención de un
vistazo— reducida a 0,67 px de gris neutro, porque el atajo `border` del plano
pisaba el longhand `border-inline-start` en los cuatro lados. Las TRES variantes
de tooltip computaban lo mismo: `outline` indistinguible de `solid`, `ghost` un
backdrop-filter invisible tras una superficie opaca. Y ~35 claves públicas
quedaban adjudicadas como mudas en cinco componentes, con tres recetas que
habían RETIRADO su tipografía por esto.

LA REGLA

La regla de apariencia del plano se emite envuelta en `:where(...)` —
especificidad CERO. La receta gana donde el componente HABLA, en cualquier
orden de carga; el plano sigue pintando todo lo que el componente CALLA, que es
exactamente lo que significa «baseline». Vale igual para un plano que añada un
tema (jaula abierta).

EL FROST NO BAJA, Y ES DELIBERADO

`[data-depth='{plane}'][data-frost]` conserva sus (0,2,0). El baseline es un
suelo que la receta puede pisar; el frost es una petición explícita por
elemento —alguien escribió `data-frost`— y honrarla significa ganarle al fondo
propio del componente. Mismo atributo, intención opuesta: queda comentado en el
emisor para que nadie los «armonice», y el guard lo fija por los dos lados.

QUÉ MUEVE DE PÍXEL, ENTERO

De 19 adoptantes, DIECISÉIS con diff CERO sobre ~49.000 valores computados. Los
tres que se mueven son la firma haciendo su trabajo:

- toast: 35 diffs (7 fondos + 7 bordes de tono + 21 anchuras). Cuatro tonos
  medidos en la demo real: 4 de 4 DISTINTOS donde antes 3 de 3 eran idénticos,
  y la franja de acento pasa de 0,67 px de gris a 3 px del color de la
  intención.
- tooltip: sus tres variantes vuelven a distinguirse — solid opaco con sombra,
  outline transparente con borde, ghost translúcido al 70 %.
- popover, tooltip y link-preview: `line-height` de 1.25 (el `--leading-ui` del
  plano) al que cada receta pide. Es EXACTAMENTE la decisión que la receta de
  link-preview tenía escrita desde el 2026-08-21: «se quedan declaradas A
  PROPÓSITO; retirarlas arreglaría el empate a favor del plano — una decisión
  de píxel que pertenece a la firma pendiente de §12.9».

`font-family` no se movió en ninguno: las recetas piden el mismo
`--style-label-font-family` que el plano pinta, así que la seguridad
tipográfica del portal (Decisión 8) queda intacta.

Refutada una hipótesis propia: temía que el atajo `background` del plano
estuviera matando el velo de hover del sistema y que bajarlo lo resucitara
moviendo píxel. `backgroundImage` no cambia en ninguno de los 19 — el velo vive
en los ítems, no en la superficie.

EL LEDGER

17 claves salieron STALE solas y se retiran: toast (3), tooltip (5),
float-panel (5), link-preview (2), popover (2). Los cinco vuelven verdes —
toast pasa de 51/85 a 72/85. El ledger sigue en 87 bloques, sin duplicados, y
parsea.

DOS VECES QUE EL INSTRUMENTO MINTIÓ

La sonda compartida mide UN nodo en dialog, drawer, popover, tooltip,
context-menu, dropdown-menu y link-preview: nunca abre la superficie, que es
justo donde el plano pinta, así que sus «0 diffs» no probaban nada. La medición
buena reproduce el «antes» EN RUNTIME, inyectando la regla vieja a (0,1,0) al
final del head en vez de revertir el fichero — entra como
`scripts/__plane-open.mjs`.

Y el primer barrido del ledger dio 87 de 87 rojos: «todos fallan igual» es la
firma del instrumento, y era la misma trampa del CR de Python en Windows que ya
está registrada. Re-corrido con la lista limpia.

EL BARRIDO DEL LEDGER ENTERO

87 de 87 componentes con entrada en el ledger, re-corridos tras el flip: cero
NO EFFECT sin adjudicar, cero STALE, cero errores. El cambio es global y se
verificó globalmente.

GUARDS

vitest src/uix/eidos 439/440 (el rojo conocido skin-media-player) ·
active-eidos-config 75/75 · npm run check COMPLETED con los 72 errores
preexistentes de la rama y ninguno en ficheros de este cambio · rtl:check 0 ·
docs:check 0 · eidos-lint 0 invalid en los cinco tocados · capturas de las tres
superficies que se mueven.

Un test hermano afirmaba el selector desnudo para un plano añadido por un tema:
ahora afirma `:where()`, porque un plano de tema es un suelo igual que los
cinco de fábrica.

Doctrina en docs/theming/changelog.md §29 (junto a la Decisión 8 que corrige),
cierre del expediente en next-features §13 y handoff al día.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent d4a83cca68
commit 5d71d830e9

@ -1166,6 +1166,49 @@ Lo que `chart` destapó (2026-08-22):
muertos: o el guard los mide en una pasada sin congelar, o cada uno paga su
adjudicación escrita.
- **✅ §12.9 FIRMADA Y EJECUTADA (2026-08-24) — el plano es el SUELO,
`:where()`, especificidad cero.** Todo lo que sigue en este apartado sobre el
plano queda RESUELTO; se conserva porque es el expediente de cómo se midió.
La doctrina vive en `docs/theming/changelog.md` §29 y el emisor en
`render-css.ts` (`renderDepthBlocks`), con guarda en
`active-eidos-config.test.ts`.
**Lo que restauró, medido en la demo real con motion congelada**: los seis
tonos de `toast` vuelven a pintar —4 de 4 distintos donde antes 3 de 3 eran
idénticos— y su franja de acento pasa de **0,67 px de gris neutro a 3 px del
color de la intención**; las tres variantes de `tooltip` vuelven a
distinguirse (`solid` opaco con sombra · `outline` transparente con borde ·
`ghost` translúcido al 70 %); y 17 claves adjudicadas como mudas en cinco
componentes salieron STALE solas y se retiraron del ledger.
**Lo que movió de píxel, dicho entero**: de 19 adoptantes, **16 con diff
CERO** sobre ~49.000 valores computados. Los tres que se mueven son la firma
haciendo su trabajo: `toast` (35 diffs: 7 fondos + 7 bordes de tono + 21
anchuras por la franja de 3 px), `popover` (`line-height` 17,5 → 22,4 px, y
el panel crece 26 px porque el texto respira) y —sólo visible abriendo la
superficie, que la sonda no hace— `tooltip` y `link-preview`, mismo
`line-height`. Es EXACTAMENTE la decisión que la receta de `link-preview`
tenía escrita desde el 2026-08-21: «se quedan declaradas A PROPÓSITO;
retirarlas arreglaría el empate a favor del plano — una decisión de píxel
(1.25 contra el 1.5 que esta receta pide) que pertenece a la firma pendiente
de §12.9».
**`font-family` no se movió en ninguno**: los tres recetarios piden
`var(--style-label-font-family)`, el mismo valor que el plano pinta, así que
la seguridad tipográfica del portal (Decisión 8) queda intacta.
**Refutada una hipótesis propia**: temía que el atajo `background` del plano
estuviera matando el velo de hover del sistema (que va en `:where()`, cero) y
que bajar el plano lo resucitara moviendo píxel. `backgroundImage` no cambia
en ninguno de los 19 — el velo vive en los ítems, no en la superficie.
⚠ **Al medir el plano, cuenta los nodos**: la sonda compartida mide UN nodo
en `dialog`, `drawer`, `popover`, `tooltip`, `context-menu`, `dropdown-menu` y
`link-preview` —nunca abre la superficie, que es justo donde el plano pinta—,
así que sus «0 diffs» no probaban nada. La medición buena reproduce el
«antes» **en runtime**, inyectando la regla vieja a `(0,1,0)` al final del
head, en vez de revertir el fichero (`scripts/__plane-open.mjs`).
- **⚠⚠ §12.9 tiene MECANISMO: el plano `overlay` no «impone» tipografía donde
el componente calla — GANA donde el componente habla** (medido en `menubar`,
2026-08-23). Acuñadas `panel-font-family` y `panel-line-height` sobre una

@ -1,4 +1,93 @@
# CONTINUE — eje Theming «theme-reach» (handoff, act. 2026-08-24)
# CONTINUE — eje Theming «theme-reach» (handoff, act. 2026-08-24 noche)
## 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 %**.
Lo que queda NO es ejecución: son **seis firmas** y una lista de deuda acotada.
No abras nada nuevo sin leer §«Lo que espera TU FIRMA» de aquí abajo.
### Lo primero que debes saber mañana
1. **⚠⚠ EL ÍNDICE DE GIT ES COMPARTIDO.** Un solo `.git/index` para todas las
sesiones de este worktree. Verificar con `git diff --cached` NO basta: entre
que miras y confirmas, otra sesión puede reescribirlo. **Verificación y
`git commit` van ENCADENADOS en la misma invocación de shell.** Costó un
commit con trabajo ajeno dentro y un ledger sin parsear durante cuatro
commits de HEAD. Y me lo hice yo mismo una segunda vez con un `git add`
derivado de `git diff` en vez de rutas explícitas.
2. **El instrumento miente antes que el código, y hoy mintió cinco veces.**
La sonda no congelaba nada · el guard mide UN nodo en siete portalados · un
árbol de trabajo rancio da un `0/98` falso · un grep con la frase exacta
encuentra 2 de 7 · y una lista escrita por Python en Windows lleva `\r` y
revienta el barrido entero con «todos fallan igual». **Un `0/N` o un «todos
igual» no es un resultado: es el instrumento.**
3. **Antes de retirar una clave, mira quién la lee** (paso 8.bis de §7.3, nuevo).
`scroll-area.auto-max-block` leía muerta en su demo y la consumen CINCO
recetas ajenas. Retirarla habría roto seis componentes sin que un guard se
pusiera rojo.
### Qué hay para hacer, por orden
- **Nada de tokenización.** Si aparece un componente nuevo, el protocolo §7 del
PLAN es la ley y el brief de agentes vive en el scratchpad de la sesión.
- **Deuda B — 3, cada uno por su razón**: `audio-player` (no tiene README de
eidos donde escribir «Talla y tema»), `palabras` y `proof-of-human` (carriles
propios).
- **Deuda de instrumento, registrada en §13 y sin bloquear a nadie**: el guard
congela `transition` pero NO `animation` (la sonda ya sí) y no fotografía
`transform`/`translate`; `eidos-lint` no conoce las exenciones que el test de
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.
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
receta no escribe la regla.
3. **`file-upload` barre DESCENDIENTES** (`[data-file-upload] [data-disabled]`)
y se come el token de sus huéspedes. Única ocurrencia en 162 recetas.
4. **Las cuatro claves de `avatar` fuera de gramática** (`*-color-custom*`,
la ranura de tinta es `fg`). Son contrato público: renombrar es romper.
5. **`tags-input`** sella `aria-selected='true'` como LITERAL en cada etiqueta,
así que `archetypes.css` le fija la tinta en reposo y su token no ha
pintado nunca. Tiene cara de defecto de a11y.
6. **El chip de añadir reacción de `chat-message`** lo pinta `popover.css`:
los chips de reacción son píldoras redondas y el de añadir un cuadrado gris.
### Lo último que se hizo (2026-08-24 noche): §12.9 FIRMADA
**El plano de profundidad es el SUELO, a especificidad cero.** La Decisión 8
daba por bueno el empate a `(0,1,0)` porque «las recetas cargan después de la
fundación y ganan por orden de cascada» — y esa premisa era FALSA: la fundación
se inyecta en runtime y las recetas llegan como chunks de Vite, así que ganaba
quien cargara el último, medido AL REVÉS en dev que en producción. Una moneda al
aire, no una decisión.
La regla de apariencia del plano se emite en `:where(...)`: la receta gana donde
el componente HABLA, el plano pinta lo que CALLA. **El `frost` NO baja** — es un
opt-in explícito por elemento y debe ganarle al fondo del componente.
**Restauró**: los seis tonos de `toast` (4 de 4 distintos donde antes 3 de 3
eran idénticos, y la franja de acento de 0,67 px de gris a 3 px del color de la
intención) · las tres variantes de `tooltip` · 17 claves que salieron STALE
solas del ledger. **Movió**: 16 de 19 adoptantes con diff CERO sobre ~49.000
valores; los tres restantes son la firma trabajando, incluido el `line-height`
que la receta de `link-preview` tenía escrito como pendiente de esta firma.
⚠ **Para medir el plano**: la sonda compartida mide UN nodo en los portalados y
nunca abre la superficie. El «antes» se reproduce EN RUNTIME inyectando la regla
vieja a `(0,1,0)` (`scripts/__plane-open.mjs`), nunca revirtiendo el fichero.
---
> **LO ÚLTIMO (2026-08-24): DOCE componentes en paralelo, con SUPERVISIÓN.**
> `chat-log` 77 → **100 %** (`fd8f01bac`) · `chat-composer` 79 → **100 %**
@ -115,7 +204,19 @@ encuentra `null` y diagnostica «el tono no llega», que es un defecto DISTINTO.
contador de la pestaña pasa a DERIVARSE del contrato en las 105 demos
(`9fc38b19a` + `c88b2f861`): tres mentían (`carousel` 30/27, `drag-drop`
10/9, `link-preview` 33/35).
- **Esperan tu firma**: §12.9 (ahora con coste de producto) · la cascada de
- ✅ **§12.9 FIRMADA Y EJECUTADA (2026-08-24)** — el plano es el SUELO,
`:where()`, especificidad cero: la receta gana donde el componente HABLA, en
cualquier orden de carga, y el plano sigue pintando lo que CALLA. El `frost`
NO baja (opt-in explícito por elemento). Doctrina en `changelog.md` §29,
emisor en `render-css.ts`, guarda en `active-eidos-config.test.ts`.
**Medido**: de 19 adoptantes, **16 con diff CERO** sobre ~49.000 valores; los
tres que se mueven son la firma haciendo su trabajo (`toast` recupera sus
seis tonos y una franja de acento de 3 px en vez de 0,67 px de gris;
`tooltip` sus tres variantes; `popover`/`tooltip`/`link-preview` el
`line-height` que sus recetas piden — la decisión de píxel que la receta de
`link-preview` tenía escrita como pendiente de esta firma). 17 claves
salieron STALE y se retiraron del ledger.
- **Esperan tu firma**: la cascada de
paleta (que se lleva también el tono por defecto) · el componente compuesto
contra la receta que lo compone (con la salida barata ya probada en
`toolbar`) · el barrido de descendientes de `file-upload` · las cuatro claves

@ -766,6 +766,36 @@ unificaría también la z) queda como opción futura.
> `z-index: var(--depth-…-z)`. Contrato de tokens actualizado en
> [`rfc-depth.md §5`](../rfcs/rfc-depth.md).
> **FIRMA §12.9 (2026-08-24) — EL PLANO ES EL SUELO, A ESPECIFICIDAD CERO.**
> La Decisión 8 de arriba dice que el adopter «fija su radius/font/color encima
> y gana **por orden de cascada** (las recetas cargan después de la
> fundación)». **Esa premisa era falsa**: la fundación la inyecta `ActiveEidos`
> en runtime como `<style>` gestionado, y las recetas llegan como chunks
> code-split de Vite — a igual `(0,1,0)` ganaba quien cargara el último, y se
> midió **al revés en dev que en producción** (§13). No era una decisión de
> diseño: era una moneda al aire.
>
> Lo que costó, medido: un toast `risk` y uno `fulfill` vestían **la misma
> tarjeta gris** (los seis tonos idénticos, franja de acento de 0,67 px en gris
> neutro), las **tres variantes de `tooltip`** computaban lo mismo, y ~35 claves
> públicas quedaron adjudicadas como mudas en cinco componentes. Tres recetas
> retiraron su tipografía por esto.
>
> **La regla**: la regla de apariencia del plano se emite envuelta en
> `:where([data-depth='{plane}'])` — **especificidad cero**. La receta gana
> donde el componente HABLA, en cualquier orden de carga; el plano sigue
> pintando todo lo que el componente CALLA, que es lo que significa «baseline».
> Vale igual para un plano que añada un tema (*jaula abierta*).
>
> **El `frost` NO baja**: `[data-depth='{plane}'][data-frost]` conserva sus
> `(0,2,0)`. El baseline es un suelo que la receta puede pisar; el frost es una
> petición explícita por elemento —alguien escribió `data-frost`— y honrarla
> significa ganarle al fondo propio del componente. Mismo atributo, intención
> opuesta: no se «armonizan».
>
> Guarda: `active-eidos-config.test.ts` prohíbe el selector desnudo en los
> cinco planos y exige que el frost conserve especificidad.
**Atmósfera** (frost, hecha 2026-06-05): cue `blur` por plano + regla **opt-in**
`[data-depth='{plane}'][data-frost]` → superficie translúcida
(`color-mix(surface var(--depth-{plane}-translucency, 80%), transparent)`) +

@ -0,0 +1,121 @@
/**
* §12.9 — measure the OPENED surface of every portaled adopter, before vs after.
*
* The shared probe measures 1 node on these components: it never opens them, so a
* "0 diffs" there proves nothing about the plane, which paints exactly the surface
* the probe cannot see (the 6.bis law: count the nodes before believing anything).
*
* The "before" is reproduced AT RUNTIME instead of reverting the file: inject the
* plane's appearance rules at their OLD specificity (0,1,0), appended last so they
* win by order — which is what they did in dev, measured. Whatever moves between
* the two reads is exactly what the `:where()` flip changed on that surface.
*/
import { chromium } from 'playwright';
const OLD_PLANE = `
[data-depth='raised'] { background: var(--depth-raised-surface); font-family: var(--style-label-font-family); line-height: var(--leading-ui); border: var(--border-width) solid var(--depth-raised-border); box-shadow: var(--depth-raised-shadow), var(--depth-raised-halo); }
[data-depth='overlay'] { background: var(--depth-overlay-surface); font-family: var(--style-label-font-family); line-height: var(--leading-ui); border: var(--border-width) solid var(--depth-overlay-border); box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo); }
[data-depth='modal'] { background: var(--depth-modal-surface); font-family: var(--style-label-font-family); line-height: var(--leading-ui); border: var(--border-width) solid var(--depth-modal-border); box-shadow: var(--depth-modal-shadow), var(--depth-modal-halo); }
`;
const PROPS = [
'backgroundColor',
'backgroundImage',
'borderTopColor',
'borderTopWidth',
'borderInlineStartColor',
'borderInlineStartWidth',
'boxShadow',
'fontFamily',
'lineHeight',
'color',
'borderRadius'
];
/** component → [how to open, surface selector]. `hover` for the ones that open on pointer. */
const CASES = [
['dialog', 'click', '[data-dialog-content]'],
['drawer', 'click', '[data-drawer-content]'],
['dropdown-menu', 'click', '[data-dropdown-menu-content]'],
['popover', 'click', '[data-popover-content]'],
['select', 'click', '[data-select-content]'],
['combobox', 'click', '[data-combobox-content]'],
['menubar', 'click', '[data-menubar-content]'],
['tooltip', 'hover', '[data-tooltip-content]'],
['link-preview', 'hover', '[data-link-preview-content]'],
['navigation-menu', 'hover', '[data-navigation-menu-content]'],
['context-menu', 'context', '[data-context-menu-content]']
];
const browser = await chromium.launch();
const report = [];
for (const [comp, how, surface] of CASES) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const url = `http://localhost:5180/uix/components/${comp}`;
try {
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 });
} catch {
await page.goto(url, { waitUntil: 'load', timeout: 25000 });
}
await page.waitForTimeout(1500);
await page.addStyleTag({
content: '*, *::before, *::after { transition: none !important; animation: none !important; }'
});
const trigger = page.locator(`[data-${comp}-trigger]`).first();
if (!(await trigger.count())) {
report.push({ comp, err: 'no trigger' });
await page.close();
continue;
}
if (how === 'click') await trigger.click({ timeout: 5000 }).catch(() => {});
else if (how === 'hover') await trigger.hover({ timeout: 5000 }).catch(() => {});
else await trigger.click({ button: 'right', timeout: 5000 }).catch(() => {});
await page.waitForTimeout(900);
const res = await page.evaluate(
({ surface, PROPS, OLD_PLANE }) => {
const nodes = [...document.querySelectorAll(surface)];
if (!nodes.length) return { err: 'surface not mounted' };
const snap = () =>
nodes.map((n) => {
const cs = getComputedStyle(n);
const o = {};
for (const p of PROPS) o[p] = cs[p];
return o;
});
const after = snap(); // current code = :where()
const st = document.createElement('style');
st.textContent = OLD_PLANE; // reproduce the old (0,1,0), appended last
document.head.append(st);
const before = snap();
st.remove();
const diffs = [];
after.forEach((a, i) => {
for (const p of PROPS)
if (a[p] !== before[i][p]) diffs.push({ node: i, prop: p, antes: before[i][p], despues: a[p] });
});
return { nodes: nodes.length, depth: nodes[0].getAttribute('data-depth'), diffs };
},
{ surface, PROPS, OLD_PLANE }
);
report.push({ comp, ...res });
} catch (e) {
report.push({ comp, err: String(e).slice(0, 80) });
}
await page.close();
}
await browser.close();
for (const r of report) {
if (r.err) {
console.log(`${r.comp.padEnd(18)} ⚠ ${r.err}`);
continue;
}
const head = `${r.comp.padEnd(18)} nodos=${r.nodes} depth=${r.depth} diffs=${r.diffs.length}`;
console.log(head);
for (const d of r.diffs.slice(0, 8))
console.log(` ${d.prop}: «${String(d.antes).slice(0, 46)}» → «${String(d.despues).slice(0, 46)}»`);
}

@ -522,10 +522,6 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'the demo mounts no disabled label (0 [data-disabled] nodes, counted); forced → reaches (rgb(1,2,3))'
},
'link-preview': {
'font-family':
"declared but out-ranked: the panel stamps `data-depth='overlay'`, whose plane declares font-family too at an EQUAL (0,1,0) — the generated sheet loads later, so it wins. Kept on purpose: retiring it would fix that tie in the plane's favour, a pixel decision pending signature (next-features §12.9). Measured with CDP matched-rules 2026-08-21, stable across 5 dev reloads",
'line-height':
'same load-order tie with the `overlay` plane, which imposes 1.25 where this recipe asks for --leading-normal (1.5). Declared and kept until the signature resolves who owns overlay typography',
'trigger-fg':
'the panel opens on HOVER, so measuring it REQUIRES the pointer on the trigger — and hover is exactly the state whose rule overrides the resting ink. Structural, not a demo gap: the hover token (`hover-trigger-fg`) reads live in the same run',
'trigger-underline':
@ -735,14 +731,9 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
// plane's own tokens on this node: surface, border, font and leading
// repaint it, and the plane's `box-shadow` rule is in its matched list.
// Same adjudication as `tooltip`, same open decision (PLAN 12.9).
bg: PLANE_SUPERSEDED,
border: PLANE_SUPERSEDED,
'border-width': PLANE_SUPERSEDED + ' (the plane writes the `border` shorthand)',
shadow:
PLANE_SUPERSEDED +
'; `shadow-topmost` DOES reach — its [data-topmost] rule is (0,2,0) and beats the plane (-> rgb(4,5,6))',
'font-family': PLANE_SUPERSEDED,
'line-height': PLANE_SUPERSEDED,
// Three states / parts the demo does not mount.
'active-shadow':
'only under [data-dragging] / [data-resizing]; forced -> reaches (rgba(15,23,42,0.22) 0 28px 72px -> rgb(1,2,3))',
@ -842,10 +833,6 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'overlay-bg': 'the demo boots with showOverlay off; mounted in place -> reaches (rgba(28,25,23,0.42) -> rgb(1,2,3))',
'overlay-z': 'same; mounted in place -> reaches (60 -> 4321)',
// The two that are NOT about mounting: the depth plane wins over both.
'font-family':
'the overlay DEPTH PLANE imposes font-family on the panel and beats the recipe: the section 12.9 finding, pending signature (20 components). Measured 2026-08-22, writing the token moves nothing while the plane does.',
'line-height':
'same plane, same finding: the token cannot win until the plane stops owning typography'
},
'radio-cards': {
// All five forced by hand 2026-08-22, WITH transitions frozen: the card
@ -1025,11 +1012,6 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
// not reliable enough to measure: it needs the pointer parked on the trigger
// for the whole pass).
'content-z': 'it lands on the floating WRAPPER, which carries no data-tooltip-* attr and so is outside the measured set; read on the wrapper -> reaches (90 -> 4321)',
'bg': 'SUPERSEDED by the overlay plane: [data-depth=overlay] declares background at the same specificity and later in the cascade, so the recipe loses. Measured: --tooltip-bg moves nothing, --depth-overlay-surface repaints the panel. Registered in next-features 13 with the variant fallout',
'border': 'same supersession: --depth-overlay-border repaints it, --tooltip-border does not',
'shadow': 'same supersession: --depth-overlay-shadow repaints it, --tooltip-shadow does not',
'font-family': 'same plane, the typography half of it (PLAN 12.9, unsigned)',
'line-height': 'same plane, same open decision',
'outline-arrow-bg': 'the arrow is painted through a SIBLING selector that needs the content carrying data-variant=outline AND the panel open; measured with the demo switch -> reaches (oklch(0.9911 0 0) -> rgb(1,2,3))',
'outline-arrow-border': 'same sibling rule; measured -> reaches (oklch(0.7921 0 0) -> rgb(1,2,3))'
},
@ -1335,15 +1317,6 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
// --depth-overlay-surface / -border / -shadow / --border-width on the same
// root repaint every one of them (rgb(4, 5, 6), 999px, the 9px ring). The
// tone keys ride the PATTERN entries below.
'provider-border-width':
PLANE_SUPERSEDED +
' — the plane writes the `border` SHORTHAND, so its `var(--border-width)` beats the card`s own `border: var(--toast-provider-border-width) solid …`. Measured 2026-08-24: 1234px on the token leaves borderTopWidth at 1px; 999px on --border-width moves it',
'accent-width':
PLANE_SUPERSEDED +
' — same shorthand, and this is the one that COSTS something visible: the accent stripe is a `border-inline-start` longhand, which the later shorthand overwrites on all four sides. Measured 2026-08-24: borderInlineStartWidth stays 1px under a 1234px write and follows --border-width instead',
'provider-shadow':
PLANE_SUPERSEDED +
' — and here it costs nothing at all: the key`s default IS `var(--depth-overlay-shadow), var(--depth-overlay-halo)`, the very value the plane paints, so the computed result is identical either way. Measured 2026-08-24: a 7px sentinel ring does not appear, the plane`s 9px one does',
'transition-duration':
'the guard freezes transitions and this token IS the transition; measured unfrozen -> reaches (0.12s -> 11.5s on the two transitioned properties of the card)',
'transition-ease':

@ -533,13 +533,26 @@ describe('ActiveEidos config', () => {
);
// The rule paints the visual elevation bundle (surface · border · shadow ·
// halo) + anchors the on-surface UI font (portal-safe typography, Decisión 8).
expect(css).toContain("[data-depth='overlay'] {");
expect(css).toContain(":where([data-depth='overlay']) {");
expect(css).toContain('box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);');
expect(css).toContain('font-family: var(--style-label-font-family);');
// z stays a TOKEN (asserted above) but is NOT applied as `z-index` in the
// bundle rule — positioning is the floating wrapper's job, not the elevation
// bundle (Decisión 8). Lock that the rule never re-adds it.
expect(css).not.toContain('z-index: var(--depth-overlay-z);');
// §12.9 (signed 2026-08-24) — THE PLANE IS THE FLOOR. Its appearance bundle
// is emitted at specificity ZERO so the recipe wins wherever it speaks, in
// any load order; the premise of Decisión 8 ("recipes load after the
// foundation") does not hold when the foundation is injected at runtime and
// recipes are code-split chunks. Lock the selector: a bare
// `[data-depth='{plane}'] {` would silently restore the coin-flip that made
// a `risk` toast and a `fulfill` toast wear the same grey card.
for (const plane of ['flush', 'raised', 'overlay', 'modal', 'recessed'])
expect(css).not.toContain(`\n[data-depth='${plane}'] {`);
// …but frost is a per-element OPT-IN and must still beat the component's own
// background, so it keeps its specificity. Same attribute, opposite intent.
expect(css).toContain("[data-depth='overlay'][data-frost] {");
expect(css).not.toContain(":where([data-depth='overlay'][data-frost])");
// a plane without a halo (flush) keeps a single-value box-shadow — no dangling comma
expect(css).toContain('box-shadow: var(--depth-flush-shadow);');
});
@ -551,7 +564,9 @@ describe('ActiveEidos config', () => {
};
const css = createEidos(cfg).renderStaticCss();
expect(css).toContain('--depth-sheet-shadow: 0 -8px 30px rgba(0, 0, 0, 0.2);');
expect(css).toContain("[data-depth='sheet'] {");
// A plane a THEME adds is a floor like the built-in ones (§12.9, 2026-08-24):
// same `:where()` zero specificity, so an adopting recipe overrides it too.
expect(css).toContain(":where([data-depth='sheet']) {");
});
it('emits the Fase 4 frost atmosphere (opt-in [data-frost] + blur tokens)', () => {

@ -7788,11 +7788,11 @@
--navigation-menu-list-gap: var(--navigation-menu-list-gap-lg);
}
[data-depth='flush'] {
:where([data-depth='flush']) {
box-shadow: var(--depth-flush-shadow);
}
[data-depth='raised'] {
:where([data-depth='raised']) {
background: var(--depth-raised-surface);
font-family: var(--style-label-font-family);
line-height: var(--leading-ui);
@ -7800,7 +7800,7 @@
box-shadow: var(--depth-raised-shadow), var(--depth-raised-halo);
}
[data-depth='overlay'] {
:where([data-depth='overlay']) {
background: var(--depth-overlay-surface);
font-family: var(--style-label-font-family);
line-height: var(--leading-ui);
@ -7814,7 +7814,7 @@
-webkit-backdrop-filter: blur(var(--depth-overlay-blur));
}
[data-depth='modal'] {
:where([data-depth='modal']) {
background: var(--depth-modal-surface);
font-family: var(--style-label-font-family);
line-height: var(--leading-ui);
@ -7828,7 +7828,7 @@
-webkit-backdrop-filter: blur(var(--depth-modal-blur));
}
[data-depth='recessed'] {
:where([data-depth='recessed']) {
background: var(--depth-recessed-surface);
font-family: var(--style-label-font-family);
line-height: var(--leading-ui);

@ -1461,11 +1461,26 @@ function renderDepthBlocks(depth: DepthPrimitiveSet): string[] {
if (cues.shadow !== undefined) shadowParts.push(`var(--depth-${plane}-shadow)`);
if (cues.halo !== undefined) shadowParts.push(`var(--depth-${plane}-halo)`);
if (shadowParts.length) lines.push(`box-shadow: ${shadowParts.join(', ')};`);
if (lines.length) blocks.push(renderBlock(`[data-depth='${plane}']`, lines));
// `:where()` — SPECIFICITY ZERO. The plane is the FLOOR, not the ceiling.
// Decisión 8 says the adopter "keeps setting its own radius / font / color on
// top and wins by cascade order (recipes load after the foundation)". That
// premise was false: the foundation is injected at runtime by ActiveEidos
// while recipes arrive as code-split Vite chunks, so at equal (0,1,0) the
// winner was whoever loaded last — measured opposite in dev and in prod
// (next-features §13), and it cost real product: a `risk` toast and a
// `fulfill` toast wore the same grey card, and tooltip's three variants
// computed identical. With `:where()` the recipe wins wherever it SPEAKS, in
// any load order, and the plane still paints everything the component leaves
// silent — which is what "baseline" means. (§12.9, signed 2026-08-24)
if (lines.length) blocks.push(renderBlock(`:where([data-depth='${plane}'])`, lines));
// Atmosphere (Fase 4) — frost is opt-in via `data-frost` so it never turns an opaque
// overlay translucent by default. Gated on the plane attribute → reliably overrides the
// component's own background (specificity 0,2,0). Needs a surface to dilute + a blur.
// NOTE: unlike the baseline above, frost keeps its specificity ON PURPOSE. The baseline
// is a floor the recipe may override; frost is a per-element request the author made by
// writing `data-frost`, and honouring it means beating the component's own background.
// Same attribute, opposite intent — do not "harmonise" these two into `:where()`.
if (cues.blur !== undefined) {
const frost: string[] = [];
if (cues.surface !== undefined)

Loading…
Cancel
Save

Powered by TurnKey Linux.