diff --git a/scripts/morfo-vocabulary-check.ts b/scripts/morfo-vocabulary-check.ts index 0c5f2c225..6bda95719 100644 --- a/scripts/morfo-vocabulary-check.ts +++ b/scripts/morfo-vocabulary-check.ts @@ -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 * 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 * `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 * TODO pointing at where they will be resolved. * * Exit codes: - * 0 — every declared verb is canonical (or allowlisted). - * 1 — at least one declared verb drifts from canon AND is not allowlisted. + * 0 — verb canon and sema coverage rules satisfied (warnings allowed). + * 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 { dirname, join } from 'node:path'; 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 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, @@ -185,6 +206,41 @@ type 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 evt of morfo.events ?? []) { 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( - '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.' ); } diff --git a/src/uix/morfo/components/announce.ts b/src/uix/morfo/components/announce.ts index b68183565..cea5c5abf 100644 --- a/src/uix/morfo/components/announce.ts +++ b/src/uix/morfo/components/announce.ts @@ -4,7 +4,10 @@ import { v } from '../types'; export const announceMorfo = { name: '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: { label: '#?components.announce.label|Announcements' }, diff --git a/src/uix/morfo/components/button.ts b/src/uix/morfo/components/button.ts index 238acbbd3..76433753c 100644 --- a/src/uix/morfo/components/button.ts +++ b/src/uix/morfo/components/button.ts @@ -45,6 +45,7 @@ export const buttonMorfo = { name: 'Button', kebab: 'button', scope: ['soma', 'sema', 'eidos'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/', texts: { label: '#?components.button.label|Button', diff --git a/src/uix/morfo/components/card.ts b/src/uix/morfo/components/card.ts index edbe965f0..92096c76e 100644 --- a/src/uix/morfo/components/card.ts +++ b/src/uix/morfo/components/card.ts @@ -28,6 +28,7 @@ export const cardMorfo = { name: 'Card', kebab: 'card', scope: ['sema', 'eidos'], + expression: "family-default", events: [ { name: 'commit-select', diff --git a/src/uix/morfo/components/carousel.ts b/src/uix/morfo/components/carousel.ts index b359d99c8..a71958d4e 100644 --- a/src/uix/morfo/components/carousel.ts +++ b/src/uix/morfo/components/carousel.ts @@ -5,6 +5,7 @@ export const carouselMorfo = { name: 'Carousel', kebab: 'carousel', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/carousel/', texts: { label: '#?components.carousel.label|Carousel', diff --git a/src/uix/morfo/components/clipboard.ts b/src/uix/morfo/components/clipboard.ts index 4a209716e..7cb11038e 100644 --- a/src/uix/morfo/components/clipboard.ts +++ b/src/uix/morfo/components/clipboard.ts @@ -4,7 +4,10 @@ import { v } from '../types'; export const clipboardMorfo = { name: '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: { label: '#?components.clipboard.label|Clipboard', copy: '#?components.clipboard.copy|Copy to clipboard', diff --git a/src/uix/morfo/components/collapsible.ts b/src/uix/morfo/components/collapsible.ts index 214459d21..7a2d26888 100644 --- a/src/uix/morfo/components/collapsible.ts +++ b/src/uix/morfo/components/collapsible.ts @@ -20,6 +20,7 @@ export const collapsibleMorfo = { name: 'Collapsible', kebab: 'collapsible', scope: ['soma', 'sema'], + expression: "family-default", texts: { label: '#?components.collapsible.label|Collapsible' }, diff --git a/src/uix/morfo/components/color-field.ts b/src/uix/morfo/components/color-field.ts index 0a7332055..ece9ec159 100644 --- a/src/uix/morfo/components/color-field.ts +++ b/src/uix/morfo/components/color-field.ts @@ -5,6 +5,7 @@ export const colorFieldMorfo = { name: 'ColorField', kebab: 'color-field', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/', texts: { label: '#?components.color-field.label|Color field', diff --git a/src/uix/morfo/components/command.ts b/src/uix/morfo/components/command.ts index 84952cfcd..39e6f9e75 100644 --- a/src/uix/morfo/components/command.ts +++ b/src/uix/morfo/components/command.ts @@ -5,6 +5,7 @@ export const commandMorfo = { name: 'Command', kebab: 'command', scope: ['soma', 'sema'], + expression: "family-default", texts: { label: '#?components.command.label|Command palette', list: '#?components.command.list|Command palette' diff --git a/src/uix/morfo/components/context-menu.ts b/src/uix/morfo/components/context-menu.ts index 7245f959d..7748fa23d 100644 --- a/src/uix/morfo/components/context-menu.ts +++ b/src/uix/morfo/components/context-menu.ts @@ -7,6 +7,7 @@ export const contextMenuMorfo = { // Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/context-menu/`). // Same family/event vocabulary as dropdown-menu — sound from family bases. scope: ['soma', 'sema', 'eidos'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu/', texts: { label: '#?components.context-menu.label|Context menu' diff --git a/src/uix/morfo/components/date-picker.ts b/src/uix/morfo/components/date-picker.ts index 6308f347f..e6cbe5432 100644 --- a/src/uix/morfo/components/date-picker.ts +++ b/src/uix/morfo/components/date-picker.ts @@ -11,6 +11,7 @@ export const datePickerMorfo = { name: 'DatePicker', kebab: 'date-picker', scope: ['soma', 'sema'], + expression: "delegated", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', texts: { label: '#?components.date-picker.label|Date Picker', diff --git a/src/uix/morfo/components/date-range-picker.ts b/src/uix/morfo/components/date-range-picker.ts index 841eee6e6..9d8d20ae0 100644 --- a/src/uix/morfo/components/date-range-picker.ts +++ b/src/uix/morfo/components/date-range-picker.ts @@ -11,6 +11,7 @@ export const dateRangePickerMorfo = { name: 'DateRangePicker', kebab: 'date-range-picker', scope: ['soma', 'sema'], + expression: "delegated", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', texts: { label: '#?components.date-range-picker.label|Date range picker', diff --git a/src/uix/morfo/components/drag-drop.ts b/src/uix/morfo/components/drag-drop.ts index 5340ed13f..c25295bf4 100644 --- a/src/uix/morfo/components/drag-drop.ts +++ b/src/uix/morfo/components/drag-drop.ts @@ -5,6 +5,7 @@ export const dragDropMorfo = { name: 'DragDrop', kebab: 'drag-drop', scope: ['soma', 'sema'], + expression: "family-default", texts: { label: '#?components.drag-drop.label|Drag and drop', 'drag-started': '#?components.drag-drop.drag-started|Started dragging {{item}}', diff --git a/src/uix/morfo/components/dropdown-menu.ts b/src/uix/morfo/components/dropdown-menu.ts index 6e02e3d87..7dff8fc34 100644 --- a/src/uix/morfo/components/dropdown-menu.ts +++ b/src/uix/morfo/components/dropdown-menu.ts @@ -9,6 +9,7 @@ export const dropdownMenuMorfo = { // commit-select from the `commit` family base — both ship sound // out of the box, no per-component cascade needed. scope: ['soma', 'sema', 'eidos'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/', texts: { label: '#?components.dropdown-menu.label|Dropdown menu' diff --git a/src/uix/morfo/components/feed.ts b/src/uix/morfo/components/feed.ts index 00bcf748d..61f6a9d38 100644 --- a/src/uix/morfo/components/feed.ts +++ b/src/uix/morfo/components/feed.ts @@ -5,6 +5,7 @@ export const feedMorfo = { name: 'Feed', kebab: 'feed', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/feed/', texts: { label: '#?components.feed.label|Feed' diff --git a/src/uix/morfo/components/grid-list.ts b/src/uix/morfo/components/grid-list.ts index fd4f490f4..696493774 100644 --- a/src/uix/morfo/components/grid-list.ts +++ b/src/uix/morfo/components/grid-list.ts @@ -5,6 +5,7 @@ export const gridListMorfo = { name: 'GridList', kebab: 'grid-list', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', texts: { label: '#?components.grid-list.label|Grid list', diff --git a/src/uix/morfo/components/listbox.ts b/src/uix/morfo/components/listbox.ts index 1d38b3608..74f11d6e3 100644 --- a/src/uix/morfo/components/listbox.ts +++ b/src/uix/morfo/components/listbox.ts @@ -4,7 +4,10 @@ import { v } from '../types'; export const listboxMorfo = { name: '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/', texts: { label: '#?components.listbox.label|Options' diff --git a/src/uix/morfo/components/menubar.ts b/src/uix/morfo/components/menubar.ts index 4526c8570..9f6a4a846 100644 --- a/src/uix/morfo/components/menubar.ts +++ b/src/uix/morfo/components/menubar.ts @@ -5,6 +5,7 @@ export const menubarMorfo = { name: 'Menubar', kebab: 'menubar', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menubar/', texts: { label: '#?components.menubar.label|Menubar' diff --git a/src/uix/morfo/components/month-grid.ts b/src/uix/morfo/components/month-grid.ts index fbf2a16be..b62ac302c 100644 --- a/src/uix/morfo/components/month-grid.ts +++ b/src/uix/morfo/components/month-grid.ts @@ -5,6 +5,7 @@ export const monthGridMorfo = { name: 'MonthGrid', kebab: 'month-grid', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', texts: { label: '#?components.month-grid.label|Month grid', diff --git a/src/uix/morfo/components/navigation-menu.ts b/src/uix/morfo/components/navigation-menu.ts index 3ebc591ec..c10b89a88 100644 --- a/src/uix/morfo/components/navigation-menu.ts +++ b/src/uix/morfo/components/navigation-menu.ts @@ -5,6 +5,7 @@ export const navigationMenuMorfo = { name: 'NavigationMenu', kebab: 'navigation-menu', scope: ['soma', 'sema'], + expression: "family-default", texts: { label: '#?components.navigation-menu.label|Main' }, diff --git a/src/uix/morfo/components/range-calendar.ts b/src/uix/morfo/components/range-calendar.ts index d36568a24..251d39de5 100644 --- a/src/uix/morfo/components/range-calendar.ts +++ b/src/uix/morfo/components/range-calendar.ts @@ -5,6 +5,7 @@ export const rangeCalendarMorfo = { name: 'RangeCalendar', kebab: 'range-calendar', scope: ['soma', 'sema', 'eidos'], + expression: 'family-default', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', texts: { label: '#?components.range-calendar.label|Range calendar', diff --git a/src/uix/morfo/components/switch.ts b/src/uix/morfo/components/switch.ts index ee32fd5d9..d9f7ad5ad 100644 --- a/src/uix/morfo/components/switch.ts +++ b/src/uix/morfo/components/switch.ts @@ -22,6 +22,7 @@ export const switchMorfo = { name: 'Switch', kebab: 'switch', scope: ['soma', 'sema'], + expression: "family-default", texts: { label: '#?components.switch.label|Switch' }, diff --git a/src/uix/morfo/components/table.ts b/src/uix/morfo/components/table.ts index 0c01a223d..c474d713f 100644 --- a/src/uix/morfo/components/table.ts +++ b/src/uix/morfo/components/table.ts @@ -5,6 +5,7 @@ export const tableMorfo = { name: 'Table', kebab: 'table', scope: ['soma', 'sema'], + expression: "family-default", texts: { label: '#?components.table.label|Data table', 'toggle-detail': '#?components.table.toggle-detail|Toggle detail', diff --git a/src/uix/morfo/components/time-field.ts b/src/uix/morfo/components/time-field.ts index fb0c4eb09..0e2366f02 100644 --- a/src/uix/morfo/components/time-field.ts +++ b/src/uix/morfo/components/time-field.ts @@ -5,6 +5,7 @@ export const timeFieldMorfo = { name: 'TimeField', kebab: 'time-field', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/', texts: { label: '#?components.time-field.label|Time field', diff --git a/src/uix/morfo/components/time-picker.ts b/src/uix/morfo/components/time-picker.ts index 4bd0bb876..de07139fe 100644 --- a/src/uix/morfo/components/time-picker.ts +++ b/src/uix/morfo/components/time-picker.ts @@ -11,6 +11,7 @@ export const timePickerMorfo = { name: 'TimePicker', kebab: 'time-picker', scope: ['soma', 'sema'], + expression: "delegated", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', texts: { label: '#?components.time-picker.label|Time Picker', diff --git a/src/uix/morfo/components/time-range-picker.ts b/src/uix/morfo/components/time-range-picker.ts index ee8a23b93..c6fe97299 100644 --- a/src/uix/morfo/components/time-range-picker.ts +++ b/src/uix/morfo/components/time-range-picker.ts @@ -11,6 +11,7 @@ export const timeRangePickerMorfo = { name: 'TimeRangePicker', kebab: 'time-range-picker', scope: ['soma', 'sema'], + expression: "delegated", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', texts: { label: '#?components.time-range-picker.label|Time Range Picker', diff --git a/src/uix/morfo/components/toggle-group.ts b/src/uix/morfo/components/toggle-group.ts index b01131260..a80913b77 100644 --- a/src/uix/morfo/components/toggle-group.ts +++ b/src/uix/morfo/components/toggle-group.ts @@ -8,6 +8,7 @@ export const toggleGroupMorfo = { // Sema picks up the `commit-toggle` event via the `commit` family base — // no per-component cascade needed (commit ships sound out of the box). scope: ['soma', 'sema', 'eidos'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/', texts: { label: '#?components.toggle-group.label|Toggle group' diff --git a/src/uix/morfo/components/toggle.ts b/src/uix/morfo/components/toggle.ts index b7cae49c5..c0a26ca0e 100644 --- a/src/uix/morfo/components/toggle.ts +++ b/src/uix/morfo/components/toggle.ts @@ -23,6 +23,7 @@ export const toggleMorfo = { name: 'Toggle', kebab: 'toggle', scope: ['soma', 'sema'], + expression: "family-default", texts: { label: '#?components.toggle.label|Toggle' }, diff --git a/src/uix/morfo/components/tooltip.ts b/src/uix/morfo/components/tooltip.ts index 63d0961f6..68dac7595 100644 --- a/src/uix/morfo/components/tooltip.ts +++ b/src/uix/morfo/components/tooltip.ts @@ -12,6 +12,7 @@ export const tooltipMorfo = { name: 'Tooltip', kebab: 'tooltip', scope: ['soma', 'eidos', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/', texts: { label: '#?components.tooltip.label|Tooltip' diff --git a/src/uix/morfo/components/tree-grid.ts b/src/uix/morfo/components/tree-grid.ts index da60522a8..235e45395 100644 --- a/src/uix/morfo/components/tree-grid.ts +++ b/src/uix/morfo/components/tree-grid.ts @@ -5,6 +5,7 @@ export const treeGridMorfo = { name: 'TreeGrid', kebab: 'tree-grid', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/', texts: { label: '#?components.tree-grid.label|Tree grid', diff --git a/src/uix/morfo/components/tree-view.ts b/src/uix/morfo/components/tree-view.ts index 4d81683f5..2ed8b49c8 100644 --- a/src/uix/morfo/components/tree-view.ts +++ b/src/uix/morfo/components/tree-view.ts @@ -5,6 +5,7 @@ export const treeViewMorfo = { name: 'TreeView', kebab: 'tree-view', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/treeview/', texts: { label: '#?components.tree-view.label|Tree' diff --git a/src/uix/morfo/components/virtual-grid.ts b/src/uix/morfo/components/virtual-grid.ts index 09df05a33..d6004b9ff 100644 --- a/src/uix/morfo/components/virtual-grid.ts +++ b/src/uix/morfo/components/virtual-grid.ts @@ -4,7 +4,10 @@ import { v } from '../types'; export const virtualGridMorfo = { name: 'VirtualGrid', 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: { label: '#?components.virtual-grid.label|Virtual Grid' }, diff --git a/src/uix/morfo/components/virtual-list.ts b/src/uix/morfo/components/virtual-list.ts index a7e86c074..e3a8edae4 100644 --- a/src/uix/morfo/components/virtual-list.ts +++ b/src/uix/morfo/components/virtual-list.ts @@ -4,7 +4,9 @@ import { v } from '../types'; export const virtualListMorfo = { name: 'VirtualList', kebab: 'virtual-list', - scope: ['soma'], + scope: ['soma', 'sema'], + // Same rationale as virtual-grid. + expression: 'family-default', texts: { label: '#?components.virtual-list.label|Virtual List' }, diff --git a/src/uix/morfo/components/year-grid.ts b/src/uix/morfo/components/year-grid.ts index b149b5115..e24c92016 100644 --- a/src/uix/morfo/components/year-grid.ts +++ b/src/uix/morfo/components/year-grid.ts @@ -5,6 +5,7 @@ export const yearGridMorfo = { name: 'YearGrid', kebab: 'year-grid', scope: ['soma', 'sema'], + expression: "family-default", apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', texts: { label: '#?components.year-grid.label|Year grid', diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index ae0220361..865a728d2 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -296,6 +296,13 @@ const partShallowSchema = object({ // ── Morfo root ──────────────────────────────────────────────────────────── +const semaExpressionModeSchema = union( + literal('pack'), + literal('family-default'), + literal('delegated'), + literal('none') +); + const morfoShallowSchema = object( { name: string(), @@ -304,6 +311,7 @@ const morfoShallowSchema = object( apg: optional(string()), focus: optional(focusSchema), events: optional(array(eventSchema)), + expression: optional(semaExpressionModeSchema), translations: optional(object({}, { unknownKeys: 'passthrough' })), parts: array(object({}, { unknownKeys: 'passthrough' })) // ^ parts are opaque here; walker recurses with `partShallowSchema` diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index 83c693f1b..87d2b220d 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -593,6 +593,24 @@ export interface 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 ────────────────────────────────────────────────────────────────── /** @@ -624,6 +642,37 @@ export interface Morfo { * events exist, what family they belong to, and which intents are valid. */ 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. *