fix(theming): S2 de la GRANDE - el centinela elige la sonda por el VALOR, no por el nombre

Segundo barrido de la firma de instrumento grande (ceguera 3 del expediente
VA). sentinelFor(key) pasa a sentinelFor(key, kind): el guard ya leia base.ts
para extraer las claves y ahora captura en el mismo pase el VALOR por defecto
de cada una y tipa la sonda por el (literales por sintaxis; var() por el
vocabulario del sistema, con resolucion transitiva dentro del catalogo via
recipeTokenIndex). La escalera de nombre queda como respaldo para lo no
clasificable. La regla: la sonda tiene que ser imposible EN LA DIRECCION en
que la propiedad puede moverse.

Censo del cambio: 357 claves cambian de sonda (320 clase A color<-1234px en
40 componentes - la clase entera del "vivo por accidente" IACVT - + 17 B +
8 E + 1 F + 11 menores). Easing (52 claves) intacto y explicito. Dos
desviaciones del boceto, ambas medidas: el % NO se clasifica (opacity 62% vs
width 42% - ambiguo) y el entero/0 tampoco (la "clase E de 24" del expediente
era una mala lectura: 22 son calc(px * var(--scaling)), longitudes; y tipar
el 0 habria matado en falso 7 claves vivas con default 0 - no monotono).

Barrido S2 del ledger entero contra la base S1: 91/91 verdes, MONOTONO (0
componentes bajan), 0 rojos, 0 avisos de prepareWith, 39,7 min. Catalogo
2776/3577 -> 2778/3577: avatar 80->81 y stepper 62->63, exactamente las dos
victimas medidas del expediente. Ninguna de las 102 claves clase A cubiertas
por patron revivio - esperan la ceguera 2 (S3), como estaba escrito. Un
fantasma no reproducido (spin-field 0/20 en 6,2s, la pagina no rindio; rerun
13/20 identico a S1) queda archivado como flake del instrumento.

Ledger: DOS retiradas exactas con acta y medida - avatar.badge-fg-custom-
contrast (default white, la entrada describia la sonda, no el token) y
stepper.neutral-text (el unico caso donde la sonda mala daba VEREDICTO malo:
la IACVT caia en la tinta heredada que el tono neutral practicamente es).
background.scrim-fg-over-* siguen muertas por su puerta real y su entrada
sobrevive tal cual. Re-corridos tras editar: verdes, sin STALE.

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

