feat(morfo)!: MorfoFocus es una unión discriminada — la declaración nombra el MECANISMO

MorfoFocus = MorfoFocusTrap | MorfoFocusRoving (discriminada por `kind`).
La forma única antigua ({initial, trap, return, restore}) solo sabía hablar
de overlays y no tenía ejecutor: el censo midió 21+ componentes con foco
contra 9 declaraciones, con la familia roving (14) estructuralmente muda.

- trap: familia overlay; la declaración es el DEFAULT DE ESPECIE y la prop
  per-instancia gana (patrón dir).
- roving: composite WAI-ARIA (un solo tab-stop; parts es LISTA porque
  toolbar rueda por varias); `orientation:'grid'` EXIGE executor.custom
  firmado (esquema lanza) — el primitivo compartido es 1D y forzar 2D a 1D
  sería mentir.
- schema.ts: invariantes nuevos (partRefs del trap; parts ⊆ kebabs y no
  vacío; custom sin razón lanza).
- index.ts exporta MorfoFocusTrap (lo consume FocusScope); el export de
  errores viaja colapsado por prettier (contenido idéntico).

Eje focus-first (docs/process/PLAN-focus-first.md), nacido de la inversión
del autor: el error no era el campo sin lectores, era la flota sin declarar.

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

@ -21,6 +21,7 @@ export type {
MorfoAriaEntry,
MorfoKeyboard,
MorfoFocus,
MorfoFocusTrap,
MorfoSemanticIntent,
MorfoEventSemantic,
MorfoA11ySemantic,
@ -106,8 +107,4 @@ export {
type EventNameTargeting
} from './selectors';
export {
MorfoCompileError,
MorfoInvariantError,
MorfoSelectorError
} from './errors';
export { MorfoCompileError, MorfoInvariantError, MorfoSelectorError } from './errors';

@ -335,6 +335,11 @@ 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
// schema can't express (grid ⇒ custom, non-empty reason, partRef
// cross-checks) live in `validateInvariants`.
const focusTargetSchema = union(
literal('first-focusable'),
@ -343,12 +348,27 @@ const focusTargetSchema = union(
object({ partRef: string() })
);
const focusSchema = object({
initial: optional(focusTargetSchema),
trap: optional(boolean()),
return: optional(focusTargetSchema),
restore: optional(boolean())
});
const focusSchema = discriminated('kind', [
object({
kind: literal('trap'),
trap: boolean(),
initial: optional(focusTargetSchema),
return: optional(focusTargetSchema),
restore: optional(boolean())
}),
object({
kind: literal('roving'),
parts: array(string()),
orientation: union(
literal('horizontal'),
literal('vertical'),
literal('both'),
literal('grid')
),
loop: optional(boolean()),
executor: optional(object({ custom: string() }))
})
]);
// ── MorfoPart (non-recursive — `parts?` handled by manual walker) ─────────
//
@ -621,15 +641,42 @@ function validateInvariants(morfo: Morfo): void {
if (morfo.focus) {
const f = morfo.focus;
if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) {
throw new MorfoInvariantError(
`focus.initial.partRef "${f.initial.partRef}" does not match any part`
);
}
if (typeof f.return === 'object' && !kebabs.has(f.return.partRef)) {
throw new MorfoInvariantError(
`focus.return.partRef "${f.return.partRef}" does not match any part`
);
if (f.kind === 'trap') {
if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) {
throw new MorfoInvariantError(
`focus.initial.partRef "${f.initial.partRef}" does not match any part`
);
}
if (typeof f.return === 'object' && !kebabs.has(f.return.partRef)) {
throw new MorfoInvariantError(
`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`);
}
}
// 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) {
throw new MorfoInvariantError(
`focus.orientation "grid" requires a signed executor.custom — ` +
`the shared roving primitive is 1D (eje focus-first)`
);
}
// A signed exception with no reason is not a signature.
if (f.executor && f.executor.custom.trim().length === 0) {
throw new MorfoInvariantError(
`focus.executor.custom must carry a non-empty reason — ` +
`the census renders it as the signed exception`
);
}
}
}

@ -694,15 +694,39 @@ export interface MorfoEvent {
// ── Focus policy ──────────────────────────────────────────────────────────
/**
* Focus coordination for overlay / composite components.
* Focus coordination — eje focus-first (signed 2026-08-26, author's premise:
* best architecture regardless of cost; `docs/process/PLAN-focus-first.md`).
*
* Not every component has one — a Tooltip trigger doesn't trap focus, a
* Dialog does. When omitted, the component has no special focus policy.
* The DECLARATION is the SPECIES DEFAULT; the per-instance prop wins (the
* `dir` pattern): `<Dialog modal={false}>` still decides at the consumer.
* The old single shape ({initial, trap, return, restore}) could only express
* the overlay family and had no executor — the census measured 21+ focus-
* bearing components against 9 declarations, with the 14-strong roving
* family structurally unable to declare. This union names the MECHANISM:
*
* Critical for overlay components per WAI-ARIA APG and React Aria:
* keyboard + ARIA alone are incomplete without focus discipline.
* - `trap` — overlay family. Executed by `FocusScope` (soma layer),
* which takes `compiled.focus` as its policy fallback:
* `trapFocus ?? modal ?? policy.trap`.
* - `roving` — composite-widget family (WAI-ARIA roving tabindex: exactly
* one tab stop, arrows move it). Executed by `RovingFocusGroup`
* (`$adom`), element-keyed; components whose pattern genuinely exceeds
* 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.
*
* 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.
*/
export interface MorfoFocus {
export interface MorfoFocusTrap {
kind: 'trap';
/**
* Species default for whether Tab / Shift+Tab are trapped while active.
* MUST match the wrapper's shipped default (`modal` / `trapFocus`) — the
* census compares them; where they differ, the shipped truth wins and the
* declaration is wrong (the Popover precedent).
*/
trap: boolean;
/**
* What receives focus when the component activates.
*
@ -711,8 +735,6 @@ export interface MorfoFocus {
* `{ partRef: <kebab> }`— a specific part by kebab name
*/
initial?: 'first-focusable' | 'trigger' | { partRef: string };
/** Whether Tab / Shift+Tab are trapped within the component while active. */
trap?: boolean;
/**
* Where focus goes when the component deactivates.
*
@ -725,6 +747,34 @@ export interface MorfoFocus {
restore?: boolean;
}
export interface MorfoFocusRoving {
kind: 'roving';
/**
* The repeated part(s) that participate in the single-tab-stop composite,
* by kebab. Usually one; a toolbar-class composite roves across several
* (button, link, group-item), which is why this is a list — a singular
* `partRef` would misdeclare that family.
*/
parts: readonly string[];
/**
* Arrow-key axis. `'both'` maps both axes onto next/prev (the primitive's
* dual-axis mode); `'grid'` is 2D movement, which the 1D primitive cannot
* express — it REQUIRES `executor.custom` (schema-enforced).
*/
orientation: 'horizontal' | 'vertical' | 'both' | 'grid';
/** Whether the stop wraps at the ends. Defaults to the executor's default. */
loop?: boolean;
/**
* SIGNED exception: this component's focus pattern exceeds the shared
* primitive (2D grids, typeahead trees) and keeps its own executor. The
* string is the reason, non-empty (schema-enforced) — the census renders
* it, so the exception is a visible decision, not a silent divergence.
*/
executor?: { custom: string };
}
export type MorfoFocus = MorfoFocusTrap | MorfoFocusRoving;
// ── Archetypes ────────────────────────────────────────────────────────────
/**

Loading…
Cancel
Save

Powered by TurnKey Linux.