docs(next-features): el registro de incidencias del eje theme-reach

Encargo del autor: anotar todas las incidencias de esta clase para resolverlas
después. Van al registro canónico (`next-features.md`), no a un sitio nuevo, y
con su MEDICIÓN — no como impresión.

**§12 — El contrato de cascada del velo de estado.** Seis incidencias que
resultaron ser la misma: nadie fijó nunca cómo compone el velo de
`archetypes.css` con las reglas de una receta. Y la raíz explica por qué se
manifiestan de formas tan distintas: el velo se pinta con DOS pesos según el
arquetipo — `:where()` (0,0,0) para `trigger`, especificidad plena (0,5,0) para
`item`/`option`.

  131 declaraciones-atajo en 45 componentes cancelan el velo (66 BASE ·
  32 ACENTO · 23 HOVER · 10 OTRA; cero valores con gradiente, así que el paso a
  longhand no tiene riesgo técnico; 32 nodos velados que hoy no reaccionan al
  ratón pasarían a hacerlo) · la prop `hoverable` de table no suprime nada · en
  tree-grid el empate (0,5,0) lo decide el ORDEN DE CARGA y varía entre
  recargas · la banda de tree-grid mata el hover en las filas pares · un token
  de tinta no puede ganar al arquetipo (`selected-row-fg` se retiró por eso) ·
  el thumb de scroll-area no tiene velo al que migrar.

Esto explica de paso la adopción 21/135 que midió la auditoría del 2026-07-01:
el velo estaba escrito y estructuralmente derrotado.

**§13 — Huecos de instrumento y de demo.** Lo que hizo que una medición
mintiera o no existiera, que importa porque el eje entero se apoya en ellas:
falsos negativos del centinela sobre propiedades transicionadas, pseudo-elementos
y componentes compuestos que no ve, la sonda que no pasa el ratón por un `<tr>`,
y la lección general —**una sonda sobre una parte que la demo no monta compara
CERO valores y pasa**—, con la lista de partes condicionales afectadas.

El handoff gana la regla para las sesiones del eje: si una incidencia mueve
píxel o toca morfo, se mide, se anota ahí y se sigue; no se arregla dentro del
commit del componente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 9748733adc
commit 5596fc8707

