fix(theming): la mitad gratis del instrumento - el codemod lee la IIFE y la sonda habla la puerta del centinela

Firma de instrumento (expediente VA, cegueras 4 y 5): coste de ledger CERO,
tres scripts, ni una entrada de theming-sentinel-exceptions.ts tocada.

- __names-codemod.ts: blocks() devuelve {name, at, end, depth} con offsets
  reales del fichero; la IIFE de avatar acota su region al return{...} con
  depth 3 (y LANZA si no lo encuentra) y los muros 2-3 leen por fin sus 97
  claves (86 publicas + 11 privadas); el pase A renombra a \t{depth}.
  Laboratorio sobre copias: duplicado inyectado a 3 tabs DETECTADO,
  renombrado de una clave de avatar = 1 linea. La via de carga SE QUEDA en
  tsx: theming-census importa src/uix/morfo/compile sin extension y todo
  src/uix/morfo esta escrito asi.
- theming-sentinel.ts: COMPONENT_OVERRIDES se exporta (UNA tabla de puertas,
  no dos) con guarda de entrypoint via realpathSync - sin el realpath la
  guarda muere a traves de un junction (exit 0 sin inspeccionar nada) y este
  repo usa worktrees con junctions; UIX_DEV_URL reemplaza el :5173 clavado.
  Importarlo: 0,85 s sin navegador. label 1/2 y switch 27/52 byte a byte
  identicos a las bases pre-firma. Incluye +10 lineas de comentario
  PREEXISTENTES de otra sesion (la justificacion de image-adjustments en
  COMPONENT_OVERRIDES, solo prosa, verificada contra su acta del 24).
- __theming-probe.ts: el pase open habla el vocabulario del centinela
  (prepareWith/openBy/openWith/openMarker, con OPEN_TRIGGER podada de sus
  copias literales) y su catch AVISA en stderr distinguiendo "no hay trigger"
  de "el gesto fallo"; editable registra por fin su bloque open (25 filas, 6
  con data-editing; dos corridas sobre codigo identico = 0 diffs en TODOS los
  bloques); pases nuevos de FOCO (la cabecera lo prometia y nada llamaba a
  .focus()) y disabled (bloque aparte y ULTIMO de la corrida);
  PROBE_ROLE_BOUND_SCALES + PROBE_SAFE_SCALES + assertProbeSafeScale con la
  discrepancia risk amber/orange documentada; UIX_DEV_URL. card y editable a
  0 diffs contra sus bases en los bloques pre-firma; menubar (6 nodos -> 7
  con el panel) y chat-message (24 filas x 2 corridas, 0 diffs) abren via las
  entradas del centinela.

Fuera de la firma, registrado en next-features SS13: el barrido de variantes
de la sonda (ensanche diferido), el clear-trigger de search-field en el pase
open, la asimetria de escenario de los seis con prepareWith sin fila en
DEMO_VARIANTS, y la GRANDE (cegueras 6+1+3+2, barridos S1-S3), que sigue
esperando firma. El catch del prepareWith del centinela sigue MUDO a
proposito: es la ceguera 6, S1 de la GRANDE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent fb2e12ea9f
commit 0474643895

