diff --git a/scripts/theming-sentinel-exceptions.ts b/scripts/theming-sentinel-exceptions.ts index c5cc540ab..e091fabad 100644 --- a/scripts/theming-sentinel-exceptions.ts +++ b/scripts/theming-sentinel-exceptions.ts @@ -95,10 +95,15 @@ export const SENTINEL_EXCEPTIONS: Record> = { '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> = { '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': diff --git a/scripts/theming-sentinel.ts b/scripts/theming-sentinel.ts index 68b51e181..cbadc1f87 100644 --- a/scripts/theming-sentinel.ts +++ b/scripts/theming-sentinel.ts @@ -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 = { + 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, + seen: Set +): 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, + seen: Set = 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: [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 { // `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(); + 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 { + const index = new Map(); + 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: [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);