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

1550 lines
73 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* theming-sentinel — the sentinel GUARD of PLAN-theming §7.4 point 11 (R-5.4).
*
* node scripts/theming-sentinel.ts <component> [url]
* npm run theming:sentinel -- <component> [url]
*
* For every PUBLIC token the component declares in `recipes/base.ts`, sets an
* unmistakable value and checks that SOME node's computed style follows it. A
* token that moves nothing is a token that lies — unless its silence is
* ADJUDICATED in `theming-sentinel-exceptions.ts` with a written reason (a part
* the demo does not mount, a pseudo-element the instrument cannot read, a
* forward into a composed component). Dead + unadjudicated = exit 1.
*
* Born as `__theming-sentinel.ts` during F2-A; promoted to a guard after the
* adversarial review of 2026-08-21 measured 22 false negatives in 26 "no
* effect" verdicts and two genuinely dead declarations that only THIS
* instrument can see (gradient-picker's popover-owned chrome, carousel's
* inline-gap from soma — invisible to any static CSS analysis).
*
* The review fixed three false-negative causes, each measured:
* - the open step used to CLICK `[data-{c}-input]`, and a mouse click on a
* text input DOES match `:focus-visible`, so the focus rule repainted the
* rest-state chrome and rest tokens read dead (command.input-border, the
* "unexplained" §13 entry). The active element is now blurred after opening.
* - the override was written on `[data-{c}]` only, so components whose parts
* hang from `{c}-root` (and every node the TSC declares resolved names on)
* never received it. It is now written on :root AND every node carrying any
* `data-{c}…` attribute — property-level competition is untouched, so the
* gradient-picker / carousel class of genuine deaths still reads dead.
* - `::before` / `::after` were invisible (media-player's buffering ring,
* feed's spinner). Both pseudos are snapshotted now. `::placeholder` still
* is not — that limit stays adjudicated per token.
* Plus: a hover pass for hover-only tokens, a settle wait for [data-busy]
* demos, and inset/animation props in the read set.
*
* Same environment rules as the probe: plain `node`, repo root, live dev
* server, headless.
*/
import { readFileSync, 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 `<a>`) navigates instead of revealing the panel, so the portaled
// content never enters the document and 32 of its 35 tokens read dead.
'link-preview': { openBy: 'hover' },
// 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 `<svg data-svg='dot'>` 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 `<a>`, styled through
// `[data-navigation-menu-content] :is(a, …)` — no attribute of the
// component's own, so the filter never measured them. Measured 2026-08-23:
// `content-link-padding-block` and `-radius` read dead, while
// `-padding-inline` read LIVE off the panel it widens — a false negative and
// a false positive from the same blind spot. And its panel is a HOVER panel:
// soma opens on `pointerenter` and `pointerleave` schedules the close, so the
// pointer parking the guard does after a click (`mouse.move(0, 0)`) shut it
// MID-RUN — two runs of the same code disagreed on one token.
'navigation-menu': { openBy: 'hover', extraNodes: '[data-navigation-menu-content] a' },
// Its parts are independent OPT-IN layers, each behind its own demo control,
// and only ONE pattern renders at a time: on the default stage the guard saw
// a single `glow` layer and 3 of 37 tokens moved. Switch the extra layers on,
// then sweep the pattern axis.
background: {
// ONLY the layers that do not COVER what is already being measured:
// switching the spotlight, the parallax speed and the pointer depth on
// repaints the pattern layer's own `background-image` and its transform,
// and the run went BACKWARDS (17 → 9 tokens). A guard that mounts more
// can measure less.
prepareWith: [
"[data-uix-control]:has([data-uix-control-label]:text-matches('^scrim')) input[type=checkbox]",
"[data-uix-control]:has([data-uix-control-label]:text-matches('^blur')) [data-uix-chip]:text-is('xl')"
],
sweepAttr: {
attr: 'data-pattern',
values: ['glow', 'mesh', 'grid', 'dots', 'noise', 'vignette', 'lines', 'rings']
}
},
// Same class: its chrome is an embedded Slider the recipe re-tints, and the
// playhead IS that slider's thumb — `data-waveform*` matched 4 nodes and none
// of them was it.
waveform: { extraNodes: '[data-waveform] [data-slider], [data-waveform] [data-slider] *' },
// No route of its own (`/uix/components/audio-player` = 404, the FOURTH canon
// component without a demo). It is the audio skin of the media-player chassis:
// its parts are `data-media-player-*` and it only renders after the demo's
// `media: audio` chip. Measured there.
'audio-player': {
urls: ['/uix/components/media-player'],
openWith: [
'[data-uix-control]:has([data-uix-control-label]:text-is("media")) [data-uix-chip]:text-is("audio")'
],
openMarker: '[data-media-player][data-variant]',
extraNodes: '[data-media-player][data-variant], [data-media-player][data-variant] *',
sweepAttr: { attr: 'data-variant', values: ['card', 'row', 'bar', 'inline'] }
},
// No route of its own — `/uix/components/surface` is a 404, like picker-shell
// and mockup — so the guard measured an EMPTY page and reported 0/25. It is
// the paint side of Box: measured where it actually renders.
surface: { urls: ['/temas/gradientes', '/blocks/cta'] },
// Its palette resolves per INTENT and then per VARIANT: the solid/contrast
// slots only paint under `solid`, the border slot only under `outline`, and
// the demo mounts one of each. 38 of its 42 tokens read dead without the
// sweep (2026-08-23).
banner: { sweepAttr: { attr: 'data-variant', values: ['soft', 'solid', 'outline', 'ghost'] } },
// Same class-hook blindness as prose, inside an SVG: sectors, labels, icons
// and the trigger glyph carry no `data-onion-*`.
'onion-menu': {
openMarker: '.onion-menu-sector',
extraNodes:
'.onion-menu-sector, .onion-menu-label, .onion-menu-icon, .onion-menu-icon svg, .onion-menu-trigger, .onion-menu-trigger-glyph'
},
// The shared control trigger (the icon affordance a picker drops inside the
// field) is mounted by NO field demo: /field has plain inputs and the
// segmented fields have segments, not triggers. It lives on the pickers, so
// its four tokens read dead on the field route alone.
field: { urls: ['/uix/components/field', '/uix/components/date-picker'] },
// The bar drops TWO different surfaces and only one carries its public knobs:
// the first entry opens a role=menu Content (chrome owned by dropdown-menu),
// while the role=dialog Panel — z, min-width, max-height, padding, radius,
// typography — hangs off the "Format" entry. Opening the first trigger left
// every panel token dead. The marker is the panel, not `-content`: with the
// default marker the re-open guard closed it again on every token.
menubar: {
openWith: ["[data-menubar-trigger][data-menubar-value='format']"],
openMarker: '[data-menubar-panel]'
},
// A context menu opens on RIGHT click and on nothing else: with a plain
// click the panel never enters the document and the guard measured ONE node.
'context-menu': {
openBy: 'contextmenu',
openWith: ['[data-uix-stage-area] [data-context-menu-trigger]']
},
// The row that holds link + trigger is a bare <div> with no attribute of its
// own, so the gap between them read dead (measured 2026-08-22).
'nav-tree': { extraNodes: '[data-nav-tree-item] > div' },
// The ring is an SVG: its arc and halo are `<circle>` children with no
// `data-aura*` attr, so three live tokens read dead (measured 2026-08-22).
aura: { extraNodes: '[data-aura-ring] svg *' },
// The 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 `<svg>` (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 `<ColorSwatch>` components: they carry
// `data-color-swatch`, not `data-color-picker-*`, so the filter never saw the
// nodes its own `swatch-*` tokens paint (measured 2026-08-22).
'color-picker': {
// Two families of node the filter cannot see: its swatches ARE
// `<ColorSwatch>` components (`data-color-swatch`) and its channel sliders
// are SLIDERS — the thumb and the track carry `data-slider-*`, so four
// tokens that paint them read dead (measured 2026-08-22).
extraNodes: '[data-color-swatch], [data-color-picker-channel-slider] *'
},
// Five triggers live on the popover page; the default `.first()` opens one of
// the EXAMPLES further down, not the stage's, so the parts the stage mounts
// (arrow, close) never enter the document. Measured 2026-08-22.
popover: {
openWith: ['[data-uix-stage-area] [data-popover-trigger]'],
// `content-z` is consumed on the FLOATING WRAPPER (`:has(> [data-popover-content])`),
// a node that carries no `data-popover*` attr — invisible to the filter and
// dead-looking though it moves 80 -> 4321 (measured 2026-08-22).
extraNodes: '[data-floating-wrapper]'
},
chart: {
// The tooltip and the crosshair only exist WHILE the pointer is over the
// plot — there is no trigger to click and no state to latch. Hovering the
// plot is what mounts them, and `openBy: 'hover'` also keeps the pointer
// parked there instead of moving it away before each token.
openBy: 'hover',
openWith: ['[data-chart-plot]'],
urls: [
'/uix/components/chart',
'/uix/components/line-chart',
'/uix/components/area-chart',
'/uix/components/bar-chart',
'/uix/components/scatter-chart',
'/uix/components/bubble-chart',
'/uix/components/pie-chart',
'/uix/components/sparkline',
'/uix/components/bar-list',
'/uix/components/bar-segment',
'/uix/components/radar-chart',
'/uix/components/smith-chart',
'/uix/components/polar-area',
'/uix/components/funnel',
'/uix/components/gauge',
'/uix/components/heatmap'
]
},
'text-gradient': {
openWith: [
'[data-uix-control]:has([data-uix-control-label]:text-is("showBorder")) [data-uix-chip]:text-is("true")'
]
},
// It boots EMPTY — a FileUpload dropzone — and every part the recipe paints
// (preview, canvas, toolbar, the two icon buttons) exists ONLY in the `ready`
// state, which no trigger reaches: the demo's sample chip is the only way in.
// The marker keeps the re-open guard from clicking that chip before every
// token, which would rebuild the File and remount the preview mid-run.
'image-picker': {
prepareWith: ['[data-uix-chip]:text-is("Load sample image")'],
openMarker: '[data-image-picker-preview]'
},
// Two states the default stage does not show, and NINE of its seventeen
// tokens live in them: the loading indicator renders only while `loading`
// (eight tokens), and the clear affordance only fades while the field is
// EMPTY — and the demo boots with a value. Clearing it is what empties it,
// and the click has to come FIRST: the same rule then drops
// `pointer-events` on the very button that fired it.
'search-field': {
prepareWith: [
'[data-search-field-clear-trigger]',
'[data-uix-control]:has([data-uix-control-label]:text-is("loading")) input[type=checkbox]'
]
},
// 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
// `<Toaster>`, 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<SentinelKind, string> = {
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<string, string>,
seen: Set<string>
): 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<string, string>,
seen: Set<string> = 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<string, string> {
// `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<string, string>();
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<string, string> {
const index = new Map<string, string>();
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: <component> [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<HTMLElement>('*')].filter(
(n) =>
[...n.attributes].some((a) => a.name.startsWith(prefix)) ||
(extra ? n.matches(extra as string) : false)
);
// Write on :root AND on every component node: resolved names are
// declared per part (root/host), and a portaled panel never sees the
// component root. Writing the var everywhere cannot fake a win at the
// PROPERTY level — a declaration another rule (or an inline style)
// beats stays beaten.
const hosts = () => [document.documentElement, ...all()];
// 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<HTMLElement>('*')].filter(
(n) =>
[...n.attributes].some((a) => a.name.startsWith(prefix)) ||
(extra ? n.matches(extra as string) : false)
)[idx as number] ?? null,
[attrPrefix, i, override.extraNodes ?? null] as const
);
const el = handle.asElement();
if (!el) continue;
try {
await el.hover({ timeout: 800 });
} catch {
continue;
}
const moved = await page.evaluate(
([prefix, token, val, props, idx, kebab, extra, sweeps, tone]) => {
const all = () =>
[...document.querySelectorAll<HTMLElement>('*')].filter(
(n) =>
[...n.attributes].some((a) => a.name.startsWith(prefix)) ||
(extra ? n.matches(extra as string) : false)
);
const node = all()[idx as number];
if (!node) return false;
const snap = () => {
const cs = getComputedStyle(node);
return (props as string[]).map((p) => cs[p as never]).join('|');
};
// …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();

Powered by TurnKey Linux.