@ -95,10 +95,15 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'only the step in force paints (the demo boots ringWidth=md); forced data-ring-width=sm -> reaches (halo 1.5px -> 17px)',
'group-overlap-xxl':
'same xxl step, on AvatarGroup; forced data-size=xxl on the real group -> reaches (margin-inline-start -33.6px -> 37px)',
// 2026-08-25, added with the D-TH.6 rename. This one is NOT about the
// stage: it is the guard picking the wrong probe VALUE.
'badge-fg-custom-contrast':
'INSTRUMENT, not the token. `sentinelFor` chooses the probe value by regex on the key NAME, and its ink test is `fg$` — `fg` as the FINAL segment — while D-TH.6 leaves the slot MEDIAL here; the chain carries `-bg-` but has no `-fg-`. So the key gets the `1234px` default, which is invalid for `color:` and moves nothing. Measured under the conditions the guard itself uses (same node set of 55, same data-variant sweep, same size forcing) on the real custom badge of /uix/components/avatar: with `1234px` the snapshot does not move on any combo; with `rgb(1, 2, 3)` it moves on `solid` (rgb(255,255,255) -> rgb(1,2,3)). From `:root` alone it also reaches (white -> rgb(4,5,6)). It read alive until 2026-08-25 under its old name `badge-color-custom-contrast`, which matched `color`. Same class as `bubble-fg-in` / `bubble-fg-out` and `scrim-fg-over-dark` / `scrim-fg-over-light` — the five keys of the catalogue whose `fg` is medial (next-features §13)'
// RETIRED 2026-08-25 (sweep S2 of the instrument firma): the entry
// `badge-fg-custom-contrast`, added the same day with the D-TH.6 rename.
// It never described the token — it described the PROBE: `sentinelFor`
// chose its value by regex on the key NAME (ink test `fg$`, and D-TH.6
// leaves the `fg` MEDIAL here), so a slot whose default is `white` was
// written `1234px`, invalid for `color:`. The chooser now reads the
// DEFAULT VALUE out of base.ts and types the key as a colour. Measured on
// the same route with the same overrides: 80/86 -> 81/86, and this is the
// one key that moved.
},
editable: {
// 2026-08-23, revised 2026-08-24. The transition pair IS the transition the
@ -1377,10 +1382,17 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'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"
// RETIRED 2026-08-25 (sweep S2 of the instrument firma): the entry
// `neutral-text`. It was never a tone problem — stepper emits a `neutral`
// block and the ladder gives it to the node — and it was never the token:
// it was the PROBE. The old chooser picked a colour only for names
// matching /color|bg$|fg$|…|track$/, so a slot whose default is
// `var(--color-neutral-text)` got `1234px`, invalid for `color`, and the
// IACVT fallback landed on the inherited ink the neutral tone practically
// IS — the one case where a wrong probe produced a wrong VERDICT, not just
// a wrong reason. The chooser now types the key by its default value.
// Measured on the same route with the same overrides: 62/69 -> 63/69, and
// this is the one key that moved.
},
'tag-group': {
'transition-duration':

@ -694,7 +694,186 @@ export const COMPONENT_OVERRIDES: Record<
}
};
function sentinelFor(key: string): string {
/**
* ── The probe, chosen by TYPE ─────────────────────────────────────────────
*
* `sentinelFor` used to pick its value from the key's NAME, and a name is not a
* type: 313 keys across 40 components whose default is a COLOUR were getting
* `1234px`, which is invalid for `color:` / `background:`. An invalid
* declaration is IACVT — the property falls back to its inherited or initial
* value — so the snapshot usually moved ANYWAY and the verdict came out right
* for the wrong reason. Where that fallback landed on the value already painted
* it came out WRONG, and the ledger had to adjudicate the instrument twice:
* `stepper.neutral-text` (the neutral tone IS practically the inherited ink)
* and `avatar.badge-fg-custom-contrast` (its `fg` is MEDIAL, so the ink test —
* `fg$` — never saw it).
*
* A token's DEFAULT VALUE is its type, and `base.ts` is already read here. The
* rule of the expediente: the probe has to be impossible IN THE DIRECTION the
* property can move.
*/
type SentinelKind =
| 'color'
| 'length'
| 'number'
| 'weight'
| 'leading'
| 'tracking'
| 'family'
| 'shadow'
| 'time'
| 'z';
/** The same probe values the name ladder has always written, keyed by TYPE. */
const SENTINEL_BY_KIND: Record<SentinelKind, string> = {
color: 'rgb(1, 2, 3)',
length: '1234px',
number: '0.123',
weight: '123',
leading: '3.77',
tracking: '4.5px',
family: 'Zapfino, cursive',
shadow: '0 0 0 7px rgb(1, 2, 3)',
time: '11.5s',
z: '4321'
};
/**
* What a `var(--…)` name MEANS in the system's vocabulary. ORDERED — first
* match wins, so the narrow rules (`…-line-height`, `…-color`) come before the
* broad ones (`…-height`, `^color-`).
*
* `null` is a DELIBERATE non-classification: the key falls through to the name
* ladder and its probe does not change. Easing is the whole of it — the 51
* `*-ease` keys were adjudicated EN MASSE against the ladder's `1234px`, and
* re-probing them is a different expediente.
*/
const SYSTEM_VOCABULARY: [RegExp, SentinelKind | null][] = [
[/^ease-/, null],
[/^gradient-/, null],
[/^font-feature-/, null],
[/-color$/, 'color'],
[/^color-/, 'color'],
[/^primitive-/, 'color'],
[/^scale-[a-z]+-\d+$/, 'color'],
[/font-family$/, 'family'],
[/^font-family-/, 'family'],
[/font-weight/, 'weight'],
[/^leading-/, 'leading'],
[/line-height/, 'leading'],
[/^tracking-/, 'tracking'],
[/letter-spacing$/, 'tracking'],
[/^opacity-/, 'number'],
[/^(scaling|press-scale)$/, 'number'],
[/^z-index-/, 'z'],
[/^(duration-|press-duration$)/, 'time'],
[/shadow/, 'shadow'],
[/halo$/, 'shadow'],
[
/^(space|radius|border-width|blur|measure|container-width|font-size|icon-stroke-width)/,
'length'
],
[
/(font-size|control-height|icon-size|padding(-inline|-block)?|gap|radius|width|height|offset)$/,
'length'
]
];
const COLOR_LITERAL =
/^(#[0-9a-f]{3,8}$|(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(|transparent$|currentcolor$|white$|black$)/i;
const LENGTH_LITERAL = /^-?(\d+\.?\d*|\.\d+)(px|rem|em|ch|ex|vh|vw|dvh|dvw|vmin|vmax)$/;
const TIME_LITERAL = /^-?(\d+\.?\d*|\.\d+)m?s$/;
/** Split a value on TOP-LEVEL whitespace: `rgb(0 0 0 / 0.55)` is ONE term. */
function valueTerms(value: string): string[] {
const out: string[] = [];
let depth = 0;
let current = '';
for (const ch of value) {
if (ch === '(') depth++;
else if (ch === ')') depth--;
if (depth === 0 && /\s/.test(ch)) {
if (current) out.push(current);
current = '';
} else current += ch;
}
if (current) out.push(current);
return out;
}
function kindOfTerm(
term: string,
index: Map<string, string>,
seen: Set<string>
): SentinelKind | null {
const ref = term.match(/^var\(--([a-z0-9-]+)\s*[,)]/);
if (ref) {
const name = ref[1];
const target = index.get(name);
if (target !== undefined && !seen.has(name)) {
seen.add(name);
const kind = kindOfValue(target, index, seen);
if (kind) return kind;
}
for (const [pattern, kind] of SYSTEM_VOCABULARY) if (pattern.test(name)) return kind;
return null;
}
// An arithmetic expression has the dimension of its DIMENSIONED operand, not
// of the first thing inside it: `calc(56px * var(--scaling, 1))` is a length
// a unitless factor scales, and reading the factor made 22 lengths (every
// gradient-builder / color-swatch size) come out as numbers.
if (/^(calc|min|max|clamp)\(/.test(term)) {
const operands = [...term.matchAll(/var\(--[a-z0-9-]+|[\d.]+[a-z%]+/g)].map((m) =>
kindOfTerm(m[0].startsWith('var(') ? `${m[0]})` : m[0], index, seen)
);
return operands.find((k) => k && k !== 'number') ?? null;
}
if (COLOR_LITERAL.test(term)) return 'color';
if (LENGTH_LITERAL.test(term)) return 'length';
if (TIME_LITERAL.test(term)) return 'time';
// A FRACTIONAL bare number cannot be a length (only zero may drop its unit),
// so it is a ratio, an opacity, a strength or a line-height — all probed the
// same way. `background.scrim-strength-*` and `stepper.nav-hover-brightness`
// live here: the ladder wrote `1234px` into a `color-mix()` percentage and a
// `brightness()` factor, where it is invalid.
if (/^-?(\d+\.\d+|\.\d+)$/.test(term) && Number(term) !== 0) return 'number';
// An INTEGER (and `0`) stays unclassified on purpose: `0` is a length AND a
// number AND a z, `900` is a weight, `2` is a z and `20` is a count — only
// the NAME separates them. Typing them as numbers would put `0.123`, invalid
// for a length, on the seven `0`-valued radius / gap / min-width keys, whose
// IACVT fallback is the authored `0` itself: seven live tokens reading dead.
// A PERCENTAGE is ambiguous for the same reason (`width: 42%` is a length,
// `opacity: 62%` is a number, `background-position: 60% 55%` is neither).
return null;
}
export function kindOfValue(
value: string | undefined,
index: Map<string, string>,
seen: Set<string> = new Set()
): SentinelKind | null {
if (!value) return null;
const terms = valueTerms(value.trim());
if (!terms.length) return null;
if (terms.length === 1) return kindOfTerm(terms[0], index, seen);
const kinds = terms.map((t) => kindOfTerm(t, index, seen));
// `0 1px 2px rgb(17 14 10 / 0.12)` / `0 0 0 1px var(--color-border-default)`:
// a colour riding on a run of OFFSETS is a shadow. The offsets are what tells
// it from `underline dotted var(--color-content-muted)`, a text-decoration
// shorthand that a colour plus three terms alone would have called a shadow.
const offsets = terms.filter((t, i) => kinds[i] === 'length' || /^-?0(\.0+)?$/.test(t));
if (kinds.includes('color') && offsets.length >= 2) return 'shadow';
if (kinds.every((k) => k === 'length')) return 'length';
return null;
}
/**
* The type decides; the NAME LADDER below is the fallback for everything the
* catalogue does not type (a keyword like `revert-layer`, a raw number, an
* easing curve).
*/
export function sentinelFor(key: string, kind: SentinelKind | null): string {
if (kind) return SENTINEL_BY_KIND[kind];
if (/z$/.test(key)) return '4321';
if (/font-family/.test(key)) return 'Zapfino, cursive';
if (/font-weight/.test(key)) return '123';
@ -715,20 +894,12 @@ function sentinelFor(key: string): string {
return '1234px';
}
async function main() {
const [component, urlArg] = process.argv.slice(2);
if (!component) throw new Error('usage: <component> [url]');
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
// when the cursor leaves; a focus-only surface disappears when the focus does.
const openingIsFragile = override.openBy === 'hover' || override.openBy === 'focus';
const contract = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace(
/\r\n/g,
'\n'
);
/**
* The keys AND DEFAULT VALUES a component declares in `base.ts`, in source
* order. The value is what types the token, so the guard reads it in the same
* pass that used to read the names alone.
*/
export function recipeEntries(contract: string, component: string): Map<string, string> {
// `avatar` is the ONLY entry whose value is an IIFE (a local matrix helper
// generates its 24 composite scopes), so its map lives in the `return {` one
// tab deeper: this probe found no block at all and the guard threw «no recipe
@ -748,9 +919,53 @@ async function main() {
iife >= 0
? contract.slice(start, end).replace(/^\t/gm, '')
: contract.slice(start, end < 0 ? undefined : end);
const keys = [...block.matchAll(/^\t\t'?([a-z0-9-]+)'?\s*:/gm)]
.map((m) => m[1])
.filter((k) => !k.startsWith('_'));
const heads = [...block.matchAll(/^\t\t'?([a-z0-9-]+)'?\s*:/gm)];
const entries = new Map<string, string>();
heads.forEach((head, i) => {
const from = head.index + head[0].length;
const to = i + 1 < heads.length ? heads[i + 1].index : block.length;
// A token is `'value'`, or a declaration object / array whose FIRST
// `value:` is the host default. A `scope:` string is not a value.
const quoted = block
.slice(from, to)
.replace(/scope:\s*'[^']*'/g, '')
.match(/'([^']*)'/);
entries.set(head[1], quoted ? quoted[1] : '');
});
return entries;
}
/**
* Every recipe token by the custom property it emits (`--{component}-{key}`),
* so a `var()` pointing INTO the catalogue resolves to the value it really
* carries. Without it `--color-picker-trigger-radius` reads as system `color-*`
* vocabulary and a radius is probed with a colour.
*/
export function recipeTokenIndex(contract: string): Map<string, string> {
const index = new Map<string, string>();
for (const [, component] of contract.matchAll(/^\t'?([a-z0-9-]+)'?:\s*(?:\{|\(\()/gm))
for (const [key, value] of recipeEntries(contract, component))
index.set(`${component}-${key}`, value);
return index;
}
async function main() {
const [component, urlArg] = process.argv.slice(2);
if (!component) throw new Error('usage: <component> [url]');
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
// when the cursor leaves; a focus-only surface disappears when the focus does.
const openingIsFragile = override.openBy === 'hover' || override.openBy === 'focus';
const contract = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace(
/\r\n/g,
'\n'
);
const entries = recipeEntries(contract, component);
const tokenIndex = recipeTokenIndex(contract);
const keys = [...entries.keys()].filter((k) => !k.startsWith('_'));
// A component whose surface is spread over SEVERAL demo routes cannot be
// judged on one page: what that page does not mount reads dead. A token is
@ -1115,7 +1330,7 @@ async function main() {
const stillDead: string[] = [];
for (const key of pending) {
await reopen();
const value = sentinelFor(key);
const value = sentinelFor(key, kindOfValue(entries.get(key), tokenIndex));
let moved = await staticPass(key, value);
if (!moved && /hover/.test(key)) {
moved = await hoverPass(key, value);

Loading…
Cancel
Save

Powered by TurnKey Linux.