feat(morfo)!: decisión 3/3 — la unión de foco nombra SEIS especies: dos contratos, una taxonomía

Firmada por el autor (b54bb3ad0). El censo bidireccional había destapado
SIETE componentes cuya conducta de foco REAL no tenía nombre en el contrato,
aparcados con acta en INVERSE_EXCEPTIONS. Las excepciones firmadas existen
para lo genuinamente único, no para ESPECIES enteras: siete sin nombre era
contrato incompleto POR MEDICIÓN.

- `walk` (parts[]) — tabulables nativos, las flechas pasean como
  conveniencia, sin gestión de stop único: accordion, navigation-menu.
- `activedescendant` (anchor + items[]) — highlight VIRTUAL, el único stop
  del DOM se queda en el ancla: select, command y combobox (los dos últimos
  MEDIDOS, no asumidos: los tres usan highlightWalk, declaran
  aria-activedescendant y ponen tabindex -1 en trigger/content/item).
- `slider` (desnudo) — una raíz enfocable, las flechas mueven el VALOR:
  rating-group.
- `segments` (parts[]) — todos los segmentos spinbutton son stop NATIVO
  (tabindex 0 constante): date-field, time-field, color-field.

FORMA MÍNIMA, con los campos que NO se pusieron y su razón (un campo sin
ejecutor es una mentira esperando turno): `walk` sin orientation (nadie la
leería: sus dos hosts la toman de una prop por instancia); `slider` desnudo
(no hay partes que pasear ni stop que nombrar); las listas SON listas porque
el corpus lo exige (command resalta DOS clases de parte, navigation-menu
pasea trigger Y link).

LA CONVERGENCIA, escrita en el JSDoc de la unión: esta taxonomía CALCA la de
los primitivos de teclado del wave C+ — trap→FocusScope, roving→
RovingFocusGroup, walk→getDirectionalKeys, activedescendant→highlightWalk,
slider→valueStep, segments→segmentWalk. La declaración nombra la ESPECIE, el
primitivo la EJECUTA.

Censo: de 4 tests a 9, con regla por especie e INVERSE_EXCEPTIONS VACIADA a
lo composicional (alert-dialog). Guard nuevo del instrumento: un bloque focus
cuyo `kind` el loader no sepa leer ya no cae en silencio del lado «no
declara» — falla nombrándolo; y los 6 kinds deben tener población ≥ 1 (un
kind con 0 miembros es contrato que nadie midió). PRUEBA NEGATIVA: 6
mentiras plantadas (slider en tabs, walk en date-field, activedescendant en
accordion, segments en select, bloque borrado, kind inventado), 6 rojos
nombrándolas, 6 reversiones byte-idénticas.

Barrido: 168 morfos validan, 37 declaran foco (9 trap · 19 roving · 2 walk ·
3 activedescendant · 3 segments · 1 slider). Corregida además una fila de
doctrina del esquema que enumeraba solo el invariante viejo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent c8361a6880
commit 8178c41520