@ -13,6 +13,12 @@
* `base.ts` (añadir un bloque que ya existe lo descarta EN SILENCIO) · el
* diff de `generated/` como renombres 1:1.
*
* Corre con `tsx`, no con el `node` pelado del centinela, porque importa
* `theming-census`, que a su vez importa `../src/uix/morfo/compile` SIN extensión
* — y todo `src/uix/morfo` está escrito así. El type stripping de Node no resuelve
* un import extensionless; alinear la vía de carga es un barrido de `src/`, no de
* este fichero.
*
* Lo que NO reescribe, y por qué:
* docs/process/** — actas, planes y handoffs: son HISTORIA
* docs/theming/changelog.md — crónica fechada
@ -30,32 +36,52 @@ const HISTORY = /^(docs\/process\/|docs\/theming\/changelog\.md$)/;
const read = (p: string) => readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
/**
* Los bloques de primer nivel. Casi todos abren `nombre: {`, pero `avatar` envuelve
* su mapa en una IIFE (`nombre: ((): RecipeTokenMap => {`) y sus claves viven un
* tabulador más adentro, dentro del `return {` del cierre — la misma lectura que
* `theming-sentinel.ts`. Exigir `: {` dejaba a `avatar` fuera de la lista: sin
* bloque para el muro de colisiones, engullido por el rango de su vecino anterior
* para el de duplicadas, e intocable para el pase A.
*
* `at`/`end` acotan la REGIÓN DE CLAVES y `depth` dice a cuántos tabuladores están.
* Son offsets REALES del fichero — dedentar el bloque para reusar el regex de dos
* tabuladores rompería el mapeo con el que el pase A edita por rango.
*/
type Block = { name: string; at: number; end: number; depth: number };
function blocks(src: string): Block[] {
const opens = [...src.matchAll(/^\t'?([a-z0-9-]+)'?: (\{|\(\()/gm)];
return opens.map((m, i) => {
const at = m.index!;
const end = i + 1 < opens.length ? opens[i + 1].index! : src.length;
if (m[2] === '{') return { name: m[1], at, end, depth: 2 };
const body = src.indexOf('\n\t\treturn {', at);
const close = src.indexOf('\n\t\t};', body);
if (body < 0 || body > end || close < 0 || close > end)
throw new Error(`${m[1]}: IIFE sin un \`return {\` legible`);
return { name: m[1], at: body, end: close, depth: 3 };
});
}
/** Las claves de un bloque, al nivel de indentación que ese bloque use. */
const keyRe = (depth: number) => new RegExp(`^\\t{${depth}}'?(_?[a-z0-9-]+)'?\\s*:`, 'gm');
/** Claves de primer nivel duplicadas dentro de un mismo bloque de receta. */
function duplicateKeys(src: string): string[] {
const blocks = [...src.matchAll(/^\t'?([a-z0-9-]+)'?: \{/gm)].map((m) => ({
name: m[1],
at: m.index!
}));
const dupes: string[] = [];
for (let i = 0; i < blocks.length; i++) {
const end = i + 1 < blocks.length ? blocks[i + 1].at : src.length;
for (const b of blocks(src)) {
const seen = new Set<string>();
for (const k of src.slice(blocks[i].at, end).matchAll(/^\t\t'?(_?[a-z0-9-]+)'?\s*:/gm)) {
if (seen.has(k[1])) dupes.push(blocks[i].name + '.' + k[1]);
for (const k of src.slice(b.at, b.end).matchAll(keyRe(b.depth))) {
if (seen.has(k[1])) dupes.push(b.name + '.' + k[1]);
seen.add(k[1]);
}
}
return dupes;
}
function blockRange(src: string, component: string): [number, number] | null {
const blocks = [...src.matchAll(/^\t'?([a-z0-9-]+)'?: \{/gm)].map((m) => ({
name: m[1],
at: m.index!
}));
const i = blocks.findIndex((b) => b.name === component);
if (i < 0) return null;
return [blocks[i].at, i + 1 < blocks.length ? blocks[i + 1].at : src.length];
function blockRange(src: string, component: string): Block | null {
return blocks(src).find((b) => b.name === component) ?? null;
}
async function run() {
@ -77,7 +103,7 @@ async function run() {
continue;
}
const existing = new Set(
[...base0.slice(range[0], range[1]).matchAll(/^\t\t'?(_?[a-z0-9-]+)'?\s*:/gm)].map((m) => m[1])
[...base0.slice(range.at, range.end).matchAll(keyRe(range.depth))].map((m) => m[1])
);
const targets = new Set<string>();
for (const f of fs) {
@ -119,16 +145,16 @@ async function run() {
for (const [component, fs] of byComponent) {
const range = blockRange(base, component);
if (!range) continue;
let block = base.slice(range[0], range[1]);
let block = base.slice(range.at, range.end);
// más largas primero: una clave puede ser prefijo de otra
for (const f of [...fs].sort((a, b) => b.key.length - a.key.length)) {
const re = new RegExp("^(\\t\\t)'?" + f.key + "'?(\\s*:)", 'm');
const re = new RegExp(`^(\\t{${range.depth}})'?` + f.key + "'?(\\s*:)", 'm');
if (!re.test(block)) continue;
const quoted = /-/.test(f.to!) ? `'${f.to}'` : f.to!;
block = block.replace(re, `$1${quoted}$2`);
keyHits++;
}
base = base.slice(0, range[0]) + block + base.slice(range[1]);
base = base.slice(0, range.at) + block + base.slice(range.end);
}
console.log(`pase A · claves reescritas en base.ts: ${keyHits} / ${renames.length}`);

@ -5,10 +5,10 @@
*
* Temporary instrument (the `__` prefix keeps it out of the tracked surface).
* Walks the component demo, and for every node matching `[data-{c}…]` records
* the knob computed values at rest, per size, hovered and focused. The size is
* forced by WRITING `data-size` on the nodes that already carry it — the recipe
* selects on that attribute, so the computed result is the same one the real
* interaction produces, without depending on each demo's chips.
* the knob computed values at rest, per size, open, hovered, focused and
* disabled. The size is forced by WRITING `data-size` on the nodes that already
* carry it — the recipe selects on that attribute, so the computed result is the
* same one the real interaction produces, without depending on each demo's chips.
*
* Run it with plain `node`, NOT with tsx: the tsx loader injects helpers that
* are not defined inside `page.evaluate`, and every probe throws.
@ -19,7 +19,12 @@
*/
import { writeFileSync } from 'node:fs';
import { chromium } from 'playwright';
import { chromium, type ElementHandle } from 'playwright';
// The SENTINEL's door vocabulary, not a second copy of it: a component the guard
// can only reach through an override (a surface that opens on FOCUS, a demo
// control that must be ON first, a marker that says it really came up) is a
// component this probe cannot reach either. Two tables for the same demos drift.
import { COMPONENT_OVERRIDES } from './theming-sentinel.ts';
const PROPS = [
'backgroundColor',
@ -58,6 +63,60 @@ const PROPS = [
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
/**
* The palette scales a probe may STAMP without measuring something else.
*
* Nine of the 33 scales the base theme ships (`PALETTE_SCALES`,
* `src/uix/eidos/lib/types.ts:232-270`) are BOUND to a role by
* `THEME_BASE_COLOR_ROLES` (`src/uix/eidos/lib/themes/base.ts:22-47`), and a
* bound scale is not a free surface: stamping `teal` believing you are probing a
* spare scale is stamping `affirm`. The binding is copied here because the theme
* module is NOT importable from a plain-node script — its import graph reaches
* `$…` aliases only vite resolves — so the provenance lives in this comment
* instead of in an import.
*
* KNOWN DISCREPANCY, deliberately not smoothed over: `CANONICAL_INTENT_SCALES`
* (`src/uix/eidos/lib/config-types.ts:93-100`) says `risk: 'amber'` while the
* base theme says `risk: 'orange'`. Under the base theme, probing with `amber`
* believing you are probing `risk` measures a different scale.
*
* Nothing in this file stamps a scale today: the two constants and the assert
* are here for the next writer who does. A guard, not a feature.
*/
const PROBE_ROLE_BOUND_SCALES: Record<string, string> = {
purple: 'primary',
slate: 'secondary',
indigo: 'tertiary',
gray: 'neutral',
teal: 'affirm',
green: 'fulfill',
orange: 'risk',
red: 'threat',
plum: 'loss'
};
/** The remaining 24 of the 33: bound to no role, safe to stamp. */
// prettier-ignore
const PROBE_SAFE_SCALES = [
'amber', 'blue', 'bronze', 'brown', 'crimson', 'cyan', 'fuchsia', 'gold',
'grass', 'iris', 'jade', 'lime', 'mauve', 'mint', 'olive', 'pink',
'ruby', 'sage', 'sand', 'sky', 'steel', 'tomato', 'violet', 'yellow'
];
function assertProbeSafeScale(scale: string): void {
const role = PROBE_ROLE_BOUND_SCALES[scale];
if (role)
throw new Error(
`probe scale '${scale}' is bound to the '${role}' role by THEME_BASE_COLOR_ROLES: ` +
`stamping it measures that role, not a free scale. Free scales: ${PROBE_SAFE_SCALES.join(', ')}`
);
if (!PROBE_SAFE_SCALES.includes(scale))
throw new Error(
`probe scale '${scale}' is not one of the 33 scales the base theme ships. ` +
`Free scales: ${PROBE_SAFE_SCALES.join(', ')}`
);
}
/**
* Demo controls that must be ON before measuring: a part the demo does not
* mount is a part the diff never compares, and a probe over two nodes passes
@ -117,23 +176,22 @@ const EXTRA_NODES: Record<string, string> = {
};
/**
* The trigger the OPEN pass must click. The default is the first trigger in the
* document, which is wrong whenever the surface worth measuring hangs off
* ANOTHER one: menubar drops a role=menu Content from its first entry and its
* own role=dialog Panel — the part that carries its public knobs — only from
* the "Format" entry.
* The trigger the OPEN pass must reach, for the components the SENTINEL does not
* already name. The default is the first trigger in the document, which is wrong
* whenever the surface worth measuring hangs off ANOTHER one.
*
* `COMPONENT_OVERRIDES[c].openWith` is consulted FIRST, so anything the guard
* names stays out of this table: menubar and chat-message used to live here as
* literal copies of their `openWith` entries and were removed with the import.
*/
const OPEN_TRIGGER: Record<string, string> = {
menubar: "[data-menubar-trigger][data-menubar-value='format']",
// It has NO route of its own (`/uix/components/color-swatch` is a 404, the
// fifth canon component in that state): it is measured on the color-picker
// demo, whose stage mounts exactly ONE swatch (the trigger chip). Every other
// swatch — presets and saved swatches, a dozen of them — lives in the
// PORTALED panel, so the open pass is where the node count stops being 1.
'color-swatch': '[data-color-picker-trigger]',
// Its only openable surface is the quick-reaction tapback bar, hanging off
// the add-reaction chip — the default `[data-{c}-trigger]` matches nothing.
'chat-message': '[data-chat-message-reaction-add]'
// The guard reaches it through `urls`, which this probe does not have.
'color-swatch': '[data-color-picker-trigger]'
};
const DEMO_VARIANTS: Record<string, string[]> = {
@ -204,7 +262,12 @@ const DEMO_VARIANTS: Record<string, string[]> = {
async function main() {
const [component, out, urlArg] = process.argv.slice(2);
if (!component || !out) throw new Error('usage: <component> <out.json> [url]');
const url = urlArg ?? `http://localhost:5173/uix/components/${component}`;
// The dev server does not always answer on 5173 (a second worktree, a port
// already taken): `UIX_DEV_URL` moves the whole run without spelling the route
// out on every call. An explicit URL argument still wins.
const origin = process.env.UIX_DEV_URL ?? 'http://localhost:5173';
const url = urlArg ?? `${origin}/uix/components/${component}`;
const override = COMPONENT_OVERRIDES[component] ?? {};
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
@ -321,68 +384,117 @@ async function main() {
.catch(() => page.reload({ waitUntil: 'load', timeout: 15000 }));
await page.waitForTimeout(400);
await prepare();
const trigger = page
.locator(OPEN_TRIGGER[component] ?? `[data-${component}-trigger], [data-${component}-input]`)
.first();
if (await trigger.count()) {
// The demo controls the guard needs ON before a surface can open — but only
// for components this probe has no `DEMO_VARIANTS` row of its own for: where
// it does, `prepare()` has already clicked them and clicking the same toggles
// again would UNDO them (they are checkboxes, not switches). Absence is not an
// error here, unlike in `variants()`: it is one layer this route cannot show.
if (!DEMO_VARIANTS[component])
for (const sel of override.prepareWith ?? []) {
const el = page.locator(sel).first();
if (!(await el.count())) continue;
try {
await el.click({ timeout: 1500 });
await page.waitForTimeout(350);
} catch {
/* one layer this route cannot show */
}
}
// The GESTURE is the guard's too. `editable` is the case this fixes: its edit
// surface opens on FOCUS and a blur is what closes it, so the click below threw
// and the silent catch dropped the whole `open` block from its snapshot.
const openers = [
...(override.openWith ?? []),
OPEN_TRIGGER[component] ?? `[data-${component}-trigger], [data-${component}-input]`
];
let opened = false;
let failure: string | null = null;
for (const sel of openers) {
const el = page.locator(sel).first();
if (!(await el.count())) continue;
try {
// A context menu opens on RIGHT click and on nothing else.
await trigger.click({ button: component === 'context-menu' ? 'right' : 'left', timeout: 2000 });
if (override.openBy === 'focus') await el.focus({ timeout: 2000 });
else if (override.openBy === 'hover') await el.hover({ timeout: 2000 });
else
await el.click({
// A context menu opens on RIGHT click and on nothing else.
button:
override.openBy === 'contextmenu' || component === 'context-menu' ? 'right' : 'left',
timeout: 2000
});
await page.waitForTimeout(500);
result.open = await page.evaluate(
([kebab, keys, extra]) => {
const nodes = [...document.querySelectorAll<HTMLElement>('*')].filter(
(n) =>
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ||
(extra ? n.matches(extra as string) : false)
);
const seen = new Map<string, number>();
const rows: Record<string, Record<string, string>> = {};
for (const n of nodes) {
const attrs = [...n.attributes]
.filter((a) => a.name.startsWith('data-') && !a.name.startsWith('data-event'))
.map((a) => (a.value ? `${a.name}=${a.value}` : a.name))
.sort()
.join('|');
const base = `${n.tagName.toLowerCase()}[${attrs}]`;
const i = (seen.get(base) ?? 0) + 1;
seen.set(base, i);
const cs = getComputedStyle(n);
const row: Record<string, string> = {};
for (const k of keys as string[]) row[k] = cs[k as keyof CSSStyleDeclaration] as string;
rows[`${base}#${i}`] = row;
}
return rows;
},
[component, PROPS, EXTRA_NODES[component] ?? null] as const
);
} catch {
/* no openable trigger — skip the state */
// The marker tells an opening that WORKED from one that fired and closed
// again — the editable class of failure, invisible without it.
if (override.openMarker && !(await page.locator(override.openMarker).count())) {
failure = `${sel} fired but ${override.openMarker} never appeared`;
continue;
}
opened = true;
break;
} catch (e) {
failure = `${sel}: ${String(e).split('\n')[0]}`;
}
}
if (opened) {
result.open = await page.evaluate(
([kebab, keys, extra]) => {
const nodes = [...document.querySelectorAll<HTMLElement>('*')].filter(
(n) =>
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ||
(extra ? n.matches(extra as string) : false)
);
const seen = new Map<string, number>();
const rows: Record<string, Record<string, string>> = {};
for (const n of nodes) {
const attrs = [...n.attributes]
.filter((a) => a.name.startsWith('data-') && !a.name.startsWith('data-event'))
.map((a) => (a.value ? `${a.name}=${a.value}` : a.name))
.sort()
.join('|');
const base = `${n.tagName.toLowerCase()}[${attrs}]`;
const i = (seen.get(base) ?? 0) + 1;
seen.set(base, i);
const cs = getComputedStyle(n);
const row: Record<string, string> = {};
for (const k of keys as string[]) row[k] = cs[k as keyof CSSStyleDeclaration] as string;
rows[`${base}#${i}`] = row;
}
return rows;
},
[component, PROPS, EXTRA_NODES[component] ?? null] as const
);
} else {
// A component with nothing to open is a legitimate skip — `card` has no
// trigger at all — but a component whose opening THREW is a hole in the
// measurement, and until now it vanished into a bare `catch {}`. Say which
// of the two happened, on stderr, and carry on either way.
console.warn(
`probe ${component}: no open state — ` +
(failure ?? `no opener matched (${openers.join(' | ')})`)
);
}
// Hover + focus on every interactive part the recipe could paint.
await page
.reload({ waitUntil: 'networkidle', timeout: 15000 })
.catch(() => page.reload({ waitUntil: 'load', timeout: 15000 }));
await page.waitForTimeout(400);
await prepare();
const targets = await page.evaluate(([kebab, extra]) => {
const root = document.querySelector('[data-uix-stage]') ?? document.body;
return [...root.querySelectorAll<HTMLElement>('*')]
.filter(
(n) =>
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ||
(extra ? n.matches(extra) : false)
)
.map((n, i) => ({ i, tag: n.tagName.toLowerCase() }))
.filter((t) => ['button', 'a', 'input', 'li', 'div', 'tr'].includes(t.tag))
.slice(0, 24);
}, [component, EXTRA_NODES[component] ?? null] as const);
// The interactive nodes the hover and the focus pass both walk, and the node
// handle behind an index. Recomputed per page state — each pass reloads.
const collectTargets = () =>
page.evaluate(
([kebab, extra]) => {
const root = document.querySelector('[data-uix-stage]') ?? document.body;
return [...root.querySelectorAll<HTMLElement>('*')]
.filter(
(n) =>
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ||
(extra ? n.matches(extra) : false)
)
.map((n, i) => ({ i, tag: n.tagName.toLowerCase() }))
.filter((t) => ['button', 'a', 'input', 'li', 'div', 'tr'].includes(t.tag))
.slice(0, 24);
},
[component, EXTRA_NODES[component] ?? null] as const
);
const hovered: Record<string, Record<string, string>> = {};
for (const t of targets) {
const handle = await page.evaluateHandle(
const handleAt = (index: number) =>
page.evaluateHandle(
([kebab, idx, extra]) => {
const root = document.querySelector('[data-uix-stage]') ?? document.body;
const all = [...root.querySelectorAll<HTMLElement>('*')].filter(
@ -392,24 +504,37 @@ async function main() {
);
return all[idx as number] ?? null;
},
[component, t.i, EXTRA_NODES[component] ?? null] as const
[component, index, EXTRA_NODES[component] ?? null] as const
);
const el = handle.asElement();
const rowOf = (el: ElementHandle<HTMLElement>) =>
el.evaluate((n, keys) => {
const cs = getComputedStyle(n as HTMLElement);
const attrs = [...(n as HTMLElement).attributes]
.filter((a) => a.name.startsWith('data-') && !a.name.startsWith('data-event'))
.map((a) => (a.value ? `${a.name}=${a.value}` : a.name))
.sort()
.join('|');
const out: Record<string, string> = { __key: attrs };
for (const k of keys as string[]) out[k] = cs[k as keyof CSSStyleDeclaration] as string;
return out;
}, PROPS);
// Hover on every interactive part the recipe could paint.
await page
.reload({ waitUntil: 'networkidle', timeout: 15000 })
.catch(() => page.reload({ waitUntil: 'load', timeout: 15000 }));
await page.waitForTimeout(400);
await prepare();
const hovered: Record<string, Record<string, string>> = {};
for (const t of await collectTargets()) {
const el = (await handleAt(t.i)).asElement();
if (!el) continue;
try {
await el.hover({ timeout: 1500 });
await page.waitForTimeout(400);
const row = await el.evaluate((n, keys) => {
const cs = getComputedStyle(n as HTMLElement);
const attrs = [...(n as HTMLElement).attributes]
.filter((a) => a.name.startsWith('data-') && !a.name.startsWith('data-event'))
.map((a) => (a.value ? `${a.name}=${a.value}` : a.name))
.sort()
.join('|');
const out: Record<string, string> = { __key: attrs };
for (const k of keys as string[]) out[k] = cs[k as keyof CSSStyleDeclaration] as string;
return out;
}, PROPS);
const row = await rowOf(el);
hovered[`${t.i}:${row.__key}`] = row;
} catch {
/* not hoverable (offscreen / covered) — skip */
@ -417,10 +542,79 @@ async function main() {
}
result.hover = hovered;
// Focus on the same targets. The header of this file has promised this pass
// since it was written and NOTHING ever called `.focus()` — `result.focus` was
// never assigned. It runs on its OWN reload: the hover loop parks the pointer
// on its last target, and a hover rule outweighs the focus one it shares a
// node with.
//
// What it measures is PROGRAMMATIC focus, i.e. `:focus`. `:focus-visible` can
// resolve differently on the same node — in this engine a mouse click on a
// text input DOES match it — so the two gestures are never mixed here: the
// pointer does not move during this pass.
await page
.reload({ waitUntil: 'networkidle', timeout: 15000 })
.catch(() => page.reload({ waitUntil: 'load', timeout: 15000 }));
await page.waitForTimeout(400);
await prepare();
const focused: Record<string, Record<string, string>> = {};
for (const t of await collectTargets()) {
const el = (await handleAt(t.i)).asElement();
if (!el) continue;
try {
await el.focus();
await page.waitForTimeout(400);
const row = await rowOf(el);
focused[`${t.i}:${row.__key}`] = row;
} catch {
/* not focusable / detached — skip */
}
// Undo it before the next target: a node left focused keeps its focus
// chrome while the following one is measured.
await el.evaluate((n) => (n as HTMLElement).blur?.()).catch(() => {});
}
result.focus = focused;
// Disabled state — its own reload, its own prepare, and the LAST pass of the
// run. `data-disabled` is a condition that must never be lit while anything
// else is measured: file-upload's disabled rule drops `pointer-events` on the
// whole subtree, so a run that stamped it early would hover nothing — the
// "mounting more measures less" trap, this time as a state.
await page
.reload({ waitUntil: 'networkidle', timeout: 15000 })
.catch(() => page.reload({ waitUntil: 'load', timeout: 15000 }));
await page.waitForTimeout(400);
await prepare();
await page.evaluate(
([kebab, extra]) => {
const root = document.querySelector('[data-uix-stage]') ?? document.body;
const nodes = [...root.querySelectorAll<HTMLElement>('*')].filter(
(n) =>
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ||
(extra ? n.matches(extra as string) : false)
);
for (const n of nodes) {
n.setAttribute('data-disabled', '');
// A native control wears the PROPERTY, not the attribute: `:disabled`
// is what a recipe selects on for a button / input / select / textarea,
// and the attribute alone never lights it.
if (n.matches('button, input, select, textarea, fieldset'))
(n as HTMLButtonElement).disabled = true;
}
},
[component, EXTRA_NODES[component] ?? null] as const
);
await page.waitForTimeout(200);
result.disabled = await measure(component, PROPS, null);
await browser.close();
writeFileSync(out, JSON.stringify(result, null, '\t'), 'utf8');
const count = Object.keys(result.rest as object).length;
console.log(`probe ${component}: ${count} nodes at rest, ${Object.keys(hovered).length} hovered`);
console.log(
`probe ${component}: ${count} nodes at rest, ${Object.keys(hovered).length} hovered, ` +
`${Object.keys(focused).length} focused`
);
}
main();

@ -37,8 +37,9 @@
* server, headless.
*/
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { chromium } from 'playwright';
import {
SENTINEL_EXCEPTIONS,
@ -129,8 +130,12 @@ const PROPS = [
* free" (its recipe says so), and it has no demo route of its own: it is
* measured inside a host picker, behind that picker's popover. Without this the
* guard reported 0/31 and would have needed 31 false "exceptions".
*
* Exported because the probe reads the SAME map: a component the sentinel can
* only reach through an override is a component the probe cannot reach either,
* and two hand-kept copies would drift.
*/
const COMPONENT_OVERRIDES: Record<
export const COMPONENT_OVERRIDES: Record<
string,
{
attrPrefix?: string;
@ -636,6 +641,16 @@ const COMPONENT_OVERRIDES: Record<
{ attr: 'data-invalid', values: [null, ''] }
]
},
// Its rainbow track is painted on the COMPOSED Slider — the recipe re-points
// `--slider-track-bg` on `[data-adjustment='hue'] [data-slider]`, whose paint
// lands on that node's `::before`. Those nodes carry `data-slider*`, so the
// `data-image-adjustments-*` filter never saw them and `hue-track` read dead
// (11/12). Same class as `waveform` (playhead = an embedded Slider thumb).
// …and its reset boots DISABLED (nothing has been adjusted yet), so the
// `:hover:not([disabled])` rule could never match and its ink hover read dead.
// `disabled` is the axis whose off position is the attribute's ABSENCE, and
// nothing in the contract paints under its presence (the disabled rule is
// `--opacity-disabled`, system), so removing it costs no coverage.
// The selector names the HUE row alone on purpose: it is the only row the
// recipe re-tints, and widening it to every row pushed the reset out of the
// hover pass's 30-node cap (it became the 32nd match, so its ink hover read
@ -703,7 +718,8 @@ function sentinelFor(key: string): string {
async function main() {
const [component, urlArg] = process.argv.slice(2);
if (!component) throw new Error('usage: <component> [url]');
const url = urlArg ?? `http://localhost:5173/uix/components/${component}`;
const base = process.env.UIX_DEV_URL ?? 'http://localhost:5173';
const url = urlArg ?? `${base}/uix/components/${component}`;
const override = COMPONENT_OVERRIDES[component] ?? {};
const attrPrefix = override.attrPrefix ?? `data-${component}`;
// Openings that the blur + pointer-park would UNDO. A hover panel dismisses
@ -1146,4 +1162,13 @@ async function main() {
process.exit(unadjudicated.length === 0 ? 0 : 1);
}
main();
// Only when this file IS the entrypoint: the probe imports COMPONENT_OVERRIDES,
// and a bare `main()` would launch the whole guard (browser included) on import.
// `realpathSync`, or the guard dies through a junction: the ESM loader realpaths
// the entry URL and a bare `resolve` does not, so from a junctioned worktree the
// two never match and the CLI exits 0 having inspected nothing.
if (
process.argv[1] &&
import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href
)
main();

Loading…
Cancel
Save

Powered by TurnKey Linux.