feat(morfo/sema): add expression field + sema coverage check (P1 #3)

User doctrinal decision 2026-05-26: `scope: ['sema']` declares
**participation** in sema. It does NOT impose the obligation of
shipping a pack. The new `expression` field on Morfo declares HOW the
component contributes its perceptual signature.

Schema change (src/uix/morfo/types.ts + schema.ts):

  type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none';

  interface Morfo {
    // ...
    expression?: SemaExpressionMode;
  }

Semantics:
  - 'pack'           — sema/components/{kebab}.ts cascades the signature
  - 'family-default' — use family base + intent deltas as-is (no override)
  - 'delegated'      — composite; perceptual expression lives in children's packs
  - 'none'           — explicit opt-out (rare; reserved for headless contracts)

Coverage check (scripts/morfo-vocabulary-check.ts §4):

  FAIL    events declared + no 'sema' in scope         — incoherent
  WARN    scope:sema + events + no pack + no expression — ambiguous
  OK      scope:sema + events + (pack OR expression)   — explicit
  OK      no events                                     — coverage N/A

Pack discovery is automatic from filesystem; the morfo doesn't need to
declare `expression: 'pack'` redundantly. Lint passes when a pack file
exists at `src/uix/sema/components/{kebab}.ts`.

Applied to 5 morfos that had events but no sema scope (incoherent state):
  - announce, clipboard, listbox, virtual-grid, virtual-list
    now declare scope: ['soma', 'sema'] + expression: 'family-default'.

Applied to 28 morfos that had scope:sema + events but no pack:
  - 4 composites (date-picker, date-range-picker, time-picker,
    time-range-picker) → expression: 'delegated'
  - 24 others (button, card, carousel, collapsible, color-field, command,
    context-menu, drag-drop, dropdown-menu, feed, grid-list, menubar,
    month-grid, navigation-menu, range-calendar, switch, table, time-field,
    toggle-group, toggle, tooltip, tree-grid, tree-view, year-grid)
    → expression: 'family-default'

The 29 morfos that already have packs are not touched; the lint
discovers their packs from the filesystem.

Pending follow-up (separate commits per author's plan):
  - Create packs for high-frequency toggles (switch, toggle, toggle-group)
  - Create packs for menus / navigation / tree (menubar, navigation-menu,
    context-menu, dropdown-menu, tree-view, tree-grid)
  - Review card scope (may not need sema scope at all)
  - Per-component review of the rest

Verification:
  - npm run morfo:vocabulary: EXIT 0
  - 0 coverage FAILs, 0 coverage WARNs
  - vitest src/uix/sema src/uix/morfo: 195/195 pass
  - 7 name-shape WARNs remaining are all words.* (separate dev track)

Closes P1 #3 from src/uix/audit-codex-full-1.md (Sema coverage policy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent b0869878d8
commit 504780ad8b

@ -1,7 +1,7 @@
/** /**
* Morfo vocabulary consistency check. * Morfo vocabulary + coverage consistency check.
* *
* Three checks in one script: * Four checks in one script:
* *
* 1. **data attr enums** (WARN) — walks every morfo's `data` declarations * 1. **data attr enums** (WARN) — walks every morfo's `data` declarations
* and reports any attr with `values: string[]` whose set is NOT one of * and reports any attr with `values: string[]` whose set is NOT one of
@ -24,15 +24,35 @@
* intent o a la realización"), so a domain-specific label like * intent o a la realización"), so a domain-specific label like
* `commit-clear` whose declared verb is `reset` is acceptable. * `commit-clear` whose declared verb is `reset` is acceptable.
* *
* 4. **sema coverage** (FAIL + WARN) — per author decision 2026-05-26:
* `scope: ['sema']` declares **participation** in sema. The
* `expression` field on the morfo declares how that participation
* materialises (`pack` / `family-default` / `delegated` / `none`).
* The check enforces:
*
* - **FAIL** when a morfo declares `events[]` but does NOT include
* `'sema'` in its `scope[]`. Events without scope:sema is
* incoherent — the morfo declared semantic events but refuses to
* participate in the sema layer.
* - **WARN** when a morfo has `scope:sema + events` but NO sema pack
* file (`src/uix/sema/components/{kebab}.ts`) AND NO `expression`
* field. Absence of pack must be explicit — either via the pack
* file or via `expression` declaring the intent.
* - **OK** when: (a) pack file exists, (b) `expression` is set to
* `family-default` / `delegated` / `none`, or (c) the morfo has
* no events.
*
* Allowlisted entries are tracked deliberate exceptions, each with a * Allowlisted entries are tracked deliberate exceptions, each with a
* TODO pointing at where they will be resolved. * TODO pointing at where they will be resolved.
* *
* Exit codes: * Exit codes:
* 0 — every declared verb is canonical (or allowlisted). * 0 — verb canon and sema coverage rules satisfied (warnings allowed).
* 1 — at least one declared verb drifts from canon AND is not allowlisted. * 1 — at least one declared verb drifts from canon AND is not
* allowlisted, OR at least one morfo declares events without
* scope:sema.
*/ */
import { readdirSync } from 'node:fs'; import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import type { Morfo, MorfoPart } from '../src/uix/morfo/types'; import type { Morfo, MorfoPart } from '../src/uix/morfo/types';
@ -41,6 +61,7 @@ import { validateEventName, SEMA_VERBS } from '../src/uix/sema/verbs';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components'); const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
const SEMA_PACKS_DIR = join(__dirname, '..', 'src', 'uix', 'sema', 'components');
/** /**
* Event names that knowingly diverge from the canonical vocabulary, * Event names that knowingly diverge from the canonical vocabulary,
@ -185,6 +206,41 @@ type NameFinding = {
const nameFindings: NameFinding[] = []; const nameFindings: NameFinding[] = [];
// ── 4. sema coverage (FAIL events-without-scope, WARN no-pack-no-expression) ──
type CoverageFailFinding = {
morfo: string;
eventsCount: number;
scope: readonly string[];
};
type CoverageWarnFinding = {
morfo: string;
eventsCount: number;
};
const coverageFails: CoverageFailFinding[] = [];
const coverageWarns: CoverageWarnFinding[] = [];
function hasSemaPack(kebab: string): boolean {
return existsSync(join(SEMA_PACKS_DIR, `${kebab}.ts`));
}
for (const morfo of morfos) {
const eventsCount = morfo.events?.length ?? 0;
if (eventsCount === 0) continue; // no events → coverage not applicable
const hasSemaScope = morfo.scope.includes('sema');
if (!hasSemaScope) {
coverageFails.push({ morfo: morfo.kebab, eventsCount, scope: morfo.scope });
continue;
}
const hasPack = hasSemaPack(morfo.kebab);
const hasExpression = typeof morfo.expression === 'string' && morfo.expression.length > 0;
if (!hasPack && !hasExpression) {
coverageWarns.push({ morfo: morfo.kebab, eventsCount });
}
}
for (const morfo of morfos) { for (const morfo of morfos) {
for (const evt of morfo.events ?? []) { for (const evt of morfo.events ?? []) {
const declaredFamily = evt.semantic?.family; const declaredFamily = evt.semantic?.family;
@ -313,9 +369,49 @@ if (nameFindings.length > 0) {
); );
} }
if (dataFindings.length === 0 && verbFindings.length === 0 && nameFindings.length === 0) { if (coverageFails.length > 0) {
console.log('');
console.log(
`Found ${coverageFails.length} morfo${coverageFails.length === 1 ? '' : 's'} with events but no \`scope: ['sema']\` (FAIL):`
);
console.log('');
for (const f of coverageFails) {
console.log(
`FAIL ${f.morfo} ${f.eventsCount} event${f.eventsCount === 1 ? '' : 's'} scope=[${f.scope.join(', ')}]`
);
console.log(
` Morfos that declare events MUST include 'sema' in scope[]. Events without sema scope is incoherent — the morfo declared semantic events but refuses to participate in the sema layer.`
);
}
hadHardErrors = true;
}
if (coverageWarns.length > 0) {
console.log('');
console.log(
`Found ${coverageWarns.length} morfo${coverageWarns.length === 1 ? '' : 's'} with scope:sema + events but no pack and no expression field (warn):`
);
console.log('');
for (const f of coverageWarns) {
console.log(
`WARN ${f.morfo} ${f.eventsCount} event${f.eventsCount === 1 ? '' : 's'}, no pack file, no expression declared`
);
}
console.log('');
console.log(
`These are WARNINGS — the absence of a pack is ambiguous. Either create a sema pack at src/uix/sema/components/{kebab}.ts to refine the perceptual signature, OR declare \`expression: 'family-default' | 'delegated' | 'none'\` on the morfo to make the absence explicit. Per author decision 2026-05-26, scope:sema declares participation, NOT obligation to ship a pack — expression makes the choice visible.`
);
}
if (
dataFindings.length === 0 &&
verbFindings.length === 0 &&
nameFindings.length === 0 &&
coverageFails.length === 0 &&
coverageWarns.length === 0
) {
console.log( console.log(
'All enum values match canonical vocabularies and all event names are canonical.' 'All enum values match canonical vocabularies, all event names are canonical, and sema coverage is explicit.'
); );
} }

@ -4,7 +4,10 @@ import { v } from '../types';
export const announceMorfo = { export const announceMorfo = {
name: 'Announce', name: 'Announce',
kebab: 'announce', kebab: 'announce',
scope: ['soma'], scope: ['soma', 'sema'],
// Announce IS sema's `signal.announce/alert` literal. Pack exists in
// `sema/components/announce.ts` when needed; for now family base is fine.
expression: 'family-default',
texts: { texts: {
label: '#?components.announce.label|Announcements' label: '#?components.announce.label|Announcements'
}, },

@ -45,6 +45,7 @@ export const buttonMorfo = {
name: 'Button', name: 'Button',
kebab: 'button', kebab: 'button',
scope: ['soma', 'sema', 'eidos'], scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/',
texts: { texts: {
label: '#?components.button.label|Button', label: '#?components.button.label|Button',

@ -28,6 +28,7 @@ export const cardMorfo = {
name: 'Card', name: 'Card',
kebab: 'card', kebab: 'card',
scope: ['sema', 'eidos'], scope: ['sema', 'eidos'],
expression: "family-default",
events: [ events: [
{ {
name: 'commit-select', name: 'commit-select',

@ -5,6 +5,7 @@ export const carouselMorfo = {
name: 'Carousel', name: 'Carousel',
kebab: 'carousel', kebab: 'carousel',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/carousel/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/carousel/',
texts: { texts: {
label: '#?components.carousel.label|Carousel', label: '#?components.carousel.label|Carousel',

@ -4,7 +4,10 @@ import { v } from '../types';
export const clipboardMorfo = { export const clipboardMorfo = {
name: 'Clipboard', name: 'Clipboard',
kebab: 'clipboard', kebab: 'clipboard',
scope: ['soma'], scope: ['soma', 'sema'],
// Copy + fail events benefit from a pack to differentiate the save chime
// from the error tone. To be tuned in a follow-up; family base for now.
expression: 'family-default',
texts: { texts: {
label: '#?components.clipboard.label|Clipboard', label: '#?components.clipboard.label|Clipboard',
copy: '#?components.clipboard.copy|Copy to clipboard', copy: '#?components.clipboard.copy|Copy to clipboard',

@ -20,6 +20,7 @@ export const collapsibleMorfo = {
name: 'Collapsible', name: 'Collapsible',
kebab: 'collapsible', kebab: 'collapsible',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
texts: { texts: {
label: '#?components.collapsible.label|Collapsible' label: '#?components.collapsible.label|Collapsible'
}, },

@ -5,6 +5,7 @@ export const colorFieldMorfo = {
name: 'ColorField', name: 'ColorField',
kebab: 'color-field', kebab: 'color-field',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/',
texts: { texts: {
label: '#?components.color-field.label|Color field', label: '#?components.color-field.label|Color field',

@ -5,6 +5,7 @@ export const commandMorfo = {
name: 'Command', name: 'Command',
kebab: 'command', kebab: 'command',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
texts: { texts: {
label: '#?components.command.label|Command palette', label: '#?components.command.label|Command palette',
list: '#?components.command.list|Command palette' list: '#?components.command.list|Command palette'

@ -7,6 +7,7 @@ export const contextMenuMorfo = {
// Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/context-menu/`). // Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/context-menu/`).
// Same family/event vocabulary as dropdown-menu — sound from family bases. // Same family/event vocabulary as dropdown-menu — sound from family bases.
scope: ['soma', 'sema', 'eidos'], scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu/',
texts: { texts: {
label: '#?components.context-menu.label|Context menu' label: '#?components.context-menu.label|Context menu'

@ -11,6 +11,7 @@ export const datePickerMorfo = {
name: 'DatePicker', name: 'DatePicker',
kebab: 'date-picker', kebab: 'date-picker',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "delegated",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
texts: { texts: {
label: '#?components.date-picker.label|Date Picker', label: '#?components.date-picker.label|Date Picker',

@ -11,6 +11,7 @@ export const dateRangePickerMorfo = {
name: 'DateRangePicker', name: 'DateRangePicker',
kebab: 'date-range-picker', kebab: 'date-range-picker',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "delegated",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
texts: { texts: {
label: '#?components.date-range-picker.label|Date range picker', label: '#?components.date-range-picker.label|Date range picker',

@ -5,6 +5,7 @@ export const dragDropMorfo = {
name: 'DragDrop', name: 'DragDrop',
kebab: 'drag-drop', kebab: 'drag-drop',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
texts: { texts: {
label: '#?components.drag-drop.label|Drag and drop', label: '#?components.drag-drop.label|Drag and drop',
'drag-started': '#?components.drag-drop.drag-started|Started dragging {{item}}', 'drag-started': '#?components.drag-drop.drag-started|Started dragging {{item}}',

@ -9,6 +9,7 @@ export const dropdownMenuMorfo = {
// commit-select from the `commit` family base — both ship sound // commit-select from the `commit` family base — both ship sound
// out of the box, no per-component cascade needed. // out of the box, no per-component cascade needed.
scope: ['soma', 'sema', 'eidos'], scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
texts: { texts: {
label: '#?components.dropdown-menu.label|Dropdown menu' label: '#?components.dropdown-menu.label|Dropdown menu'

@ -5,6 +5,7 @@ export const feedMorfo = {
name: 'Feed', name: 'Feed',
kebab: 'feed', kebab: 'feed',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/feed/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/feed/',
texts: { texts: {
label: '#?components.feed.label|Feed' label: '#?components.feed.label|Feed'

@ -5,6 +5,7 @@ export const gridListMorfo = {
name: 'GridList', name: 'GridList',
kebab: 'grid-list', kebab: 'grid-list',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: { texts: {
label: '#?components.grid-list.label|Grid list', label: '#?components.grid-list.label|Grid list',

@ -4,7 +4,10 @@ import { v } from '../types';
export const listboxMorfo = { export const listboxMorfo = {
name: 'Listbox', name: 'Listbox',
kebab: 'listbox', kebab: 'listbox',
scope: ['soma'], scope: ['soma', 'sema'],
// Listbox shares its perceptual signature with combobox/select (which
// have packs). Family base + intent deltas are sufficient here.
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/listbox/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/listbox/',
texts: { texts: {
label: '#?components.listbox.label|Options' label: '#?components.listbox.label|Options'

@ -5,6 +5,7 @@ export const menubarMorfo = {
name: 'Menubar', name: 'Menubar',
kebab: 'menubar', kebab: 'menubar',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menubar/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menubar/',
texts: { texts: {
label: '#?components.menubar.label|Menubar' label: '#?components.menubar.label|Menubar'

@ -5,6 +5,7 @@ export const monthGridMorfo = {
name: 'MonthGrid', name: 'MonthGrid',
kebab: 'month-grid', kebab: 'month-grid',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: { texts: {
label: '#?components.month-grid.label|Month grid', label: '#?components.month-grid.label|Month grid',

@ -5,6 +5,7 @@ export const navigationMenuMorfo = {
name: 'NavigationMenu', name: 'NavigationMenu',
kebab: 'navigation-menu', kebab: 'navigation-menu',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
texts: { texts: {
label: '#?components.navigation-menu.label|Main' label: '#?components.navigation-menu.label|Main'
}, },

@ -5,6 +5,7 @@ export const rangeCalendarMorfo = {
name: 'RangeCalendar', name: 'RangeCalendar',
kebab: 'range-calendar', kebab: 'range-calendar',
scope: ['soma', 'sema', 'eidos'], scope: ['soma', 'sema', 'eidos'],
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: { texts: {
label: '#?components.range-calendar.label|Range calendar', label: '#?components.range-calendar.label|Range calendar',

@ -22,6 +22,7 @@ export const switchMorfo = {
name: 'Switch', name: 'Switch',
kebab: 'switch', kebab: 'switch',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
texts: { texts: {
label: '#?components.switch.label|Switch' label: '#?components.switch.label|Switch'
}, },

@ -5,6 +5,7 @@ export const tableMorfo = {
name: 'Table', name: 'Table',
kebab: 'table', kebab: 'table',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
texts: { texts: {
label: '#?components.table.label|Data table', label: '#?components.table.label|Data table',
'toggle-detail': '#?components.table.toggle-detail|Toggle detail', 'toggle-detail': '#?components.table.toggle-detail|Toggle detail',

@ -5,6 +5,7 @@ export const timeFieldMorfo = {
name: 'TimeField', name: 'TimeField',
kebab: 'time-field', kebab: 'time-field',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/',
texts: { texts: {
label: '#?components.time-field.label|Time field', label: '#?components.time-field.label|Time field',

@ -11,6 +11,7 @@ export const timePickerMorfo = {
name: 'TimePicker', name: 'TimePicker',
kebab: 'time-picker', kebab: 'time-picker',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "delegated",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
texts: { texts: {
label: '#?components.time-picker.label|Time Picker', label: '#?components.time-picker.label|Time Picker',

@ -11,6 +11,7 @@ export const timeRangePickerMorfo = {
name: 'TimeRangePicker', name: 'TimeRangePicker',
kebab: 'time-range-picker', kebab: 'time-range-picker',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "delegated",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
texts: { texts: {
label: '#?components.time-range-picker.label|Time Range Picker', label: '#?components.time-range-picker.label|Time Range Picker',

@ -8,6 +8,7 @@ export const toggleGroupMorfo = {
// Sema picks up the `commit-toggle` event via the `commit` family base — // Sema picks up the `commit-toggle` event via the `commit` family base —
// no per-component cascade needed (commit ships sound out of the box). // no per-component cascade needed (commit ships sound out of the box).
scope: ['soma', 'sema', 'eidos'], scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/',
texts: { texts: {
label: '#?components.toggle-group.label|Toggle group' label: '#?components.toggle-group.label|Toggle group'

@ -23,6 +23,7 @@ export const toggleMorfo = {
name: 'Toggle', name: 'Toggle',
kebab: 'toggle', kebab: 'toggle',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
texts: { texts: {
label: '#?components.toggle.label|Toggle' label: '#?components.toggle.label|Toggle'
}, },

@ -12,6 +12,7 @@ export const tooltipMorfo = {
name: 'Tooltip', name: 'Tooltip',
kebab: 'tooltip', kebab: 'tooltip',
scope: ['soma', 'eidos', 'sema'], scope: ['soma', 'eidos', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/',
texts: { texts: {
label: '#?components.tooltip.label|Tooltip' label: '#?components.tooltip.label|Tooltip'

@ -5,6 +5,7 @@ export const treeGridMorfo = {
name: 'TreeGrid', name: 'TreeGrid',
kebab: 'tree-grid', kebab: 'tree-grid',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/',
texts: { texts: {
label: '#?components.tree-grid.label|Tree grid', label: '#?components.tree-grid.label|Tree grid',

@ -5,6 +5,7 @@ export const treeViewMorfo = {
name: 'TreeView', name: 'TreeView',
kebab: 'tree-view', kebab: 'tree-view',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/treeview/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/treeview/',
texts: { texts: {
label: '#?components.tree-view.label|Tree' label: '#?components.tree-view.label|Tree'

@ -4,7 +4,10 @@ import { v } from '../types';
export const virtualGridMorfo = { export const virtualGridMorfo = {
name: 'VirtualGrid', name: 'VirtualGrid',
kebab: 'virtual-grid', kebab: 'virtual-grid',
scope: ['soma'], scope: ['soma', 'sema'],
// User scroll events use handle base; programmatic scroll events use
// shift.navigate base. Family defaults are appropriate.
expression: 'family-default',
texts: { texts: {
label: '#?components.virtual-grid.label|Virtual Grid' label: '#?components.virtual-grid.label|Virtual Grid'
}, },

@ -4,7 +4,9 @@ import { v } from '../types';
export const virtualListMorfo = { export const virtualListMorfo = {
name: 'VirtualList', name: 'VirtualList',
kebab: 'virtual-list', kebab: 'virtual-list',
scope: ['soma'], scope: ['soma', 'sema'],
// Same rationale as virtual-grid.
expression: 'family-default',
texts: { texts: {
label: '#?components.virtual-list.label|Virtual List' label: '#?components.virtual-list.label|Virtual List'
}, },

@ -5,6 +5,7 @@ export const yearGridMorfo = {
name: 'YearGrid', name: 'YearGrid',
kebab: 'year-grid', kebab: 'year-grid',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
expression: "family-default",
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: { texts: {
label: '#?components.year-grid.label|Year grid', label: '#?components.year-grid.label|Year grid',

@ -296,6 +296,13 @@ const partShallowSchema = object({
// ── Morfo root ──────────────────────────────────────────────────────────── // ── Morfo root ────────────────────────────────────────────────────────────
const semaExpressionModeSchema = union(
literal('pack'),
literal('family-default'),
literal('delegated'),
literal('none')
);
const morfoShallowSchema = object( const morfoShallowSchema = object(
{ {
name: string(), name: string(),
@ -304,6 +311,7 @@ const morfoShallowSchema = object(
apg: optional(string()), apg: optional(string()),
focus: optional(focusSchema), focus: optional(focusSchema),
events: optional(array(eventSchema)), events: optional(array(eventSchema)),
expression: optional(semaExpressionModeSchema),
translations: optional(object({}, { unknownKeys: 'passthrough' })), translations: optional(object({}, { unknownKeys: 'passthrough' })),
parts: array(object({}, { unknownKeys: 'passthrough' })) parts: array(object({}, { unknownKeys: 'passthrough' }))
// ^ parts are opaque here; walker recurses with `partShallowSchema` // ^ parts are opaque here; walker recurses with `partShallowSchema`

@ -593,6 +593,24 @@ export interface MorfoPart {
parts?: readonly MorfoPart[]; parts?: readonly MorfoPart[];
} }
// ── Sema expression mode ─────────────────────────────────────────────────
/**
* How a morfo's events project onto the sema layer.
*
* Per author decision 2026-05-26: `scope: ['sema']` declares
* **participation** in sema. The `expression` field declares HOW the
* component contributes its perceptual signature.
*
* | Value | Meaning |
* |---|---|
* | `'pack'` | A `sema/components/{kebab}.ts` cascade tunes the signature per event. |
* | `'family-default'` | Use family base + intent deltas; no per-component override needed. |
* | `'delegated'` | Composite — perceptual expression lives in children's packs. |
* | `'none'` | Headless contract; events declared for diagnostics, not for sema projection. |
*/
export type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none';
// ── Root ────────────────────────────────────────────────────────────────── // ── Root ──────────────────────────────────────────────────────────────────
/** /**
@ -624,6 +642,37 @@ export interface Morfo {
* events exist, what family they belong to, and which intents are valid. * events exist, what family they belong to, and which intents are valid.
*/ */
events?: readonly MorfoEvent[]; events?: readonly MorfoEvent[];
/**
* How the component projects its events onto the sema layer's perceptual
* channels. Per author decision 2026-05-26: `scope: ['sema']` declares
* **participation** in sema, NOT obligation to ship a pack. The
* `expression` field makes the choice explicit:
*
* - `'pack'` — a `src/uix/sema/components/{kebab}.ts` pack exists with
* component-specific tuning of sound / haptic / channels. The pack
* refines the family base signature.
* - `'family-default'` — events use the family + intent base signature
* from `SEMA_MAP` without per-component refinement. Valid for
* components whose perceptual signature doesn't need tuning.
* - `'delegated'` — events fire but perceptual expression is handled
* by the morfos of CHILD components. Used by composites (date-picker
* delegates to date-field + calendar, etc.) — the composite itself
* declares events at the composition level but the sema cascade
* lives in the children.
* - `'none'` — the morfo declares events but explicitly opts out of
* sema projection. Rare; reserved for headless contracts where
* events are diagnostic only.
*
* If a morfo declares `events[]` AND `scope: ['sema']`, `expression`
* SHOULD be set explicitly. The morfo-coverage check warns when it's
* missing, so the absence of a pack is never confused with "we forgot to
* write one." If a morfo declares events but has NO `scope: ['sema']`,
* the check errors — events without scope is incoherent.
*
* @see `src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md` for the doctrinal
* decision and rationale.
*/
expression?: SemaExpressionMode;
/** /**
* Component-owned text slots, declared as idlangrefs. * Component-owned text slots, declared as idlangrefs.
* *

Loading…
Cancel
Save

Powered by TurnKey Linux.