You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/theming-sentinel.ts

828 lines
37 KiB

/**
* theming-sentinel — the sentinel GUARD of PLAN-theming §7.4 point 11 (R-5.4).
*
* node scripts/theming-sentinel.ts <component> [url]
* npm run theming:sentinel -- <component> [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,
SENTINEL_PATTERN_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[];
/**
* `focus` is for a surface that exists ONLY while focused: skip-link is
* sr-only until Tab reaches it, so every one of its eight tokens read dead
* (0/8, measured 2026-08-23) — the guard never focused it, and the blur it
* does after opening would have undone it anyway.
*/
openBy?: 'click' | 'hover' | 'contextmenu' | 'focus';
urls?: string[];
/** Nodes to measure that carry NO `data-{c}-*` attr (prose styles bare HTML). */
extraNodes?: string;
/**
* Demo controls to switch ON before measuring — ALL of them, unlike
* `openWith`, which stops at the first that works because it opens ONE
* surface. A component whose parts are independent OPT-IN layers
* (background: scrim, spotlight, pause… each behind its own control)
* measured 3 of 37 tokens on the default stage: what the demo does not
* mount has no node to paint. Failures are ignored — a control that is
* not there is not an error, it is one layer this route cannot show.
*/
prepareWith?: string[];
/**
* How to tell the surface is ALREADY open, when it is not
* `[data-{c}-content]`. Without it the re-open guard that runs before every
* token sees "closed", clicks the trigger again and TOGGLES the surface
* shut — half the run measured against a closed panel.
*/
openMarker?: string;
/**
* An attribute to SWEEP while measuring, with the values to try. A demo that
* mounts ONE instance (button: a single solid/primary stage driven by chips)
* hides every token that only paints under another variant — 71 of buttons
* 105 read dead without this.
*/
sweepAttr?: { attr: string; values: string[] };
}
> = {
// Two thirds of its surface is OPT-IN or lives on another route: the badge is
// off by default (and boots in DOT mode, where the chip has no text frame),
// the ring is off, and AvatarGroup — same recipe, same `data-avatar-group`
// prefix — is a component with its own demo. Without this the guard saw the
// bare portrait: no badge, no halo, no stack.
avatar: {
prepareWith: [
'[data-uix-control]:has([data-uix-control-label]:text-is("show badge")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("dot")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("ring")) [data-uix-chip]:text-is("solid")'
],
// The 16 `*-outline-*` keys only paint under the outline variant, on the
// badge (the root reads its border through the shared palette forward).
sweepAttr: { attr: 'data-variant', values: ['solid', 'soft', 'outline'] },
urls: ['/uix/components/avatar', '/uix/components/avatar-group']
},
'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']
},
// Its WHOLE surface exists only under `:focus` — sr-only until Tab reaches
// it, a pill afterwards — and there is no trigger to click: the guard read
// 0/8, every token of a recipe that works. Focusing it is the opening, and
// the blur that follows a click opening is exactly what would undo it.
// Clicking is NOT an option either: its handler moves the focus to the
// destination region, so the pill would vanish on the way in.
'skip-link': { openBy: 'focus', openWith: ['[data-skip-link]'] },
// A hover card opens on POINTER-OVER, not on click: clicking its trigger
// (an `<a>`) 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' },
// A tooltip opens on POINTER-OVER too: clicking its trigger does nothing and
// the panel never enters the document — 24 of its 24 tokens read dead
// (measured 2026-08-23).
tooltip: {
openBy: 'hover',
sweepAttr: { attr: 'data-variant', values: ['solid', 'outline', 'ghost'] }
},
// 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] *' },
// Half its contract lives in parts the default stage does not mount: the
// AutoFields tree (array, item, widget) only exists in the demo's `auto`
// mode, and the panel chrome only under `variant='panel'`. 18 of 54 tokens
// moved before this.
form: {
prepareWith: [
"[data-uix-control]:has([data-uix-control-label]:text-matches('^demo mode')) [data-uix-chip]:text-is('auto')"
],
sweepAttr: { attr: 'data-variant', values: ['plain', 'panel'] }
},
// A shared LAYER, not a component: no wrapper, no route of its own. It paints
// the stepper affordances of NumberField / CssField, so it is measured inside
// one of them.
'spin-field': { urls: ['/uix/components/number-field'] },
// Half of it is the CIRCULAR shape (the ring: 12 size / thickness steps plus
// its track and centre) and the demo boots `linear`, so 14 of its 28 tokens
// had no node to paint.
meter: { sweepAttr: { attr: 'data-shape', values: ['linear', 'circular'] } },
// Same split as its twin: half the contract is the RING (12 size / thickness
// steps plus its track and its centre) and the demo boots `linear`.
progress: {
prepareWith: [
"[data-uix-control]:has([data-uix-control-label]:text-is('indeterminate')) input[type=checkbox]",
"[data-uix-control]:has([data-uix-control-label]:text-is('orientation')) [data-uix-chip]:text-is('vertical')"
],
sweepAttr: { attr: 'data-shape', values: ['linear', 'circular'] }
},
// Its dot / icon / remove parts are OPT-IN switches, all off by default.
badge: {
prepareWith: [
"[data-uix-control]:has([data-uix-control-label]:text-is('dot')) input[type=checkbox]",
"[data-uix-control]:has([data-uix-control-label]:text-is('icon')) input[type=checkbox]",
"[data-uix-control]:has([data-uix-control-label]:text-is('removable')) input[type=checkbox]"
],
sweepAttr: { attr: 'data-variant', values: ['solid', 'soft', 'outline', 'ghost'] }
},
// Its mega-menu rows are the consumer's bare `<a>`, styled through
// `[data-navigation-menu-content] :is(a, …)` — no attribute of the
// component's own, so the filter never measured them. Measured 2026-08-23:
// `content-link-padding-block` and `-radius` read dead, while
// `-padding-inline` read LIVE off the panel it widens — a false negative and
// a false positive from the same blind spot. And its panel is a HOVER panel:
// soma opens on `pointerenter` and `pointerleave` schedules the close, so the
// pointer parking the guard does after a click (`mouse.move(0, 0)`) shut it
// MID-RUN — two runs of the same code disagreed on one token.
'navigation-menu': { openBy: 'hover', extraNodes: '[data-navigation-menu-content] a' },
// Its parts are independent OPT-IN layers, each behind its own demo control,
// and only ONE pattern renders at a time: on the default stage the guard saw
// a single `glow` layer and 3 of 37 tokens moved. Switch the extra layers on,
// then sweep the pattern axis.
background: {
// ONLY the layers that do not COVER what is already being measured:
// switching the spotlight, the parallax speed and the pointer depth on
// repaints the pattern layer's own `background-image` and its transform,
// and the run went BACKWARDS (17 → 9 tokens). A guard that mounts more
// can measure less.
prepareWith: [
"[data-uix-control]:has([data-uix-control-label]:text-matches('^scrim')) input[type=checkbox]",
"[data-uix-control]:has([data-uix-control-label]:text-matches('^blur')) [data-uix-chip]:text-is('xl')"
],
sweepAttr: {
attr: 'data-pattern',
values: ['glow', 'mesh', 'grid', 'dots', 'noise', 'vignette', 'lines', 'rings']
}
},
// Same class: its chrome is an embedded Slider the recipe re-tints, and the
// playhead IS that slider's thumb — `data-waveform*` matched 4 nodes and none
// of them was it.
waveform: { extraNodes: '[data-waveform] [data-slider], [data-waveform] [data-slider] *' },
// No route of its own (`/uix/components/audio-player` = 404, the FOURTH canon
// component without a demo). It is the audio skin of the media-player chassis:
// its parts are `data-media-player-*` and it only renders after the demo's
// `media: audio` chip. Measured there.
'audio-player': {
urls: ['/uix/components/media-player'],
openWith: ['[data-uix-control]:has([data-uix-control-label]:text-is("media")) [data-uix-chip]:text-is("audio")'],
openMarker: '[data-media-player][data-variant]',
extraNodes: '[data-media-player][data-variant], [data-media-player][data-variant] *',
sweepAttr: { attr: 'data-variant', values: ['card', 'row', 'bar', 'inline'] }
},
// No route of its own — `/uix/components/surface` is a 404, like picker-shell
// and mockup — so the guard measured an EMPTY page and reported 0/25. It is
// the paint side of Box: measured where it actually renders.
surface: { urls: ['/temas/gradientes', '/blocks/cta'] },
// Its palette resolves per INTENT and then per VARIANT: the solid/contrast
// slots only paint under `solid`, the border slot only under `outline`, and
// the demo mounts one of each. 38 of its 42 tokens read dead without the
// sweep (2026-08-23).
banner: { sweepAttr: { attr: 'data-variant', values: ['soft', 'solid', 'outline', 'ghost'] } },
// Same class-hook blindness as prose, inside an SVG: sectors, labels, icons
// and the trigger glyph carry no `data-onion-*`.
'onion-menu': { openMarker: '.onion-menu-sector', extraNodes: '.onion-menu-sector, .onion-menu-label, .onion-menu-icon, .onion-menu-icon svg, .onion-menu-trigger, .onion-menu-trigger-glyph' },
// The shared control trigger (the icon affordance a picker drops inside the
// field) is mounted by NO field demo: /field has plain inputs and the
// segmented fields have segments, not triggers. It lives on the pickers, so
// its four tokens read dead on the field route alone.
field: { urls: ['/uix/components/field', '/uix/components/date-picker'] },
// The bar drops TWO different surfaces and only one carries its public knobs:
// the first entry opens a role=menu Content (chrome owned by dropdown-menu),
// while the role=dialog Panel — z, min-width, max-height, padding, radius,
// typography — hangs off the "Format" entry. Opening the first trigger left
// every panel token dead. The marker is the panel, not `-content`: with the
// default marker the re-open guard closed it again on every token.
menubar: {
openWith: ["[data-menubar-trigger][data-menubar-value='format']"],
openMarker: '[data-menubar-panel]'
},
// 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 <div> 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 `<circle>` children with no
// `data-aura*` attr, so three live tokens read dead (measured 2026-08-22).
aura: { extraNodes: '[data-aura-ring] svg *' },
// The FIFTH canon component with no route of its own
// (`/uix/components/color-swatch` is a 404, like picker-shell / mockup /
// surface / audio-player): it is measured where it actually renders — the
// gradient-builder stop rows mount three chips in the stage, the color-picker
// trigger one more. And its `rounded` axis is mounted by NOBODY (no consumer
// in the repo passes the prop), so the three non-default corner steps have no
// node to paint: swept while measuring, like meter's shape.
'color-swatch': {
urls: ['/uix/components/gradient-builder', '/uix/components/color-picker'],
sweepAttr: { attr: 'data-rounded', values: ['sm', 'md', 'lg', 'full'] }
},
// The picker's swatches ARE `<ColorSwatch>` 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
// `<ColorSwatch>` 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")'
]
},
// It boots EMPTY — a FileUpload dropzone — and every part the recipe paints
// (preview, canvas, toolbar, the two icon buttons) exists ONLY in the `ready`
// state, which no trigger reaches: the demo's sample chip is the only way in.
// The marker keeps the re-open guard from clicking that chip before every
// token, which would rebuild the File and remount the preview mid-run.
'image-picker': {
prepareWith: ['[data-uix-chip]:text-is("Load sample image")'],
openMarker: '[data-image-picker-preview]'
},
// Two states the default stage does not show, and NINE of its seventeen
// tokens live in them: the loading indicator renders only while `loading`
// (eight tokens), and the clear affordance only fades while the field is
// EMPTY — and the demo boots with a value. Clearing it is what empties it,
// and the click has to come FIRST: the same rule then drops
// `pointer-events` on the very button that fired it.
'search-field': {
prepareWith: [
'[data-search-field-clear-trigger]',
'[data-uix-control]:has([data-uix-control-label]:text-is("loading")) input[type=checkbox]'
]
}
};
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: <component> [url]');
const url = urlArg ?? `http://localhost:5173/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'
);
// `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
// block for avatar» — the component could not be measured. Read the returned
// map and dedent it once; everything downstream works unchanged.
const iife = contract.indexOf(`\n\t${component}: ((): RecipeTokenMap => {`);
const start =
iife >= 0
? contract.indexOf('\n\t\treturn {', iife)
: 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(iife >= 0 ? '\n\t\t};' : '\n\t},', start);
const block =
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('_'));
// 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 } });
// A demo whose network never goes idle (image keeps retrying the broken src
// of its error state) would time out the WHOLE run. The idle wait is a
// convenience, not a gate: fall back to the load event (2026-08-23).
try {
await page.goto(target, { waitUntil: 'networkidle', timeout: 15000 });
} catch {
await page.goto(target, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(800);
}
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.
// A surface that boots OPEN must not be clicked SHUT: onion-menu renders its
// sectors from the start, and the opening click toggled the whole recipe out
// of the document (12 of its 20 tokens read dead, 2026-08-23).
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 */
}
}
const alreadyOpen = override.openMarker
? (await page.locator(override.openMarker).count()) > 0
: false;
for (const sel of alreadyOpen
? []
: [
...(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 === 'focus') await el.focus({ 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 (!openingIsFragile)
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 (!openingIsFragile) 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.openMarker ??
(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 === 'focus') await el.focus({ 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 (!openingIsFragile) {
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, sweepAttr, sweepVals]) => {
const all = () =>
[...document.querySelectorAll<HTMLElement>('*')].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()];
// A tone token (`risk-solid`, `affirm-track`…) only paints on an instance
// wearing that tone: the palette forward routes the slots through
// `--{c}-palette-*` per [data-color]. Stamp the tone the key names.
// The attribute this component sweeps while measuring (variant, look…), or a
// single null pass when it has none.
const swept = sweepAttr ? all() : [];
const sweptWas = swept.map((n) => n.getAttribute(sweepAttr as string));
const sweepValues: (string | null)[] = sweepAttr ? (sweepVals as string[]) : [null];
const TONES = ['primary','secondary','neutral','affirm','fulfill','risk','threat','loss'];
const tone = TONES.find((t) => (token as string).startsWith(t + '-'));
const toned = tone ? all() : [];
const toneWas = toned.map((n) => n.getAttribute('data-color'));
// Two attribute names for the same vocabulary: most recipes read
// `data-color`, banner reads `data-intent`. Stamp both — a component
// only selects on one, so the other is inert.
const toneIntentWas = toned.map((n) => n.getAttribute('data-intent'));
if (tone)
for (const n of toned) {
n.setAttribute('data-color', tone);
n.setAttribute('data-intent', tone);
}
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 sweep of sweepValues) {
if (sweep !== null) for (const n of swept) n.setAttribute(sweepAttr as string, sweep);
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;
}
}
if (moved) break;
}
sized.forEach((n, i) => (original[i] ? n.setAttribute('data-size', original[i]!) : null));
swept.forEach((n, i2) =>
sweptWas[i2] ? n.setAttribute(sweepAttr as string, sweptWas[i2]!) : n.removeAttribute(sweepAttr as string)
);
toned.forEach((n, i2) => {
toneWas[i2] ? n.setAttribute('data-color', toneWas[i2]!) : n.removeAttribute('data-color');
toneIntentWas[i2]
? n.setAttribute('data-intent', toneIntentWas[i2]!)
: n.removeAttribute('data-intent');
});
return moved;
},
[
component,
key,
value,
PROPS,
attrPrefix,
override.extraNodes ?? null,
override.sweepAttr?.attr ?? null,
override.sweepAttr?.values ?? 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<HTMLElement>('*')].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<HTMLElement>('*')].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);
// PARK THE POINTER AFTERWARDS. The hover pass leaves the cursor on the
// LAST node it hovered, and a hover rule outweighs the rest-state one it
// shares a node with: every rest token tested after a hover token could
// read dead. Measured 2026-08-22 on float-panel — `resize-grip-fg` moved
// in isolation and read dead in a full run, because the grip's own
// `:hover` rule re-points its colour to the accent. Same class as the
// click-focus poisoning fixed in F2-A, one pass later.
if (!openingIsFragile) await page.mouse.move(0, 0);
}
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 patterns = SENTINEL_PATTERN_EXCEPTIONS.filter((p) => p.component === component);
const reasonFor = (k: string) =>
ledger[k] ?? patterns.find((p) => p.pattern.test(k))?.reason ?? null;
const adjudicated = dead.filter((k) => reasonFor(k) !== null);
const unadjudicated = dead.filter((k) => reasonFor(k) === null);
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} — ${reasonFor(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();

Powered by TurnKey Linux.