fix(eidos): audio-player no es un hueco de morfo, es una receta sobre el ajeno

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. `<AudioPlayer>` 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 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 6f0f438f30
commit 76f441882c

@ -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 — `<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'");
});
});

Loading…
Cancel
Save

Powered by TurnKey Linux.