/** * 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, realpathSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; 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". * * Exported because the probe reads the SAME map: a component the sentinel can * only reach through an override is a component the probe cannot reach either, * and two hand-kept copies would drift. */ export 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. * * SEVERAL axes may be given, and then the sweep is their CARTESIAN PRODUCT * (toggle, 2026-08-23: its `{variant}-*` keys and its `{variant}-on-*` keys * are the same 3 variants in two different STATES, and forcing the state * with `prepareWith` traded one half of them for the other — the "mounting * more can measure less" trap, this time inside one component). One axis * yields the same sequence it always did, so nothing that had a single * `sweepAttr` changes behaviour. * * A `null` value REMOVES the attribute, which is the only way to sweep an * axis whose off position is the attribute's ABSENCE (`data-invalid`, * `data-rounded`): stamping `data-invalid=""` for the whole run makes the * invalid border out-rank every other border token on the node, and six * of them read dead (measured on toggle, 2026-08-23). */ sweepAttr?: | { attr: string; values: (string | null)[] } | { attr: string; values: (string | null)[] }[]; } > = { // 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]'] }, // Its opacity floor is the only knob behind a state, and switching the demo's // `disabled` toggle ON for the whole run would take the hover tint with it // (`pointer-events: none` on the provider — the "mounting more can measure // less" trap). The demo reads the state from the URL, so the disabled chrome // is measured as an EXTRA SURFACE instead of a ledger line (the radio-group // precedent): 8/11 -> 9/11, with the hover key still alive on the first URL. collapsible: { urls: ['/uix/components/collapsible', '/uix/components/collapsible?perm.disabled=true'] }, // 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' }, // Three states the stage never wears at once, and the guard's own opening // creates the third: it CLICKS the control, which stamps `data-focus`, and // the focus rule re-tints the border at the same (0,2,0) but later — so the // resting `control-border` read dead on a token that is perfectly alive // (the `command.input-border` class, measured again here 2026-08-24). // Sweeping the absence (`null`) of each is what measures the resting form. // …plus a fourth axis, the ITEM's: `track` and `text` share one rule, // `[data-tags-input-item][data-state='active']`, and the ficha boots with // every tag INACTIVE (measured: 12 nodes, four data-state=inactive). The // vocabulary is the morfo's (tags-input.ts: states ['active','inactive']), // inactive first so the resting form keeps the first combo. 'tags-input': { sweepAttr: [ { attr: 'data-focus', values: [null, ''] }, { attr: 'data-invalid', values: [null, ''] }, { attr: 'data-disabled', values: [null, ''] }, { attr: 'data-state', values: ['inactive', 'active'] } ] }, // Its palette hangs off `[data-state='selected']` AND a `data-variant` on the // group, and the ficha mounts ONE group in the SOFT variant (measured: 12 // nodes, one data-variant=soft, two tags selected). That is why the soft // slots of the seven tones were the only ones that ever read alive. Variant // vocabulary from the type union (ChipVariant, eidos/lib/types.ts — // soft/solid/outline/ghost; `soft` is the UNQUALIFIED rule of the recipe, and // the DOM does carry it), state vocabulary from the morfo (tag-group.ts: // states ['selected','unselected']). 'tag-group': { sweepAttr: [ { attr: 'data-variant', values: ['soft', 'solid', 'outline', 'ghost'] }, { attr: 'data-state', values: ['unselected', 'selected'] } ] }, // Two axes, and the guard knew NEITHER of them (3/12 without this entry). // (1) The FAB's size axis is `data-fab-size`, not `data-size` — it is an // eidos-only visual attr with its own scale off the control ladder, so the // generic size sweep stamped nothing and the six xs/sm/lg steps read dead. // (2) Its EXTENDED pill is a boolean mode whose absence is the default: the // demo boots circular, so the two keys that only paint under // `[data-extended]` had no node. Sweeping the two as a product measures the // circular form AND the pill instead of trading one for the other. fab: { // A FAB is ONE node; its second painted surface is the composed Button's // icon slot, sized by descendant selector and carrying Button's attr. extraNodes: '[data-fab] [data-button-icon]', sweepAttr: [ { attr: 'data-fab-size', values: ['md', 'xs', 'sm', 'lg'] }, { attr: 'data-extended', values: [null, ''] } ] }, // 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] *' }, // The solid palette lives at [data-checkbox][data-variant='solid'][data-state='checked'] // (checkbox.css) and its ficha mounts five boxes, all solid, four unchecked // and two checked — but the guard stamps ONE value on EVERY node at a time, // so a run only ever measured the state it had just written, and the four // `-solid-hover` tone keys never had a checked box under the pointer. // `indeterminate` — the morfo's third state (morfo/components/checkbox.ts) — // is deliberately out: every `[data-state='indeterminate']` rule in // checkbox.css shares its declaration block with the `checked` twin, so it // reaches no slot the pair does not, at half the run again. checkbox: { sweepAttr: [ { attr: 'data-variant', values: ['solid', 'outline', 'ghost'] }, { attr: 'data-state', values: ['unchecked', 'checked'] } ] }, // 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. // …and a FOURTH: the item twin of the checkbox state above. Its solid palette // lives at `[data-radio-group][data-variant='solid'] [data-radio-group-item][data-state='checked']`, // so the four `-solid-hover` tone keys need the variant on the GROUP and the // state on the ITEM at once — the sweep stamps both attrs on every node, so // the product reaches it. Vocabulary from the morfo (radio-group.ts: // states ['checked','unchecked']). 'radio-group': { extraNodes: "[data-radio-group-indicator] svg[data-svg='dot']", sweepAttr: [ { attr: 'data-variant', values: ['solid', 'outline', 'ghost', 'segmented'] }, { attr: 'data-state', values: ['unchecked', 'checked'] } ], 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]' ] }, // Its opacity floor is the only knob behind a state, and this demo has no URL // switch for it. Turning the GROUP's toggle on costs nothing measurable: the // hover rule of the bar is `[data-toolbar-link]:hover, [data-toolbar-group-item]:hover:not([data-disabled])`, // and the Link is not part of the group — it keeps both hover keys alive // (verified by running the guard before and after this control: the dead set // shrank by one and gained nothing). toolbar: { prepareWith: [ '[data-uix-control]:has([data-uix-control-label]:text-is("group 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")' ] }, // A conversation row is a pile of OPT-IN surfaces and PRESENTATION axes, and // the demo boots the plainest of them: no quoted reply, no read receipts, no // delivery status, no mention accent, `run='solo'` and `direction='in'`. // Thirty of its seventy-five tokens read dead on that stage — every one of // them alive on a row that carries the axis. The four surfaces come from the // demo controls; the five axes are swept as their product (the run corners, // the outgoing palette, the two delivery tints, the mention and the // jump-to-message flash). The quick-reaction tapback bar is PORTALED off the // add-reaction chip, and the gap it paints sits on the composed // `[data-popover-viewport]` (measured 2026-08-24). 'chat-message': { prepareWith: [ '[data-uix-control]:has([data-uix-control-label]:text-is("reply (quote)")) input[type=checkbox]', '[data-uix-control]:has([data-uix-control-label]:text-is("read-by")) input[type=checkbox]', '[data-uix-control]:has([data-uix-control-label]:text-is("delivery")) [data-uix-chip]:text-is("read")' ], openWith: ['[data-chat-message-reaction-add]'], openMarker: '[data-chat-message-quick-reactions]', extraNodes: '[data-chat-message-quick-reactions] [data-popover-viewport], [data-chat-message-reaction-add] > svg', sweepAttr: [ { attr: 'data-run', values: ['solo', 'middle', 'last'] }, { attr: 'data-direction', values: ['in', 'out'] }, { attr: 'data-delivery', values: ['delivered', 'read', 'failed'] }, { attr: 'data-mentioned', values: [null, ''] }, { attr: 'data-emphasized', values: [null, ''] } ] }, // Its three dots are bare `> span` children of the indicator — no attr of // their own — and the demo boots with NOBODY typing, which hides every child // (presence = visibility, N-7). Four of its eight tokens had no node to // paint (measured 2026-08-24). 'chat-typing': { prepareWith: [ '[data-uix-control]:has([data-uix-control-label]:text-is("typers")) [data-uix-chip]:text-is("Ada")' ], extraNodes: '[data-chat-typing-indicator] > span, [data-chat-typing-avatars] > *' }, // 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'] } }, // Its ficha mounts ONE button and it is SOLID (measured: 2 nodes, one // data-variant=solid), so four of the five palette slices had no node to // paint: `track` and `text` live on soft / surface, `border` on surface / // outline, `element` on the soft hover. Those three values are the CLOSED // COVER of the five slots (button.css) — surface repeats soft's pair plus // outline's border, and ghost and plain repeat `text` — so the other three // members of ButtonVariant would only multiply the cost. button: { sweepAttr: { attr: 'data-variant', values: ['solid', 'soft', 'outline'] } }, // Every palette slot of switch hangs off `[data-switch][data-state='checked']` // (switch.css) and its ficha boots UNCHECKED (measured: 2 nodes, both // data-state=unchecked), which is the whole reason its ten tone keys read // dead. Vocabulary from the morfo (switch.ts: states ['checked','unchecked']); // unchecked first, so the resting half still measures on the first combo. switch: { sweepAttr: { attr: 'data-state', values: ['unchecked', 'checked'] } }, // ONE instance driven by chips, and the whole palette hangs off the PRESSED // state: the 8 `palette-*` slots — and the 48 tone forwarders behind them — // are consumed only by the `*-on-*` keys, so an OFF toggle leaves 56 tokens // without a node to paint. `invalid` is a second opt-in the stage does not // boot with. With the variant swept on top, the guard sees solid / outline / // ghost, pressed, invalid (measured 2026-08-23: 36/126 → 70/126, and the // remaining 56 are the palette cascade plus six states the demo cannot mount). toggle: { sweepAttr: [ { attr: 'data-variant', values: ['solid', 'outline', 'ghost'] }, { attr: 'data-state', values: ['off', 'on'] }, { attr: 'data-invalid', values: [null, ''] } ] }, // Its rainbow track is painted on the COMPOSED Slider — the recipe re-points // `--slider-track-bg` on `[data-adjustment='hue'] [data-slider]`, whose paint // lands on that node's `::before`. Those nodes carry `data-slider*`, so the // `data-image-adjustments-*` filter never saw them and `hue-track` read dead // (11/12). Same class as `waveform` (playhead = an embedded Slider thumb). // …and its reset boots DISABLED (nothing has been adjusted yet), so the // `:hover:not([disabled])` rule could never match and its ink hover read dead. // `disabled` is the axis whose off position is the attribute's ABSENCE, and // nothing in the contract paints under its presence (the disabled rule is // `--opacity-disabled`, system), so removing it costs no coverage. // The selector names the HUE row alone on purpose: it is the only row the // recipe re-tints, and widening it to every row pushed the reset out of the // hover pass's 30-node cap (it became the 32nd match, so its ink hover read // dead for a reason that had nothing to do with the token). 'image-adjustments': { extraNodes: "[data-image-adjustments-item][data-adjustment='hue'] [data-slider]", sweepAttr: { attr: 'disabled', values: [null] } }, // Half its recipe hangs off `[data-orientation='vertical']` — the thickness on // the inline axis and `min-length` — and the demo boots the `stacked` sample, // which mounts horizontal rules only: `min-length` had no node and read dead // (2/3). Sweeping the attribute measures BOTH orientations on the same nodes, // which switching the demo chip could not do (it trades one for the other). separator: { sweepAttr: { attr: 'data-orientation', values: ['horizontal', 'vertical'] } }, // Half its contract is VARIANT chrome (the `surface` frame, the `pills` track, // the `segmented` rail) and the demo boots `line`, so 39 of 79 read dead on // the default stage. Two more axes are URL-borne on this demo: a vertical // tablist is the only shape that gives the rail a WIDTH, and the disabled // opacity floor needs a trigger wearing the state (the radio-group precedent // — an extra surface instead of three ledger lines). // A toast does not EXIST until something fires it: the stage mounts an empty // ``, there is no `[data-toast-trigger]` for the default opener to // find, and the guard measured the viewport alone. The demo's play button is // the way in — and this entry deliberately does NOT set `openMarker`, which // is the opposite of every other portaled component here. A card // auto-dismisses after 5 s, so "is it already open?" has no useful answer: // with `openMarker: '[data-toast-item]'` the run was NON-DETERMINISTIC (three // runs on identical code disagreed on WHICH token read dead — the card // expired mid-measure). Falling through to the default marker // (`[data-toast-content]`, a part this component does not have) makes the // re-open fire a FRESH card before EVERY token, which is the only way to // outrun the timer. toast: { openWith: ['[data-uix-play]'] }, tabs: { sweepAttr: { attr: 'data-variant', values: ['line', 'surface', 'pills', 'segmented'] }, urls: [ '/uix/components/tabs', '/uix/components/tabs?perm.orientation=vertical', '/uix/components/tabs?perm.disabled=true' ] } }; /** * ── The probe, chosen by TYPE ───────────────────────────────────────────── * * `sentinelFor` used to pick its value from the key's NAME, and a name is not a * type: 313 keys across 40 components whose default is a COLOUR were getting * `1234px`, which is invalid for `color:` / `background:`. An invalid * declaration is IACVT — the property falls back to its inherited or initial * value — so the snapshot usually moved ANYWAY and the verdict came out right * for the wrong reason. Where that fallback landed on the value already painted * it came out WRONG, and the ledger had to adjudicate the instrument twice: * `stepper.neutral-text` (the neutral tone IS practically the inherited ink) * and `avatar.badge-fg-custom-contrast` (its `fg` is MEDIAL, so the ink test — * `fg$` — never saw it). * * A token's DEFAULT VALUE is its type, and `base.ts` is already read here. The * rule of the expediente: the probe has to be impossible IN THE DIRECTION the * property can move. */ type SentinelKind = | 'color' | 'length' | 'number' | 'weight' | 'leading' | 'tracking' | 'family' | 'shadow' | 'time' | 'z'; /** The same probe values the name ladder has always written, keyed by TYPE. */ const SENTINEL_BY_KIND: Record = { color: 'rgb(1, 2, 3)', length: '1234px', number: '0.123', weight: '123', leading: '3.77', tracking: '4.5px', family: 'Zapfino, cursive', shadow: '0 0 0 7px rgb(1, 2, 3)', time: '11.5s', z: '4321' }; /** * What a `var(--…)` name MEANS in the system's vocabulary. ORDERED — first * match wins, so the narrow rules (`…-line-height`, `…-color`) come before the * broad ones (`…-height`, `^color-`). * * `null` is a DELIBERATE non-classification: the key falls through to the name * ladder and its probe does not change. Easing is the whole of it — the 51 * `*-ease` keys were adjudicated EN MASSE against the ladder's `1234px`, and * re-probing them is a different expediente. */ const SYSTEM_VOCABULARY: [RegExp, SentinelKind | null][] = [ [/^ease-/, null], [/^gradient-/, null], [/^font-feature-/, null], [/-color$/, 'color'], [/^color-/, 'color'], [/^primitive-/, 'color'], [/^scale-[a-z]+-\d+$/, 'color'], [/font-family$/, 'family'], [/^font-family-/, 'family'], [/font-weight/, 'weight'], [/^leading-/, 'leading'], [/line-height/, 'leading'], [/^tracking-/, 'tracking'], [/letter-spacing$/, 'tracking'], [/^opacity-/, 'number'], [/^(scaling|press-scale)$/, 'number'], [/^z-index-/, 'z'], [/^(duration-|press-duration$)/, 'time'], [/shadow/, 'shadow'], [/halo$/, 'shadow'], [ /^(space|radius|border-width|blur|measure|container-width|font-size|icon-stroke-width)/, 'length' ], [ /(font-size|control-height|icon-size|padding(-inline|-block)?|gap|radius|width|height|offset)$/, 'length' ] ]; const COLOR_LITERAL = /^(#[0-9a-f]{3,8}$|(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(|transparent$|currentcolor$|white$|black$)/i; const LENGTH_LITERAL = /^-?(\d+\.?\d*|\.\d+)(px|rem|em|ch|ex|vh|vw|dvh|dvw|vmin|vmax)$/; const TIME_LITERAL = /^-?(\d+\.?\d*|\.\d+)m?s$/; /** Split a value on TOP-LEVEL whitespace: `rgb(0 0 0 / 0.55)` is ONE term. */ function valueTerms(value: string): string[] { const out: string[] = []; let depth = 0; let current = ''; for (const ch of value) { if (ch === '(') depth++; else if (ch === ')') depth--; if (depth === 0 && /\s/.test(ch)) { if (current) out.push(current); current = ''; } else current += ch; } if (current) out.push(current); return out; } function kindOfTerm( term: string, index: Map, seen: Set ): SentinelKind | null { // `\s*` after the paren — the same needle fix `830cd7671` made in the census // (the ACTA over `classify` in `theming-census.ts`). `valueTerms` keeps every // character inside the parens, whitespace included, so a value written // `var( --x )` reaches here whole and the intolerant needle simply stopped // LOOKING: no kind, and the token fell through to the name ladder. Zero // victims today (measured over the whole catalogue: `base.ts` holds no // `var( --`), corrected because nothing normalizes `base.ts` and the house's // own reader of these values is already tolerant (`inferDepsFromValue`, // `render-css.ts:2477`). The right-hand `\s*` was always there. const ref = term.match(/^var\(\s*--([a-z0-9-]+)\s*[,)]/); if (ref) { const name = ref[1]; const target = index.get(name); if (target !== undefined && !seen.has(name)) { seen.add(name); const kind = kindOfValue(target, index, seen); if (kind) return kind; } for (const [pattern, kind] of SYSTEM_VOCABULARY) if (pattern.test(name)) return kind; return null; } // An arithmetic expression has the dimension of its DIMENSIONED operand, not // of the first thing inside it: `calc(56px * var(--scaling, 1))` is a length // a unitless factor scales, and reading the factor made 22 lengths (every // gradient-builder / color-swatch size) come out as numbers. if (/^(calc|min|max|clamp)\(/.test(term)) { // Tolerant `var(\s*--` for the same reason as the ref needle above; the // reconstruction below still holds, `var( --x` + `)` is what that needle // now reads. const operands = [...term.matchAll(/var\(\s*--[a-z0-9-]+|[\d.]+[a-z%]+/g)].map((m) => kindOfTerm(m[0].startsWith('var(') ? `${m[0]})` : m[0], index, seen) ); return operands.find((k) => k && k !== 'number') ?? null; } if (COLOR_LITERAL.test(term)) return 'color'; if (LENGTH_LITERAL.test(term)) return 'length'; if (TIME_LITERAL.test(term)) return 'time'; // A FRACTIONAL bare number cannot be a length (only zero may drop its unit), // so it is a ratio, an opacity, a strength or a line-height — all probed the // same way. `background.scrim-strength-*` and `stepper.nav-hover-brightness` // live here: the ladder wrote `1234px` into a `color-mix()` percentage and a // `brightness()` factor, where it is invalid. if (/^-?(\d+\.\d+|\.\d+)$/.test(term) && Number(term) !== 0) return 'number'; // An INTEGER (and `0`) stays unclassified on purpose: `0` is a length AND a // number AND a z, `900` is a weight, `2` is a z and `20` is a count — only // the NAME separates them. Typing them as numbers would put `0.123`, invalid // for a length, on the seven `0`-valued radius / gap / min-width keys, whose // IACVT fallback is the authored `0` itself: seven live tokens reading dead. // A PERCENTAGE is ambiguous for the same reason (`width: 42%` is a length, // `opacity: 62%` is a number, `background-position: 60% 55%` is neither). return null; } export function kindOfValue( value: string | undefined, index: Map, seen: Set = new Set() ): SentinelKind | null { if (!value) return null; const terms = valueTerms(value.trim()); if (!terms.length) return null; if (terms.length === 1) return kindOfTerm(terms[0], index, seen); const kinds = terms.map((t) => kindOfTerm(t, index, seen)); // `0 1px 2px rgb(17 14 10 / 0.12)` / `0 0 0 1px var(--color-border-default)`: // a colour riding on a run of OFFSETS is a shadow. The offsets are what tells // it from `underline dotted var(--color-content-muted)`, a text-decoration // shorthand that a colour plus three terms alone would have called a shadow. const offsets = terms.filter((t, i) => kinds[i] === 'length' || /^-?0(\.0+)?$/.test(t)); if (kinds.includes('color') && offsets.length >= 2) return 'shadow'; if (kinds.every((k) => k === 'length')) return 'length'; return null; } /** * The type decides; the NAME LADDER below is the fallback for everything the * catalogue does not type (a keyword like `revert-layer`, a raw number, an * easing curve). */ export function sentinelFor(key: string, kind: SentinelKind | null): string { if (kind) return SENTINEL_BY_KIND[kind]; if (/z$/.test(key)) return '4321'; if (/font-family/.test(key)) return 'Zapfino, cursive'; if (/font-weight/.test(key)) return '123'; 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'; } /** * The keys AND DEFAULT VALUES a component declares in `base.ts`, in source * order. The value is what types the token, so the guard reads it in the same * pass that used to read the names alone. */ export function recipeEntries(contract: string, component: string): Map { // `avatar` is the ONLY entry whose value is an IIFE (a local matrix helper // generates its 24 composite scopes), so its map lives in the `return {` one // tab deeper: this probe found no block at all and the guard threw «no recipe // 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 heads = [...block.matchAll(/^\t\t'?([a-z0-9-]+)'?\s*:/gm)]; const entries = new Map(); heads.forEach((head, i) => { const from = head.index + head[0].length; const to = i + 1 < heads.length ? heads[i + 1].index : block.length; // A token is `'value'`, or a declaration object / array whose FIRST // `value:` is the host default. A `scope:` string is not a value. const quoted = block .slice(from, to) .replace(/scope:\s*'[^']*'/g, '') .match(/'([^']*)'/); entries.set(head[1], quoted ? quoted[1] : ''); }); return entries; } /** * Every recipe token by the custom property it emits (`--{component}-{key}`), * so a `var()` pointing INTO the catalogue resolves to the value it really * carries. Without it `--color-picker-trigger-radius` reads as system `color-*` * vocabulary and a radius is probed with a colour. */ export function recipeTokenIndex(contract: string): Map { const index = new Map(); for (const [, component] of contract.matchAll(/^\t'?([a-z0-9-]+)'?:\s*(?:\{|\(\()/gm)) for (const [key, value] of recipeEntries(contract, component)) index.set(`${component}-${key}`, value); return index; } /** * The eight tones of the palette, and how a KEY names one. * * The tone may sit ANYWHERE in the name, not only at the head: tag-group spells * its hover slots `hover-{tone}-{variant}-bg`, and a `startsWith` test stamped * NOTHING for them. Measured over the whole catalogue 2026-08-25: of 4556 public * keys, 556 name a tone — 540 at the head and 16 in the middle. All sixteen are * tag-group's, and all sixteen really ARE that tone, so the wider test gains * sixteen keys and risks no false positive. * * Resolved HERE and passed into the two `page.evaluate` bodies, which cannot * close over module scope: one list instead of one copy per pass. */ const TONES = ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']; const toneOf = (key: string) => TONES.find((t) => new RegExp(`(^|-)${t}-`).test(key)) ?? null; async function main() { const [component, urlArg] = process.argv.slice(2); if (!component) throw new Error('usage: [url]'); const base = process.env.UIX_DEV_URL ?? 'http://localhost:5173'; const url = urlArg ?? `${base}/uix/components/${component}`; const override = COMPONENT_OVERRIDES[component] ?? {}; const attrPrefix = override.attrPrefix ?? `data-${component}`; // Openings that the blur + pointer-park would UNDO. A hover panel dismisses // when the cursor leaves; a focus-only surface disappears when the focus does. const openingIsFragile = override.openBy === 'hover' || override.openBy === 'focus'; const contract = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace( /\r\n/g, '\n' ); const entries = recipeEntries(contract, component); const tokenIndex = recipeTokenIndex(contract); const keys = [...entries.keys()].filter((k) => !k.startsWith('_')); // A component whose surface is spread over SEVERAL demo routes cannot be // judged on one page: what that page does not mount reads dead. A token is // 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 (err) { // The `count()` above ALREADY filtered the absence, so what lands here // is never "one layer this route cannot show": the control WAS in the // document and the click failed anyway (covered, detached, re-rendered // mid-click). Swallowing it produces PHANTOM DEATHS — chat-message // measured 59/81 and 77/81 on identical code, 18 tokens whose opt-in // surface silently never mounted. It must not throw (a sweep has to // finish), but a run that prints this is NOT CERTIFIABLE: repeat it. const first = String((err as Error)?.message ?? err).split('\n')[0]; console.warn(`sentinel ${component}: prepareWith FAILED — ${sel} — ${first}`); } } 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, sweeps, tone]) => { 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()]; // The attributes this component sweeps while measuring (variant, look, // state…), or a single null pass when it has none. Several axes sweep // as their CARTESIAN PRODUCT; with one axis the sequence is the same // list it has always been. const axes = sweeps as { attr: string; values: (string | null)[] }[]; const swept = axes.length ? all() : []; const sweptWas = swept.map((n) => axes.map((ax) => n.getAttribute(ax.attr))); const combos: ((string | null)[] | null)[] = axes.length ? axes.reduce<(string | null)[][]>( (acc, ax) => acc.flatMap((c) => ax.values.map((v) => [...c, v])), [[]] ) : [null]; // 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. 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')); for (const n of toned) { n.setAttribute('data-color', tone as string); n.setAttribute('data-intent', tone as string); } 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 combo of combos) { if (combo !== null) for (const n of swept) axes.forEach((ax, i2) => combo[i2] === null ? n.removeAttribute(ax.attr) : n.setAttribute(ax.attr, combo[i2]!) ); 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; } // RESTORE BY `=== null`, NEVER BY TRUTHINESS (2026-08-27). `getAttribute` // answers with TWO different absences — `null` (no attribute) and `''` // (attribute present, empty) — and only the first one means "remove it". // A truthy test collapses them, so an attribute captured as `''` was // DESTROYED by its own restore. This is not hypothetical: `data-focus` // is swept as `values: [null, '']` right above, so the empty string is a // value this code hands itself. Measured on a real node carrying // `data-focus="" data-color="" data-size=""`: the restore deleted // `data-focus` and `data-color` outright and left `data-size="xl"` — the // swept value — because the `: null` branch below removed nothing at all. // The corruption is CUMULATIVE: the page is not reloaded between keys, so // every key measured afterwards reads a tree that no longer matches the // demo, and the guard reports on a component it silently mutated. The // sweep loop above already had the right idiom (`combo[i2] === null`); // only the restores were written the other way. sized.forEach((n, i) => { const was = original[i]; if (was !== null) n.setAttribute('data-size', was); }); swept.forEach((n, i2) => axes.forEach((ax, j) => { const was = sweptWas[i2][j]; if (was === null) n.removeAttribute(ax.attr); else n.setAttribute(ax.attr, was); }) ); toned.forEach((n, i2) => { const colorWas = toneWas[i2]; if (colorWas === null) n.removeAttribute('data-color'); else n.setAttribute('data-color', colorWas); const intentWas = toneIntentWas[i2]; if (intentWas === null) n.removeAttribute('data-intent'); else n.setAttribute('data-intent', intentWas); }); return moved; }, [ component, key, value, PROPS, attrPrefix, override.extraNodes ?? null, override.sweepAttr ? Array.isArray(override.sweepAttr) ? override.sweepAttr : [override.sweepAttr] : [], toneOf(key) ] 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, sweeps, tone]) => { 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('|'); }; // …and it stamped no TONE either, which is the whole of ceguera 2: // a slot that needs its tone AND a hover had one half in each pass // and was reachable from neither — the class the ledger wrote down // as TONE_UNREACHED (132 keys over 8 components). Same stamp as the // static pass, both attribute names, restored on the way out. const toned = tone ? all() : []; const toneWas = toned.map((n) => n.getAttribute('data-color')); const toneIntentWas = toned.map((n) => n.getAttribute('data-intent')); for (const n of toned) { n.setAttribute('data-color', tone as string); n.setAttribute('data-intent', tone as string); } // The hover pass swept NOTHING, so a `{variant}-hover-*` key could // only ever move on the variant the demo happens to mount — ten of // toggle's twelve hover keys read dead for that reason alone // (measured 2026-08-23). Same axes as the static pass; the pointer // does not move, so re-stamping attributes is safe. const axes = sweeps as { attr: string; values: (string | null)[] }[]; const swept = axes.length ? all() : []; const sweptWas = swept.map((n) => axes.map((ax) => n.getAttribute(ax.attr))); const combos: ((string | null)[] | null)[] = axes.length ? axes.reduce<(string | null)[][]>( (acc, ax) => acc.flatMap((c) => ax.values.map((v) => [...c, v])), [[]] ) : [null]; let moved = false; for (const combo of combos) { if (combo !== null) for (const n of swept) axes.forEach((ax, j) => combo[j] === null ? n.removeAttribute(ax.attr) : n.setAttribute(ax.attr, combo[j]!) ); 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}`); if (before !== after) { moved = true; break; } } // `=== null`, not truthiness — same law as the static pass above, same // three attributes, same empty-string victims (`data-focus`). swept.forEach((n, i2) => axes.forEach((ax, j) => { const was = sweptWas[i2][j]; if (was === null) n.removeAttribute(ax.attr); else n.setAttribute(ax.attr, was); }) ); toned.forEach((n, i2) => { const colorWas = toneWas[i2]; if (colorWas === null) n.removeAttribute('data-color'); else n.setAttribute('data-color', colorWas); const intentWas = toneIntentWas[i2]; if (intentWas === null) n.removeAttribute('data-intent'); else n.setAttribute('data-intent', intentWas); }); return moved; }, [ attrPrefix, key, value, PROPS, i, component, override.extraNodes ?? null, override.sweepAttr ? Array.isArray(override.sweepAttr) ? override.sweepAttr : [override.sweepAttr] : [], toneOf(key) ] as const ); if (moved) return true; } return false; }; const stillDead: string[] = []; for (const key of pending) { await reopen(); const value = sentinelFor(key, kindOfValue(entries.get(key), tokenIndex)); let moved = await staticPass(key, value); // A TONE key that is still dead earns the hover pass even when its NAME // says nothing about hovering: 21 of file-upload's 28 tone keys paint on // `[data-file-upload-dropzone]:hover` (or on the trigger's) and not one of // them is called `hover-*`, so the pass never ran for them. Deliberately // narrowed to the tone — the pass costs 2-6 s per dead key, and opening it // to every dead key would add ~3 min on button alone. if (!moved && (/hover/.test(key) || toneOf(key) !== null)) { 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 patternFor = (k: string) => patterns.find((p) => p.pattern.test(k)) ?? null; const reasonFor = (k: string) => ledger[k] ?? patternFor(k)?.reason ?? null; const adjudicated = dead.filter((k) => reasonFor(k) !== null); const unadjudicated = dead.filter((k) => reasonFor(k) === null); // A reason that opens with OSCILLATES: describes a token measured ALIVE that // the guard reads dead only some runs (a race in the demo, not a lie). Such // an entry must survive the run where the coin lands on the live side — // without this, every sweep that catches it alive reports a false STALE and // the next session deletes a correct adjudication (measured 2026-08-24 on // emoji-picker.open-trigger-fg, alive 4 of 5 runs on the same code). const oscillates = (k: string) => (reasonFor(k) ?? '').startsWith('OSCILLATES:'); // ADJUDICATED, not `k in ledger`: an exception written as a PATTERN covers keys // the exact map never holds, so a token revived under one could never report // STALE — 236 of the 821 adjudicated slots (29 %) were invisible to this check, // and 207 keys came back to life in silence during firma B′. `oscillates()` // already reads through `reasonFor`, so it generalises to patterns for free. const stale = live.filter((k) => reasonFor(k) !== null && !oscillates(k)); const oscillating = live.filter((k) => reasonFor(k) !== null && oscillates(k)); console.log(`sentinel ${component}: ${live.length}/${keys.length} tokens move a computed value`); for (const k of adjudicated) console.log(` adjudicated ${k} — ${reasonFor(k)}`); for (const k of oscillating) console.log(` oscillating ${k} — read ALIVE this run; the entry stays (${reasonFor(k)})`); // The two kinds do NOT get the same instruction. An exact entry names one // token and retiring it is the whole fix; a PATTERN names a FAMILY, and the // other members are still dead — retiring it would bury them. The precedent is // written by hand in the ledger (tag-group's `hover-affirm-soft-bg` is spelled // out OF the pattern because the stage reaches that one alone). for (const k of stale) console.log( k in ledger ? ` STALE exception ${k} — the token moves now, retire the exact entry (${ledger[k]})` : ` STALE pattern ${k} — the token moves now; NARROW the pattern ${patternFor(k)!.pattern.source} so it stops covering this key — do NOT retire it, the rest of the family is still dead (${patternFor(k)!.reason})` ); 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); } // Only when this file IS the entrypoint: the probe imports COMPONENT_OVERRIDES, // and a bare `main()` would launch the whole guard (browser included) on import. // `realpathSync`, or the guard dies through a junction: the ESM loader realpaths // the entry URL and a bare `resolve` does not, so from a junctioned worktree the // two never match and the CLI exits 0 having inspected nothing. if ( process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href ) main();