@ -5,10 +5,10 @@
*
* Temporary instrument ( the ` __ ` prefix keeps it out of the tracked surface ) .
* Walks the component demo , and for every node matching ` [data-{c}…] ` records
* the knob computed values at rest , per size , hovered and focused . The size is
* forced by WRITING ` data-size ` on the nodes that already carry it — the recipe
* selects on that attribute , so the computed result is the same one the real
* interaction produces , without depending on each demo ' s chips .
* the knob computed values at rest , per size , open, hovered , focused and
* disabled. The size is forced by WRITING ` data-size ` on the nodes that already
* carry it — the recipe selects on that attribute , so the computed result is the
* same one the real interaction produces , without depending on each demo ' s chips .
*
* Run it with plain ` node ` , NOT with tsx : the tsx loader injects helpers that
* are not defined inside ` page.evaluate ` , and every probe throws .
@ -19,7 +19,12 @@
* /
import { writeFileSync } from 'node:fs' ;
import { chromium } from 'playwright' ;
import { chromium , type ElementHandle } from 'playwright' ;
// The SENTINEL's door vocabulary, not a second copy of it: a component the guard
// can only reach through an override (a surface that opens on FOCUS, a demo
// control that must be ON first, a marker that says it really came up) is a
// component this probe cannot reach either. Two tables for the same demos drift.
import { COMPONENT_OVERRIDES } from './theming-sentinel.ts' ;
const PROPS = [
'backgroundColor' ,
@ -58,6 +63,60 @@ const PROPS = [
const SIZES = [ 'xs' , 'sm' , 'md' , 'lg' , 'xl' ] ;
/ * *
* The palette scales a probe may STAMP without measuring something else .
*
* Nine of the 33 scales the base theme ships ( ` PALETTE_SCALES ` ,
* ` src/uix/eidos/lib/types.ts:232-270 ` ) are BOUND to a role by
* ` THEME_BASE_COLOR_ROLES ` ( ` src/uix/eidos/lib/themes/base.ts:22-47 ` ) , and a
* bound scale is not a free surface : stamping ` teal ` believing you are probing a
* spare scale is stamping ` affirm ` . The binding is copied here because the theme
* module is NOT importable from a plain - node script — its import graph reaches
* ` $ … ` aliases only vite resolves — so the provenance lives in this comment
* instead of in an import .
*
* KNOWN DISCREPANCY , deliberately not smoothed over : ` CANONICAL_INTENT_SCALES `
* ( ` src/uix/eidos/lib/config-types.ts:93-100 ` ) says ` risk: 'amber' ` while the
* base theme says ` risk: 'orange' ` . Under the base theme , probing with ` amber `
* believing you are probing ` risk ` measures a different scale .
*
* Nothing in this file stamps a scale today : the two constants and the assert
* are here for the next writer who does . A guard , not a feature .
* /
const PROBE_ROLE_BOUND_SCALES : Record < string , string > = {
purple : 'primary' ,
slate : 'secondary' ,
indigo : 'tertiary' ,
gray : 'neutral' ,
teal : 'affirm' ,
green : 'fulfill' ,
orange : 'risk' ,
red : 'threat' ,
plum : 'loss'
} ;
/** The remaining 24 of the 33: bound to no role, safe to stamp. */
// prettier-ignore
const PROBE_SAFE_SCALES = [
'amber' , 'blue' , 'bronze' , 'brown' , 'crimson' , 'cyan' , 'fuchsia' , 'gold' ,
'grass' , 'iris' , 'jade' , 'lime' , 'mauve' , 'mint' , 'olive' , 'pink' ,
'ruby' , 'sage' , 'sand' , 'sky' , 'steel' , 'tomato' , 'violet' , 'yellow'
] ;
function assertProbeSafeScale ( scale : string ) : void {
const role = PROBE_ROLE_BOUND_SCALES [ scale ] ;
if ( role )
throw new Error (
` probe scale ' ${ scale } ' is bound to the ' ${ role } ' role by THEME_BASE_COLOR_ROLES: ` +
` stamping it measures that role, not a free scale. Free scales: ${ PROBE_SAFE_SCALES . join ( ', ' ) } `
) ;
if ( ! PROBE_SAFE_SCALES . includes ( scale ) )
throw new Error (
` probe scale ' ${ scale } ' is not one of the 33 scales the base theme ships. ` +
` Free scales: ${ PROBE_SAFE_SCALES . join ( ', ' ) } `
) ;
}
/ * *
* Demo controls that must be ON before measuring : a part the demo does not
* mount is a part the diff never compares , and a probe over two nodes passes
@ -117,23 +176,22 @@ const EXTRA_NODES: Record<string, string> = {
} ;
/ * *
* The trigger the OPEN pass must click . The default is the first trigger in the
* document , which is wrong whenever the surface worth measuring hangs off
* ANOTHER one : menubar drops a role = menu Content from its first entry and its
* own role = dialog Panel — the part that carries its public knobs — only from
* the "Format" entry .
* The trigger the OPEN pass must reach , for the components the SENTINEL does not
* already name . The default is the first trigger in the document , which is wrong
* whenever the surface worth measuring hangs off ANOTHER one .
*
* ` COMPONENT_OVERRIDES[c].openWith ` is consulted FIRST , so anything the guard
* names stays out of this table : menubar and chat - message used to live here as
* literal copies of their ` openWith ` entries and were removed with the import .
* /
const OPEN_TRIGGER : Record < string , string > = {
menubar : "[data-menubar-trigger][data-menubar-value='format']" ,
// It has NO route of its own (`/uix/components/color-swatch` is a 404, the
// fifth canon component in that state): it is measured on the color-picker
// demo, whose stage mounts exactly ONE swatch (the trigger chip). Every other
// swatch — presets and saved swatches, a dozen of them — lives in the
// PORTALED panel, so the open pass is where the node count stops being 1.
'color-swatch' : '[data-color-picker-trigger]' ,
// Its only openable surface is the quick-reaction tapback bar, hanging off
// the add-reaction chip — the default `[data-{c}-trigger]` matches nothing.
'chat-message' : '[data-chat-message-reaction-add]'
// The guard reaches it through `urls`, which this probe does not have.
'color-swatch' : '[data-color-picker-trigger]'
} ;
const DEMO_VARIANTS : Record < string , string [ ] > = {
@ -204,7 +262,12 @@ const DEMO_VARIANTS: Record<string, string[]> = {
async function main() {
const [ component , out , urlArg ] = process . argv . slice ( 2 ) ;
if ( ! component || ! out ) throw new Error ( 'usage: <component> <out.json> [url]' ) ;
const url = urlArg ? ? ` http://localhost:5173/uix/components/ ${ component } ` ;
// The dev server does not always answer on 5173 (a second worktree, a port
// already taken): `UIX_DEV_URL` moves the whole run without spelling the route
// out on every call. An explicit URL argument still wins.
const origin = process . env . UIX_DEV_URL ? ? 'http://localhost:5173' ;
const url = urlArg ? ? ` ${ origin } /uix/components/ ${ component } ` ;
const override = COMPONENT_OVERRIDES [ component ] ? ? { } ;
const browser = await chromium . launch ( ) ;
const page = await browser . newPage ( { viewport : { width : 1440 , height : 1000 } } ) ;
@ -321,68 +384,117 @@ async function main() {
. catch ( ( ) = > page . reload ( { waitUntil : 'load' , timeout : 15000 } ) ) ;
await page . waitForTimeout ( 400 ) ;
await prepare ( ) ;
const trigger = page
. locator ( OPEN_TRIGGER [ component ] ? ? ` [data- ${ component } -trigger], [data- ${ component } -input] ` )
. first ( ) ;
if ( await trigger . count ( ) ) {
// The demo controls the guard needs ON before a surface can open — but only
// for components this probe has no `DEMO_VARIANTS` row of its own for: where
// it does, `prepare()` has already clicked them and clicking the same toggles
// again would UNDO them (they are checkboxes, not switches). Absence is not an
// error here, unlike in `variants()`: it is one layer this route cannot show.
if ( ! DEMO_VARIANTS [ component ] )
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 */
}
}
// The GESTURE is the guard's too. `editable` is the case this fixes: its edit
// surface opens on FOCUS and a blur is what closes it, so the click below threw
// and the silent catch dropped the whole `open` block from its snapshot.
const openers = [
. . . ( override . openWith ? ? [ ] ) ,
OPEN_TRIGGER [ component ] ? ? ` [data- ${ component } -trigger], [data- ${ component } -input] `
] ;
let opened = false ;
let failure : string | null = null ;
for ( const sel of openers ) {
const el = page . locator ( sel ) . first ( ) ;
if ( ! ( await el . count ( ) ) ) continue ;
try {
// A context menu opens on RIGHT click and on nothing else.
await trigger . click ( { button : component === 'context-menu' ? 'right' : 'left' , timeout : 2000 } ) ;
if ( override . openBy === 'focus' ) await el . focus ( { timeout : 2000 } ) ;
else if ( override . openBy === 'hover' ) await el . hover ( { timeout : 2000 } ) ;
else
await el . click ( {
// A context menu opens on RIGHT click and on nothing else.
button :
override . openBy === 'contextmenu' || component === 'context-menu' ? 'right' : 'left' ,
timeout : 2000
} ) ;
await page . waitForTimeout ( 500 ) ;
result . open = await page . evaluate (
( [ kebab , keys , extra ] ) = > {
const nodes = [ . . . document . querySelectorAll < HTMLElement > ( '*' ) ] . filter (
( n ) = >
[ . . . n . attributes ] . some ( ( a ) = > a . name . startsWith ( ` data- ${ kebab } ` ) ) ||
( extra ? n . matches ( extra as string ) : false )
) ;
const seen = new Map < string , number > ( ) ;
const rows : Record < string , Record < string , string > > = { } ;
for ( const n of nodes ) {
const attrs = [ . . . n . attributes ]
. filter ( ( a ) = > a . name . startsWith ( 'data-' ) && ! a . name . startsWith ( 'data-event' ) )
. map ( ( a ) = > ( a . value ? ` ${ a . name } = ${ a . value } ` : a . name ) )
. sort ( )
. join ( '|' ) ;
const base = ` ${ n . tagName . toLowerCase ( ) } [ ${ attrs } ] ` ;
const i = ( seen . get ( base ) ? ? 0 ) + 1 ;
seen . set ( base , i ) ;
const cs = getComputedStyle ( n ) ;
const row : Record < string , string > = { } ;
for ( const k of keys as string [ ] ) row [ k ] = cs [ k as keyof CSSStyleDeclaration ] as string ;
rows [ ` ${ base } # ${ i } ` ] = row ;
}
return rows ;
} ,
[ component , PROPS , EXTRA_NODES [ component ] ? ? null ] as const
) ;
} catch {
/* no openable trigger — skip the state */
// The marker tells an opening that WORKED from one that fired and closed
// again — the editable class of failure, invisible without it.
if ( override . openMarker && ! ( await page . locator ( override . openMarker ) . count ( ) ) ) {
failure = ` ${ sel } fired but ${ override . openMarker } never appeared ` ;
continue ;
}
opened = true ;
break ;
} catch ( e ) {
failure = ` ${ sel } : ${ String ( e ) . split ( '\n' ) [ 0 ] } ` ;
}
}
if ( opened ) {
result . open = await page . evaluate (
( [ kebab , keys , extra ] ) = > {
const nodes = [ . . . document . querySelectorAll < HTMLElement > ( '*' ) ] . filter (
( n ) = >
[ . . . n . attributes ] . some ( ( a ) = > a . name . startsWith ( ` data- ${ kebab } ` ) ) ||
( extra ? n . matches ( extra as string ) : false )
) ;
const seen = new Map < string , number > ( ) ;
const rows : Record < string , Record < string , string > > = { } ;
for ( const n of nodes ) {
const attrs = [ . . . n . attributes ]
. filter ( ( a ) = > a . name . startsWith ( 'data-' ) && ! a . name . startsWith ( 'data-event' ) )
. map ( ( a ) = > ( a . value ? ` ${ a . name } = ${ a . value } ` : a . name ) )
. sort ( )
. join ( '|' ) ;
const base = ` ${ n . tagName . toLowerCase ( ) } [ ${ attrs } ] ` ;
const i = ( seen . get ( base ) ? ? 0 ) + 1 ;
seen . set ( base , i ) ;
const cs = getComputedStyle ( n ) ;
const row : Record < string , string > = { } ;
for ( const k of keys as string [ ] ) row [ k ] = cs [ k as keyof CSSStyleDeclaration ] as string ;
rows [ ` ${ base } # ${ i } ` ] = row ;
}
return rows ;
} ,
[ component , PROPS , EXTRA_NODES [ component ] ? ? null ] as const
) ;
} else {
// A component with nothing to open is a legitimate skip — `card` has no
// trigger at all — but a component whose opening THREW is a hole in the
// measurement, and until now it vanished into a bare `catch {}`. Say which
// of the two happened, on stderr, and carry on either way.
console . warn (
` probe ${ component } : no open state — ` +
( failure ? ? ` no opener matched ( ${ openers . join ( ' | ' ) } ) ` )
) ;
}
// Hover + focus on every interactive part the recipe could paint.
await page
. reload ( { waitUntil : 'networkidle' , timeout : 15000 } )
. catch ( ( ) = > page . reload ( { waitUntil : 'load' , timeout : 15000 } ) ) ;
await page . waitForTimeout ( 400 ) ;
await prepare ( ) ;
const targets = await page . evaluate ( ( [ kebab , extra ] ) = > {
const root = document . querySelector ( '[data-uix-stage]' ) ? ? document . body ;
return [ . . . root . querySelectorAll < HTMLElement > ( '*' ) ]
. filter (
( n ) = >
[ . . . n . attributes ] . some ( ( a ) = > a . name . startsWith ( ` data- ${ kebab } ` ) ) ||
( extra ? n . matches ( extra ) : false )
)
. map ( ( n , i ) = > ( { i , tag : n.tagName.toLowerCase ( ) } ) )
. filter ( ( t ) = > [ 'button' , 'a' , 'input' , 'li' , 'div' , 'tr' ] . includes ( t . tag ) )
. slice ( 0 , 24 ) ;
} , [ component , EXTRA_NODES [ component ] ? ? null ] as const ) ;
// The interactive nodes the hover and the focus pass both walk, and the node
// handle behind an index. Recomputed per page state — each pass reloads.
const collectTargets = ( ) = >
page . evaluate (
( [ kebab , extra ] ) = > {
const root = document . querySelector ( '[data-uix-stage]' ) ? ? document . body ;
return [ . . . root . querySelectorAll < HTMLElement > ( '*' ) ]
. filter (
( n ) = >
[ . . . n . attributes ] . some ( ( a ) = > a . name . startsWith ( ` data- ${ kebab } ` ) ) ||
( extra ? n . matches ( extra ) : false )
)
.map ( ( n , i ) = > ( { i , tag : n.tagName.toLowerCase ( ) } ) )
. filter ( ( t ) = > [ 'button' , 'a' , 'input' , 'li' , 'div' , 'tr' ] . includes ( t . tag ) )
. slice ( 0 , 24 ) ;
},
[component , EXTRA_NODES [ component ] ? ? null ] as const
) ;
const hovered : Record < string , Record < string , string > > = { } ;
for ( const t of targets ) {
const handle = await page . evaluateHandle (
const handleAt = ( index : number ) = >
page . evaluateHandle (
( [ kebab , idx , extra ] ) = > {
const root = document . querySelector ( '[data-uix-stage]' ) ? ? document . body ;
const all = [ . . . root . querySelectorAll < HTMLElement > ( '*' ) ] . filter (
@ -392,24 +504,37 @@ async function main() {
) ;
return all [ idx as number ] ? ? null ;
} ,
[ component , t. i, EXTRA_NODES [ component ] ? ? null ] as const
[ component , index , EXTRA_NODES [ component ] ? ? null ] as const
) ;
const el = handle . asElement ( ) ;
const rowOf = ( el : ElementHandle < HTMLElement > ) = >
el . evaluate ( ( n , keys ) = > {
const cs = getComputedStyle ( n as HTMLElement ) ;
const attrs = [ . . . ( n as HTMLElement ) . attributes ]
. filter ( ( a ) = > a . name . startsWith ( 'data-' ) && ! a . name . startsWith ( 'data-event' ) )
. map ( ( a ) = > ( a . value ? ` ${ a . name } = ${ a . value } ` : a . name ) )
. sort ( )
. join ( '|' ) ;
const out : Record < string , string > = { __key : attrs } ;
for ( const k of keys as string [ ] ) out [ k ] = cs [ k as keyof CSSStyleDeclaration ] as string ;
return out ;
} , PROPS ) ;
// Hover on every interactive part the recipe could paint.
await page
. reload ( { waitUntil : 'networkidle' , timeout : 15000 } )
. catch ( ( ) = > page . reload ( { waitUntil : 'load' , timeout : 15000 } ) ) ;
await page . waitForTimeout ( 400 ) ;
await prepare ( ) ;
const hovered : Record < string , Record < string , string > > = { } ;
for ( const t of await collectTargets ( ) ) {
const el = ( await handleAt ( t . i ) ) . asElement ( ) ;
if ( ! el ) continue ;
try {
await el . hover ( { timeout : 1500 } ) ;
await page . waitForTimeout ( 400 ) ;
const row = await el . evaluate ( ( n , keys ) = > {
const cs = getComputedStyle ( n as HTMLElement ) ;
const attrs = [ . . . ( n as HTMLElement ) . attributes ]
. filter ( ( a ) = > a . name . startsWith ( 'data-' ) && ! a . name . startsWith ( 'data-event' ) )
. map ( ( a ) = > ( a . value ? ` ${ a . name } = ${ a . value } ` : a . name ) )
. sort ( )
. join ( '|' ) ;
const out : Record < string , string > = { __key : attrs } ;
for ( const k of keys as string [ ] ) out [ k ] = cs [ k as keyof CSSStyleDeclaration ] as string ;
return out ;
} , PROPS ) ;
const row = await rowOf ( el ) ;
hovered [ ` ${ t . i } : ${ row . __key } ` ] = row ;
} catch {
/* not hoverable (offscreen / covered) — skip */
@ -417,10 +542,79 @@ async function main() {
}
result . hover = hovered ;
// Focus on the same targets. The header of this file has promised this pass
// since it was written and NOTHING ever called `.focus()` — `result.focus` was
// never assigned. It runs on its OWN reload: the hover loop parks the pointer
// on its last target, and a hover rule outweighs the focus one it shares a
// node with.
//
// What it measures is PROGRAMMATIC focus, i.e. `:focus`. `:focus-visible` can
// resolve differently on the same node — in this engine a mouse click on a
// text input DOES match it — so the two gestures are never mixed here: the
// pointer does not move during this pass.
await page
. reload ( { waitUntil : 'networkidle' , timeout : 15000 } )
. catch ( ( ) = > page . reload ( { waitUntil : 'load' , timeout : 15000 } ) ) ;
await page . waitForTimeout ( 400 ) ;
await prepare ( ) ;
const focused : Record < string , Record < string , string > > = { } ;
for ( const t of await collectTargets ( ) ) {
const el = ( await handleAt ( t . i ) ) . asElement ( ) ;
if ( ! el ) continue ;
try {
await el . focus ( ) ;
await page . waitForTimeout ( 400 ) ;
const row = await rowOf ( el ) ;
focused [ ` ${ t . i } : ${ row . __key } ` ] = row ;
} catch {
/* not focusable / detached — skip */
}
// Undo it before the next target: a node left focused keeps its focus
// chrome while the following one is measured.
await el . evaluate ( ( n ) = > ( n as HTMLElement ) . blur ? . ( ) ) . catch ( ( ) = > { } ) ;
}
result . focus = focused ;
// Disabled state — its own reload, its own prepare, and the LAST pass of the
// run. `data-disabled` is a condition that must never be lit while anything
// else is measured: file-upload's disabled rule drops `pointer-events` on the
// whole subtree, so a run that stamped it early would hover nothing — the
// "mounting more measures less" trap, this time as a state.
await page
. reload ( { waitUntil : 'networkidle' , timeout : 15000 } )
. catch ( ( ) = > page . reload ( { waitUntil : 'load' , timeout : 15000 } ) ) ;
await page . waitForTimeout ( 400 ) ;
await prepare ( ) ;
await page . evaluate (
( [ kebab , extra ] ) = > {
const root = document . querySelector ( '[data-uix-stage]' ) ? ? document . body ;
const nodes = [ . . . root . querySelectorAll < HTMLElement > ( '*' ) ] . filter (
( n ) = >
[ . . . n . attributes ] . some ( ( a ) = > a . name . startsWith ( ` data- ${ kebab } ` ) ) ||
( extra ? n . matches ( extra as string ) : false )
) ;
for ( const n of nodes ) {
n . setAttribute ( 'data-disabled' , '' ) ;
// A native control wears the PROPERTY, not the attribute: `:disabled`
// is what a recipe selects on for a button / input / select / textarea,
// and the attribute alone never lights it.
if ( n . matches ( 'button, input, select, textarea, fieldset' ) )
( n as HTMLButtonElement ) . disabled = true ;
}
} ,
[ component , EXTRA_NODES [ component ] ? ? null ] as const
) ;
await page . waitForTimeout ( 200 ) ;
result . disabled = await measure ( component , PROPS , null ) ;
await browser . close ( ) ;
writeFileSync ( out , JSON . stringify ( result , null , '\t' ) , 'utf8' ) ;
const count = Object . keys ( result . rest as object ) . length ;
console . log ( ` probe ${ component } : ${ count } nodes at rest, ${ Object . keys ( hovered ) . length } hovered ` ) ;
console . log (
` probe ${ component } : ${ count } nodes at rest, ${ Object . keys ( hovered ) . length } hovered, ` +
` ${ Object . keys ( focused ) . length } focused `
) ;
}
main ( ) ;