@ -352,3 +352,114 @@ yet, each a self-contained follow-up:
**Deps**: the parallax entry needs no new service — the two `$adom` ports it **Deps**: the parallax entry needs no new service — the two `$adom` ports it
consumes already shipped. The scrim entries are the author's call on the token consumes already shipped. The scrim entries are the author's call on the token
vocabulary, so they block on a decision, not on code. vocabulary, so they block on a decision, not on code.
## 12. El contrato de cascada del velo de estado — 2026-08-20
**Origen**: el eje `theme-reach` (F2-A, sesión del 2026-08-20). Al tokenizar
componente a componente aparecieron, medidas una a una, seis incidencias que
son **la misma**: nadie fijó nunca cómo compone el velo de `archetypes.css` con
las reglas de una receta. No se tocan dentro del eje de theming — mueven píxel
y son decisión aparte — pero cada una está medida y localizada.
**La raíz**: `archetypes.css` pinta el velo neutro como
`background-image: linear-gradient(var(--state-hover), var(--state-hover))`, y
lo hace con DOS pesos distintos según el arquetipo — `:where(...)` (0,0,0) para
`trigger`, y especificidad PLENA (0,5,0) para `item` / `option`, «INTENTIONAL
cross-component accent», dice su comentario. Una receta que declare fondo sobre
el mismo nodo entra en una relación que nunca se escribió.
### Las seis, con su medición
1. **El atajo `background:` cancela el velo — 131 declaraciones en 45
componentes.** El atajo fija `background-image: none`; sobre un arquetipo
velado por `:where()` eso lo mata para siempre, no sólo en hover. Medido con
`scripts/__statelayer-analysis.ts`, que comprueba contra el morfo compilado
que el atajo cae sobre el nodo VELADO y no sobre cualquiera. Por clase de
regla: **66 BASE** (mecánicas: pasar a `background-color` no mueve el
reposo), **32 ACENTO** (selected/checked/open: §38 pide `background-color`
para que el velo COMPONGA encima), **23 HOVER** (la migración de la firma 3)
y **10 OTRA** (disabled, dragging, variantes). **Cero valores con
gradiente**, así que el único riesgo técnico del paso a longhand no existe
en este catálogo. El píxel que se mueve: **32 nodos velados que hoy no
reaccionan al ratón** pasarían a hacerlo. Esto explica la adopción 21/135
que midió la auditoría del 2026-07-01: el velo estaba escrito y
estructuralmente derrotado.
2. **La prop `hoverable` de `table` no suprime nada.** El velo de `item` es
incondicional y a (0,5,0), así que una fila se ilumina al pasar el ratón
aunque `hoverable` esté apagada — verificado con hover real, y ocurre desde
antes de tocar la receta. O el arquetipo decide y la prop sobra, o la fila
no es un `item` para el sistema. Decisión de MORFO.
3. **En `tree-grid` el empate lo decide el ORDEN DE CARGA.** El velo de `item`
y el hover de la receta pesan EXACTAMENTE lo mismo (0,5,0), así que gana la
hoja que cargue después — y en el dev server varía entre recargas: seis
corridas de la misma configuración dieron cuatro «sin velo» y dos «con
velo». Un empate resuelto por orden es la misma fragilidad que el canon ya
documenta para las bandas de `z-index`.
4. **En `tree-grid` la banda mata el hover.** La regla de `striped` pesa
(0,7,0) contra los (0,5,0) del hover y usa el atajo: en las filas pares no
ocurre NADA al pasar el ratón. El hover del componente es inconsistente
entre filas.
5. **Un token de receta no puede ganar al arquetipo.** La regla de `item` fija
también `color` en el estado seleccionado, a (0,5,0) contra los (0,3,0) de
la receta: un `table.selected-row-fg` no podía mover nada nunca. Se retiró
con su declaración muerta (un token que no mueve nada es un token que
miente). Mientras el arquetipo pinte tinta, ningún componente puede
tematizar la suya en ese estado.
6. **`scroll-area.thumb-bg-hover` no tiene velo al que migrar.** Su thumb lleva
`archetype: 'thumb'`, que `archetypes.css` no vela, así que la firma 3 no
puede ejecutarse ahí sin cambiar el arquetipo — decisión de MORFO. Es el
único knob de los seis de la cola que sigue sin resolver.
### Qué habría que decidir
En una frase: **quién manda cuando el arquetipo y la receta pintan el mismo
eje**. Las tres piezas concretas: si el velo de `item` baja a `:where()` como
el de `trigger` (y entonces la receta manda, y `hoverable` vuelve a significar
algo); si las recetas pasan a longhand en bloque (66 BASE + 32 ACENTO, mecánico
y sin gradientes de por medio); y si un arquetipo puede fijar `color` sobre un
componente que quiere tematizarlo.
**Deps**: ninguna de código. Es decisión del autor sobre píxel, y el
instrumental para medirla ya existe (`__statelayer-analysis.ts` +
`__theming-probe.ts` + `__tg-rowhover.ts`).
## 13. Huecos de instrumento y de demo del eje theme-reach — 2026-08-20
**Origen**: la misma sesión. Cosas que hicieron que una medición mintiera o no
existiera. Se anotan porque el eje entero se apoya en esas mediciones.
- **El centinela da falsos negativos sobre una propiedad transicionada.**
`command.input-border` salió «no effect» y el token SÍ alcanza — verificado
tres veces a mano con la misma congelación de transición que el propio
centinela aplica. No se encontró la causa; queda sin explicar.
- **El centinela no ve pseudo-elementos.** `::placeholder`, `::after` (el scrim
de media-player) y demás quedan fuera de `getComputedStyle` sobre el nodo, así
que sus tokens siempre salen «muertos».
- **El centinela no ve lo que pinta un componente COMPUESTO.** Los tokens que
media-player reenvía al Slider (`--media-player-track` →
`--slider-track-bg`) pintan en nodos `[data-slider-*]`, fuera del espacio de
atributos del componente medido.
- **La sonda no pasa el ratón por un `<tr>`.** En `table` el conjunto de hover
no incluyó ninguna fila, así que el diff no cubría el estado donde estaba el
hallazgo. Hubo que medirlo con un script aparte.
- **Una sonda sobre una demo que no monta la parte compara CERO valores y
pasa.** Ocurrió con el skin de audio: `/uix/components/audio-player` da 404
porque la demo es la de `media-player` con el chip `media`, y la sonda cargaba
en modo vídeo. Resuelto con `scripts/__probe-audio-skin.ts`, pero la clase de
fallo es general: **una parte condicional necesita que la sonda la monte**.
Afecta al menos a captions, buffering y paneles portalados de media-player, al
estado vacío y la barra de carga de `command`, y al overview de
`date-range-picker`.
- **`date-range-picker` no expone `kind='month'` / `'year'` en su demo**
(norma N-6), así que la mitad de su API no se ve ni se mide.
- **La demo del bloque F2-A ya enseña los tokens** (`TokensPanel`), pero los
~155 componentes sin contrato siguen sin superficie donde verlos hasta que se
tokenicen.
**Deps**: ninguna. Son mejoras del instrumental del eje, ejecutables cuando
estorben.

@ -315,6 +315,28 @@ la base, y fuera el hover propio.
capa de estado, sea cual sea su valor. Esos 41 eran deuda de nombre y se capa de estado, sea cual sea su valor. Esos 41 eran deuda de nombre y se
renombraron (`93017975c`). renombraron (`93017975c`).
### Las incidencias van al registro, no al eje
Todo lo que este eje destapa y NO le toca arreglar está registrado en
[`docs/next-features.md`](../next-features.md) §12 y §13 — con su medición, no
como impresión:
- **§12 · El contrato de cascada del velo de estado.** Seis incidencias que son
la misma: nadie fijó cómo compone el velo de `archetypes.css` con las reglas
de una receta. Incluye las 131 declaraciones-atajo en 45 componentes, la prop
`hoverable` que no suprime nada, el empate (0,5,0) que resuelve el orden de
carga, la banda de `tree-grid` que mata el hover, el token de tinta que no
puede ganar al arquetipo, y el thumb de `scroll-area` sin velo al que migrar.
- **§13 · Huecos de instrumento y de demo.** Lo que hizo que una medición
mintiera o no existiera: falsos negativos del centinela, pseudo-elementos y
componentes compuestos que no ve, la sonda que no pasa el ratón por un `<tr>`,
y la lección general — **una sonda sobre una parte que la demo no monta compara
CERO valores y pasa**.
**Regla para las sesiones de este eje**: si una incidencia mueve píxel o toca
morfo, se MIDE, se anota ahí y se sigue; no se arregla dentro del commit del
componente.
### Lo que queda de la firma 3, y por qué no lo he hecho ### Lo que queda de la firma 3, y por qué no lo he hecho
1. **`scroll-area.thumb-bg-hover` — pide DECISIÓN de morfo.** El thumb lleva 1. **`scroll-area.thumb-bg-hover` — pide DECISIÓN de morfo.** El thumb lleva

