|
|
|
|
@ -0,0 +1,190 @@
|
|
|
|
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
|
|
|
|
|
import type { Morfo, MorfoFocus } from './types';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The census guard of the focus-first axis (signed 2026-08-26,
|
|
|
|
|
* `docs/process/PLAN-focus-first.md`): the `focus` declaration and its
|
|
|
|
|
* executor live in different files, and history shows exactly what that
|
|
|
|
|
* permits — the field shipped for months with ZERO readers, Popover declared
|
|
|
|
|
* `trap: true` while shipping `trap: false`, and two normative doc rows
|
|
|
|
|
* claimed consumers that did not exist. This census closes the drift in
|
|
|
|
|
* BOTH directions:
|
|
|
|
|
*
|
|
|
|
|
* 1. a `trap` declaration must have `FocusScope` wired WITH the policy
|
|
|
|
|
* (and `trap: true` species MUST actually use FocusScope);
|
|
|
|
|
* 2. a `roving` declaration must consume the shared `RovingFocusGroup`
|
|
|
|
|
* OR carry a signed `executor.custom` reason;
|
|
|
|
|
* 3. the INVERSE (the author's inversion that founded the axis): a
|
|
|
|
|
* component whose provider uses FocusScope or hand-rolls a roving
|
|
|
|
|
* tab stop without declaring `focus` fails the census.
|
|
|
|
|
*
|
|
|
|
|
* The signed exceptions are listed with their reasons; growing a list is a
|
|
|
|
|
* decision, not a convenience.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const COMPONENTS_DIR = join(__dirname, '..', 'soma', 'components');
|
|
|
|
|
const MORFO_DIR = join(__dirname, 'components');
|
|
|
|
|
|
|
|
|
|
/** kebab -> reason. Declaring morfos whose executor evidence lives elsewhere. */
|
|
|
|
|
const EXECUTOR_EXCEPTIONS: Record<string, string> = {
|
|
|
|
|
// Composes Dialog: the FocusScope (and its policy wiring) lives in
|
|
|
|
|
// dialog-provider, which alert-dialog's provider re-exports/extends.
|
|
|
|
|
'alert-dialog': 'composes Dialog — the executor is dialog-provider’s FocusScope',
|
|
|
|
|
// Eidos-scope components (no soma provider): their morfo declares the
|
|
|
|
|
// species policy for docs/agents, and the eidos component hand-implements
|
|
|
|
|
// focus behaviour — the 63-without-executor class, narrowed here to two.
|
|
|
|
|
'menu-dial': 'eidos-scope: no soma provider; trap:false policy, hand-rolled focus in eidos',
|
|
|
|
|
'onion-menu': 'eidos-scope: no soma provider; trap:false policy, hand-rolled focus in eidos'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* kebab -> reason. Components whose soma sources SHOW focus machinery but
|
|
|
|
|
* which legitimately do not declare `focus` themselves.
|
|
|
|
|
*/
|
|
|
|
|
const INVERSE_EXCEPTIONS: Record<string, string> = {
|
|
|
|
|
// Extends Dialog's provider (see EXECUTOR_EXCEPTIONS) — the declaration
|
|
|
|
|
// lives on its own morfo, the machinery on dialog's.
|
|
|
|
|
'alert-dialog': 'machinery belongs to the composed dialog-provider',
|
|
|
|
|
// ── Adjudicated 2026-08-26 when the hardened net surfaced them: each is
|
|
|
|
|
// a REAL focus behaviour that the trap|roving union does not yet name.
|
|
|
|
|
// The candidate kinds (`walk`, `activedescendant`, `slider`, `segments`)
|
|
|
|
|
// are the axis' logged follow-up — the author's call, never a census
|
|
|
|
|
// dodge. Verified against the shipped providers, not assumed:
|
|
|
|
|
accordion:
|
|
|
|
|
'directional walk over natively tabbable triggers (APG accordion: no roving tabindex — every trigger is a tab stop)',
|
|
|
|
|
'navigation-menu':
|
|
|
|
|
'disclosure navigation: tabbable triggers walked with arrows, no stop management; focus behaviour owned by the live navigation-menu axis',
|
|
|
|
|
'rating-group': 'slider model: one focusable root, arrows move the VALUE, not focus',
|
|
|
|
|
select:
|
|
|
|
|
'aria-activedescendant strategy: virtual highlight (highlightedId), the single DOM stop stays on the anchor',
|
|
|
|
|
'date-field':
|
|
|
|
|
'segment field: every spinbutton segment is a native tab stop (constant tabindex 0); Left/Right walk focus as a convenience',
|
|
|
|
|
'time-field':
|
|
|
|
|
'segment field: every spinbutton segment is a native tab stop (constant tabindex 0); Left/Right walk focus as a convenience',
|
|
|
|
|
'color-field':
|
|
|
|
|
'segment field: every spinbutton segment is a native tab stop (constant tabindex 0); Left/Right walk focus as a convenience'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function loadMorfos(): Array<{ kebab: string; focus: MorfoFocus | undefined }> {
|
|
|
|
|
const out: Array<{ kebab: string; focus: MorfoFocus | undefined }> = [];
|
|
|
|
|
for (const file of readdirSync(MORFO_DIR)) {
|
|
|
|
|
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue;
|
|
|
|
|
const source = readFileSync(join(MORFO_DIR, file), 'utf-8');
|
|
|
|
|
const kebabMatch = /\n\tkebab: '([a-z0-9-]+)'/.exec(source);
|
|
|
|
|
if (!kebabMatch) continue;
|
|
|
|
|
const kebab = kebabMatch[1];
|
|
|
|
|
const focusMatch = /\n\tfocus: \{/.test(source);
|
|
|
|
|
if (!focusMatch) {
|
|
|
|
|
out.push({ kebab, focus: undefined });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const kind = /\n\tfocus: \{\r?\n(?:\t\t\/\/[^\n]*\r?\n)*\t\tkind: '(trap|roving)'/.exec(
|
|
|
|
|
source
|
|
|
|
|
)?.[1] as MorfoFocus['kind'] | undefined;
|
|
|
|
|
const trapDefault = /\n\t\ttrap: (true|false)/.exec(source)?.[1];
|
|
|
|
|
const hasCustom = /\n\t\texecutor: \{/.test(source);
|
|
|
|
|
out.push({
|
|
|
|
|
kebab,
|
|
|
|
|
focus:
|
|
|
|
|
kind === 'trap'
|
|
|
|
|
? ({ kind: 'trap', trap: trapDefault === 'true' } as MorfoFocus)
|
|
|
|
|
: kind === 'roving'
|
|
|
|
|
? ({
|
|
|
|
|
kind: 'roving',
|
|
|
|
|
parts: [],
|
|
|
|
|
orientation: 'horizontal',
|
|
|
|
|
...(hasCustom ? { executor: { custom: 'signed' } } : {})
|
|
|
|
|
} as MorfoFocus)
|
|
|
|
|
: undefined
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function somaSources(kebab: string): string {
|
|
|
|
|
const dir = join(COMPONENTS_DIR, kebab);
|
|
|
|
|
if (!existsSync(dir)) return '';
|
|
|
|
|
let out = '';
|
|
|
|
|
const walk = (d: string) => {
|
|
|
|
|
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
|
|
|
const p = join(d, entry.name);
|
|
|
|
|
if (entry.isDirectory()) walk(p);
|
|
|
|
|
else if (/\.(svelte\.ts|svelte)$/.test(entry.name) && !entry.name.includes('.test.'))
|
|
|
|
|
out += readFileSync(p, 'utf-8');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
walk(dir);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('focus census — declaration ↔ executor, both directions', () => {
|
|
|
|
|
const morfos = loadMorfos();
|
|
|
|
|
const declaring = morfos.filter((m) => m.focus !== undefined);
|
|
|
|
|
|
|
|
|
|
it('the census inspects a real corpus (guard against the empty sweep)', () => {
|
|
|
|
|
expect(morfos.length).toBeGreaterThan(150);
|
|
|
|
|
expect(declaring.length).toBeGreaterThanOrEqual(28);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('every trap declaration has FocusScope wired with the declared policy', () => {
|
|
|
|
|
const offenders: string[] = [];
|
|
|
|
|
for (const m of declaring) {
|
|
|
|
|
if (m.focus?.kind !== 'trap') continue;
|
|
|
|
|
if (EXECUTOR_EXCEPTIONS[m.kebab]) continue;
|
|
|
|
|
const src = somaSources(m.kebab);
|
|
|
|
|
const usesScope = src.includes('FocusScope.use');
|
|
|
|
|
const wiresPolicy = /policy:.*runtime\.focus/.test(src);
|
|
|
|
|
// trap:false species may legitimately skip FocusScope entirely
|
|
|
|
|
// (nothing to trap); but if the scope IS used, the policy must ride.
|
|
|
|
|
if (m.focus.trap && !usesScope) offenders.push(`${m.kebab}: trap:true without FocusScope`);
|
|
|
|
|
if (usesScope && !wiresPolicy) offenders.push(`${m.kebab}: FocusScope without policy`);
|
|
|
|
|
}
|
|
|
|
|
expect(offenders).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('every roving declaration consumes the shared primitive or signs its executor', () => {
|
|
|
|
|
const offenders: string[] = [];
|
|
|
|
|
for (const m of declaring) {
|
|
|
|
|
if (m.focus?.kind !== 'roving') continue;
|
|
|
|
|
if (m.focus.executor) continue; // signed custom — visible in the morfo
|
|
|
|
|
if (EXECUTOR_EXCEPTIONS[m.kebab]) continue;
|
|
|
|
|
const src = somaSources(m.kebab);
|
|
|
|
|
if (!src.includes('RovingFocusGroup')) {
|
|
|
|
|
offenders.push(`${m.kebab}: roving without RovingFocusGroup and without a signed executor`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(offenders).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('the INVERSE: focus machinery without a declaration fails (the axis’ founding rule)', () => {
|
|
|
|
|
const declared = new Set(declaring.map((m) => m.kebab));
|
|
|
|
|
const offenders: string[] = [];
|
|
|
|
|
for (const m of morfos) {
|
|
|
|
|
if (declared.has(m.kebab)) continue;
|
|
|
|
|
if (INVERSE_EXCEPTIONS[m.kebab]) continue;
|
|
|
|
|
const src = somaSources(m.kebab);
|
|
|
|
|
if (!src) continue;
|
|
|
|
|
// The primitive/scope imports catch executor users; the pattern
|
|
|
|
|
// catches hand-rolled FOCUS-STOP machinery. Deliberately NOT
|
|
|
|
|
// `getDirectionalKeys` alone — arrows that move a VALUE (rating,
|
|
|
|
|
// media seek) or an aria-activedescendant HIGHLIGHT (select,
|
|
|
|
|
// command) are not tab-stop machinery; the activedescendant
|
|
|
|
|
// strategy is the axis' logged follow-up (a third `kind`, the
|
|
|
|
|
// author's call), not this census' net. Hand-rolled focus movement
|
|
|
|
|
// shows as directional keys COMBINED with a stop/`.focus()` walk —
|
|
|
|
|
// the tab-stop registry names are the reliable signal.
|
|
|
|
|
const machinery =
|
|
|
|
|
src.includes('FocusScope.use') ||
|
|
|
|
|
src.includes('RovingFocusGroup') ||
|
|
|
|
|
/currentTabStop|rovingIndex|rovingTarget/.test(src) ||
|
|
|
|
|
(/getDirectionalKeys/.test(src) && /(dom\.focus\(|\.focus\(\))/.test(src));
|
|
|
|
|
if (machinery) {
|
|
|
|
|
offenders.push(`${m.kebab}: uses focus machinery but declares no focus policy`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(offenders).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|