/** * 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, 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'] }, // Its whole edit surface — input, submit, cancel — is `display: none` until the // component enters edit mode, and the demo boots in PREVIEW. But the opening is // FRAGILE: leaving edit mode is what a blur does (whatever `submitMode` says — // that flag only decides whether the value commits), and the guard blurs right // after opening, so clicking the edit trigger measured a surface that was // already closed again (`submit-fg` read dead and moves 1234 when it is really // open). Switching the demo to `activationMode=focus` makes FOCUS the opening, // which is the one the guard does not undo. editable: { prepareWith: [ '[data-uix-control]:has([data-uix-control-label]:text-is("activationMode")) [data-uix-chip]:text-is("focus")' ], openBy: 'focus', openWith: ['[data-editable-preview]'], openMarker: '[data-editable][data-editing]' }, '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 ``) 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] *' }, // Three blind spots at once (measured 2026-08-23, 27 of 61 tokens moved // before this): the DOT is a bare `` inside the // indicator, so the five `dot-size-*` steps had no node in the filter; the // whole SEGMENTED chrome (track, segment, pill — twelve keys) only paints // under `data-variant='segmented'` and the demo boots `solid`; and the // disabled / invalid chrome needs a STATE the stage does not wear — this // demo reads both from the URL (`perm.disabled`, `perm.invalid`), so they // are measured as extra surfaces instead of eleven ledger lines. 'radio-group': { extraNodes: "[data-radio-group-indicator] svg[data-svg='dot']", sweepAttr: { attr: 'data-variant', values: ['solid', 'outline', 'ghost', 'segmented'] }, urls: [ '/uix/components/radio-group', '/uix/components/radio-group?perm.disabled=true', '/uix/components/radio-group?perm.invalid=true' ] }, // Its opacity floor is the only knob behind a state, and the demo has no URL // switch for it: the toggle is the way in, and turning it on costs nothing // (the one hover token of the recipe moves `transform`, which the guard does // not read anyway — adjudicated). 'rating-group': { prepareWith: [ '[data-uix-control]:has([data-uix-control-label]:text-is("disabled")) input[type=checkbox]' ] }, // 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 ``, 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
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 scrolling viewport is the composed VirtualList's — it carries // `data-virtual-list-viewport`, not a `data-chat-log-*` attr — so the shell's // own inline/block padding, painted on it, had no node in the filter; the // to-latest glyph is a bare `` (measured 2026-08-23). 'chat-log': { extraNodes: '[data-chat-log] [data-virtual-list-viewport], [data-chat-log-to-latest] svg, [data-chat-log-separator-day] > *' }, // Its context bar (ten of its tokens) and its attachment tray are OPT-IN and // the demo boots with neither: 14 of 31 read dead on the default stage // (measured 2026-08-23). 'chat-composer': { prepareWith: [ '[data-uix-control]:has([data-uix-control-label]:text-is("context")) [data-uix-chip]:text-is("reply")', '[data-uix-control]:has([data-uix-control-label]:text-is("simulate")) button:has-text("attach file")' ] }, // 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 `` 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")' ] }, // 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]' ] }, // Three of its knobs are painted on nodes that carry NO `data-select-*` attr: // the panel's z sits on the floating WRAPPER soma portals it into (the // `popover.content-z` case, same fix), and the viewport rhythm — gap between // options and the block-end inset — is painted on the composed ScrollArea's // viewport. Measured 2026-08-24: without this the three read dead while // moving 80 -> 4321, 4px -> 1234px and 4px -> 1234px. select: { extraNodes: '[data-floating-wrapper]:has(> [data-select-content]), [data-select-content] [data-scroll-area-viewport]' }, // The `calendar` recipe entry is the FAMILY's vocabulary, not this // component's: `lib/calendar-surface.css` says so, and `month-grid`, // `year-grid` and `range-calendar` consume `--calendar-*` while minting // NOTHING of their own (0 % honest in the census). So a token of this entry // lives if ANY surface of the family follows it — and their parts carry // `data-month-grid-*` / `data-range-calendar-*`, invisible to the // `data-calendar` filter. The header chrome (`control-*`, `heading-*`) has no // node on THIS route either: the demo puts month / year SELECTORS where the // text heading would be, and prev / next compose the system IconButton. calendar: { urls: [ '/uix/components/calendar', '/uix/components/month-grid', '/uix/components/range-calendar' ], extraNodes: '[data-month-grid], [data-month-grid] *, [data-range-calendar], [data-range-calendar] *', sweepAttr: { attr: 'data-variant', values: ['surface', 'outline', 'ghost'] } } }; 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}`; // 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('*')].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('*')].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); // 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();