@ -0,0 +1,109 @@
/**
* Análisis del hallazgo sistémico (firma 3): las declaraciones `background:`
* (atajo) sobre nodos velados, clasificadas por la CLASE de regla — porque la
* clase decide el trabajo:
*
* BASE la regla sin estado: el fix es mecánico (→ background-color) y
* NO mueve píxel en reposo; destapa el velo en hover.
* HOVER un hover propio: es la migración de la firma 3 (retirar).
* ACENTO selected/checked/active/current con valor de rol: §38 manda
* background-color para que el velo COMPONGA encima.
* OTRA dragging, disabled, variantes…: caso a caso.
*
* Y por el VALOR: un token que resuelva a un GRADIENTE no puede pasar a
* background-color — romperlo sería peor que el velo ausente.
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { compileMorfo } from '../src/uix/morfo/compile';
import type { Morfo } from '../src/uix/morfo/types';
const ROOT = resolve('src/uix/eidos/components');
const VEILED = new Set(['trigger', 'item', 'option']);
const CONTRACT = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace(/\r\n/g, '\n');
// ¿el valor (o el token que referencia, un salto) contiene un gradiente?
function hasGradient(value: string): boolean {
if (/gradient\(/.test(value)) return true;
for (const m of value.matchAll(/var\((--[a-z0-9-]+)/g)) {
const name = m[1];
// buscar la declaración del token en el contrato (clave → valor)
const key = name.replace(/^--[a-z0-9]+(-[a-z0-9]+)*?-/, ''); // aproximación
const re = new RegExp("'" + name.slice(2).split('-').slice(1).join('-') + "'\s*:\s*'([^']*)'");
const hit = CONTRACT.match(re);
if (hit && /gradient\(/.test(hit[1])) return true;
}
return false;
}
type Klass = 'BASE' | 'HOVER' | 'ACENTO' | 'OTRA';
const stats = new Map<Klass, number>();
const perComp = new Map<string, Map<Klass, number>>();
const gradients: string[] = [];
const accentSamples: string[] = [];
const otherSamples: string[] = [];
let total = 0;
let baseNoHover = 0; // nodos con base-atajo y SIN hover propio: el velo aparecería como hover NUEVO
for (const dir of readdirSync(ROOT)) {
const css = join(ROOT, dir, `${dir}.css`);
if (!existsSync(css)) continue;
if (!existsSync(resolve(`src/uix/morfo/components/${dir}.ts`))) continue;
let compiled;
try {
const mod = (await import(`../src/uix/morfo/components/${dir}`)) as Record<string, unknown>;
const key = `${dir.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())}Morfo`;
if (!mod[key]) continue;
compiled = compileMorfo(mod[key] as Morfo);
} catch { continue; }
const veiledAttrs: string[] = [];
for (const kebab of compiled.parts.order) {
const part = compiled.parts.byKebab.get(kebab);
if (part && VEILED.has(String(part.archetype))) veiledAttrs.push(compiled.parts.attrs[kebab]);
}
if (!veiledAttrs.length) continue;
const lines = readFileSync(css, 'utf8').replace(/\r\n/g, '\n').split('\n');
const seenBase = new Set<string>();
const seenHover = new Set<string>();
for (let i = 0; i < lines.length; i++) {
if (!/^\s*background\s*:/.test(lines[i])) continue;
let sel = '';
for (let j = i; j >= 0; j--)
if (lines[j].includes('{')) { sel = lines[j].replace('{', '').trim(); break; }
const last = sel.split(/\s+(?![^[]*\])/).pop() ?? '';
const attr = veiledAttrs.find((a) => last.includes(`[${a}]`));
if (!attr || /::/.test(last)) continue;
total++;
const value = lines[i].replace(/^\s*background\s*:\s*/, '').replace(/;.*/, '');
let k: Klass;
if (/:hover/.test(last) || /data-state='hover'/.test(last)) { k = 'HOVER'; seenHover.add(attr); }
else if (/data-(selected|state='(checked|on|open|active|indeterminate)'|current|highlighted|in-range|range-(start|end))|aria-selected/.test(last)) {
k = 'ACENTO';
if (accentSamples.length < 8) accentSamples.push(`${dir} :: ${last.slice(0, 55)} → ${value.slice(0, 38)}`);
}
else if (/:active|:focus|data-(disabled|dragging|readonly|invalid|variant|mode|outside-month)/.test(last)) {
k = 'OTRA';
if (otherSamples.length < 8) otherSamples.push(`${dir} :: ${last.slice(0, 55)} → ${value.slice(0, 38)}`);
}
else { k = 'BASE'; seenBase.add(attr); }
stats.set(k, (stats.get(k) ?? 0) + 1);
const pc = perComp.get(dir) ?? new Map();
pc.set(k, (pc.get(k) ?? 0) + 1);
perComp.set(dir, pc);
if (hasGradient(value)) gradients.push(`${dir} :: ${last.slice(0, 50)} → ${value.slice(0, 45)}`);
}
for (const a of seenBase) if (!seenHover.has(a)) baseNoHover++;
}
console.log('declaraciones-atajo sobre nodo velado:', total, 'en', perComp.size, 'componentes\n');
console.log('por clase de regla:');
for (const [k, n] of [...stats].sort((a, b) => b[1] - a[1])) console.log(` ${k.padEnd(7)} ${n}`);
console.log('\nnodos con base-atajo y SIN hover propio (el velo sería un hover NUEVO):', baseNoHover);
console.log('\nvalores con GRADIENTE (no pueden pasar a background-color):', gradients.length);
for (const g of gradients) console.log(' ' + g);
console.log('\nmuestras ACENTO:'); for (const s of accentSamples) console.log(' ' + s);
console.log('\nmuestras OTRA:'); for (const s of otherSamples) console.log(' ' + s);
console.log('\npor componente (BASE/HOVER/ACENTO/OTRA):');
const rows = [...perComp].map(([c, m]) => [c, (m.get('BASE')??0)+(m.get('HOVER')??0)+(m.get('ACENTO')??0)+(m.get('OTRA')??0), m] as const).sort((a,b)=>b[1]-a[1]);
for (const [c, n, m] of rows) console.log(` ${c.padEnd(20)} ${String(n).padStart(3)} ${m.get('BASE')??0}/${m.get('HOVER')??0}/${m.get('ACENTO')??0}/${m.get('OTRA')??0}`);

