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.
1123 lines
52 KiB
1123 lines
52 KiB
/**
|
|
* theming-sentinel — the sentinel GUARD of PLAN-theming §7.4 point 11 (R-5.4).
|
|
*
|
|
* node scripts/theming-sentinel.ts <component> [url]
|
|
* npm run theming:sentinel -- <component> [url]
|
|
*
|
|
* For every PUBLIC token the component declares in `recipes/base.ts`, sets an
|
|
* unmistakable value and checks that SOME node's computed style follows it. A
|
|
* token that moves nothing is a token that lies — unless its silence is
|
|
* ADJUDICATED in `theming-sentinel-exceptions.ts` with a written reason (a part
|
|
* the demo does not mount, a pseudo-element the instrument cannot read, a
|
|
* forward into a composed component). Dead + unadjudicated = exit 1.
|
|
*
|
|
* Born as `__theming-sentinel.ts` during F2-A; promoted to a guard after the
|
|
* adversarial review of 2026-08-21 measured 22 false negatives in 26 "no
|
|
* effect" verdicts and two genuinely dead declarations that only THIS
|
|
* instrument can see (gradient-picker's popover-owned chrome, carousel's
|
|
* inline-gap from soma — invisible to any static CSS analysis).
|
|
*
|
|
* The review fixed three false-negative causes, each measured:
|
|
* - the open step used to CLICK `[data-{c}-input]`, and a mouse click on a
|
|
* text input DOES match `:focus-visible`, so the focus rule repainted the
|
|
* rest-state chrome and rest tokens read dead (command.input-border, the
|
|
* "unexplained" §13 entry). The active element is now blurred after opening.
|
|
* - the override was written on `[data-{c}]` only, so components whose parts
|
|
* hang from `{c}-root` (and every node the TSC declares resolved names on)
|
|
* never received it. It is now written on :root AND every node carrying any
|
|
* `data-{c}…` attribute — property-level competition is untouched, so the
|
|
* gradient-picker / carousel class of genuine deaths still reads dead.
|
|
* - `::before` / `::after` were invisible (media-player's buffering ring,
|
|
* feed's spinner). Both pseudos are snapshotted now. `::placeholder` still
|
|
* is not — that limit stays adjudicated per token.
|
|
* Plus: a hover pass for hover-only tokens, a settle wait for [data-busy]
|
|
* demos, and inset/animation props in the read set.
|
|
*
|
|
* Same environment rules as the probe: plain `node`, repo root, live dev
|
|
* server, headless.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
import {
|
|
SENTINEL_EXCEPTIONS,
|
|
SENTINEL_PATTERN_EXCEPTIONS
|
|
} from './theming-sentinel-exceptions.ts';
|
|
|
|
const PROPS = [
|
|
'backgroundColor',
|
|
'backgroundImage',
|
|
'backgroundSize',
|
|
'backgroundPosition',
|
|
'color',
|
|
'borderTopColor',
|
|
'borderTopWidth',
|
|
'borderTopLeftRadius',
|
|
'borderInlineStartWidth',
|
|
// The colour of a border on any side but the TOP was invisible: prose tints
|
|
// its blockquote rule on the inline start and its table rules on the block
|
|
// edges, and all three read dead with a live token (measured 2026-08-22).
|
|
'borderInlineStartColor',
|
|
// …and their WIDTHS, for the same reason: nav-tree draws its chevron with two
|
|
// borders on the inline-end and block-end edges (measured 2026-08-22).
|
|
'borderInlineEndWidth',
|
|
'borderBlockStartWidth',
|
|
'borderBlockEndWidth',
|
|
'borderInlineEndColor',
|
|
'borderBlockStartColor',
|
|
'borderBlockEndColor',
|
|
'borderBottomColor',
|
|
'paddingTop',
|
|
'paddingLeft',
|
|
'paddingRight',
|
|
'paddingBottom',
|
|
'marginBottom',
|
|
'marginLeft',
|
|
'rowGap',
|
|
'columnGap',
|
|
'fontSize',
|
|
'fontWeight',
|
|
'fontFamily',
|
|
'lineHeight',
|
|
'letterSpacing',
|
|
'blockSize',
|
|
'inlineSize',
|
|
'minBlockSize',
|
|
'minInlineSize',
|
|
'maxBlockSize',
|
|
'maxInlineSize',
|
|
'boxShadow',
|
|
'opacity',
|
|
'filter',
|
|
'backdropFilter',
|
|
'textDecorationColor',
|
|
'textDecorationThickness',
|
|
'textUnderlineOffset',
|
|
'outlineColor',
|
|
'outlineWidth',
|
|
'outlineOffset',
|
|
'zIndex',
|
|
// SVG paint + geometry. Without these EVERY token of an SVG recipe reads
|
|
// dead: chart's axis / grid / separator ink, its stroke widths, the point
|
|
// radius and the area's fill-opacity are all painted through presentation
|
|
// attributes the box properties above never see (measured 2026-08-22 —
|
|
// 12 of chart's 39 tokens). Same class as the `filter` / `backdrop-filter`
|
|
// gap fixed the same day.
|
|
'fill',
|
|
'fillOpacity',
|
|
'stroke',
|
|
'strokeWidth',
|
|
'strokeOpacity',
|
|
'strokeDasharray',
|
|
'r',
|
|
'rx',
|
|
'ry',
|
|
'left',
|
|
'right',
|
|
'top',
|
|
'bottom',
|
|
'animationDuration'
|
|
];
|
|
|
|
/**
|
|
* Components whose DOM does not follow `data-{component}-{part}`, so neither the
|
|
* node filter nor the open step can find them by convention.
|
|
*
|
|
* `picker-shell` names its parts GENERICALLY on purpose — `data-picker-header`
|
|
* / `-body` / `-footer` — "so every picker gets the same visual contract for
|
|
* free" (its recipe says so), and it has no demo route of its own: it is
|
|
* measured inside a host picker, behind that picker's popover. Without this the
|
|
* guard reported 0/31 and would have needed 31 false "exceptions".
|
|
*/
|
|
const COMPONENT_OVERRIDES: Record<
|
|
string,
|
|
{
|
|
attrPrefix?: string;
|
|
openWith?: string[];
|
|
/**
|
|
* `focus` is for a surface that exists ONLY while focused: skip-link is
|
|
* sr-only until Tab reaches it, so every one of its eight tokens read dead
|
|
* (0/8, measured 2026-08-23) — the guard never focused it, and the blur it
|
|
* does after opening would have undone it anyway.
|
|
*/
|
|
openBy?: 'click' | 'hover' | 'contextmenu' | 'focus';
|
|
urls?: string[];
|
|
/** Nodes to measure that carry NO `data-{c}-*` attr (prose styles bare HTML). */
|
|
extraNodes?: string;
|
|
/**
|
|
* Demo controls to switch ON before measuring — ALL of them, unlike
|
|
* `openWith`, which stops at the first that works because it opens ONE
|
|
* surface. A component whose parts are independent OPT-IN layers
|
|
* (background: scrim, spotlight, pause… each behind its own control)
|
|
* measured 3 of 37 tokens on the default stage: what the demo does not
|
|
* mount has no node to paint. Failures are ignored — a control that is
|
|
* not there is not an error, it is one layer this route cannot show.
|
|
*/
|
|
prepareWith?: string[];
|
|
/**
|
|
* How to tell the surface is ALREADY open, when it is not
|
|
* `[data-{c}-content]`. Without it the re-open guard that runs before every
|
|
* token sees "closed", clicks the trigger again and TOGGLES the surface
|
|
* shut — half the run measured against a closed panel.
|
|
*/
|
|
openMarker?: string;
|
|
/**
|
|
* An attribute to SWEEP while measuring, with the values to try. A demo that
|
|
* mounts ONE instance (button: a single solid/primary stage driven by chips)
|
|
* hides every token that only paints under another variant — 71 of buttons
|
|
* 105 read dead without this.
|
|
*
|
|
* 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.
|
|
'tags-input': {
|
|
sweepAttr: [
|
|
{ attr: 'data-focus', values: [null, ''] },
|
|
{ attr: 'data-invalid', values: [null, ''] },
|
|
{ attr: 'data-disabled', values: [null, ''] }
|
|
]
|
|
},
|
|
// 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] *' },
|
|
// 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.
|
|
'radio-group': {
|
|
extraNodes: "[data-radio-group-indicator] svg[data-svg='dot']",
|
|
sweepAttr: { attr: 'data-variant', values: ['solid', 'outline', 'ghost', 'segmented'] },
|
|
urls: [
|
|
'/uix/components/radio-group',
|
|
'/uix/components/radio-group?perm.disabled=true',
|
|
'/uix/components/radio-group?perm.invalid=true'
|
|
]
|
|
},
|
|
// Its opacity floor is the only knob behind a state, and the demo has no URL
|
|
// switch for it: the toggle is the way in, and turning it on costs nothing
|
|
// (the one hover token of the recipe moves `transform`, which the guard does
|
|
// not read anyway — adjudicated).
|
|
'rating-group': {
|
|
prepareWith: [
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("disabled")) input[type=checkbox]'
|
|
]
|
|
},
|
|
// 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'] }
|
|
},
|
|
// 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, ''] }
|
|
]
|
|
},
|
|
// 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'] } }
|
|
};
|
|
|
|
function sentinelFor(key: string): string {
|
|
if (/z$/.test(key)) return '4321';
|
|
if (/font-family/.test(key)) return 'Zapfino, cursive';
|
|
if (/font-weight/.test(key)) return '123';
|
|
if (/line-height/.test(key)) return '3.77';
|
|
if (/opacity|scale/.test(key)) return '0.123';
|
|
if (/duration/.test(key)) return '11.5s';
|
|
if (/letter-spacing/.test(key)) return '4.5px';
|
|
// BEFORE the colour test on purpose: a DIMENSION whose name merely contains a
|
|
// colour word was getting `rgb(1, 2, 3)` and, being invalid for a length,
|
|
// moved nothing and read dead — measured on `chart.slice-stroke-width`
|
|
// (2026-08-22), which the substring `stroke` was capturing.
|
|
if (/(width|size|radius|gap|height|padding|offset|thickness|inset)$/.test(key)) return '1234px';
|
|
if (
|
|
/color|bg$|fg$|border$|ring$|separator|-bg-|fill|stroke|outline$|glass|scrim$|track$/.test(key)
|
|
)
|
|
return 'rgb(1, 2, 3)';
|
|
if (/shadow/.test(key)) return '0 0 0 7px rgb(1, 2, 3)';
|
|
return '1234px';
|
|
}
|
|
|
|
async function main() {
|
|
const [component, urlArg] = process.argv.slice(2);
|
|
if (!component) throw new Error('usage: <component> [url]');
|
|
const url = urlArg ?? `http://localhost:5173/uix/components/${component}`;
|
|
const override = COMPONENT_OVERRIDES[component] ?? {};
|
|
const attrPrefix = override.attrPrefix ?? `data-${component}`;
|
|
// Openings that the blur + pointer-park would UNDO. A hover panel dismisses
|
|
// when the cursor leaves; a focus-only surface disappears when the focus does.
|
|
const openingIsFragile = override.openBy === 'hover' || override.openBy === 'focus';
|
|
const contract = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace(
|
|
/\r\n/g,
|
|
'\n'
|
|
);
|
|
// `avatar` is the ONLY entry whose value is an IIFE (a local matrix helper
|
|
// generates its 24 composite scopes), so its map lives in the `return {` one
|
|
// tab deeper: this probe found no block at all and the guard threw «no recipe
|
|
// block for avatar» — the component could not be measured. Read the returned
|
|
// map and dedent it once; everything downstream works unchanged.
|
|
const iife = contract.indexOf(`\n\t${component}: ((): RecipeTokenMap => {`);
|
|
const start =
|
|
iife >= 0
|
|
? contract.indexOf('\n\t\treturn {', iife)
|
|
: Math.max(
|
|
contract.indexOf(`\n\t'${component}': {`),
|
|
contract.indexOf(`\n\t${component}: {`)
|
|
);
|
|
if (start < 0) throw new Error(`no recipe block for ${component}`);
|
|
const end = contract.indexOf(iife >= 0 ? '\n\t\t};' : '\n\t},', start);
|
|
const block =
|
|
iife >= 0
|
|
? contract.slice(start, end).replace(/^\t/gm, '')
|
|
: contract.slice(start, end < 0 ? undefined : end);
|
|
const keys = [...block.matchAll(/^\t\t'?([a-z0-9-]+)'?\s*:/gm)]
|
|
.map((m) => m[1])
|
|
.filter((k) => !k.startsWith('_'));
|
|
|
|
// A component whose surface is spread over SEVERAL demo routes cannot be
|
|
// judged on one page: what that page does not mount reads dead. A token is
|
|
// LIVE when ANY of the component's surfaces follows it; only what nothing
|
|
// moves anywhere is dead. Each URL only re-tests what is still pending, so
|
|
// the common case (one route) costs exactly what it did before.
|
|
const origin = new URL(url).origin;
|
|
const urls = override.urls ? override.urls.map((path) => origin + path) : [url];
|
|
|
|
const browser = await chromium.launch();
|
|
const live: string[] = [];
|
|
let pending = keys;
|
|
|
|
for (const target of urls) {
|
|
if (!pending.length) break;
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } });
|
|
// A demo whose network never goes idle (image keeps retrying the broken src
|
|
// of its error state) would time out the WHOLE run. The idle wait is a
|
|
// convenience, not a gate: fall back to the load event (2026-08-23).
|
|
try {
|
|
await page.goto(target, { waitUntil: 'networkidle', timeout: 15000 });
|
|
} catch {
|
|
await page.goto(target, { waitUntil: 'load', timeout: 15000 });
|
|
await page.waitForTimeout(800);
|
|
}
|
|
await page.waitForTimeout(600);
|
|
// A [data-busy] demo is still mutating — measuring it is measuring an instant.
|
|
await page
|
|
.waitForFunction(() => !document.querySelector('[data-busy]'), null, { timeout: 15000 })
|
|
.catch(() => {});
|
|
|
|
// Freeze transitions: a transitioned property reads its STARTING value right
|
|
// after the write, so a live token looked dead (measured on combobox's
|
|
// `box-shadow`, which transitions on `--duration-fast`).
|
|
await page.addStyleTag({
|
|
content: '*, *::before, *::after { transition: none !important; }'
|
|
});
|
|
|
|
// Open whatever can be opened, so portaled parts are in the document — then
|
|
// BLUR: a mouse click on a text input matches `:focus-visible`, and the
|
|
// focus rule repaints the rest-state chrome (the input-border false
|
|
// negative). Popovers dismiss on outside pointerdown, not on blur, so the
|
|
// open state survives.
|
|
// A surface that boots OPEN must not be clicked SHUT: onion-menu renders its
|
|
// sectors from the start, and the opening click toggled the whole recipe out
|
|
// of the document (12 of its 20 tokens read dead, 2026-08-23).
|
|
for (const sel of override.prepareWith ?? []) {
|
|
const el = page.locator(sel).first();
|
|
if (!(await el.count())) continue;
|
|
try {
|
|
await el.click({ timeout: 1500 });
|
|
await page.waitForTimeout(350);
|
|
} catch {
|
|
/* one layer this route cannot show */
|
|
}
|
|
}
|
|
|
|
const alreadyOpen = override.openMarker
|
|
? (await page.locator(override.openMarker).count()) > 0
|
|
: false;
|
|
for (const sel of alreadyOpen
|
|
? []
|
|
: [
|
|
...(override.openWith ?? []),
|
|
`[data-${component}-trigger]`,
|
|
`[data-${component}-input]`,
|
|
`[data-${component}-stop]`
|
|
]) {
|
|
const el = page.locator(sel).first();
|
|
if (await el.count()) {
|
|
try {
|
|
if (override.openBy === 'hover') await el.hover({ timeout: 1500 });
|
|
else if (override.openBy === 'focus') await el.focus({ timeout: 1500 });
|
|
else if (override.openBy === 'contextmenu')
|
|
await el.click({ button: 'right', timeout: 1500 });
|
|
else await el.click({ timeout: 1500 });
|
|
await page.waitForTimeout(400);
|
|
break;
|
|
} catch {
|
|
/* not clickable */
|
|
}
|
|
}
|
|
}
|
|
if (!openingIsFragile)
|
|
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.());
|
|
// ...and PARK THE POINTER (never for a hover-opened panel: moving the cursor
|
|
// away is exactly what dismisses it). blur() drops the focus but Playwright leaves the
|
|
// cursor where it clicked, so `:hover` keeps matching — and a hover rule
|
|
// usually outweighs the rest / focus / invalid ones it shares a node with
|
|
// (measured on textarea 2026-08-21: hover (0,4,0) beats focus (0,3,0) beats
|
|
// invalid (0,2,0) beats rest (0,1,0), so THREE rest-state tokens read dead).
|
|
// Same class as the click-focus false negative above, and the half that fix
|
|
// left behind. The per-token hover pass re-hovers on purpose further down.
|
|
if (!openingIsFragile) await page.mouse.move(0, 0);
|
|
|
|
// Runs before EVERY token. A component with no `content` part (textarea,
|
|
// any flat control) falls through to the click branch on every single key,
|
|
// so the pointer parking below is not belt-and-braces — without it the
|
|
// cursor sits on the input for the whole run.
|
|
const reopen = async () => {
|
|
const open = await page
|
|
.locator(
|
|
override.openMarker ??
|
|
(override.attrPrefix ? `[data-${component}]` : `[data-${component}-content]`)
|
|
)
|
|
.count();
|
|
if (open) return;
|
|
for (const sel of [
|
|
...(override.openWith ?? []),
|
|
`[data-${component}-trigger]`,
|
|
`[data-${component}-input]`
|
|
]) {
|
|
const el = page.locator(sel).first();
|
|
if (await el.count()) {
|
|
try {
|
|
if (override.openBy === 'hover') await el.hover({ timeout: 1000 });
|
|
else if (override.openBy === 'focus') await el.focus({ timeout: 1000 });
|
|
else if (override.openBy === 'contextmenu')
|
|
await el.click({ button: 'right', timeout: 1000 });
|
|
else await el.click({ timeout: 1000 });
|
|
await page.waitForTimeout(250);
|
|
if (!openingIsFragile) {
|
|
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.());
|
|
await page.mouse.move(0, 0);
|
|
}
|
|
return;
|
|
} catch {
|
|
/* keep trying */
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const staticPass = (key: string, value: string) =>
|
|
page.evaluate(
|
|
([kebab, token, val, props, prefix, extra, sweeps]) => {
|
|
const all = () =>
|
|
[...document.querySelectorAll<HTMLElement>('*')].filter(
|
|
(n) =>
|
|
[...n.attributes].some((a) => a.name.startsWith(prefix)) ||
|
|
(extra ? n.matches(extra as string) : false)
|
|
);
|
|
// Write on :root AND on every component node: resolved names are
|
|
// declared per part (root/host), and a portaled panel never sees the
|
|
// component root. Writing the var everywhere cannot fake a win at the
|
|
// PROPERTY level — a declaration another rule (or an inline style)
|
|
// beats stays beaten.
|
|
const hosts = () => [document.documentElement, ...all()];
|
|
// A tone token (`risk-solid`, `affirm-track`…) only paints on an instance
|
|
// wearing that tone: the palette forward routes the slots through
|
|
// `--{c}-palette-*` per [data-color]. Stamp the tone the key names.
|
|
// The 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];
|
|
const TONES = ['primary','secondary','neutral','affirm','fulfill','risk','threat','loss'];
|
|
const tone = TONES.find((t) => (token as string).startsWith(t + '-'));
|
|
const toned = tone ? all() : [];
|
|
const toneWas = toned.map((n) => n.getAttribute('data-color'));
|
|
// Two attribute names for the same vocabulary: most recipes read
|
|
// `data-color`, banner reads `data-intent`. Stamp both — a component
|
|
// only selects on one, so the other is inert.
|
|
const toneIntentWas = toned.map((n) => n.getAttribute('data-intent'));
|
|
if (tone)
|
|
for (const n of toned) {
|
|
n.setAttribute('data-color', tone);
|
|
n.setAttribute('data-intent', tone);
|
|
}
|
|
const sized = all().filter((n) => n.hasAttribute('data-size'));
|
|
const original = sized.map((n) => n.getAttribute('data-size'));
|
|
const snap = () =>
|
|
all()
|
|
.map((n) => {
|
|
const cs = getComputedStyle(n);
|
|
const own = (props as string[]).map((p) => cs[p as never]).join('|');
|
|
const b = getComputedStyle(n, '::before');
|
|
const a = getComputedStyle(n, '::after');
|
|
// ::placeholder IS readable through getComputedStyle - measured
|
|
// 2026-08-21 on textarea (the sentinel colour came straight back).
|
|
// The next-features §13 note saying it is not was wrong, and it had
|
|
// already cost command.input-placeholder-fg a hand-checked entry.
|
|
const ph = getComputedStyle(n, '::placeholder');
|
|
const pseudo = (props as string[])
|
|
.map((p) => `${b[p as never]}~${a[p as never]}~${ph[p as never]}`)
|
|
.join('|');
|
|
return own + '#' + pseudo;
|
|
})
|
|
.join('@');
|
|
let moved = false;
|
|
for (const 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;
|
|
}
|
|
sized.forEach((n, i) => (original[i] ? n.setAttribute('data-size', original[i]!) : null));
|
|
swept.forEach((n, i2) =>
|
|
axes.forEach((ax, j) =>
|
|
sweptWas[i2][j] ? n.setAttribute(ax.attr, sweptWas[i2][j]!) : n.removeAttribute(ax.attr)
|
|
)
|
|
);
|
|
toned.forEach((n, i2) => {
|
|
toneWas[i2] ? n.setAttribute('data-color', toneWas[i2]!) : n.removeAttribute('data-color');
|
|
toneIntentWas[i2]
|
|
? n.setAttribute('data-intent', toneIntentWas[i2]!)
|
|
: n.removeAttribute('data-intent');
|
|
});
|
|
return moved;
|
|
},
|
|
[
|
|
component,
|
|
key,
|
|
value,
|
|
PROPS,
|
|
attrPrefix,
|
|
override.extraNodes ?? null,
|
|
override.sweepAttr
|
|
? Array.isArray(override.sweepAttr)
|
|
? override.sweepAttr
|
|
: [override.sweepAttr]
|
|
: []
|
|
] 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]) => {
|
|
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('|');
|
|
};
|
|
// 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;
|
|
}
|
|
}
|
|
swept.forEach((n, i2) =>
|
|
axes.forEach((ax, j) =>
|
|
sweptWas[i2][j] ? n.setAttribute(ax.attr, sweptWas[i2][j]!) : n.removeAttribute(ax.attr)
|
|
)
|
|
);
|
|
return moved;
|
|
},
|
|
[
|
|
attrPrefix,
|
|
key,
|
|
value,
|
|
PROPS,
|
|
i,
|
|
component,
|
|
override.extraNodes ?? null,
|
|
override.sweepAttr
|
|
? Array.isArray(override.sweepAttr)
|
|
? override.sweepAttr
|
|
: [override.sweepAttr]
|
|
: []
|
|
] as const
|
|
);
|
|
if (moved) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const stillDead: string[] = [];
|
|
for (const key of pending) {
|
|
await reopen();
|
|
const value = sentinelFor(key);
|
|
let moved = await staticPass(key, value);
|
|
if (!moved && /hover/.test(key)) {
|
|
moved = await hoverPass(key, value);
|
|
// PARK THE POINTER AFTERWARDS. The hover pass leaves the cursor on the
|
|
// LAST node it hovered, and a hover rule outweighs the rest-state one it
|
|
// shares a node with: every rest token tested after a hover token could
|
|
// read dead. Measured 2026-08-22 on float-panel — `resize-grip-fg` moved
|
|
// in isolation and read dead in a full run, because the grip's own
|
|
// `:hover` rule re-points its colour to the accent. Same class as the
|
|
// click-focus poisoning fixed in F2-A, one pass later.
|
|
if (!openingIsFragile) await page.mouse.move(0, 0);
|
|
}
|
|
if (moved) live.push(key);
|
|
else stillDead.push(key);
|
|
}
|
|
pending = stillDead;
|
|
await page.close();
|
|
}
|
|
|
|
await browser.close();
|
|
const dead = pending;
|
|
|
|
const ledger = SENTINEL_EXCEPTIONS[component] ?? {};
|
|
const patterns = SENTINEL_PATTERN_EXCEPTIONS.filter((p) => p.component === component);
|
|
const reasonFor = (k: string) =>
|
|
ledger[k] ?? patterns.find((p) => p.pattern.test(k))?.reason ?? null;
|
|
const adjudicated = dead.filter((k) => reasonFor(k) !== null);
|
|
const unadjudicated = dead.filter((k) => reasonFor(k) === null);
|
|
// 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:');
|
|
const stale = live.filter((k) => k in ledger && !oscillates(k));
|
|
const oscillating = live.filter((k) => k in ledger && 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)})`);
|
|
if (stale.length)
|
|
console.log(
|
|
` STALE exception(s) — the token moves now, the ledger entry should go: ${stale.join(', ')}`
|
|
);
|
|
if (unadjudicated.length)
|
|
console.log(
|
|
` NO EFFECT, UNADJUDICATED (${unadjudicated.length}): ${unadjudicated.join(', ')}\n` +
|
|
` R-5.4: a public token that moves nothing and carries no written adjudication is a token that lies.`
|
|
);
|
|
process.exit(unadjudicated.length === 0 ? 0 : 1);
|
|
}
|
|
|
|
main();
|