@ -1,15 +1,15 @@
import { existsSync , readFileSync , readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe , expect , it } from 'vitest'
import { existsSync , readFileSync , readdirSync } from 'node:fs' ;
import { resolve } from 'node:path' ;
import { describe , expect , it } from 'vitest' ;
import { compileMorfo } from '../morfo/compile'
import { dialogMorfo } from '../morfo/components/dialog'
import { iconMorfo } from '../morfo/components/icon'
import { toggleMorfo } from '../morfo/components/toggle'
import { lintEidosCss } from './lint'
import { compileMorfo } from '../morfo/compile' ;
import { dialogMorfo } from '../morfo/components/dialog' ;
import { iconMorfo } from '../morfo/components/icon' ;
import { toggleMorfo } from '../morfo/components/toggle' ;
import { lintEidosCss } from './lint' ;
function readEidos ( name : string ) : string {
return readFileSync ( resolve ( ` src/uix/eidos/components/ ${ name } / ${ name } .css ` ) , 'utf-8' )
return readFileSync ( resolve ( ` src/uix/eidos/components/ ${ name } / ${ name } .css ` ) , 'utf-8' ) ;
}
/ * * C o n c a t t h e p e r - c o m p o n e n t r e c i p e C S S w i t h t h e g e n e r a t e d f o u n d a t i o n
@ -18,37 +18,37 @@ function readEidos(name: string): string {
* because the config now owns the palette overrides . The lint contract
* is "the eidos layer reacts to morfo's attrs" — we include both files . * /
function readEidosWithGenerated ( name : string ) : string {
const recipe = readEidos ( name )
const generated = readFileSync ( resolve ( 'src/uix/eidos/generated/base.css' ) , 'utf-8' )
const recipe = readEidos ( name ) ;
const generated = readFileSync ( resolve ( 'src/uix/eidos/generated/base.css' ) , 'utf-8' ) ;
// Extract only the rules targeting this component to keep the lint
// scope narrow.
const componentRulePattern = new RegExp ( ` \\ [data- ${ name } \\ ][^{]* \\ {[^}]* \\ } ` , 'g' )
const generatedComponentRules = ( generated . match ( componentRulePattern ) ? ? [ ] ) . join ( '\n' )
return ` ${ recipe } \ n ${ generatedComponentRules } `
const componentRulePattern = new RegExp ( ` \\ [data- ${ name } \\ ][^{]* \\ {[^}]* \\ } ` , 'g' ) ;
const generatedComponentRules = ( generated . match ( componentRulePattern ) ? ? [ ] ) . join ( '\n' ) ;
return ` ${ recipe } \ n ${ generatedComponentRules } ` ;
}
function listEidosCssComponents ( ) : string [ ] {
const eidosDir = resolve ( 'src/uix/eidos/components' )
const eidosDir = resolve ( 'src/uix/eidos/components' ) ;
return readdirSync ( eidosDir , { withFileTypes : true } ) . flatMap ( ( entry ) = > {
if ( ! entry . isDirectory ( ) ) return [ ]
if ( ! entry . isDirectory ( ) ) return [ ] ;
const cssPath = resolve ( eidosDir , entry . name , ` ${ entry . name } .css ` )
return existsSync ( cssPath ) ? [ entry . name ] : [ ]
} )
const cssPath = resolve ( eidosDir , entry . name , ` ${ entry . name } .css ` ) ;
return existsSync ( cssPath ) ? [ entry . name ] : [ ] ;
} ) ;
}
function listRootCssFiles ( ) : string [ ] {
const eidosDir = resolve ( 'src/uix/eidos/components' )
const eidosDir = resolve ( 'src/uix/eidos/components' ) ;
return readdirSync ( eidosDir , { withFileTypes : true } )
. filter ( ( entry ) = > entry . isFile ( ) && entry . name . endsWith ( '.css' ) )
. map ( ( entry ) = > entry . name )
. sort ( )
. sort ( ) ;
}
describe ( 'eidos component contract coverage' , ( ) = > {
it ( 'keeps component CSS inside component subdirectories' , ( ) = > {
expect ( listRootCssFiles ( ) ) . toEqual ( [ ] )
} )
expect ( listRootCssFiles ( ) ) . toEqual ( [ ] ) ;
} ) ;
it ( 'has a morfo file for every eidos CSS component' , ( ) = > {
// Morfos live under `src/uix/morfo/components/{name}.ts` for public
@ -64,108 +64,131 @@ describe('eidos component contract coverage', () => {
// - KNOWN MORFO GAPS: real morfo-first violations tracked for a build
// pass (NOT a silent exclusion). card-group ships parts + chrome but
// no morfo — tracked in PLAN-component-coherence.md. Remove when built.
const CSS_ONLY_LAYERS = new Set ( [ 'spin-field' ] )
const WIP_TRACKS = new Set ( [ 'palabras' , 'chronos' ] )
const KNOWN_MISSING_MORFO = new Set ( [ 'card-group' ] )
const CSS_ONLY_LAYERS = new Set ( [ 'spin-field' ] ) ;
const WIP_TRACKS = new Set ( [ 'palabras' , 'chronos' ] ) ;
const KNOWN_MISSING_MORFO = new Set ( [ 'card-group' ] ) ;
// COMPETING ROOTS: a recipe that dresses ANOTHER component's morfo. It
// has no morfo of its own and never will — `<AudioPlayer>` is a competing
// root over the media-player contract (D-AP2.2, the Toast/Toaster
// precedent), so all of `audio-player.css` selects `[data-media-player…]`.
// Kept apart from KNOWN_MISSING_MORFO on purpose: that set means "real
// morfo-first violation, delete the entry once built", and filing a
// competing root there would promise a build that must never happen.
const COMPETING_ROOTS = new Set ( [ 'audio-player' ] ) ;
const missing = listEidosCssComponents ( ) . filter (
( name ) = >
! CSS_ONLY_LAYERS . has ( name ) &&
! WIP_TRACKS . has ( name ) &&
! KNOWN_MISSING_MORFO . has ( name ) &&
! COMPETING_ROOTS . has ( name ) &&
! existsSync ( resolve ( ` src/uix/morfo/components/ ${ name } .ts ` ) ) &&
! existsSync ( resolve ( ` src/uix/morfo/internal/ ${ name } .ts ` ) )
)
expect ( missing ) . toEqual ( [ ] )
} )
} )
) ;
expect ( missing ) . toEqual ( [ ] ) ;
} ) ;
it ( 'holds competing roots to what makes them competing' , ( ) = > {
// The exemption above is only honest while the claim behind it is true.
// A competing root styles a FOREIGN morfo: the moment its recipe grows a
// selector on its own name it has become a component, and it owes a
// morfo like everyone else. Asserted, not trusted.
const COMPETING_ROOTS = [ 'audio-player' ] ;
const selfTargeting = COMPETING_ROOTS . filter ( ( name ) = > {
const css = readFileSync ( resolve ( ` src/uix/eidos/components/ ${ name } / ${ name } .css ` ) , 'utf8' ) ;
return css . includes ( ` [data- ${ name } ` ) ;
} ) ;
expect ( selfTargeting ) . toEqual ( [ ] ) ;
} ) ;
} ) ;
describe ( 'lintEidosCss — class hooks (norm S1, checkpoint 2026-07-07)' , ( ) = > {
const compiled = compileMorfo ( toggleMorfo )
const compiled = compileMorfo ( toggleMorfo ) ;
it ( 'flags class selectors carrying styles and reports rule + className' , ( ) = > {
const css = [
'.eidos-toggle-body { color: red; }' ,
"[data-toggle][data-state='on'] .thumb-glow { opacity: 1; }" ,
'[data-toggle] { padding: 0; }'
] . join ( '\n' )
const report = lintEidosCss ( css , compiled )
expect ( report . counts . classHooks ) . toBe ( 2 )
] . join ( '\n' ) ;
const report = lintEidosCss ( css , compiled ) ;
expect ( report . counts . classHooks ) . toBe ( 2 ) ;
expect ( report . classHooks . map ( ( h ) = > h . className ) . sort ( ) ) . toEqual ( [
'eidos-toggle-body' ,
'thumb-glow'
] )
} )
] ) ;
} ) ;
it ( 'does not misread dots inside attribute values as classes' , ( ) = > {
const css = "[data-toggle][data-value='1.5rem'] { padding: 0; }"
const report = lintEidosCss ( css , compiled )
expect ( report . counts . classHooks ) . toBe ( 0 )
} )
const css = "[data-toggle][data-value='1.5rem'] { padding: 0; }" ;
const report = lintEidosCss ( css , compiled ) ;
expect ( report . counts . classHooks ) . toBe ( 0 ) ;
} ) ;
it ( 'keeps the shipped toggle CSS honest about its census entry' , ( ) = > {
// The S1 codemod landed (2026-07): toggle's class hook is gone and the
// guard now pins the census at ZERO so it can never regress.
const report = lintEidosCss ( readEidosWithGenerated ( 'toggle' ) , compiled )
expect ( report . counts . classHooks ) . toBe ( 0 )
} )
} )
const report = lintEidosCss ( readEidosWithGenerated ( 'toggle' ) , compiled ) ;
expect ( report . counts . classHooks ) . toBe ( 0 ) ;
} ) ;
} ) ;
describe ( 'lintEidosCss — toggle (clean baseline)' , ( ) = > {
const compiled = compileMorfo ( toggleMorfo )
const compiled = compileMorfo ( toggleMorfo ) ;
// Toggle's data-color cascade now lives in `generated/base.css` (config-
// owned, TSC v2). The lint must consider both the recipe file and the
// generated rules that target [data-toggle] to validate the full
// reaction surface.
const report = lintEidosCss ( readEidosWithGenerated ( 'toggle' ) , compiled )
const report = lintEidosCss ( readEidosWithGenerated ( 'toggle' ) , compiled ) ;
it ( 'finds no invalid selectors' , ( ) = > {
expect ( report . counts . invalid ) . toBe ( 0 )
} )
expect ( report . counts . invalid ) . toBe ( 0 ) ;
} ) ;
it ( 'classifies the canonical morfo-backed selectors' , ( ) = > {
const rules = report . findings . filter ( ( f ) = > f . status === 'morfo-backed' ) . map ( ( f ) = > f . rule )
expect ( rules ) . toContain ( "[data-toggle][data-state='on']" )
expect ( rules ) . toContain ( '[data-toggle][data-disabled]' )
expect ( rules ) . toContain ( "[data-toggle][aria-pressed='true']" )
} )
const rules = report . findings . filter ( ( f ) = > f . status === 'morfo-backed' ) . map ( ( f ) = > f . rule ) ;
expect ( rules ) . toContain ( "[data-toggle][data-state='on']" ) ;
expect ( rules ) . toContain ( '[data-toggle][data-disabled]' ) ;
expect ( rules ) . toContain ( "[data-toggle][aria-pressed='true']" ) ;
} ) ;
it ( "classifies layout-only design tokens (data-size, data-variant) as eidos-only" , ( ) = > {
it ( 'classifies layout-only design tokens (data-size, data-variant) as eidos-only' , ( ) = > {
// data-color is morfo-backed since toggle declares it in the morfo
// (the resolved visual-color from intent + color). Only the
// pure-layout tokens (size, variant, block, icon-only) are eidos-only.
const rules = report . findings . filter ( ( f ) = > f . status === 'eidos-only' ) . map ( ( f ) = > f . rule )
expect ( rules ) . toContain ( "[data-toggle][data-size='sm']" )
expect ( rules ) . toContain ( "[data-toggle][data-variant='ghost']" )
} )
const rules = report . findings . filter ( ( f ) = > f . status === 'eidos-only' ) . map ( ( f ) = > f . rule ) ;
expect ( rules ) . toContain ( "[data-toggle][data-size='sm']" ) ;
expect ( rules ) . toContain ( "[data-toggle][data-variant='ghost']" ) ;
} ) ;
it ( 'classifies data-color as morfo-backed (declared in toggleMorfo)' , ( ) = > {
// data-color is part of the unified semantic+hierarchy color system
// (8 system tokens, 6 in toggle's subset). Toggle morfo declares it
// with the 6 valid values; eidos CSS reacts via [data-color='X'].
const rules = report . findings . filter ( ( f ) = > f . status === 'morfo-backed' ) . map ( ( f ) = > f . rule )
expect ( rules ) . toContain ( "[data-toggle][data-color='primary']" )
expect ( rules ) . toContain ( "[data-toggle][data-color='affirm']" )
expect ( rules ) . toContain ( "[data-toggle][data-color='threat']" )
} )
const rules = report . findings . filter ( ( f ) = > f . status === 'morfo-backed' ) . map ( ( f ) = > f . rule ) ;
expect ( rules ) . toContain ( "[data-toggle][data-color='primary']" ) ;
expect ( rules ) . toContain ( "[data-toggle][data-color='affirm']" ) ;
expect ( rules ) . toContain ( "[data-toggle][data-color='threat']" ) ;
} ) ;
it ( 'reports the unused state values from the contract' , ( ) = > {
// Morfo declares data-state ∈ {on, off}; eidos only styles 'on'.
expect ( report . unusedFromContract ) . toContain ( "[data-toggle][data-state='off']" )
} )
} )
expect ( report . unusedFromContract ) . toContain ( "[data-toggle][data-state='off']" ) ;
} ) ;
} ) ;
describe ( 'lintEidosCss — dialog (drift detector)' , ( ) = > {
const compiled = compileMorfo ( dialogMorfo )
const report = lintEidosCss ( readEidos ( 'dialog' ) , compiled )
const compiled = compileMorfo ( dialogMorfo ) ;
const report = lintEidosCss ( readEidos ( 'dialog' ) , compiled ) ;
it ( 'catches eidos selectors for parts that no longer exist on the morfo' , ( ) = > {
// The morfo has Provider, Trigger, Content, Overlay, Title,
// Description, Close — no Header, no Footer. Eidos still styles them.
const rules = report . findings . filter ( ( f ) = > f . status === 'eidos-only' ) . map ( ( f ) = > f . rule )
expect ( rules ) . toContain ( '[data-dialog-header]' )
expect ( rules ) . toContain ( '[data-dialog-footer]' )
} )
const rules = report . findings . filter ( ( f ) = > f . status === 'eidos-only' ) . map ( ( f ) = > f . rule ) ;
expect ( rules ) . toContain ( '[data-dialog-header]' ) ;
expect ( rules ) . toContain ( '[data-dialog-footer]' ) ;
} ) ;
it ( 'detects dead `[data-disabled]` on parts where the morfo never writes it' , ( ) = > {
// Morfo only declares data-disabled on Provider — runtime won't write it
@ -174,50 +197,50 @@ describe('lintEidosCss — dialog (drift detector)', () => {
// compose <Button>/<IconButton>, which own the disabled chrome), so this
// is a SYNTHETIC fixture: it proves the detector still flags such a rule
// as eidos-only, independent of the live recipe's state.
const synthetic = ` [data-dialog-trigger][data-disabled] { opacity: 0.4; } `
const syntheticReport = lintEidosCss ( synthetic , compiled )
const synthetic = ` [data-dialog-trigger][data-disabled] { opacity: 0.4; } ` ;
const syntheticReport = lintEidosCss ( synthetic , compiled ) ;
const rules = syntheticReport . findings
. filter ( ( f ) = > f . status === 'eidos-only' )
. map ( ( f ) = > f . rule )
expect ( rules ) . toContain ( '[data-dialog-trigger][data-disabled]' )
} )
. map ( ( f ) = > f . rule ) ;
expect ( rules ) . toContain ( '[data-dialog-trigger][data-disabled]' ) ;
} ) ;
it ( 'reports unused contract selectors so eidos authors know what is on offer' , ( ) = > {
// Morfo promises the Presence-layer markers and provider-level state,
// but eidos hasn't picked them up.
expect ( report . unusedFromContract ) . toContain ( '[data-dialog-content][data-starting-style]' )
expect ( report . unusedFromContract ) . toContain ( '[data-dialog-content][data-ending-style]' )
expect ( report . unusedFromContract ) . toContain ( "[data-dialog][data-state='open']" )
} )
expect ( report . unusedFromContract ) . toContain ( '[data-dialog-content][data-starting-style]' ) ;
expect ( report . unusedFromContract ) . toContain ( '[data-dialog-content][data-ending-style]' ) ;
expect ( report . unusedFromContract ) . toContain ( "[data-dialog][data-state='open']" ) ;
} ) ;
it ( 'finds no invalid (out-of-enum) values' , ( ) = > {
// `data-last-action` values match the morfo enum exactly (saved,
// failed, cancelled, dismissed, dismissed-outside).
expect ( report . counts . invalid ) . toBe ( 0 )
} )
} )
expect ( report . counts . invalid ) . toBe ( 0 ) ;
} ) ;
} ) ;
describe ( 'lintEidosCss — icon (contract baseline)' , ( ) = > {
const compiled = compileMorfo ( iconMorfo )
const report = lintEidosCss ( readEidos ( 'icon' ) , compiled )
const compiled = compileMorfo ( iconMorfo ) ;
const report = lintEidosCss ( readEidos ( 'icon' ) , compiled ) ;
it ( 'finds no invalid selectors' , ( ) = > {
expect ( report . counts . invalid ) . toBe ( 0 )
} )
expect ( report . counts . invalid ) . toBe ( 0 ) ;
} ) ;
it ( 'classifies the SVG marker as morfo-backed' , ( ) = > {
const rules = report . findings . filter ( ( f ) = > f . status === 'morfo-backed' ) . map ( ( f ) = > f . rule )
expect ( rules ) . toContain ( '[data-icon]' )
} )
} )
const rules = report . findings . filter ( ( f ) = > f . status === 'morfo-backed' ) . map ( ( f ) = > f . rule ) ;
expect ( rules ) . toContain ( '[data-icon]' ) ;
} ) ;
} ) ;
describe ( 'lintEidosCss — synthetic invalid case' , ( ) = > {
it ( 'flags a value that is outside the declared enum' , ( ) = > {
const compiled = compileMorfo ( toggleMorfo )
const css = ` [data-toggle][data-state='maybe'] { color: red; } `
const report = lintEidosCss ( css , compiled )
expect ( report . counts . invalid ) . toBe ( 1 )
expect ( report . findings [ 0 ] . reason ) . toContain ( "'maybe'" )
expect ( report . findings [ 0 ] . reason ) . toContain ( "'on'" )
} )
} )
const compiled = compileMorfo ( toggleMorfo ) ;
const css = ` [data-toggle][data-state='maybe'] { color: red; } ` ;
const report = lintEidosCss ( css , compiled ) ;
expect ( report . counts . invalid ) . toBe ( 1 ) ;
expect ( report . findings [ 0 ] . reason ) . toContain ( "'maybe'" ) ;
expect ( report . findings [ 0 ] . reason ) . toContain ( "'on'" ) ;
} ) ;
} ) ;