@ -0,0 +1,34 @@
// ¿La fila coge el velo del sistema al pasar el ratón? Y si la tabla NO es
// hoverable, ¿lo coge igual? (el velo de `item` no está gated por la prop)
import { chromium } from 'playwright';
const url = process.argv[2] ?? 'http://localhost:5180/uix/components/table';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
await page.addStyleTag({ content: '*, *::before, *::after { transition: none !important; }' });
const read = async (label: string) => {
const row = page.locator('[data-table-body] [data-table-row]').first();
const before = await row.evaluate((n) => {
const cs = getComputedStyle(n);
return { img: cs.backgroundImage, bg: cs.backgroundColor };
});
await row.hover();
await page.waitForTimeout(120);
const after = await row.evaluate((n) => {
const cs = getComputedStyle(n);
return { img: cs.backgroundImage, bg: cs.backgroundColor };
});
console.log(`${label}\n reposo img: ${before.img}\n hover img: ${after.img}\n hover bg : ${after.bg}`);
await page.mouse.move(0, 0);
await page.waitForTimeout(120);
};
await read('CON data-hoverable (como la demo)');
await page.evaluate(() => {
for (const r of document.querySelectorAll('[data-table-root]')) r.removeAttribute('data-hoverable');
});
await read('SIN data-hoverable (la prop apagada)');
await browser.close();

@ -0,0 +1,38 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.goto('http://localhost:5180/uix/components/table', { waitUntil: 'networkidle' });
await page.waitForTimeout(400);
const out = await page.evaluate(() => {
const root = document.querySelector('[data-table-root]') as HTMLElement;
const row = root.querySelector('[data-table-body] [data-table-row]') as HTMLElement;
row.setAttribute('data-selected', '');
const res: Record<string, unknown> = { archetype: row.getAttribute('data-archetype') };
// ¿quién gana el `color` de la fila seleccionada?
const winners: string[] = [];
const walk = (rules: CSSRuleList | undefined) => {
for (const r of rules ?? []) {
const any = r as CSSStyleRule & { cssRules?: CSSRuleList };
if (any.cssRules) walk(any.cssRules);
if (!any.selectorText || !any.style?.color) continue;
try {
if (row.matches(any.selectorText)) winners.push(`${any.selectorText.slice(0, 70)} => ${any.style.color}`);
} catch { /* selector no evaluable */ }
}
};
for (const sh of document.styleSheets) { try { walk(sh.cssRules) } catch { res.corsSheets = (res.corsSheets as number ?? 0) + 1 } }
res.reglasColor = winners;
res.computed = getComputedStyle(row).color;
// filas pares con striped
root.setAttribute('data-striped', '');
const even = root.querySelector('[data-table-body] [data-table-row]:nth-of-type(even)') as HTMLElement;
res.filaPar = !!even;
if (even) {
const b = getComputedStyle(even).backgroundColor;
root.style.setProperty('--table-striped-row-bg', 'rgb(1, 2, 3)');
res.stripedAlcanza = getComputedStyle(even).backgroundColor !== b;
}
return res;
});
console.log(JSON.stringify(out, null, 1));
await browser.close();

@ -0,0 +1,25 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.goto('http://localhost:5180/uix/components/tree-grid', { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
await page.addStyleTag({ content: '*, *::before, *::after { transition: none !important; }' });
const boxes = await page.evaluate(() =>
[...document.querySelectorAll('[data-tree-grid-row]')].slice(0, 4).map((n, i) => {
const r = (n as HTMLElement).getBoundingClientRect();
const cs = getComputedStyle(n);
return { i, x: r.x + r.width / 2, y: r.y + r.height / 2, restBg: cs.backgroundColor, restImg: cs.backgroundImage.slice(0, 30) };
})
);
for (const b of boxes) {
await page.mouse.move(b.x, b.y);
await page.waitForTimeout(140);
const h = await page.evaluate((i) => {
const n = [...document.querySelectorAll('[data-tree-grid-row]')][i] as HTMLElement;
const cs = getComputedStyle(n);
return { bg: cs.backgroundColor, img: cs.backgroundImage.slice(0, 46), hov: n.matches(':hover') };
}, b.i);
console.log(`fila ${b.i}: reposo ${b.restBg} → hover ${h.bg} | velo ${h.img} | :hover ${h.hov}`);
}
await browser.close();
Loading…
Cancel
Save

Powered by TurnKey Linux.