@ -59,6 +59,15 @@ export const accordionMorfo = {
],
direction: {},
focus: {
// Acta (focus census, 2026-08-27): «directional walk over natively
// tabbable triggers (APG accordion: no roving tabindex — every trigger is
// a tab stop)». Measured in the provider: it writes no `tabindex` at all,
// and `AccordionTriggerProvider.onkeydown` resolves the neighbour with
// `getDirectionalKeys` + `dom.focus`. Arrows are a convenience over Tab.
kind: 'walk',
parts: ['trigger']
},
parts: [
{
name: 'Provider',

@ -40,6 +40,16 @@ export const colorFieldMorfo = {
}
],
direction: {},
focus: {
// Acta (focus census, 2026-08-27): «segment field: every spinbutton segment
// is a native tab stop (constant tabindex 0); Left/Right walk focus as a
// convenience». Measured in the provider: `sharedSegmentAttrs` ships
// `tabindex: 0` unconditionally; the walk is `segmentWalk` /
// `segmentAdvance` / `segmentRetreat`. `format-select` is NOT listed — it
// is a Select, a tab stop of its own that the segment walk never visits.
kind: 'segments',
parts: ['segment']
},
parts: [
{
name: 'Provider',

@ -76,6 +76,17 @@ export const comboboxMorfo = {
}
],
direction: {},
focus: {
// Acta (focus census, 2026-08-27): the same species select's row named —
// MEASURED here rather than assumed. The Input carries the single DOM stop
// and `aria-activedescendant` (`propRef('highlightedId')`, combobox.ts:129);
// `highlightWalk` moves that id and the provider never calls
// `dom.focus(item)` — Trigger, Content and Item all ship `tabindex: -1`,
// so the APG combobox contract (focus stays in the textbox) holds.
kind: 'activedescendant',
anchor: 'input',
items: ['item']
},
parts: [
{
name: 'Provider',

@ -30,6 +30,19 @@ export const commandMorfo = {
}
],
direction: {},
focus: {
// Acta (focus census, 2026-08-27): the same species select's row named —
// MEASURED here rather than assumed. The Input carries the single DOM stop
// and `aria-activedescendant` (`propRef('selectedId')`, command.ts:75);
// `walkOpts` moves a VALUE cursor through `visibleOrderedValues` via
// `highlightWalk`, and no part is ever DOM-focused. Both item kinds are
// listed because both register into `_items` (`CommandItemProvider` and
// `CommandLinkItemProvider` call `registerItem`), so the palette
// highlights them in ONE sequence.
kind: 'activedescendant',
anchor: 'input',
items: ['item', 'link-item']
},
parts: [
{
name: 'Provider',

@ -35,6 +35,16 @@ export const dateFieldMorfo = {
}
],
direction: {},
focus: {
// Acta (focus census, 2026-08-27): «segment field: every spinbutton segment
// is a native tab stop (constant tabindex 0); Left/Right walk focus as a
// convenience». Measured in the provider: `sharedSegmentAttrs` ships
// `tabindex: 0` unconditionally and the per-segment override only drops it
// when the field is DISABLED (never to -1); the walk is `segmentWalk` /
// `segmentAdvance` / `segmentRetreat`.
kind: 'segments',
parts: ['segment']
},
parts: [
{
name: 'Provider',

@ -117,6 +117,16 @@ export const navigationMenuMorfo = {
// the defect this component's audit found in `Field`.
],
direction: {},
focus: {
// Acta (focus census, 2026-08-27): «disclosure navigation: tabbable
// triggers walked with arrows, no stop management». Measured in the
// provider: `getTriggers()` collects BOTH the triggers and the bare links
// (`[data-…-trigger]:not([data-disabled]),[data-…-link]`) and
// `handleListKeydown` walks that list with `getDirectionalKeys` +
// `dom.focus`; no `tabindex` is ever written. Hence two walked parts.
kind: 'walk',
parts: ['trigger', 'link']
},
parts: [
{
name: 'Provider',

@ -26,6 +26,14 @@ export const ratingGroupMorfo = {
}
],
direction: {},
focus: {
// Acta (focus census, 2026-08-27): «slider model: one focusable root,
// arrows move the VALUE, not focus». Measured in the provider: the root
// part carries `tabindex: isDisabled ? -1 : 0` and the items carry none,
// so there is exactly one stop and it never moves; `onkeydown` delegates
// to `valueStep`, which returns a new value and touches no element.
kind: 'slider'
},
parts: [
{
name: 'Provider',

@ -71,12 +71,20 @@ export const selectMorfo = {
}
],
// No `focus` block: Select uses VIRTUAL focus — focus stays on the Trigger
// (aria-activedescendant), items are never DOM-focused, so there is no
// content focus-trap to declare (a `trap: true` here was dead + contradicted
// the implementation). Focus return to the trigger on close is done
// imperatively in the provider (`handleClose`). Matches Combobox.
direction: {},
focus: {
// Acta (focus census, 2026-08-27): «aria-activedescendant strategy:
// virtual highlight (highlightedId), the single DOM stop stays on the
// anchor». The block this replaces said "no `focus` block" and spelled the
// species out in prose — the species now has a NAME, so it is declared
// instead of excused. Measured in the provider: `highlightWalk` moves
// `highlightedId`, the Trigger's `aria-activedescendant` is sourced from
// it, Content and Item ship `tabindex: -1` and are never DOM-focused.
// Focus return to the trigger on close stays imperative (`handleClose`).
kind: 'activedescendant',
anchor: 'trigger',
items: ['item']
},
parts: [
{
name: 'Provider',

@ -26,6 +26,16 @@ export const timeFieldMorfo = {
}
],
direction: {},
focus: {
// Acta (focus census, 2026-08-27): «segment field: every spinbutton segment
// is a native tab stop (constant tabindex 0); Left/Right walk focus as a
// convenience». Measured in the provider: `sharedSegmentAttrs` ships
// `tabindex: 0` unconditionally (the -1 rows are the hidden input and the
// aria-hidden literal separators, which are not stops); the walk is
// `segmentWalk` / `segmentAdvance` / `segmentRetreat`.
kind: 'segments',
parts: ['segment']
},
parts: [
{
name: 'Provider',

@ -2,7 +2,7 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import type { Morfo, MorfoFocus } from './types';
import type { MorfoFocus } from './types';
/**
* The census guard of the focus-first axis (signed 2026-08-26,
@ -13,13 +13,24 @@ import type { Morfo, MorfoFocus } from './types';
* 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.
* 1. FORWARD, one rule per kind — a declaration must have the executor the
* kind's JSDoc names (`MorfoFocus` in types.ts):
* · `trap` → `FocusScope` wired WITH the policy
* · `roving` → `RovingFocusGroup` or a signed `executor.custom`
* · `walk` → the directional primitive AND no tab stop managed
* · `activedescendant` → `aria-activedescendant` declared + `highlightWalk`
* · `slider` → `valueStep`, one focusable root, no focus paseo
* · `segments` → the segment primitives + a CONSTANT `tabindex: 0`
* 2. the INVERSE (the author's inversion that founded the axis): a
* component whose provider shows focus machinery without declaring
* `focus` fails the census.
*
* The four kinds beyond `trap | roving` arrived 2026-08-27 (author-signed
* decision 3/3) BECAUSE OF THIS FILE: the inverse net parked seven components
* in `INVERSE_EXCEPTIONS` with a written reason each, and seven components
* without a name in the contract is an incomplete contract, measured. The
* seven reasons became the four kinds' specification, so the list below is
* back to the one genuinely COMPOSITIONAL case.
*
* The signed exceptions are listed with their reasons; growing a list is a
* decision, not a convenience.
@ -28,6 +39,20 @@ import type { Morfo, MorfoFocus } from './types';
const COMPONENTS_DIR = join(__dirname, '..', 'soma', 'components');
const MORFO_DIR = join(__dirname, 'components');
type FocusKind = MorfoFocus['kind'];
/** The closed set the loader parses. Keep in step with `MorfoFocus`. */
const FOCUS_KINDS = ['trap', 'roving', 'walk', 'activedescendant', 'slider', 'segments'] as const;
/**
* The tab-stop REGISTRIES: a provider that writes one is managing a single
* stop, whatever it calls itself.
*/
const ROVING_STOP = /RovingFocusGroup|currentTabStop|rovingIndex|rovingTarget/;
/** A `tabindex:` object property — the provider WRITING a tab stop. */
const WRITES_TABINDEX = /\btabindex:/;
/** 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
@ -43,62 +68,63 @@ const EXECUTOR_EXCEPTIONS: Record<string, string> = {
/**
* kebab -> reason. Components whose soma sources SHOW focus machinery but
* which legitimately do not declare `focus` themselves.
*
* EMPTIED to the compositional case 2026-08-27: the seven rows adjudicated
* the day before described REAL focus behaviours the `trap | roving` union
* could not name (a directional walk over native tab stops, the
* aria-activedescendant strategy, the slider model, the segment field). They
* are now DECLARED — `walk` (accordion, navigation-menu), `activedescendant`
* (select, command, combobox), `slider` (rating-group), `segments`
* (date-field, time-field, color-field) — and a declaring component is
* skipped by the inverse test on its own merit, with no exception row.
*/
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'
// lives on its own morfo, the machinery on dialog's. The ONE case that is
// composition rather than an unnamed species.
'alert-dialog': 'machinery belongs to the composed dialog-provider'
};
function loadMorfos(): Array<{ kebab: string; focus: MorfoFocus | undefined }> {
const out: Array<{ kebab: string; focus: MorfoFocus | undefined }> = [];
interface MorfoRecord {
kebab: string;
/** The morfo's own source — the activedescendant rule reads it. */
source: string;
/** `undefined` when the morfo declares no focus policy at all. */
kind: FocusKind | undefined;
/** The `trap` species default. Meaningless for the other kinds. */
trap: boolean;
/** Whether the declaration carries `executor: { custom }` (roving only). */
signedExecutor: boolean;
/** A `focus: {` block the loader could not parse — an instrument failure. */
unparsed: boolean;
}
function loadMorfos(): MorfoRecord[] {
const kindRe = new RegExp(
`\\n\\tfocus: \\{\\r?\\n(?:\\t\\t//[^\\n]*\\r?\\n)*\\t\\tkind: '(${FOCUS_KINDS.join('|')})'`
);
const out: MorfoRecord[] = [];
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 });
const base = { kebab, source, trap: false, signedExecutor: false, unparsed: false };
if (!/\n\tfocus: \{/.test(source)) {
out.push({ ...base, kind: 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);
const kind = kindRe.exec(source)?.[1] as FocusKind | undefined;
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
...base,
kind,
// A declared block whose kind the loader cannot read is NOT "no
// declaration": it would silently move the component into the inverse
// net. The instrument lies before the code does, so it is reported.
unparsed: kind === undefined,
trap: /\n\t\ttrap: (true|false)/.exec(source)?.[1] === 'true',
signedExecutor: /\n\t\texecutor: \{/.test(source)
});
}
return out;
@ -122,24 +148,31 @@ function somaSources(kebab: string): string {
describe('focus census — declaration ↔ executor, both directions', () => {
const morfos = loadMorfos();
const declaring = morfos.filter((m) => m.focus !== undefined);
const declaring = morfos.filter((m) => m.kind !== undefined);
it('the census inspects a real corpus (guard against the empty sweep)', () => {
expect(morfos.length).toBeGreaterThan(150);
expect(declaring.length).toBeGreaterThanOrEqual(28);
expect(declaring.length).toBeGreaterThanOrEqual(37);
// Every kind of the union has at least one declared member: a kind with
// zero population is a contract nobody measured.
expect([...new Set(declaring.map((m) => m.kind))].sort()).toEqual([...FOCUS_KINDS].sort());
});
it('the loader reads every focus block it finds (the instrument lies first)', () => {
expect(morfos.filter((m) => m.unparsed).map((m) => m.kebab)).toEqual([]);
});
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 (m.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 (m.trap && !usesScope) offenders.push(`${m.kebab}: trap:true without FocusScope`);
if (usesScope && !wiresPolicy) offenders.push(`${m.kebab}: FocusScope without policy`);
}
expect(offenders).toEqual([]);
@ -148,8 +181,8 @@ describe('focus census — declaration ↔ executor, both directions', () => {
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 (m.kind !== 'roving') continue;
if (m.signedExecutor) continue; // signed custom — visible in the morfo
if (EXECUTOR_EXCEPTIONS[m.kebab]) continue;
const src = somaSources(m.kebab);
if (!src.includes('RovingFocusGroup')) {
@ -159,6 +192,93 @@ describe('focus census — declaration ↔ executor, both directions', () => {
expect(offenders).toEqual([]);
});
it('every walk declaration walks NATIVE tab stops — arrows as convenience, no stop managed', () => {
const offenders: string[] = [];
for (const m of declaring) {
if (m.kind !== 'walk') continue;
const src = somaSources(m.kebab);
if (!/getDirectionalKeys/.test(src)) {
offenders.push(`${m.kebab}: walk without the directional primitive`);
}
// The species IS the absence of stop management: the parts are
// tabbable on their own. A provider writing `tabindex` is declaring
// the wrong kind — that is `roving`.
if (WRITES_TABINDEX.test(src)) {
offenders.push(`${m.kebab}: walk writes a tabindex — that is a roving stop, not a walk`);
}
if (ROVING_STOP.test(src)) {
offenders.push(`${m.kebab}: walk manages a roving tab stop`);
}
}
expect(offenders).toEqual([]);
});
it('every activedescendant declaration ships the virtual highlight it claims', () => {
const offenders: string[] = [];
for (const m of declaring) {
if (m.kind !== 'activedescendant') continue;
// The attr IS the species: without it the "virtual focus" reaches no
// assistive technology at all.
if (!m.source.includes("attr: 'aria-activedescendant'")) {
offenders.push(`${m.kebab}: activedescendant without an aria-activedescendant declaration`);
}
const src = somaSources(m.kebab);
if (!src.includes('highlightWalk')) {
offenders.push(`${m.kebab}: activedescendant without highlight machinery`);
}
// The single DOM stop stays on the anchor: a roving registry here
// would mean the highlight and a real tab stop both move.
if (ROVING_STOP.test(src)) {
offenders.push(`${m.kebab}: activedescendant that also manages a roving tab stop`);
}
}
expect(offenders).toEqual([]);
});
it('every slider declaration has ONE focusable root and moves the value, not the focus', () => {
const offenders: string[] = [];
for (const m of declaring) {
if (m.kind !== 'slider') continue;
const src = somaSources(m.kebab);
if (!src.includes('valueStep')) {
offenders.push(`${m.kebab}: slider without the value-step primitive`);
}
// One focusable root: something in the component takes tabindex 0.
if (!/\btabindex:[^\n]*\b0\b/.test(src)) {
offenders.push(`${m.kebab}: slider without a focusable root`);
}
if (ROVING_STOP.test(src) || /segment(Walk|Advance|Retreat)|gridWalk/.test(src)) {
offenders.push(`${m.kebab}: slider that manages tab stops`);
}
// The pair-signal: directional keys COMBINED with a focus move is a
// focus paseo, and this species does not have one.
if (/getDirectionalKeys/.test(src) && /(dom\.focus\(|\.focus\(\))/.test(src)) {
offenders.push(`${m.kebab}: slider walks focus with directional keys`);
}
}
expect(offenders).toEqual([]);
});
it('every segments declaration keeps a CONSTANT tabindex 0 on its segments', () => {
const offenders: string[] = [];
for (const m of declaring) {
if (m.kind !== 'segments') continue;
const src = somaSources(m.kebab);
if (!/segment(Walk|Advance|Retreat)/.test(src)) {
offenders.push(`${m.kebab}: segments without the segment-walk primitives`);
}
// The invariant that separates this species from `roving`: the stop is
// never taken away, so the shared segment attrs carry a literal 0.
if (!/\btabindex: 0\b/.test(src)) {
offenders.push(`${m.kebab}: segments without the constant tabindex 0`);
}
if (ROVING_STOP.test(src)) {
offenders.push(`${m.kebab}: segments that manages a roving tab stop`);
}
}
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[] = [];
@ -167,15 +287,12 @@ describe('focus census — declaration ↔ executor, both directions', () => {
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.
// The net catches two populations: the EXECUTORS of a named species
// (each kind of `MorfoFocus` names one) and hand-rolled tab-stop
// machinery. Deliberately NOT `getDirectionalKeys` alone — arrows that
// move a VALUE (rating, media seek) are not tab-stop machinery; a
// hand-rolled focus walk shows as directional keys COMBINED with a
// `.focus()`, which is the pair-signal at the bottom.
const machinery =
src.includes('FocusScope.use') ||
src.includes('RovingFocusGroup') ||
@ -189,31 +306,25 @@ describe('focus census — declaration ↔ executor, both directions', () => {
// it absorbed that pair out of date-field / time-field / color-field.
// All three names, not just the walk — a field that only auto-advances
// (`segmentAdvance`) or only retreats on delete (`segmentRetreat`) is
// moving focus just the same.
// moving focus just the same. Those three now DECLARE `kind:
// 'segments'`, so this line guards the next one instead of them.
/segment(Walk|Advance|Retreat)/.test(src) ||
// The THIRD wave-C+ primitive, `$soma/keyboard/highlight-walk`, is
// deliberately ABSENT from this list. It absorbed the key tables of
// select / command / combobox, but what it moves is the virtual
// HIGHLIGHT the paragraph above excludes by name — it has no focus
// port at all, and the anchor keeps the single DOM stop. Adding
// `highlightWalk` here would make the net catch the very species this
// census decided is not tab-stop machinery. Note that absorbing those
// tables also takes `getDirectionalKeys` out of select-provider and
// command-provider, so neither trips the pair-signal below any more;
// select's INVERSE_EXCEPTIONS row stays because it states the SPECIES,
// not a symptom (and its reason is still literally true).
//
// The FOURTH, `$soma/keyboard/value-step`, is absent for the same
// reason and by the same sentence: what it moves is the VALUE the
// paragraph above excludes by name, on a control that has ONE focusable
// stop and never moves it. Adding `valueStep` here would catch the very
// species this census decided is not tab-stop machinery. It absorbed
// `getDirectionalKeys` out of rating-group's and slider's providers, so
// rating-group no longer trips the pair-signal below (it did until now,
// through the `dom.focus` that returns focus to the root after a click);
// its INVERSE_EXCEPTIONS row stays because, like select's, it states the
// SPECIES — «slider model: one focusable root, arrows move the VALUE,
// not focus» — and that is still literally true.
// `$soma/keyboard/highlight-walk` JOINED the net 2026-08-27. It used
// to be excluded by name — what it moves is a virtual highlight, not
// a tab stop — and the exclusion was correct while the contract had
// no word for that species. `kind: 'activedescendant'` is that word,
// so the executor becomes evidence like any other: select, command
// and combobox all declare, and the line costs nothing today while
// making the fourth member impossible to add in silence.
src.includes('highlightWalk') ||
// `$soma/keyboard/value-step` stays OUT, and this is the one place
// the decision is written down rather than assumed: the species has
// a name now (`kind: 'slider'`, rating-group declares it), but the
// primitive's population is SIX (rating-group, slider, number-field,
// knob, css-field, gradient-builder) and decision 3/3 measured only
// rating-group. Adding `valueStep` here would fail five components
// whose species nobody has measured yet — a scope cut announced, not
// taken in silence. Measure them, declare them, then add the line.
/currentTabStop|rovingIndex|rovingTarget/.test(src) ||
(/getDirectionalKeys/.test(src) && /(dom\.focus\(|\.focus\(\))/.test(src));
if (machinery) {

@ -26,6 +26,11 @@ export type {
MorfoKeyboardKind,
MorfoFocus,
MorfoFocusTrap,
MorfoFocusRoving,
MorfoFocusWalk,
MorfoFocusActiveDescendant,
MorfoFocusSlider,
MorfoFocusSegments,
MorfoSemanticIntent,
MorfoEventSemantic,
MorfoA11ySemantic,

@ -562,3 +562,75 @@ describe('validateMorfo — targetFallback invariants', () => {
expect(() => validateMorfo(withFallback(['trigger', 'trigger']))).toThrow(/twice/);
});
});
describe('validateMorfo — focus kinds measured by the census (decision 3/3, 2026-08-27)', () => {
const itemPart = {
name: 'Item',
kebab: 'item',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
};
function withFocus(focus: Record<string, unknown>) {
return {
...baseMorfo,
parts: [...baseMorfo.parts, itemPart],
focus
};
}
it('accepts a walk over a declared part', () => {
expect(() => validateMorfo(withFocus({ kind: 'walk', parts: ['item'] }))).not.toThrow();
});
it('throws on a walk over an unknown part', () => {
expect(() => validateMorfo(withFocus({ kind: 'walk', parts: ['nope'] }))).toThrow(
/focus\.parts entry "nope" does not match any part/
);
});
it('throws on a walk that walks nothing', () => {
expect(() => validateMorfo(withFocus({ kind: 'walk', parts: [] }))).toThrow(
/must name at least one walked part/
);
});
it('accepts an activedescendant whose anchor and items are declared', () => {
expect(() =>
validateMorfo(withFocus({ kind: 'activedescendant', anchor: 'provider', items: ['item'] }))
).not.toThrow();
});
it('throws on an activedescendant anchored on nothing', () => {
expect(() =>
validateMorfo(withFocus({ kind: 'activedescendant', anchor: 'nope', items: ['item'] }))
).toThrow(/focus\.anchor "nope" does not match any part/);
});
it('throws on an activedescendant with no highlightable part', () => {
expect(() =>
validateMorfo(withFocus({ kind: 'activedescendant', anchor: 'provider', items: [] }))
).toThrow(/must name at least one highlightable part/);
});
it('accepts a bare slider — focus does not move, so there is nothing to cross-check', () => {
expect(() => validateMorfo(withFocus({ kind: 'slider' }))).not.toThrow();
});
it('accepts segments over a declared part', () => {
expect(() => validateMorfo(withFocus({ kind: 'segments', parts: ['item'] }))).not.toThrow();
});
it('throws on segments over an unknown part', () => {
expect(() => validateMorfo(withFocus({ kind: 'segments', parts: ['nope'] }))).toThrow(
/focus\.parts entry "nope" does not match any part/
);
});
it('throws on a kind outside the union', () => {
expect(() => validateMorfo(withFocus({ kind: 'wander', parts: ['item'] }))).toThrow();
});
});

@ -17,7 +17,11 @@
* - Every `kebab` unique across the whole morfo.
* - `MorfoCondition` discriminator values resolve (e.g. `state-equals`
* must refer to a state declared somewhere in the morfo).
* - `MorfoFocus.initial`/`return` `partRef` must match a kebab.
* - `MorfoFocus`, per species: `trap`'s `initial`/`return` `partRef`
* must match a kebab; `roving`/`walk`/`segments` need a non-empty
* `parts` ⊆ kebabs (and `roving: 'grid'` a SIGNED custom executor);
* `activedescendant` needs `anchor` ∈ kebabs plus non-empty `items`;
* `slider` declares no part, so there is nothing to cross-check.
*
* Sium has no `lazy()` today, so recursion in `MorfoPart.parts?` is
* handled by a manual walker that calls `morfoPartSchema.decode()` for
@ -349,7 +353,10 @@ const eventSchema = object({
// ── MorfoFocus ────────────────────────────────────────────────────────────
// Eje focus-first (2026-08-26): discriminated union — `trap` (overlays,
// executed by FocusScope) vs `roving` (composites, executed by
// RovingFocusGroup or a SIGNED custom executor). Structural rules the
// RovingFocusGroup or a SIGNED custom executor). Widened 2026-08-27
// (decision 3/3) with the four kinds the census MEASURED: `walk`,
// `activedescendant`, `slider`, `segments` — see `MorfoFocus` in types.ts
// for the taxonomy and the primitive that executes each. Structural rules the
// schema can't express (grid ⇒ custom, non-empty reason, partRef
// cross-checks) live in `validateInvariants`.
@ -379,6 +386,22 @@ const focusSchema = discriminated('kind', [
),
loop: optional(boolean()),
executor: optional(object({ custom: string() }))
}),
object({
kind: literal('walk'),
parts: array(string())
}),
object({
kind: literal('activedescendant'),
anchor: string(),
items: array(string())
}),
object({
kind: literal('slider')
}),
object({
kind: literal('segments'),
parts: array(string())
})
]);
@ -821,6 +844,18 @@ function validateInvariants(morfo: Morfo): void {
if (morfo.focus) {
const f = morfo.focus;
// The invariant every part-bearing kind shares: the list is non-empty and
// each kebab exists. Declared once so a kind added later cannot forget it.
const requirePartList = (parts: readonly string[], field: string, noun: string) => {
if (parts.length === 0) {
throw new MorfoInvariantError(`focus.${field} must name at least one ${noun}`);
}
for (const kebab of parts) {
if (!kebabs.has(kebab)) {
throw new MorfoInvariantError(`focus.${field} entry "${kebab}" does not match any part`);
}
}
};
if (f.kind === 'trap') {
if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) {
throw new MorfoInvariantError(
@ -832,16 +867,8 @@ function validateInvariants(morfo: Morfo): void {
`focus.return.partRef "${f.return.partRef}" does not match any part`
);
}
} else {
// kind === 'roving'
if (f.parts.length === 0) {
throw new MorfoInvariantError(`focus.parts must name at least one roving candidate part`);
}
for (const kebab of f.parts) {
if (!kebabs.has(kebab)) {
throw new MorfoInvariantError(`focus.parts entry "${kebab}" does not match any part`);
}
}
} else if (f.kind === 'roving') {
requirePartList(f.parts, 'parts', 'roving candidate part');
// The 1D primitive cannot express 2D movement: a grid without a
// signed custom executor is a structural lie, made unrepresentable.
if (f.orientation === 'grid' && !f.executor) {
@ -857,7 +884,20 @@ function validateInvariants(morfo: Morfo): void {
`the census renders it as the signed exception`
);
}
} else if (f.kind === 'walk') {
requirePartList(f.parts, 'parts', 'walked part');
} else if (f.kind === 'segments') {
requirePartList(f.parts, 'parts', 'segment part');
} else if (f.kind === 'activedescendant') {
// The anchor is the whole point of the species: the ONE element that
// keeps the DOM tab stop and carries `aria-activedescendant`.
if (!kebabs.has(f.anchor)) {
throw new MorfoInvariantError(`focus.anchor "${f.anchor}" does not match any part`);
}
requirePartList(f.items, 'items', 'highlightable part');
}
// `slider` declares nothing beyond its kind — focus does not move, so
// there is no part list, no axis and no stop to cross-check.
}
const events = morfo.events ?? [];

@ -887,10 +887,37 @@ export interface MorfoEvent {
* the 1D primitive (grids, tree typeahead) declare a SIGNED custom
* executor instead — the census verifies the signature exists, so the
* exception is visible, never silent.
* - `walk`, `activedescendant`, `slider`, `segments` — the four kinds the
* MEASUREMENT added (author-signed 2026-08-27, decision 3/3). See below.
*
* THE FOUR KINDS AND WHY THEY EXIST. The bidirectional census parked SEVEN
* components in `INVERSE_EXCEPTIONS` whose real, shipped focus behaviour had
* no name in a union that only knew `trap | roving`. Signed exceptions exist
* for the genuinely unique; seven components is not a set of oddities, it is
* an incomplete contract MEASURED. So the contract grew instead of the
* exception list.
*
* THE CONVERGENCE — the argument that made this taxonomy the right one, not
* merely a possible one: it TRACES the keyboard primitives decision C+ had
* just extracted into `src/uix/soma/keyboard/`.
*
* | focus kind | keyboard primitive that executes it |
* |---------------------|------------------------------------------------|
* | `trap` | `FocusScope` (soma layer, not a keyboard prim) |
* | `roving` | `RovingFocusGroup` (`$adom`) |
* | `walk` | `getDirectionalKeys` (`$soma/keyboard/directional`) |
* | `activedescendant` | `highlightWalk` (`$soma/keyboard/highlight-walk`) |
* | `slider` | `valueStep` (`$soma/keyboard/value-step`) |
* | `segments` | `segmentWalk` (`$soma/keyboard/segment-walk`) |
*
* TWO CONTRACTS, ONE TAXONOMY: the focus declaration NAMES the species, the
* keyboard primitive EXECUTES it. Two layers arriving independently at the
* same partition is the evidence that the partition is the real one.
*
* Verified by `focus-census.test.ts` in BOTH directions: a declaration must
* have its executor wired, and a component using FocusScope / roving code
* without a declaration fails the census.
* have its executor wired (one rule per kind), and a component using
* FocusScope / roving / highlight / segment code without a declaration fails
* the census.
*/
export interface MorfoFocusTrap {
kind: 'trap';
@ -947,7 +974,109 @@ export interface MorfoFocusRoving {
executor?: { custom: string };
}
export type MorfoFocus = MorfoFocusTrap | MorfoFocusRoving;
/**
* Directional WALK over parts that are ALL native tab stops.
*
* The species the APG accordion pattern describes and `roving` cannot: every
* trigger is reachable with Tab on its own, and the arrows move focus as a
* CONVENIENCE on top. There is no single tab stop and therefore nothing to
* manage — the provider never writes `tabindex`, which is exactly what the
* census checks. Executed by `getDirectionalKeys` (`$soma/keyboard/directional`)
* plus a plain `dom.focus` on the resolved neighbour.
*
* Population (measured 2026-08-27): `accordion`, `navigation-menu`.
*
* NO `orientation` FIELD, on purpose. In `roving` the axis is load-bearing —
* the schema forbids `grid` without a signed executor and the primitive is
* configured by it. Here nothing reads it: both species take the axis from a
* per-instance `orientation` prop, and a field no executor consumes is a lie
* waiting to happen. The parts it walks are the whole of what the species
* needs to say.
*/
export interface MorfoFocusWalk {
kind: 'walk';
/**
* The parts the arrows walk, by kebab. All of them are natively tabbable —
* a part listed here that the provider gives a roving `tabindex` is a
* `roving` declaration wearing the wrong name.
*/
parts: readonly string[];
}
/**
* VIRTUAL focus: the DOM tab stop never moves; `aria-activedescendant` on the
* anchor points at the highlighted item.
*
* The APG listbox/combobox strategy. Items carry `tabindex="-1"` and are never
* DOM-focused, so none of the tab-stop machinery applies — the census net
* deliberately excludes this species from its "hand-rolled focus" signal, and
* this kind is the declaration that replaces the exclusion. Executed by
* `highlightWalk` (`$soma/keyboard/highlight-walk`), which has no focus port
* at all: it moves an index, not a selection.
*
* Population (measured 2026-08-27): `select`, `command`, `combobox`.
*/
export interface MorfoFocusActiveDescendant {
kind: 'activedescendant';
/**
* The part that keeps the single DOM tab stop and carries
* `aria-activedescendant` — `trigger` for a select, `input` for a combobox
* or a command palette. Cross-checked against the part tree by the schema.
*/
anchor: string;
/**
* Parts eligible for the virtual highlight, by kebab. A list because a
* palette highlights more than one part kind (`command` walks `item` and
* `link-item` in one sequence).
*/
items: readonly string[];
}
/**
* ONE focusable root; the arrows move the VALUE, not the focus.
*
* The slider model — `rating-group` is the measured member: the root carries
* `tabindex=0`, the items carry none, and Arrow / Home / End change what the
* control HOLDS. Executed by `valueStep` (`$soma/keyboard/value-step`).
*
* NOTHING TO CONFIGURE, and that is the declaration's content: there are no
* parts to walk, no axis to resolve and no stop to manage, because focus does
* not move. Naming a focusable part would only restate that the root is the
* root (population of one), so the kind stays bare.
*/
export interface MorfoFocusSlider {
kind: 'slider';
}
/**
* SEGMENTED field: every segment is a native tab stop, arrows walk between
* them as a convenience.
*
* Mechanically a `walk` whose parts happen to be `spinbutton` segments — kept
* distinct because the executor is distinct (`segmentWalk` / `segmentAdvance`
* / `segmentRetreat`, `$soma/keyboard/segment-walk`, which also owns the
* auto-advance on type and the retreat on delete that no directional walk
* has), and because the invariant the census checks is the segments' CONSTANT
* `tabindex: 0` — the exact opposite of a roving stop.
*
* Population (measured 2026-08-27): `date-field`, `time-field`, `color-field`.
*/
export interface MorfoFocusSegments {
kind: 'segments';
/**
* The segment part(s), by kebab. A list for the same reason `walk` has
* one: nothing guarantees a field spells its segments as a single part.
*/
parts: readonly string[];
}
export type MorfoFocus =
| MorfoFocusTrap
| MorfoFocusRoving
| MorfoFocusWalk
| MorfoFocusActiveDescendant
| MorfoFocusSlider
| MorfoFocusSegments;
// ── Archetypes ────────────────────────────────────────────────────────────

Loading…
Cancel
Save

Powered by TurnKey Linux.