From 76f441882cfc6efd85eecc627cd0225337ad9890 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 4 Aug 2026 23:02:10 +0200 Subject: [PATCH] fix(eidos): audio-player no es un hueco de morfo, es una receta sobre el ajeno MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El guard `has a morfo file for every eidos CSS component` llevaba en rojo por `audio-player`, y la duda era donde archivarlo. `KNOWN_MISSING_MORFO` habria sido mentir: ese conjunto esta documentado como «violacion real del morfo-first, seguida para una pasada de construccion — quitar la entrada cuando se construya», y aqui no hay nada que construir. La evidencia lo zanja: las 30 reglas de primer nivel de `audio-player.css` seleccionan `[data-media-player…]`, y no existe ningun `data-audio-player` en todo el arbol. `` es una RAIZ COMPETIDORA sobre el contrato del media-player (D-AP2.2, precedente Toast/Toaster): viste el morfo de otro y por eso no tiene ni tendra el suyo. Asi que categoria propia, `COMPETING_ROOTS`. Y como una exencion solo vale mientras su premisa siga siendo cierta, la premisa pasa a estar COMPROBADA en vez de confiada: un segundo test afirma que la receta de una raiz competidora nunca selecciona su propio nombre. En cuanto lo haga habra dejado de ser una raiz competidora y debera un morfo como todo el mundo. Comprobado que el guard nuevo muerde: anadiendo `[data-audio-player] {…}` a la receta, falla. Restaurada, 18/18 en verde. Co-Authored-By: Claude Opus 5 --- src/uix/eidos/lint.test.ts | 225 ++++++++++++++++++++----------------- 1 file changed, 124 insertions(+), 101 deletions(-) diff --git a/src/uix/eidos/lint.test.ts b/src/uix/eidos/lint.test.ts index 28823f02f..28ab29042 100644 --- a/src/uix/eidos/lint.test.ts +++ b/src/uix/eidos/lint.test.ts @@ -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'); } /** Concat the per-component recipe CSS with the generated foundation @@ -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 — `` 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