El framework llamaba a la misma cosa de dos maneras: `open` pelado en ocho
componentes y `emerge-open` en tres. No era estetica — un preset de movimiento
engancha el nombre con `^=`, asi que el dialecto pelado no casaba con ninguna
firma y simplemente no animaba, sin romper una sola prueba.
Los 40 nombres sin prefijo pasan a `{familia}-{verbo}[-{matiz}]`: 256 eventos,
256 con prefijo, 0 ambiguos. El plan decia 36 y decia `handle-drag-start`; eran
40, y el canon (c25) dice que esos verbos son `pick` y `drop` — `handle-pick` y
`handle-drop` ya existian en 10 y 4 componentes.
`validateMorfo` cierra la puerta: un `events[].name` que no empiece por su
familia ahora lanza. Visto fallar antes con un nombre pelado inyectado.
Lo que el renombrado destapo, y va aqui tambien:
- La receta del splitter enganchaba `commit-resize`, muerto desde
`bd2e40366`. No casaba desde mayo y nadie chillo. Reescrita por FAMILIA, como
slider y knob, y `eidos-lint` valida ahora el VALOR de `data-event*` contra el
catalogo de morfos — el guard que lo habria cazado en su dia.
- La familia `shift` era muda en el canal visual, contra su propia doctrina
(c27: el cruce debe percibirse; c34 tipifica el «shift invisible»). Su mapa ya
describia la firma que le faltaba y su sonido por defecto es `slide`. Ahora
tiene firma direccional: sexto atributo del sello (`data-event-direction`,
`forward`|`backward`, por emision) y deslizamiento de 320ms RTL-safe por
`:dir()`. Medido: LTR -30px/+30px, RTL los invierte.
- El sello de `shift-navigate` pasa del BOTON al `grid` en los cuatro
calendarios. Medido: el boton recibia `contact-activate` y 8,5 ms despues
—media trama— el `shift-navigate` pisaba la misma ranura y el `press-squeeze`
moria sin pintar un fotograma. Una superficie, una ranura (A-36).
- 101 contradicciones docs<->morfo adjudicadas con evidencia (git log, docs de
decision, el componente vivo). Las docs desfasadas, corregidas; los nueve
DEFECTOS de codigo obsoleto quedan abiertos y sin tocar.
- `SoundDirection` -> `SoundContour`: era un contorno de tono, no un sentido, y
habia tres cosas distintas deletreadas «direction».
check en su linea base con 0 errores nuevos por diferencia de conjuntos ·
docs:check 0/0 · eidos-lint invalid 0 · el censo y las escenas de navegador
medidas con raton real y rAF vivo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
parent
8864ba93ca
commit
6f42eebfe8
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* The closed vocabulary an eidos recipe may NAME through the `data-event*`
|
||||
* stamp.
|
||||
*
|
||||
* `src/uix/sema/stamp.ts` writes `data-event` = the morfo event's `name`, plus
|
||||
* `data-event-family` / `data-event-intent` / `data-event-direction` from the
|
||||
* same signal. The
|
||||
* structural linter (`src/uix/eidos/lint.ts`) classifies those attrs as
|
||||
* "eidos-only" and never reads their VALUE — which is how
|
||||
* `[data-event='commit-resize']` survived in `splitter.css` from the
|
||||
* 2026-05-22 rename (`commit-resize` → `commit-set`, bd2e40366) without a
|
||||
* single test complaining. A recipe hooked to a name nobody emits is dead
|
||||
* CSS that looks alive.
|
||||
*
|
||||
* The check is CATALOGUE-wide, not per-component, and deliberately so:
|
||||
* composition means a node receives stamps from another component's morfo
|
||||
* (the card-group item carries `data-toggle-group-item` on the same node and
|
||||
* receives `commit-block`, which toggle-group declares; the calendar
|
||||
* nav-buttons receive `contact-activate` from the composed `Button`). A
|
||||
* per-component ERROR would be a dozen false positives. The mismatch is
|
||||
* still surfaced — as a WARN.
|
||||
*/
|
||||
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { INTENTS } from '../src/uix/intent';
|
||||
import { SEMA_DIRECTIONS, SEMA_FAMILY_POLICY } from '../src/uix/sema/types';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MORFO_DIRS = [
|
||||
join(__dirname, '..', 'src', 'uix', 'morfo', 'components'),
|
||||
join(__dirname, '..', 'src', 'uix', 'morfo', 'internal')
|
||||
];
|
||||
|
||||
const FAMILIES: readonly string[] = Object.keys(SEMA_FAMILY_POLICY);
|
||||
const INTENT_VALUES: readonly string[] = INTENTS;
|
||||
/**
|
||||
* Imported, never restated. `scripts/` is outside the `tsconfig` graph, so a
|
||||
* literal `['forward', 'backward']` written here would be unchecked prose that
|
||||
* silently outlives the vocabulary it guards — the same shape of defect this
|
||||
* whole file exists to prevent. `SemaDirection` is derived FROM this array
|
||||
* (`sema/types.ts`), so the two cannot diverge.
|
||||
*/
|
||||
const DIRECTION_VALUES: readonly string[] = SEMA_DIRECTIONS;
|
||||
|
||||
export interface EventVocabulary {
|
||||
/** event `name` → the morfo kebabs that declare it. */
|
||||
readonly names: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
/** Import every morfo in the catalogue and index its declared event names. */
|
||||
export async function loadEventVocabulary(): Promise<EventVocabulary> {
|
||||
const names = new Map<string, string[]>();
|
||||
for (const dir of MORFO_DIRS) {
|
||||
for (const file of readdirSync(dir)) {
|
||||
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue;
|
||||
// file:// URL — Node's ESM loader rejects bare Windows paths ("g:\…").
|
||||
const mod = (await import(pathToFileURL(join(dir, file)).href)) as Record<string, unknown>;
|
||||
for (const value of Object.values(mod)) {
|
||||
if (typeof value !== 'object' || value === null) continue;
|
||||
const morfo = value as { kebab?: unknown; parts?: unknown; events?: unknown };
|
||||
if (typeof morfo.kebab !== 'string' || !Array.isArray(morfo.parts)) continue;
|
||||
if (!Array.isArray(morfo.events)) continue;
|
||||
for (const event of morfo.events as readonly { name?: unknown }[]) {
|
||||
if (typeof event?.name !== 'string') continue;
|
||||
const owners = names.get(event.name);
|
||||
if (owners) owners.push(morfo.kebab);
|
||||
else names.set(event.name, [morfo.kebab]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { names };
|
||||
}
|
||||
|
||||
export interface EventValueFinding {
|
||||
readonly level: 'error' | 'warn';
|
||||
/** The selector the attribute was authored in. */
|
||||
readonly rule: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
||||
const SELECTOR_LINE = /(?:^|\})\s*([^\s{}][^{}]*?)\s*\{/g;
|
||||
const EVENT_ATTR =
|
||||
/\[\s*(data-event-family|data-event-intent|data-event-direction|data-event)\s*([~^$*|]?)=\s*(?:'([^']*)'|"([^"]*)"|([^\]\s]+))/gi;
|
||||
|
||||
/**
|
||||
* Validate every `data-event` / `-family` / `-intent` / `-direction` VALUE in
|
||||
* a CSS file.
|
||||
*
|
||||
* `ownKebab` (optional) turns "the name exists, but no morfo of this
|
||||
* component declares it" into a WARN — the composition case.
|
||||
*/
|
||||
export function lintEventValues(
|
||||
cssText: string,
|
||||
vocabulary: EventVocabulary,
|
||||
ownKebab?: string
|
||||
): EventValueFinding[] {
|
||||
// Comments first: splitter.css documents the hook in prose right above the
|
||||
// rule, and a commented name is not a selector.
|
||||
const text = cssText.replace(COMMENT, '');
|
||||
const out: EventValueFinding[] = [];
|
||||
|
||||
for (const ruleMatch of text.matchAll(SELECTOR_LINE)) {
|
||||
const rule = ruleMatch[1].trim().replace(/\s+/g, ' ');
|
||||
for (const m of rule.matchAll(EVENT_ATTR)) {
|
||||
const attr = m[1].toLowerCase();
|
||||
const op = m[2];
|
||||
const value = m[3] ?? m[4] ?? m[5];
|
||||
|
||||
if (attr === 'data-event-family') {
|
||||
check(out, rule, attr, op, value, FAMILIES, 'a canon sema family');
|
||||
continue;
|
||||
}
|
||||
if (attr === 'data-event-intent') {
|
||||
check(out, rule, attr, op, value, INTENT_VALUES, 'a canon intent');
|
||||
continue;
|
||||
}
|
||||
if (attr === 'data-event-direction') {
|
||||
check(out, rule, attr, op, value, DIRECTION_VALUES, 'a sense of traversal');
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = matchable(value, op, vocabulary.names.keys());
|
||||
if (!matched) {
|
||||
out.push(unmodelledOperator(rule, attr, op, value));
|
||||
continue;
|
||||
}
|
||||
if (matched.length === 0) {
|
||||
out.push({
|
||||
level: 'error',
|
||||
rule,
|
||||
message: `[${attr}${op}='${value}'] — no morfo declares an event ${op === '^' ? 'starting with' : 'named'} '${value}'.`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!ownKebab) continue;
|
||||
const owners = [...new Set(matched.flatMap((name) => vocabulary.names.get(name) ?? []))];
|
||||
if (owners.includes(ownKebab)) continue;
|
||||
out.push({
|
||||
level: 'warn',
|
||||
rule,
|
||||
message: `[${attr}${op}='${value}'] — declared by ${owners.join(', ')}, not by the '${ownKebab}' morfo (composition, or drift).`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function check(
|
||||
out: EventValueFinding[],
|
||||
rule: string,
|
||||
attr: string,
|
||||
op: string,
|
||||
value: string,
|
||||
vocabulary: readonly string[],
|
||||
label: string
|
||||
): void {
|
||||
const matched = matchable(value, op, vocabulary);
|
||||
if (!matched) {
|
||||
out.push(unmodelledOperator(rule, attr, op, value));
|
||||
return;
|
||||
}
|
||||
if (matched.length > 0) return;
|
||||
out.push({
|
||||
level: 'error',
|
||||
rule,
|
||||
message: `[${attr}${op}='${value}'] — '${value}' is not ${label} {${vocabulary.join(', ')}}.`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The vocabulary entries this attribute selector can ever match.
|
||||
* `undefined` — an operator this guard does not model (nothing uses one today).
|
||||
*/
|
||||
function matchable(value: string, op: string, vocabulary: Iterable<string>): string[] | undefined {
|
||||
if (op === '') return [...vocabulary].filter((entry) => entry === value);
|
||||
if (op === '^') return [...vocabulary].filter((entry) => entry.startsWith(value));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unmodelledOperator(
|
||||
rule: string,
|
||||
attr: string,
|
||||
op: string,
|
||||
value: string
|
||||
): EventValueFinding {
|
||||
return {
|
||||
level: 'warn',
|
||||
rule,
|
||||
message: `[${attr}${op}='${value}'] — operator "${op}=" is not modelled; value left unvalidated.`
|
||||
};
|
||||
}
|
||||
|
||||
export function formatEventFindings(findings: readonly EventValueFinding[], indent = ' '): string {
|
||||
const lines: string[] = [];
|
||||
for (const f of findings) {
|
||||
lines.push(`${indent}${f.level === 'error' ? '✗' : '·'} ${f.rule}`);
|
||||
lines.push(`${indent} ${f.message}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue