/** * theming-sentinel — the sentinel GUARD of PLAN-theming §7.4 point 11 (R-5.4). * * node scripts/theming-sentinel.ts [url] * npm run theming:sentinel -- [url] * * For every PUBLIC token the component declares in `recipes/base.ts`, sets an * unmistakable value and checks that SOME node's computed style follows it. A * token that moves nothing is a token that lies — unless its silence is * ADJUDICATED in `theming-sentinel-exceptions.ts` with a written reason (a part * the demo does not mount, a pseudo-element the instrument cannot read, a * forward into a composed component). Dead + unadjudicated = exit 1. * * Born as `__theming-sentinel.ts` during F2-A; promoted to a guard after the * adversarial review of 2026-08-21 measured 22 false negatives in 26 "no * effect" verdicts and two genuinely dead declarations that only THIS * instrument can see (gradient-picker's popover-owned chrome, carousel's * inline-gap from soma — invisible to any static CSS analysis). * * The review fixed three false-negative causes, each measured: * - the open step used to CLICK `[data-{c}-input]`, and a mouse click on a * text input DOES match `:focus-visible`, so the focus rule repainted the * rest-state chrome and rest tokens read dead (command.input-border, the * "unexplained" §13 entry). The active element is now blurred after opening. * - the override was written on `[data-{c}]` only, so components whose parts * hang from `{c}-root` (and every node the TSC declares resolved names on) * never received it. It is now written on :root AND every node carrying any * `data-{c}…` attribute — property-level competition is untouched, so the * gradient-picker / carousel class of genuine deaths still reads dead. * - `::before` / `::after` were invisible (media-player's buffering ring, * feed's spinner). Both pseudos are snapshotted now. `::placeholder` still * is not — that limit stays adjudicated per token. * Plus: a hover pass for hover-only tokens, a settle wait for [data-busy] * demos, and inset/animation props in the read set. * * Same environment rules as the probe: plain `node`, repo root, live dev * server, headless. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { chromium } from 'playwright'; import { SENTINEL_EXCEPTIONS } from './theming-sentinel-exceptions.ts'; const PROPS = [ 'backgroundColor', 'backgroundImage', 'backgroundSize', 'backgroundPosition', 'color', 'borderTopColor', 'borderTopWidth', 'borderTopLeftRadius', 'borderInlineStartWidth', // The colour of a border on any side but the TOP was invisible: prose tints // its blockquote rule on the inline start and its table rules on the block // edges, and all three read dead with a live token (measured 2026-08-22). 'borderInlineStartColor', // …and their WIDTHS, for the same reason: nav-tree draws its chevron with two // borders on the inline-end and block-end edges (measured 2026-08-22). 'borderInlineEndWidth', 'borderBlockStartWidth', 'borderBlockEndWidth', 'borderInlineEndColor', 'borderBlockStartColor', 'borderBlockEndColor', 'borderBottomColor', 'paddingTop', 'paddingLeft', 'paddingRight', 'paddingBottom', 'marginBottom', 'marginLeft', 'rowGap', 'columnGap', 'fontSize', 'fontWeight', 'fontFamily', 'lineHeight', 'letterSpacing', 'blockSize', 'inlineSize', 'minBlockSize', 'minInlineSize', 'maxBlockSize', 'maxInlineSize', 'boxShadow', 'opacity', 'filter', 'backdropFilter', 'textDecorationColor', 'textDecorationThickness', 'textUnderlineOffset', 'outlineColor', 'outlineWidth', 'outlineOffset', 'zIndex', // SVG paint + geometry. Without these EVERY token of an SVG recipe reads // dead: chart's axis / grid / separator ink, its stroke widths, the point // radius and the area's fill-opacity are all painted through presentation // attributes the box properties above never see (measured 2026-08-22 — // 12 of chart's 39 tokens). Same class as the `filter` / `backdrop-filter` // gap fixed the same day. 'fill', 'fillOpacity', 'stroke', 'strokeWidth', 'strokeOpacity', 'strokeDasharray', 'r', 'rx', 'ry', 'left', 'right', 'top', 'bottom', 'animationDuration' ]; /** * Components whose DOM does not follow `data-{component}-{part}`, so neither the * node filter nor the open step can find them by convention. * * `picker-shell` names its parts GENERICALLY on purpose — `data-picker-header` * / `-body` / `-footer` — "so every picker gets the same visual contract for * 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". */ const COMPONENT_OVERRIDES: Record< string, { attrPrefix?: string; openWith?: string[]; openBy?: 'click' | 'hover' | 'contextmenu'; urls?: string[]; /** Nodes to measure that carry NO `data-{c}-*` attr (prose styles bare HTML). */ extraNodes?: string; } > = { 'picker-shell': { attrPrefix: 'data-picker', openWith: ['[data-uix-stage-area] [data-popover-trigger]'], // It has no route of its own — `/uix/components/picker-shell` is a 404 — // so the default URL measured NOTHING and the guard reported 0/31 with six // unadjudicated. Named here so nobody has to know: it is measured inside a // host picker (6/31, the figure its own commit `67b4c810c` recorded). urls: ['/uix/components/date-picker'] }, // A hover card opens on POINTER-OVER, not on click: clicking its trigger // (an ``) navigates instead of revealing the panel, so the portaled // content never enters the document and 32 of its 35 tokens read dead. 'link-preview': { openBy: 'hover' }, // Not a panel to open but a VARIANT to switch on: the demo boots with // `showBorder=false`, and the frame owns three of the seven tokens. The // chip is outside the component, so clicking it cannot poison a hover // state (the pointer is parked right after, as for every other opener). // Chart is ONE recipe whose surface is spread over SIXTEEN demo routes, one // per chart type: `/chart` mounts a line+area chart and NOTHING else, so // two thirds of the contract (bar list, stacked bar, radar, smith, gauge, // funnel, polar, heat, pie…) reads dead on it. Measured 2026-08-22: 58 // chart nodes on `/chart` against 302 in the whole page and the rest in // sibling routes. // Prose styles RAW HTML through `:where([data-prose] el)`, so its parts carry // no attribute of its own: without this the guard measures ONE node (the root) // and 27 of its 37 tokens read dead. Measured 2026-08-22. prose: { extraNodes: '[data-prose] *' }, // A context menu opens on RIGHT click and on nothing else: with a plain // click the panel never enters the document and the guard measured ONE node. 'context-menu': { openBy: 'contextmenu', openWith: ['[data-uix-stage-area] [data-context-menu-trigger]'] }, // The row that holds link + trigger is a bare
with no attribute of its // own, so the gap between them read dead (measured 2026-08-22). 'nav-tree': { extraNodes: '[data-nav-tree-item] > div' }, // The ring is an SVG: its arc and halo are `` children with no // `data-aura*` attr, so three live tokens read dead (measured 2026-08-22). aura: { extraNodes: '[data-aura-ring] svg *' }, // The picker's swatches ARE `` components: they carry // `data-color-swatch`, not `data-color-picker-*`, so the filter never saw the // nodes its own `swatch-*` tokens paint (measured 2026-08-22). 'color-picker': { // Two families of node the filter cannot see: its swatches ARE // `` components (`data-color-swatch`) and its channel sliders // are SLIDERS — the thumb and the track carry `data-slider-*`, so four // tokens that paint them read dead (measured 2026-08-22). extraNodes: '[data-color-swatch], [data-color-picker-channel-slider] *' }, // Five triggers live on the popover page; the default `.first()` opens one of // the EXAMPLES further down, not the stage's, so the parts the stage mounts // (arrow, close) never enter the document. Measured 2026-08-22. 'popover': { openWith: ['[data-uix-stage-area] [data-popover-trigger]'], // `content-z` is consumed on the FLOATING WRAPPER (`:has(> [data-popover-content])`), // a node that carries no `data-popover*` attr — invisible to the filter and // dead-looking though it moves 80 -> 4321 (measured 2026-08-22). extraNodes: '[data-floating-wrapper]' }, chart: { // The tooltip and the crosshair only exist WHILE the pointer is over the // plot — there is no trigger to click and no state to latch. Hovering the // plot is what mounts them, and `openBy: 'hover'` also keeps the pointer // parked there instead of moving it away before each token. openBy: 'hover', openWith: ['[data-chart-plot]'], urls: [ '/uix/components/chart', '/uix/components/line-chart', '/uix/components/area-chart', '/uix/components/bar-chart', '/uix/components/scatter-chart', '/uix/components/bubble-chart', '/uix/components/pie-chart', '/uix/components/sparkline', '/uix/components/bar-list', '/uix/components/bar-segment', '/uix/components/radar-chart', '/uix/components/smith-chart', '/uix/components/polar-area', '/uix/components/funnel', '/uix/components/gauge', '/uix/components/heatmap' ] }, 'text-gradient': { openWith: [ '[data-uix-control]:has([data-uix-control-label]:text-is("showBorder")) [data-uix-chip]:text-is("true")' ] } }; function sentinelFor(key: string): string { if (/z$/.test(key)) return '4321'; if (/font-family/.test(key)) return 'Zapfino, cursive'; if (/font-weight/.test(key)) return '123'; if (/line-height/.test(key)) return '3.77'; if (/opacity|scale/.test(key)) return '0.123'; if (/duration/.test(key)) return '11.5s'; if (/letter-spacing/.test(key)) return '4.5px'; // BEFORE the colour test on purpose: a DIMENSION whose name merely contains a // colour word was getting `rgb(1, 2, 3)` and, being invalid for a length, // moved nothing and read dead — measured on `chart.slice-stroke-width` // (2026-08-22), which the substring `stroke` was capturing. if (/(width|size|radius|gap|height|padding|offset|thickness|inset)$/.test(key)) return '1234px'; if ( /color|bg$|fg$|border$|ring$|separator|-bg-|fill|stroke|outline$|glass|scrim$|track$/.test(key) ) return 'rgb(1, 2, 3)'; if (/shadow/.test(key)) return '0 0 0 7px rgb(1, 2, 3)'; return '1234px'; } async function main() { const [component, urlArg] = process.argv.slice(2); if (!component) throw new Error('usage: [url]'); const url = urlArg ?? `http://localhost:5173/uix/components/${component}`; const override = COMPONENT_OVERRIDES[component] ?? {}; const attrPrefix = override.attrPrefix ?? `data-${component}`; const contract = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace( /\r\n/g, '\n' ); const start = Math.max( contract.indexOf(`\n\t'${component}': {`), contract.indexOf(`\n\t${component}: {`) ); if (start < 0) throw new Error(`no recipe block for ${component}`); const end = contract.indexOf('\n\t},', start); const block = 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('_')); // 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 // LIVE when ANY of the component's surfaces follows it; only what nothing // moves anywhere is dead. Each URL only re-tests what is still pending, so // the common case (one route) costs exactly what it did before. const origin = new URL(url).origin; const urls = override.urls ? override.urls.map((path) => origin + path) : [url]; const browser = await chromium.launch(); const live: string[] = []; let pending = keys; for (const target of urls) { if (!pending.length) break; const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } }); await page.goto(target, { waitUntil: 'networkidle' }); await page.waitForTimeout(600); // A [data-busy] demo is still mutating — measuring it is measuring an instant. await page .waitForFunction(() => !document.querySelector('[data-busy]'), null, { timeout: 15000 }) .catch(() => {}); // Freeze transitions: a transitioned property reads its STARTING value right // after the write, so a live token looked dead (measured on combobox's // `box-shadow`, which transitions on `--duration-fast`). await page.addStyleTag({ content: '*, *::before, *::after { transition: none !important; }' }); // Open whatever can be opened, so portaled parts are in the document — then // BLUR: a mouse click on a text input matches `:focus-visible`, and the // focus rule repaints the rest-state chrome (the input-border false // negative). Popovers dismiss on outside pointerdown, not on blur, so the // open state survives. for (const sel of [ ...(override.openWith ?? []), `[data-${component}-trigger]`, `[data-${component}-input]`, `[data-${component}-stop]` ]) { const el = page.locator(sel).first(); if (await el.count()) { try { if (override.openBy === 'hover') await el.hover({ timeout: 1500 }); else if (override.openBy === 'contextmenu') await el.click({ button: 'right', timeout: 1500 }); else await el.click({ timeout: 1500 }); await page.waitForTimeout(400); break; } catch { /* not clickable */ } } } if (override.openBy !== 'hover') await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.()); // ...and PARK THE POINTER (never for a hover-opened panel: moving the cursor // away is exactly what dismisses it). blur() drops the focus but Playwright leaves the // cursor where it clicked, so `:hover` keeps matching — and a hover rule // usually outweighs the rest / focus / invalid ones it shares a node with // (measured on textarea 2026-08-21: hover (0,4,0) beats focus (0,3,0) beats // invalid (0,2,0) beats rest (0,1,0), so THREE rest-state tokens read dead). // Same class as the click-focus false negative above, and the half that fix // left behind. The per-token hover pass re-hovers on purpose further down. if (override.openBy !== 'hover') await page.mouse.move(0, 0); // Runs before EVERY token. A component with no `content` part (textarea, // any flat control) falls through to the click branch on every single key, // so the pointer parking below is not belt-and-braces — without it the // cursor sits on the input for the whole run. const reopen = async () => { const open = await page.locator( override.attrPrefix ? `[data-${component}]` : `[data-${component}-content]` ).count(); if (open) return; for (const sel of [ ...(override.openWith ?? []), `[data-${component}-trigger]`, `[data-${component}-input]` ]) { const el = page.locator(sel).first(); if (await el.count()) { try { if (override.openBy === 'hover') await el.hover({ timeout: 1000 }); else if (override.openBy === 'contextmenu') await el.click({ button: 'right', timeout: 1000 }); else await el.click({ timeout: 1000 }); await page.waitForTimeout(250); if (override.openBy !== 'hover') { await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.()); await page.mouse.move(0, 0); } return; } catch { /* keep trying */ } } } }; const staticPass = (key: string, value: string) => page.evaluate( ([kebab, token, val, props, prefix, extra]) => { const all = () => [...document.querySelectorAll('*')].filter( (n) => [...n.attributes].some((a) => a.name.startsWith(prefix)) || (extra ? n.matches(extra as string) : false) ); // Write on :root AND on every component node: resolved names are // declared per part (root/host), and a portaled panel never sees the // component root. Writing the var everywhere cannot fake a win at the // PROPERTY level — a declaration another rule (or an inline style) // beats stays beaten. const hosts = () => [document.documentElement, ...all()]; const sized = all().filter((n) => n.hasAttribute('data-size')); const original = sized.map((n) => n.getAttribute('data-size')); const snap = () => all() .map((n) => { const cs = getComputedStyle(n); const own = (props as string[]).map((p) => cs[p as never]).join('|'); const b = getComputedStyle(n, '::before'); const a = getComputedStyle(n, '::after'); // ::placeholder IS readable through getComputedStyle - measured // 2026-08-21 on textarea (the sentinel colour came straight back). // The next-features §13 note saying it is not was wrong, and it had // already cost command.input-placeholder-fg a hand-checked entry. const ph = getComputedStyle(n, '::placeholder'); const pseudo = (props as string[]) .map((p) => `${b[p as never]}~${a[p as never]}~${ph[p as never]}`) .join('|'); return own + '#' + pseudo; }) .join('@'); let moved = false; for (const size of ['md', 'xs', 'sm', 'lg', 'xl']) { for (const n of sized) n.setAttribute('data-size', size); const before = snap(); for (const h of hosts()) h.style.setProperty(`--${kebab}-${token}`, val as string); const after = snap(); for (const h of hosts()) h.style.removeProperty(`--${kebab}-${token}`); if (before !== after) { moved = true; break; } } sized.forEach((n, i) => (original[i] ? n.setAttribute('data-size', original[i]!) : null)); return moved; }, [component, key, value, PROPS, attrPrefix, override.extraNodes ?? null] as const ); // Hover pass — a `hover-*` token can only move a computed value while some // node is really hovered (tree-grid's hover-row-bg, gradient-picker's // hover-preset-border were false negatives without it). const hoverPass = async (key: string, value: string) => { const count = await page.evaluate( ([prefix, extra]) => [...document.querySelectorAll('*')].filter( (n) => [...n.attributes].some((a) => a.name.startsWith(prefix)) || (extra ? n.matches(extra) : false) ).length, [attrPrefix, override.extraNodes ?? null] as const ); for (let i = 0; i < Math.min(count, 30); i++) { const handle = await page.evaluateHandle( ([prefix, idx, extra]) => [...document.querySelectorAll('*')].filter( (n) => [...n.attributes].some((a) => a.name.startsWith(prefix)) || (extra ? n.matches(extra as string) : false) )[idx as number] ?? null, [attrPrefix, i, override.extraNodes ?? null] as const ); const el = handle.asElement(); if (!el) continue; try { await el.hover({ timeout: 800 }); } catch { continue; } const moved = await page.evaluate( ([prefix, token, val, props, idx, kebab, extra]) => { const all = () => [...document.querySelectorAll('*')].filter( (n) => [...n.attributes].some((a) => a.name.startsWith(prefix)) || (extra ? n.matches(extra as string) : false) ); const node = all()[idx as number]; if (!node) return false; const snap = () => { const cs = getComputedStyle(node); return (props as string[]).map((p) => cs[p as never]).join('|'); }; const before = snap(); for (const h of [document.documentElement, ...all()]) h.style.setProperty(`--${kebab}-${token}`, val as string); const after = snap(); for (const h of [document.documentElement, ...all()]) h.style.removeProperty(`--${kebab}-${token}`); return before !== after; }, [attrPrefix, key, value, PROPS, i, component, override.extraNodes ?? null] as const ); if (moved) return true; } return false; }; const stillDead: string[] = []; for (const key of pending) { await reopen(); const value = sentinelFor(key); let moved = await staticPass(key, value); if (!moved && /hover/.test(key)) moved = await hoverPass(key, value); if (moved) live.push(key); else stillDead.push(key); } pending = stillDead; await page.close(); } await browser.close(); const dead = pending; const ledger = SENTINEL_EXCEPTIONS[component] ?? {}; const adjudicated = dead.filter((k) => k in ledger); const unadjudicated = dead.filter((k) => !(k in ledger)); const stale = live.filter((k) => k in ledger); console.log(`sentinel ${component}: ${live.length}/${keys.length} tokens move a computed value`); for (const k of adjudicated) console.log(` adjudicated ${k} — ${ledger[k]}`); if (stale.length) console.log( ` STALE exception(s) — the token moves now, the ledger entry should go: ${stale.join(', ')}` ); if (unadjudicated.length) console.log( ` NO EFFECT, UNADJUDICATED (${unadjudicated.length}): ${unadjudicated.join(', ')}\n` + ` R-5.4: a public token that moves nothing and carries no written adjudication is a token that lies.` ); process.exit(unadjudicated.length === 0 ? 0 : 1); } main();