From 6f42eebfe80bc9ac5656be2bbf621c6d62e768fb Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 12 Aug 2026 00:21:23 +0200 Subject: [PATCH] feat(morfo,sema,eidos): todo evento dice de que familia es, y el cruce por fin se ve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El framework llamaba a la misma cosa de dos maneras: `open` pelado en ocho componentes y `emerge-open` en tres. No era estetica — un preset de movimiento engancha el nombre con `^=`, asi que el dialecto pelado no casaba con ninguna firma y simplemente no animaba, sin romper una sola prueba. Los 40 nombres sin prefijo pasan a `{familia}-{verbo}[-{matiz}]`: 256 eventos, 256 con prefijo, 0 ambiguos. El plan decia 36 y decia `handle-drag-start`; eran 40, y el canon (c25) dice que esos verbos son `pick` y `drop` — `handle-pick` y `handle-drop` ya existian en 10 y 4 componentes. `validateMorfo` cierra la puerta: un `events[].name` que no empiece por su familia ahora lanza. Visto fallar antes con un nombre pelado inyectado. Lo que el renombrado destapo, y va aqui tambien: - La receta del splitter enganchaba `commit-resize`, muerto desde `bd2e40366`. No casaba desde mayo y nadie chillo. Reescrita por FAMILIA, como slider y knob, y `eidos-lint` valida ahora el VALOR de `data-event*` contra el catalogo de morfos — el guard que lo habria cazado en su dia. - La familia `shift` era muda en el canal visual, contra su propia doctrina (c27: el cruce debe percibirse; c34 tipifica el «shift invisible»). Su mapa ya describia la firma que le faltaba y su sonido por defecto es `slide`. Ahora tiene firma direccional: sexto atributo del sello (`data-event-direction`, `forward`|`backward`, por emision) y deslizamiento de 320ms RTL-safe por `:dir()`. Medido: LTR -30px/+30px, RTL los invierte. - El sello de `shift-navigate` pasa del BOTON al `grid` en los cuatro calendarios. Medido: el boton recibia `contact-activate` y 8,5 ms despues —media trama— el `shift-navigate` pisaba la misma ranura y el `press-squeeze` moria sin pintar un fotograma. Una superficie, una ranura (A-36). - 101 contradicciones docs<->morfo adjudicadas con evidencia (git log, docs de decision, el componente vivo). Las docs desfasadas, corregidas; los nueve DEFECTOS de codigo obsoleto quedan abiertos y sin tocar. - `SoundDirection` -> `SoundContour`: era un contorno de tono, no un sentido, y habia tres cosas distintas deletreadas «direction». check en su linea base con 0 errores nuevos por diferencia de conjuntos · docs:check 0/0 · eidos-lint invalid 0 · el censo y las escenas de navegador medidas con raton real y rAF vivo. Co-Authored-By: Claude Opus 5 --- docs/CANON.md | 36 ++- docs/architecture/active-architecture.md | 34 +-- docs/architecture/eidos.md | 71 +++++- docs/architecture/morfo.md | 110 ++++++++-- docs/architecture/overview.md | 5 +- docs/architecture/sema.md | 122 ++++++----- docs/architecture/soma-architecture.md | 2 +- docs/canon/recipe-contract.md | 3 +- docs/canon/vocabularies.md | 18 ++ docs/glossary.md | 3 +- docs/guides/completion-checklist.md | 2 +- docs/theming/channels.md | 34 ++- docs/theming/motion.md | 18 +- scripts/docs-vocabularies.ts | 29 ++- scripts/eidos-event-vocabulary.ts | 206 ++++++++++++++++++ scripts/eidos-lint-all.ts | 33 ++- scripts/eidos-lint.ts | 20 +- src/arts/motion/types.ts | 9 + .../eidos/components/alert-dialog/README.md | 8 +- src/uix/eidos/components/calendar/README.md | 24 +- .../eidos/components/calendar/calendar.css | 9 +- src/uix/eidos/components/card-group/README.md | 4 +- .../eidos/components/collapsible/README.md | 9 +- .../components/collapsible/collapsible.css | 6 +- .../eidos/components/color-picker/README.md | 3 +- src/uix/eidos/components/combobox/README.md | 29 ++- src/uix/eidos/components/date-field/README.md | 2 +- .../eidos/components/date-picker/README.md | 9 - .../components/date-range-picker/README.md | 27 ++- src/uix/eidos/components/dialog/README.md | 24 +- src/uix/eidos/components/drawer/README.md | 8 +- .../eidos/components/dropdown-menu/README.md | 2 +- .../eidos/components/field-langs/README.md | 6 +- src/uix/eidos/components/field/README.md | 18 +- .../eidos/components/file-upload/README.md | 12 +- src/uix/eidos/components/form/README.md | 6 +- src/uix/eidos/components/menu-dial/README.md | 2 +- .../components/menu-dial/menu-dial.svelte | 4 +- src/uix/eidos/components/month-grid/README.md | 15 +- .../components/month-grid/month-grid.css | 12 +- src/uix/eidos/components/onion-menu/README.md | 18 +- .../components/onion-menu/onion-menu.svelte | 4 +- .../eidos/components/range-calendar/README.md | 14 +- .../range-calendar/range-calendar.css | 6 +- .../eidos/components/split-button/README.md | 2 +- src/uix/eidos/components/splitter/README.md | 105 ++++++--- .../eidos/components/splitter/splitter.css | 42 +++- src/uix/eidos/components/toast/README.md | 2 +- src/uix/eidos/components/toast/toaster.svelte | 2 +- src/uix/eidos/components/tooltip/README.md | 6 +- src/uix/eidos/components/year-grid/README.md | 14 +- .../eidos/components/year-grid/year-grid.css | 10 +- src/uix/eidos/generated/base.css | 56 ++++- src/uix/eidos/lib/config.ts | 4 +- src/uix/eidos/lib/motion/presets/css.ts | 71 +++++- src/uix/eidos/lib/render-css.ts | 16 ++ src/uix/morfo/compile.test.ts | 6 +- src/uix/morfo/components/accordion.ts | 4 +- src/uix/morfo/components/calendar.ts | 26 +-- src/uix/morfo/components/collapsible.ts | 11 +- src/uix/morfo/components/context-menu.ts | 8 +- src/uix/morfo/components/dialog.test.ts | 14 +- src/uix/morfo/components/dialog.ts | 4 +- src/uix/morfo/components/drawer.ts | 14 +- src/uix/morfo/components/dropdown-menu.ts | 8 +- src/uix/morfo/components/file-upload.ts | 2 +- src/uix/morfo/components/float-panel.ts | 12 +- src/uix/morfo/components/menu-dial.ts | 4 +- src/uix/morfo/components/month-grid.ts | 8 +- src/uix/morfo/components/onion-menu.ts | 4 +- src/uix/morfo/components/popover.ts | 6 +- src/uix/morfo/components/range-calendar.ts | 29 ++- src/uix/morfo/components/tabs.ts | 2 +- src/uix/morfo/components/toast.test.ts | 4 +- src/uix/morfo/components/toast.ts | 6 +- src/uix/morfo/components/tooltip.ts | 6 +- src/uix/morfo/components/year-grid.ts | 8 +- src/uix/morfo/schema.test.ts | 78 ++++++- src/uix/morfo/schema.ts | 13 ++ src/uix/morfo/test-fixtures.ts | 24 ++ src/uix/morfo/types.ts | 4 +- src/uix/sema/components/accordion.ts | 17 +- src/uix/sema/components/calendar.ts | 9 +- src/uix/sema/components/collapsible.ts | 16 +- src/uix/sema/components/context-menu.ts | 18 +- src/uix/sema/components/dialog.ts | 4 +- src/uix/sema/components/drawer.ts | 20 +- src/uix/sema/components/dropdown-menu.ts | 12 +- src/uix/sema/components/file-upload.ts | 2 +- src/uix/sema/components/float-panel.ts | 13 +- src/uix/sema/components/menu-dial.ts | 4 +- src/uix/sema/components/onion-menu.ts | 4 +- src/uix/sema/components/popover.ts | 4 +- src/uix/sema/components/splitter.ts | 2 +- src/uix/sema/components/tabs.ts | 2 +- src/uix/sema/components/toast.ts | 18 +- src/uix/sema/components/tooltip.ts | 16 +- src/uix/sema/engine.test.ts | 1 + src/uix/sema/exports.ts | 3 +- src/uix/sema/pack-census.test.ts | 54 ++++- src/uix/sema/projection/dom.test.ts | 25 ++- src/uix/sema/signal.ts | 14 +- src/uix/sema/sounds.test.ts | 6 +- src/uix/sema/sounds.ts | 10 +- src/uix/sema/stamp.ts | 16 +- src/uix/sema/types.ts | 29 +++ .../accordion-provider.svelte.test.ts | 8 +- .../accordion/accordion-provider.svelte.ts | 6 +- src/uix/soma/components/aura/README.md | 4 +- src/uix/soma/components/calendar/README.md | 9 +- .../soma/components/calendar/calendar-nav.ts | 4 +- .../calendar/calendar-provider.svelte.ts | 70 +++--- src/uix/soma/components/chronos/README.md | 2 +- src/uix/soma/components/collapsible/README.md | 10 +- .../collapsible-provider.svelte.ts | 11 +- .../soma/components/color-picker/README.md | 44 ++-- .../soma/components/context-menu/README.md | 12 +- .../context-menu-provider.svelte.ts | 8 +- src/uix/soma/components/date-field/README.md | 2 +- src/uix/soma/components/date-picker/README.md | 4 +- .../dialog/dialog-provider.svelte.test.ts | 2 +- .../dialog/dialog-provider.svelte.ts | 18 +- .../drawer/drawer-provider.svelte.test.ts | 4 +- .../drawer/drawer-provider.svelte.ts | 68 +++--- .../soma/components/dropdown-menu/README.md | 18 +- .../dropdown-menu-provider.svelte.ts | 8 +- .../file-upload-provider.svelte.ts | 2 +- src/uix/soma/components/float-panel/README.md | 10 +- .../float-panel-provider.svelte.ts | 28 +-- .../components/menu-dial/menu-dial.svelte.ts | 4 +- src/uix/soma/components/month-grid/README.md | 2 +- .../month-grid/month-grid-provider.svelte.ts | 26 ++- .../onion-menu/onion-menu.svelte.ts | 5 +- src/uix/soma/components/palabras/README.md | 4 +- .../popover/popover-provider.svelte.test.ts | 2 +- .../popover/popover-provider.svelte.ts | 12 +- .../soma/components/range-calendar/README.md | 3 - .../range-calendar-provider.svelte.ts | 9 +- .../components/tabs/tabs-provider.svelte.ts | 2 +- src/uix/soma/components/textarea/README.md | 5 +- src/uix/soma/components/time-field/README.md | 2 +- .../components/time-range-picker/README.md | 5 +- .../components/toast/toast-provider.svelte.ts | 30 +-- src/uix/soma/components/tooltip/README.md | 10 +- src/uix/soma/components/year-grid/README.md | 2 +- .../year-grid/year-grid-provider.svelte.ts | 41 +++- src/uix/soma/runtime.svelte.test.ts | 132 ++++++----- src/uix/soma/runtime.svelte.ts | 12 + web/routes/temas/animations/+page.svelte | 16 +- web/routes/temas/profundidad/+page.svelte | 2 +- .../uix/components/accordion/+page.svelte | 4 +- .../uix/components/alert-dialog/+page.svelte | 10 +- .../uix/components/card-group/+page.svelte | 2 +- .../uix/components/collapsible/+page.svelte | 16 +- .../uix/components/color-picker/+page.svelte | 39 ++-- .../uix/components/context-menu/+page.svelte | 21 +- web/routes/uix/components/dialog/+page.svelte | 15 +- web/routes/uix/components/drawer/+page.svelte | 4 +- .../uix/components/dropdown-menu/+page.svelte | 25 ++- .../uix/components/menu-dial/+page.svelte | 8 +- .../uix/components/onion-menu/+page.svelte | 11 +- .../components/password-field/+page.svelte | 9 +- .../uix/components/pin-input/+page.svelte | 107 ++++----- .../uix/components/popover/+page.svelte | 27 ++- .../uix/components/split-button/+page.svelte | 2 +- .../uix/components/splitter/+page.svelte | 53 +++-- web/routes/uix/components/switch/+page.svelte | 12 +- .../uix/components/textarea/+page.svelte | 16 +- .../uix/components/time-picker/+page.svelte | 57 ++--- .../components/time-range-picker/+page.svelte | 46 ++-- web/routes/uix/components/toast/+page.svelte | 8 +- .../uix/components/toggle-group/+page.svelte | 35 ++- web/routes/uix/components/toggle/+page.svelte | 10 +- .../uix/components/tooltip/+page.svelte | 9 +- 174 files changed, 2218 insertions(+), 996 deletions(-) create mode 100644 scripts/eidos-event-vocabulary.ts diff --git a/docs/CANON.md b/docs/CANON.md index 3163865c9..7af9de3af 100644 --- a/docs/CANON.md +++ b/docs/CANON.md @@ -83,6 +83,14 @@ active channels and hold: `SEMA_MAP.families` in That split is a *classification*; it no longer dictates the intent rule — the policy (§4) does. +The perceptual question is a **demand on the expression**, not a label: a family +that fails to answer its own question has failed at the only thing it exists +for. `shift` is the case that proves it — its question IS the crossing, so a +`shift` nobody perceives crossing is the *shift invisible* of the antipattern +catalogue (ch. 34 §14), and it was exactly that here until 2026-08-11: it +sounded like a slide and did not slide (§8 rule 5; the sense it now travels in, +`data-event-direction`, in §9). + --- ## 3. The 6 intents @@ -179,8 +187,8 @@ declarada."*). This is exactly the morfo → soma → sema → eidos contract. Verbs concrete the action within a family (book ch. 8 §"niveles", ch. 22–29). `morfo.events[].semantic.verb` MUST be in its family's set; `events[].name` -should follow `{family}-{verb}[-{variant}]` or `{verb}-{variant}` so sema / sound -/ haptic / eidos can subscribe transversally. +**declares the family** — `{family}-{verb}[-{nuance}]`, guaranteed by +`validateMorfo` — so sema / sound / haptic / eidos can subscribe transversally. **Executable source of truth:** `SEMA_VERBS` in [`src/uix/sema/verbs.ts`](../src/uix/sema/verbs.ts) — including the documented @@ -279,7 +287,16 @@ ch. 6 §13). The rules are derived from perceptual need, not decree (ch. 30 §3) consequence). 4. **emerge does not absorb the content's intent** — `emerge.open → signal.warn + risk`, not `emerge.open + risk` (frame ≠ message). -5. **shift must orient** the context change (focus + title + landmark). +5. **shift must orient** the context change (focus + title + landmark) — and it + must be PERCEIVED crossing it. A frame that swaps with nothing moving is the + *shift invisible* of the antipattern catalogue (ch. 34 §14): the user is + somewhere else and was never told they travelled. Until 2026-08-11 `shift` + was exactly that in this framework — it sounded (`slide`) and did not slide, + because `BUILTIN_SIGNATURES` had a FAMILY-keyed visual signature for + `contact`, `commit` and `delegate` and none for `shift` (`emerge` and + `signal` have theirs keyed by event name, so they were never mute). It has + one now, and it is directional: + see `data-event-direction` in §9. 6. **handle concentrates evaluation on the drop**, not the carry. 7. **every open process needs an exit** (commit / cancel / fail / persisted-warn). @@ -306,7 +323,18 @@ Cross-layer vocabularies that anchor this: [`src/uix/morfo/types.ts`](../src/uix/morfo/types.ts), emitted as `data-archetype`. - **The `data-event-*` tokens** — the contact surface sema stamps and eidos reads - (see [architecture/sema](./architecture/sema.md)). + (see [architecture/sema](./architecture/sema.md)). Six: `data-event` · + `-family` · `-intent` · `-direction` · `-phase` · `-id`. +- **Sense of traversal** — `data-event-direction`, `'forward' | 'backward'` + (`SemaDirection`). Two values because the event NAME already carries every + distinction a DECLARATION can make (`shift-enter-mode` ≠ `shift-exit-mode`); + what a name cannot carry is which way THIS occurrence went, since one + `shift-navigate` is the previous month and the next one is the following + month. So the sense is decided per emit, like `intent`, and like `intent` it + is optional — a route with no sense of its own (a month picked from a select) + stamps none, because an invented sense is worse than none. It is a SENSE, not + an axis: eidos maps it onto the inline axis, so `:dir(rtl)` flips it and no + layer above CSS knows about left or right. --- diff --git a/docs/architecture/active-architecture.md b/docs/architecture/active-architecture.md index 6ad5312f8..e9856389d 100644 --- a/docs/architecture/active-architecture.md +++ b/docs/architecture/active-architecture.md @@ -159,7 +159,7 @@ export const dialogMorfo = { kebab: 'dialog', scope: ['soma', 'sema'], events: [{ - name: 'close-cancel', + name: 'emerge-close-cancel', semantic: { family: 'emerge', verb: 'close', @@ -222,8 +222,8 @@ Canonical vocabulary (`SEMA_MAP` in `src/uix/sema/sema-map.ts`): Each intent declares per-channel `deltas` applied over the family base when the family is valenced. - **Action verbs** (`SEMA_VERBS` in `src/uix/sema/verbs.ts`) — `present`, - `dismiss`, `commit`, `cancel`, `announce`, `warn`, … — the canonical names - for `morfo.events[].name`. + `dismiss`, `commit`, `cancel`, `announce`, `warn`, … — the canonical verbs + for `morfo.events[].semantic.verb`, and the tail of `morfo.events[].name`. Sema **does not decide which event happened** — the provider decides. The `EngineSemantic` only: @@ -251,7 +251,8 @@ The **visual channel** (built-in) is the only one sharing the DOM plane with the subsequent structural commit, and therefore the only one that blocks the caller. `EngineSemantic` runs generic channel hooks; `VisualChannel.prepare()` projects `data-event` + `data-event-id` + `data-event-phase` (and optionally -`data-event-family` and `data-event-intent`) onto the target through a +`data-event-family`, `data-event-intent` and `data-event-direction`) onto the +target through a `SignalProjector`. In `ActiveUix` that projector receives `uix.dom`, so attr writes enter through the same DOM owner soma uses. `VisualChannel` holds the configurable window and the cleanup removes the projection before resolving @@ -513,12 +514,12 @@ A concrete example: the user clicks a Toast's **×** button. 1. Browser fires click → Svelte calls Close.onclick 2. Close.onclick runs: - void this.toastItem.runtime.trigger('dismiss') + void this.toastItem.runtime.trigger('emerge-dismiss') -3. SomaRuntime.trigger('dismiss'): - 3.1. Looks up event 'dismiss' in morfo.events ✓ +3. SomaRuntime.trigger('emerge-dismiss'): + 3.1. Looks up event 'emerge-dismiss' in morfo.events ✓ 3.2. Resolves target = the Item DOM element via partRef('item') - 3.3. AWAITS events.emit({ target, name: 'dismiss', family: 'emerge' }) + 3.3. AWAITS events.emit({ target, name: 'emerge-dismiss', family: 'emerge' }) EngineSemantic dispatches the signal to ALL registered channels: - VisualChannel.prepare(): SignalProjector applies data-event* via dom.apply(target, data-event-family=emerge) @@ -529,7 +530,7 @@ A concrete example: the user clicks a Toast's **×** button. (strict sequential semantics) 4. SomaRuntime invokes the provider's handler: - sources.events.dismiss() → + sources.events['emerge-dismiss']() → this.provider.toaster.dismiss(opts.toast.current.id) → toast.dismissing = true (state mutation) @@ -538,7 +539,7 @@ A concrete example: the user clicks a Toast's **×** button. dom.apply(target, { 'data-state': 'closed' }) on the next tick 6. Eidos (CSS) has been reacting throughout the sequence: - - during t=0..240ms: [data-event^="dismiss"] fires an @keyframes fade-out + - during t=0..240ms: [data-event^="emerge-dismiss"] fires an @keyframes fade-out (CSS animation, not transition: it runs full-duration even if the attr disappears afterwards) - at t≈245ms: [data-state="closed"] takes over @@ -568,11 +569,12 @@ Everything that travels between layers travels through DOM attributes: | `aria-*` | dom.apply (effect) | Screen readers, Eidos | | `data-state="open"` | dom.apply (effect) | Eidos (variant selector) | | `data-disabled` | dom.apply (effect) | Eidos (state selector) | -| `data-event="dismiss"` | sema.emit (transient) | Eidos (event selector) | +| `data-event="emerge-dismiss"` | sema.emit (transient) | Eidos (event selector) | | `data-event-phase="active"` | sema.emit (transient) | Eidos | | `data-event-id="sig-N"` | sema.emit (transient) | Future sound/haptic | | `data-event-family="commit"` | sema.emit (transient) | Eidos (family selector) | | `data-event-intent="risk"` | sema.emit (transient) | Eidos (signal tinting) | +| `data-event-direction="forward"` | sema.emit (transient, per-emit only) | Eidos (directional signature) | | `data-color="primary"` | dom.apply (effect) | Eidos (per-token recipe) | | `data-intent="risk"` | dom.apply (effect, optional per morfo) | Eidos (persistent state) | | `dir` | prefs projection (page) / provider | Browser bidi, Eidos `:dir()` | @@ -621,11 +623,11 @@ Verbs that look like one family but belong to another per the canon: **select / toggle / acknowledge** are `commit` (they fix state; they are not mere contact); **edit** is `shift.enter-mode` (it changes the regime). -`morfo.events[].name` should align with this vocabulary in one of two -shapes: `{verb}-{variant}` (`dismiss-outside`, `close-cancel`) or -`{family}-{verb}` (`commit-toggle`, `commit-save`). That lets -Sema/Sound/Haptic/Eidos subscribe or style by verb or family without -enumerating components. `validateEventName` recognizes both shapes. +`morfo.events[].name` **declares its family**: the shape is +`{family}-{verb}[-{nuance}]` (`commit-toggle`, `emerge-close-cancel`), and +`validateMorfo` rejects a name that does not start with its own family. That +lets Sema/Sound/Haptic/Eidos subscribe or style by family or verb without +enumerating components. **Intents** (`src/uix/sema/sema-map.ts:SEMA_MAP.intents`), 6 values: diff --git a/docs/architecture/eidos.md b/docs/architecture/eidos.md index b1ec7546e..0a8f5b31f 100644 --- a/docs/architecture/eidos.md +++ b/docs/architecture/eidos.md @@ -454,7 +454,7 @@ components keep working headless because behavior belongs to Soma. | `parts[].archetype` | transversal rules `[data-archetype=trigger]` | | `parts[].states` + `data[].values` | variants `[data-state=open]` | | `parts[].data` with `data-starting-style` / `data-ending-style` | enter/exit animation hooks | -| `events[].name` | selectors `[data-event=dismiss]`, `[data-event^=commit]` | +| `events[].name` | selectors `[data-event=emerge-dismiss]`, `[data-event^=commit]` | | `events[].semantic.family` + `.intent` | semantic tinting of transitions | | `events[].prewrite[]` (e.g. `data-last-action`) | tinting the exit anim by cause | | `focus.trap` | a layout hint for overlays | @@ -507,6 +507,9 @@ Only the DOM. The visual channel projects `data-event-*` during the hold via [data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] { animation: eidos-announce-pulse-threat 400ms var(--ease-spring); } +[data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] { + animation: shift-cross-forward var(--duration-slow) var(--ease-emphasized); +} ``` The per-family/intent signatures live in `EidosConfig.motion` and are @@ -514,6 +517,41 @@ generated into `generated/base.css`; `events.css` keeps only the global compositor hint + the reduced-motion cap (see [`eidos-motion.md`](../theming/motion.md) §15). +The third rule is where the layer split earns its keep. `shift` **sounds** like +a slide (`SEMA_MAP.families.shift.sounds.default = 'slide'`) and until +2026-08-11 it did not slide, because sema owns no motion and eidos had written +FAMILY-keyed signatures for `contact`, `commit` and `delegate` only (`emerge` +and `signal` are covered, but keyed by EVENT name) — the *shift invisible* +antipattern (book ch. 34 §14) living inside the framework that names it. The +missing half was always eidos's to write: sema stamps the SENSE +(`data-event-direction`, decided per emit, `forward` | `backward`) and eidos +decides that a sense means the inline axis. The keyframes multiply their +distance by `--motion-shift-sign`, emitted `+1` under `:dir(ltr)` and `−1` under +`:dir(rtl)` — a physical `translateX` would have shipped a slide that runs +backwards in Arabic. + +The stamped node is the event's **subject**, not a paint instruction. Morfo +puts the gesture where the hand is and the terminal where the value lives +([`architecture/morfo.md`](./morfo.md) §Where the stamp lands), so a recipe +routinely needs to paint something the stamp never touches. That is a +**descendant selector** — never a reason to move the stamp: + +```css +/* Splitter commits on the provider; the handle is what pulses. */ +[data-splitter][data-event='commit-set'][data-event-phase='active'] + [data-splitter-resize-trigger] { + background: var(--splitter-handle-bg-active, var(--color-primary-solid)); +} +``` + +The descent costs nothing in specificity terms: four attribute selectors +(0,4,0) against the (0,2,0) ceiling of every other rule on that handle, so the +signature wins without an `!important` or a manufactured hook. The diagnostic +question when a rule doesn't fire is always **is the stamped node the subject +of the event?** — if it is, the recipe descends; if it isn't, the morfo is +wrong. A stamp relocated to make a selector shorter breaks the sound and haptic +projections, which read the same target and have no CSS to compensate with. + ## What it does NOT consume - **The provider's logical computed state** (e.g. the composition of a @@ -864,6 +902,37 @@ It classifies every `[data-*]` selector as: - **invalid** — references a declared attr with a value outside the enum. A bug. +#### The `data-event*` VALUE check (2026-08-11) + +The classifier above allowlists `data-event`, `-family`, `-id`, `-intent`, +`-direction` and +`-phase` as eidos-only — they come from the sema stamp, not from a morfo part — +and therefore never looked at their **value**. That is how +`[data-event='commit-resize']` stayed in `splitter.css` after the event was +renamed to `commit-set` (bd2e40366, 2026-05-22): a hook to a name nobody emits, +dead for almost three months, with every test green. + +`scripts/eidos-event-vocabulary.ts` closes it. Both linters now check that +every `data-event` value in a recipe is the `name` of an event declared by +**some** morfo in the catalogue (`^=` matches by prefix), that every +`data-event-family` is one of the 8 canon families, that every +`data-event-intent` is one of the 6 intents, and that every +`data-event-direction` is `forward` or `backward`. + +The direction row nearly shipped as unchecked prose. `scripts/` is outside the +`tsconfig` graph — `svelte-check` never reads it — so a literal +`['forward', 'backward']` written in the linter would have been free to outlive +the vocabulary it guards, which is this section's own defect wearing a new hat. +`SemaDirection` is therefore derived from a const array (`SEMA_DIRECTIONS` in +`sema/types.ts`, the `INTENTS` pattern) and the linter imports it: the guard +iterates the same thing the compiler enforces. + +The event-name check is catalogue-wide on purpose: composition means a node +receives another component's stamps (the card-group item also carries +`data-toggle-group-item` and receives `commit-block`, which toggle-group +declares). A name that exists elsewhere but not in the component's own morfo is +a **WARN**; only a name that exists nowhere is an **ERROR** (exit 1). + **The lint is a safety net, not the contract.** The contract lives in the morfo and is defended at the type level where possible. The lint exists only for the pure-CSS portion that doesn't yet consume the morfo through diff --git a/docs/architecture/morfo.md b/docs/architecture/morfo.md index c3a60e2cd..e6e476d52 100644 --- a/docs/architecture/morfo.md +++ b/docs/architecture/morfo.md @@ -143,10 +143,10 @@ const runtime = createSomaRuntime(morfo, { props: { disabled: () => this.opts.disabled.current }, parts: { content: () => this.contentId.current }, events: { - open: () => { + 'emerge-open': () => { this.opts.open.current = true; }, - 'close-cancel': () => { + 'emerge-close-cancel': () => { this.opts.open.current = false; } } @@ -297,17 +297,18 @@ directional `emerge` events: ```ts { - name: 'expand', + name: 'emerge-expand', semantic: { family: 'emerge', verb: 'expand', target: v.partRef('content'), sequence: 'post' } }, { - name: 'collapse', - semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'pre' } + name: 'emerge-collapse', + semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'post' } } ``` -`expand` is `post` so Eidos reacts after content exists. `collapse` is `pre` so -the exit signal can play while content is still visible. Any visual color, +`emerge-expand` is `post` so Eidos reacts after content exists. `emerge-collapse` is `post` +too (collapsible-NEW-001), so the conceal runs against a content the flip has +not yet hidden. Any visual color, motion or density response belongs to Eidos recipes, not to the morfo event. --- @@ -614,10 +615,10 @@ events: [ Field rules: -- **`name`** — the addressable id used by `runtime.trigger(name)`. - Convention: `{verb}-{variant}` (e.g. `dismiss-outside`, - `close-cancel`) or `{family}-{verb}` (e.g. `commit-toggle`, - `commit-save`). The validator accepts both shapes. +- **`name`** — the addressable id used by `runtime.trigger(name)`. The name + **declares the family**: `{family}-{verb}[-{nuance}]` (e.g. `commit-toggle`, + `emerge-dismiss-outside`). `validateMorfo` rejects a name that does not + start with its own `semantic.family`. - **`semantic.family`** — one of the 8: `contact`, `commit`, `signal`, `handle`, `emerge`, `shift`, `sustain`, `delegate` (per `SEMA_MAP`). Whether `intent` is required is set per-family by `SEMA_FAMILY_POLICY` @@ -646,6 +647,39 @@ Field rules: declared `target` nor one of these. Runtime rather than static because the override is an expression (`e.currentTarget`) and only the live element can answer which part it is. +- **`semantic.targetFallback`** — ordered `partRef` chain the RUNTIME resolves + when the canonical `target` has **no live element** at emit time: the first + listed part with a registered instance takes the stamp (and the a11y focus + move, when the event declares one — `resolveEmitTarget` is the ONE + resolution both share, so they can never disagree). This is the second axis + of emission targeting, split from `allowedTargets` on the precedent of + `intentRequirement`/`intentGuidance`: + + | Axis | Who decides | When | Example | + | --- | --- | --- | --- | + | `allowedTargets` | the CALLER, per trigger | normal operation, repeated parts | the pressed `day`, the clicked `item` | + | `targetFallback` | the RUNTIME, from mount state | the declared target is unmounted | drawer `emerge-close` → `trigger` once `content` is gone | + + It replaces the hand-rolled `content ?? partRef('trigger')` every overlay + provider used to write around `targetOverride` (dialog / drawer / popover / + float-panel `close`; aura's terminals landing on `provider` when the + decorative ring was never composed; chronos' editor commits landing on + `provider` when no chip names them). Declared in the morfo so soma, sema and + eidos read the same truth: `pack-census.test.ts` counts these parts as + stampable, exactly like `allowedTargets` — but note the perceptual + difference: an `allowedTargets` part is stamped in routine use, a + `targetFallback` part only in the degraded mount, so a sound rule that + matches ONLY fallback parts almost never fires. + + `validateMorfo` enforces three invariants (all tested): every entry is an + existing part; the canonical `target` may not list itself (it is always + resolved first); no duplicates (order is meaning — a duplicate reads as two + chances where there is one). Anchored emissions (`SomaRuntimePart.trigger` / + `partInstance(...).trigger`) do NOT fall back: an anchored emit stamps ITS + instance or raises a target error, never a silent redirection — and the + anchored name union (`EventNameTargeting`) excludes fallback-only events on + purpose, because anchoring to the degraded surface would force the poor + landing while the primary is mounted. - **`regime`** — what this event does when it arrives and the target surface already carries a live occurrence: `replace` (default) or `queue`. The `data-event-*` projection is ONE SLOT per element. Declare it only for pairs @@ -659,6 +693,16 @@ Field rules: (commit pulses on completed actions). `'coincident'` is for in-flight processes (sustain). +**Not declarable here: `direction`.** The morfo cannot state the sense of a +traversal, because the same declared event goes backward on one press and +forward on the next — only the emitter knows which. It travels per-call as +`TriggerOptions.direction` (`forward` | `backward`, the `SemaDirection` +vocabulary) and lands as `data-event-direction`, which is what lets the `shift` +family's motion firma slide in the right sense. Omit it where the route has no +clear sense — a month picked from a select is a jump, not a step, and a sense +inferred from comparing dates is not a sense. Contrast with `intent`, the other +per-emission axis, which the morfo CAN declare a default for. + For a comprehensive worked example see the toggle and dialog morfos. ### Step 6 — Wire the provider @@ -807,6 +851,42 @@ These don't change the morfo shape — they're authoring conventions that enable --- +## Where the stamp lands — gesture vs terminal + +`semantic.target` is a claim about **subject**, not about paint: the part it names is the one the occurrence is _about_. Read across the catalogue, that claim resolves into a single rule in two halves: + +> **The gesture is stamped where the hand is. The terminal is stamped where the value lives.** + +Seventeen components declare the `handle` family. Twelve put the two halves on different parts: + +| Component | Grip (`handle-*`) | Terminal | +| ------------------------------- | ------------------------------------ | --------------------------------------- | +| `rotate-align` | `needle` | `dial` (`handle-drop`) | +| `path-trace` | `token` | `track` (`handle-drop`) | +| `drag-drop` | `draggable` | `droppable` (`handle-drop`) | +| `splitter` | `resize-trigger` | `provider` (`commit-set`) | +| `color-picker` | `area` | `provider` | +| `css-field` · `number-field` | `scrubber` | `provider` | +| `gradient-builder` | `track` | `provider` | +| `cropper` | `selection` · `handle` · `viewport` | `provider` (`commit-crop`) | +| `image-picker` | `preview` | `provider` | +| `virtual-list` · `virtual-grid` | `viewport` | `provider` (`commit-set-resize`) | +| `chronos` | `event-chip` · `event-resize-handle` | `event-chip` (falls back to `provider`) | +| `knob` | `control` | `control` | +| `drawer` · `float-panel` | `content` | `content` | +| `slider` | `provider` | `provider` | + +The five that do not separate them are not exceptions — they are the components where grip and value are the **same node**: Chronos' chip _is_ the event, Knob's control _is_ the dial, Drawer's and FloatPanel's content _is_ the position. Slider is the borderline case: it declares a `thumb` part and the provider gates `handle-pick` on it (`isHandleTarget` in `slider-provider.svelte.ts`), but the pointer capture and the whole grabbable track belong to the provider, so the provider is the surface under the hand. + +Two consequences worth naming: + +- **The terminal is not a synonym for `commit`.** Rotate-align, path-trace and drag-drop terminate on a `handle-drop`. The family says what kind of occurrence it is; the target says whose. +- **A signature that must paint a node other than the stamped one is a descendant selector, never a reason to move the stamp.** The corollary and its worked example live in [`architecture/eidos.md`](./eidos.md) §From sema (DOM). + +When a rule doesn't fire, the diagnostic question is always: **is the stamped node the subject of the event?** If it is, the recipe descends. If it isn't, the morfo is wrong. + +--- + ## Typed selector builder — `semaSelector` When a TypeScript consumer needs to construct a CSS selector that targets the morfo's emitted attrs (e.g. `sema/components/*.ts` cascade rules), it MUST use [`semaSelector`](../../src/uix/morfo/selectors.ts) instead of hand-writing strings: @@ -818,11 +898,11 @@ import { dialogMorfo } from '$uix/morfo/components/dialog'; // [data-dialog-content][data-event-family="commit"] semaSelector(dialogMorfo, 'content', { eventFamily: 'commit' }); -// [data-dialog-content][data-event="close-after-fail"] -semaSelector(dialogMorfo, 'content', { eventName: 'close-after-fail' }); +// [data-dialog-content][data-event="signal-alert-close-fail"] +semaSelector(dialogMorfo, 'content', { eventName: 'signal-alert-close-fail' }); -// [data-dialog-content][data-event^="close-"][data-event-family="emerge"] -semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-', eventFamily: 'emerge' }); +// [data-dialog-content][data-event^="emerge-close"][data-event-family="emerge"] +semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'emerge-close', eventFamily: 'emerge' }); ``` ### What it guarantees diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index c75db4698..f4a1721b3 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -470,8 +470,9 @@ virtual prop on the provider — do not extend the contract. `src/uix/morfo/types.ts:ARCHETYPE_VOCABULARY`. Emitted as `data-archetype="..."` by `runtime.partProps`. - **Verbs**: the canonical action verbs for `morfo.events[].name`. Defined in - `src/uix/sema/verbs.ts:SEMA_VERBS`. Convention for composite names: - `{verb}-{variant}` (e.g. `commit-save`, `dismiss-outside`). + `src/uix/sema/verbs.ts:SEMA_VERBS`. Name convention: + `{family}-{verb}[-{nuance}]` (e.g. `commit-save`, `emerge-dismiss-escape`). + The family prefix is mandatory — `validateMorfo` throws without it. --- diff --git a/docs/architecture/sema.md b/docs/architecture/sema.md index 3f527db12..ad9f86b45 100644 --- a/docs/architecture/sema.md +++ b/docs/architecture/sema.md @@ -166,8 +166,8 @@ On every `emit`, the engine: 1. Runs the channels' `prepare` hooks. The `VisualChannel` projects the semantic tokens `data-event`, `data-event-family`, `data-event-intent`, - `data-event-phase`, `data-event-id` onto `signal.target`. These are the - tokens the cascade and eidos's CSS read. + `data-event-direction`, `data-event-phase`, `data-event-id` onto + `signal.target`. These are the tokens the cascade and eidos's CSS read. 2. Calls `resolveSignature(signal, opts)`, which applies the cascade (canonical numbering **1 · 2 · 3 · 4 · 5a · 5b** — the same in `engine.ts`, `resolver.ts` and CLAUDE.md; each layer overrides the @@ -239,13 +239,22 @@ If `signal.family` is missing or not in the map, it returns an empty BEFORE the cascade resolves. Rules with selectors over these attrs match natively via `target.matches()` / `target.closest()`: -| Attr | Value | Origin | -| ------------------- | -------------------- | ------------------------------ | -| `data-event` | `'close-after-fail'` | `signal.name` | -| `data-event-family` | `'signal'` | `signal.family` | -| `data-event-intent` | `'threat'` | `signal.intent` (when present) | -| `data-event-phase` | `'active'` | while the hold lasts | -| `data-event-id` | `'sig-42'` | occurrence id | +| Attr | Value | Origin | +| ---------------------- | -------------------- | --------------------------------- | +| `data-event` | `'signal-alert-close-fail'` | `signal.name` | +| `data-event-family` | `'signal'` | `signal.family` | +| `data-event-intent` | `'threat'` | `signal.intent` (when present) | +| `data-event-direction` | `'forward'` | `signal.direction` (when present) | +| `data-event-phase` | `'active'` | while the hold lasts | +| `data-event-id` | `'sig-42'` | occurrence id | + +`data-event-direction` (`forward` / `backward`, `SemaDirection`) is the SENSE of +a traversal, decided per emit: the event name already separates +`shift-enter-mode` from `shift-exit-mode`, but one `shift-navigate` goes to the +previous month and the next one to the following month under the same name. It +is a sense, never an axis — eidos maps it onto the inline axis so `:dir(rtl)` +flips it. A route with no clear sense (a month picked from a select) stamps +nothing. Those tokens are the **cross-channel contact surface**: sema's cascade (`sound`, `haptic` and future channels) reads them with CSS selectors, the @@ -254,7 +263,7 @@ hold. One perceptual surface, separate owners. ### The surface is ONE SLOT — ownership and `regime` -Those five attrs are a **single slot per element**. Two occurrences on one node +Those six attrs are a **single slot per element**. Two occurrences on one node cannot both express, and the framework spent a year not saying so: three independent audits found the same defect and none closed it (fable **S1** 2026-07-01 · sema **S-17** 2026-08-05 · blocks **A-36 / A-65**, reproduced in @@ -285,10 +294,19 @@ channel resolve that number through one path (`VisualChannel.holdMsFor`). and no re-targeting can separate — a toggle's `contact-press` + `commit-toggle` (its provider IS the button), the knob's `handle-drop` + `commit-set`. When the collision comes from a **redirection** instead, the fix is to stop redirecting: -that is how A-36 closed, with the overlays' `open` giving up a `targetOverride` -left over from when they were `sequence: 'pre'`. Measured after: the Button's -`contact-activate` stamps the trigger and the Drawer's `open` stamps its -content — two surfaces, both expressing. +that is how A-36 closed, with the overlays' `emerge-open` giving up a +`targetOverride` left over from when they were `sequence: 'pre'`. Measured +after: the Button's `contact-activate` stamps the trigger and the Drawer's +`emerge-open` stamps its content — two surfaces, both expressing. + +The redirection those overlays DID need — landing `emerge-close` on the trigger +once the content has unmounted — is not a `targetOverride` either: it is +**mount-state resolution, declared in the morfo as `targetFallback`** and +resolved by the runtime through the same single path that moves a11y focus +(`resolveEmitTarget`). A rule may select a fallback part — the census counts it +stampable — but it only fires in the degraded mount; the routine surface is +still the declared target. The field, its invariants and its split from +`allowedTargets`: [`architecture/morfo.md` §Step 5.5](./morfo.md). `collapse` and `lock` were declared here from the founding commit and never meant anything; both were retired on 2026-08-10 rather than left as a contract @@ -328,12 +346,12 @@ cascade: [ // [data-dialog-content][data-event-family="commit"] { selector: onContent({ eventFamily: 'commit' }), haptic: { kind: 'tap' } }, - // [data-dialog-content][data-event="close-after-fail"] - { selector: onContent({ eventName: 'close-after-fail' }), sound: { sampleUrl: '/fail.wav' } }, + // [data-dialog-content][data-event="signal-alert-close-fail"] + { selector: onContent({ eventName: 'signal-alert-close-fail' }), sound: { sampleUrl: '/fail.wav' } }, - // [data-dialog-content][data-event^="close-"][data-event-family="emerge"] + // [data-dialog-content][data-event^="emerge-close"][data-event-family="emerge"] { - selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }), + selector: onContent({ eventNamePrefix: 'emerge-close', eventFamily: 'emerge' }), sound: { contour: 'descending', pitch: { op: 'add', value: -150 } } } ]; @@ -583,13 +601,14 @@ was the Form — before anyone noticed, since no check ever looked at these keys `VisualChannel.prepare()` projects **only** attributes under the `data-event-*` prefix: -| Attr | When | -| ------------------- | ------------------- | -| `data-event` | always | -| `data-event-id` | always | -| `data-event-phase` | always (`'active'`) | -| `data-event-family` | if `signal.family` | -| `data-event-intent` | if `signal.intent` | +| Attr | When | +| ---------------------- | --------------------- | +| `data-event` | always | +| `data-event-id` | always | +| `data-event-phase` | always (`'active'`) | +| `data-event-family` | if `signal.family` | +| `data-event-intent` | if `signal.intent` | +| `data-event-direction` | if `signal.direction` | **Rule**: the channel never touches state attrs (`data-state`, `data-intent`, `data-disabled`, ...). State is managed by the runtime/morfo. @@ -632,13 +651,13 @@ falls back to `setTimeout` when a channel is built without a scheduler (direct unit tests); production always injects the scheduler. > **Overlay openings — `sequence: 'post'`, not `'pre'`.** An appearance event -> whose provider sets `open` in the HANDLER (Popover `present`, Dialog -> `open`, Drawer `present`) MUST declare `sequence: 'post'`. With `'pre'` the +> whose provider sets `open` in the HANDLER (Popover, Dialog and Drawer's +> `emerge-open`) MUST declare `sequence: 'post'`. With `'pre'` the > runtime awaits the emit — and therefore the ~240ms hold — BEFORE the > handler, gating the content mount behind the hold: the overlay opens late > and its first render lands inside the hold's `setTimeout` turn (the > "setTimeout handler took N ms" violation). Same doctrine as the -> checkbox-lag fix. Closing (`close`) stays `'pre'`: there the element exists +> checkbox-lag fix. Closing (`emerge-close`) stays `'pre'`: there the element exists > and the signal MUST precede the unmount. ```ts @@ -731,7 +750,7 @@ const semantic = new EngineSemantic({ // src/uix/morfo/components/dialog.ts { - name: 'close-after-fail', + name: 'signal-alert-close-fail', semantic: { family: 'signal', verb: 'alert', @@ -775,10 +794,10 @@ hand-written examples this section used to show targeted `[data-toast-root]` { selector: semaSelector(toastMorfo, 'item', { eventFamily: 'signal' }), sound: { gain: 0.4 } } // Vary by exact event name (typed against the morfo's declared events) -{ selector: semaSelector(toastMorfo, 'item', { eventName: 'dismiss' }), sound: { sampleUrl: '/dismiss.wav' } } +{ selector: semaSelector(toastMorfo, 'item', { eventName: 'emerge-dismiss' }), sound: { sampleUrl: '/dismiss.wav' } } // Vary by event prefix -{ selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-' }), sound: { contour: 'descending' } } +{ selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'emerge-close' }), sound: { contour: 'descending' } } ``` ### SoundChannel — doctrine here, machine in `$sound` @@ -1001,21 +1020,22 @@ That Promise is the control the caller uses to opt into or out of the hold: - `void runtime.trigger('handle-drag')` — **fire-and-forget**. The signal starts; the caller does not wait for the hold. Correct for high-frequency emits, where blocking the gesture loop on a ~240ms hold would be absurd. -- `await runtime.trigger('close', …)` — **blocking**. The caller waits for the - hold to finish before its next step. Correct when a structural change must - observe the resolved signal (e.g. a `close` whose element unmounts after the - pulse — see the `sequence: 'post'` note under _Hold_). +- `await runtime.trigger('emerge-close', …)` — **blocking**. The caller waits for + the hold to finish before its next step. Correct when a structural change must + observe the resolved signal (e.g. an `emerge-close` whose element unmounts after + the pulse — see the `sequence: 'post'` note under _Hold_). Slider and Drawer go through `trigger` exclusively — `handle-pick`, -`handle-drag`, `commit-set` on the slider; `drag-start`, `drag-progress`, -`drag-end` on the drawer. Every continuous one is `void`. +`handle-drag`, `commit-set` on the slider; `handle-pick`, +`handle-drag-progress`, `handle-drop` on the drawer. Every continuous one is +`void`. ### `coincident` vs `post` for a moving value A continuous gesture has two temporally distinct moments, and the morfo encodes each with its `sequence`: -- **The move** (`handle-drag`, `drag-progress`) is `sequence: 'coincident'` — +- **The move** (`handle-drag`, `handle-drag-progress`) is `sequence: 'coincident'` — the perceptual signal and the value update are indivisible. The user _is_ the value changing, so the signal fires alongside the mutation, neither anticipating nor trailing it. (`coincident` is emit-then-handler, like `pre`; @@ -1133,9 +1153,9 @@ Cross-component action verbs grouped by family. The canon lives in [`verbs.ts`](../../src/uix/sema/verbs.ts) and reflects the book _Diseñando lo que ocurre_ ch. 22–29 (families) + ch. 10 (intents). `morfo.events[].semantic.verb` MUST be in this vocabulary; -`morfo.events[].name` should follow the `{family}-{verb}[-{variant}]` shape -so sema/sound/haptic can subscribe by verb and eidos can write transversal -selectors (`[data-event^="dismiss"]`). +`morfo.events[].name` MUST follow the `{family}-{verb}[-{nuance}]` shape +(guaranteed by `validateMorfo`) so sema/sound/haptic can subscribe by verb and +eidos can write transversal selectors (`[data-event^="emerge-dismiss"]`). > **The literal table used to live here, and it rotted.** It was missing > `commit.unselect` and `handle.zoom` — both live in `verbs.ts` and both used @@ -1155,25 +1175,25 @@ Defined in [`verbs.ts:SEMA_VERBS`](../../src/uix/sema/verbs.ts). ### Naming shapes -A `morfo.events[].name` can take two canonical shapes: +A `morfo.events[].name` takes ONE canonical shape — the name declares the +family, and `validateMorfo` rejects any that does not: ```ts -// Shape 1: {verb}-{variant} — head is the verb, tail explains the nuance. -'dismiss'; // bare verb -'dismiss-outside'; // verb + variant -'close-cancel'; // verb (close) + variant (cancel) - -// Shape 2: {family}-{verb} — head is the family, tail the canonical verb. +// {family}-{verb}[-{nuance}] — head is the family, then the canonical verb, +// then the nuance when the component needs one. +'emerge-dismiss'; // family=emerge, verb=dismiss +'emerge-dismiss-outside'; // + nuance (outside) 'commit-toggle'; // family=commit, verb=toggle 'commit-save'; // family=commit, verb=save ``` -`validateEventName(name)` recognizes both shapes and returns `{ family, -verb, variant, matchesCanonical }`. Used by +`validateEventName(name)` parses a name — it still accepts the retired +bare-verb shape — and returns `{ family, verb, variant, matchesCanonical }`. +Used by `scripts/morfo-vocabulary-check.ts` (`npm run morfo:vocabulary`), which hard-fails when a morfo's declared `semantic.verb` is not in its family's canon, and soft-warns when the event NAME doesn't fit -`{family}-{verb}[-{variant}]` even though the verb is canonical. A temporary +`{family}-{verb}[-{nuance}]` even though the verb is canonical. A temporary allowlist in the script covers deliberate divergences. ```ts diff --git a/docs/architecture/soma-architecture.md b/docs/architecture/soma-architecture.md index da54d6874..12cf296bd 100644 --- a/docs/architecture/soma-architecture.md +++ b/docs/architecture/soma-architecture.md @@ -370,7 +370,7 @@ therefore what the runtime emits: archetype. - **`data-event*`** — emitted by `events.emit` (through the VisualChannel) during a configurable hold. Eidos uses it to tint event transitions - (`[data-event^=dismiss]`). Per-family hold values live in + (`[data-event^=emerge-dismiss]`). Per-family hold values live in `SEMA_MAP.families[*].hold`. - **`data-{component}` / `data-{component}-{part}`** — the classic structural markers. Eidos uses them for per-component selectors. diff --git a/docs/canon/recipe-contract.md b/docs/canon/recipe-contract.md index af4e18ac9..3f6ce2137 100644 --- a/docs/canon/recipe-contract.md +++ b/docs/canon/recipe-contract.md @@ -144,7 +144,8 @@ motion channel**: `delayed-open` alias), content mounts (`motionAttrs`). 2. **Event signature** (`EidosConfig.motion.signatures`) when the animation reacts to sema's `data-event-*` and is generic per verb/family - (expand/collapse, present/dismiss, announce per intent). + (expand/collapse, present/dismiss, announce per intent, the `shift` + crossing per `data-event-direction`). 3. **Materials pattern** (the rule lives in the recipe but consumes ONLY registered keyframes + `--motion-*` hooks) when the trigger is irreducible to the generic: a semantic `data-state` of its own (card), diff --git a/docs/canon/vocabularies.md b/docs/canon/vocabularies.md index 856116f92..29de9b6fd 100644 --- a/docs/canon/vocabularies.md +++ b/docs/canon/vocabularies.md @@ -108,6 +108,24 @@ require one; the intent policy per family is `SEMA_FAMILY_POLICY`. `neutral` · `affirm` · `fulfill` · `risk` · `threat` · `loss` +## Directions (2) + +The sense of a traversal (`SEMA_DIRECTIONS`, `src/uix/sema/types.ts`), +projected as `data-event-direction`. Per emission like the intent, and +unlike it undeclarable on the EVENT: one `shift-navigate` is the previous +month and the next one is the following month, so only the caller knows +(`TriggerOptions.direction`). Optional — most occurrences have no sense to +declare, and an invented one is worse than none. + +A SENSE, never an axis: eidos maps `forward` onto the inline end and +`backward` onto the inline start, so RTL flips through `:dir(rtl)` and +nothing upstream knows about it. Distinct from `SoundContour` +(`ascending` / `descending`, `src/uix/sema/sounds.ts`), which shapes a pitch, +and from `Morfo.direction`, which names the parts that carry the `dir` stamp +(the RTL contract — see `docs/canon/direction-contract.md`). + +`forward` · `backward` + ## Hold / perceptual durations The named perceptual scale (`SEMA_DURATIONS`, `src/uix/sema/durations.ts`); diff --git a/docs/glossary.md b/docs/glossary.md index d8e2a9898..a402897f8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -59,7 +59,7 @@ New here? Start at [`docs/README.md`](./README.md). | **Active\ / State\** | Reactive containers (readonly / mutable, exposing `.current`) that let runes be passed by reference between classes. | | **context convention** | The `X.create()` / `X.get()` / `X.require()` static methods every context-using class follows. | | **roving vs virtual focus** | Two keyboard strategies: real DOM focus with one `tabindex=0` (roving) vs focus stays on the input and items are `data-highlighted` via `aria-activedescendant` (virtual). | -| **polymorphic close** | One `close` event with `allowedFamilies`; the provider chooses the family at dismiss time (used by Dialog/Drawer/Popover). | +| **polymorphic close** | One `emerge-close` event with `allowedFamilies`; the provider chooses the family at dismiss time (used by Dialog/Drawer/Popover). | | **prewrite / commit** | DOM written imperatively *before* the semantic emit (`prewrite`, e.g. `data-last-action`) vs the structural state written *after* (`commit`). | | **activeDir** | The direction resolver a wrapper runs for a component; returns `Active`, where `undefined` means nobody asserted a direction. → [`canon/direction-contract`](./canon/direction-contract.md) | | **resolvedDir** | A provider's concrete direction — `activeDir`'s value with the fallback applied, once, for the component's own maths. | @@ -73,6 +73,7 @@ The values live in [`CANON.md`](./CANON.md); these are the term shapes. | **family** | One of the **8** perceptual event families (contact · commit · signal · handle · emerge · shift · sustain · delegate). → CANON | | **intent** | The evaluative load of an occurrence (neutral · affirm · fulfill · risk · threat · loss) — only on valenced families. → CANON | | **verb** | The specific act within a family (`tap`, `select`, `close`, …). → CANON | +| **direction** | The sense of a traversal (`forward` \| `backward`, `SemaDirection`), stamped as `data-event-direction`. Two values, because the event NAME already separates `shift-enter-mode` from `shift-exit-mode`; what a name cannot carry is which way THIS occurrence went. Decided per emit and optional, like intent. A SENSE, not an axis — eidos maps it onto the inline axis so `:dir(rtl)` flips it. → CANON | | **channel** | An expression modality. Sema runs two at runtime (sound, haptic) and projects `visual`; eidos owns the rest (motion/presence/depth/shape/color). → CANON | | **hold** | The minimum perceptible duration a signal stays projected (`data-event-*` stamped during it). | | **cascade** | The layered resolution of a perceptual signature (1 family base → 2 intent deltas → 3 per-event → 4 globals → 5a packs / 5b app rules). → [`architecture/sema`](./architecture/sema.md) | diff --git a/docs/guides/completion-checklist.md b/docs/guides/completion-checklist.md index faffcb9fb..21f58ab9f 100644 --- a/docs/guides/completion-checklist.md +++ b/docs/guides/completion-checklist.md @@ -82,7 +82,7 @@ The morfo is DNA. If it's incomplete, every downstream layer is incomplete. | A-3.4 | `semantic.verb` ∈ `SEMA_VERBS[family]` | error | all | audit | | A-3.4b | Per-event `family.verb` pairing is canonical (no verb borrowed from another family) | warn | all | audit | | A-3.5 | `semantic.sequence` ∈ `'pre' \| 'coincident' \| 'post'` declared explicitly | warn | interactive | audit | -| A-3.6 | Event name follows `{verb}-{variant}` or `{family}-{verb}` pattern | warn | interactive | audit | +| A-3.6 | Event name follows the `{family}-{verb}[-{nuance}]` pattern (`validateMorfo` throws otherwise) | error | interactive | audit | | A-3.7 | **Event/keyboard coverage**: every distinct keyboard action that mutates state has a corresponding semantic event. Pure focus moves don't need an event. | error | interactive | audit | | A-3.8 | If component supports value reset/clear: `commit.reset` or `commit.discard` event declared | warn | interactive | manual | | A-3.9 | If component supports navigation steps (calendar, stepper, pagination): `shift.navigate` event declared with target part | warn | interactive | manual | diff --git a/docs/theming/channels.md b/docs/theming/channels.md index 464f589a5..7ad0405cb 100644 --- a/docs/theming/channels.md +++ b/docs/theming/channels.md @@ -24,7 +24,7 @@ An occurrence flows through **two moments**, connected by the **token**: - **Sema's moment (emission)** — sema evaluates the occurrence and **emits** it. Sound + haptics it **executes right there** (runtime channels); for the visual, it **stamps it as tokens** `data-event-*` (family · intent · - phase). Sema **knows no DOM/CSS**. + direction · phase). Sema **knows no DOM/CSS**. - **Eidos's moment (materialization)** — eidos **reads** those tokens (+ `data-state`) and **materializes** them in CSS (the visual channel). It is the **sole visual owner**. @@ -92,6 +92,38 @@ a shim: > (family / semantic intent), eidos contributes the **how** (the visual > vocabulary and its materialization). Co-layers, not one subordinate. +The split is load-bearing, and the `shift` repair of 2026-08-11 is the case +that shows it. `shift` had a sound (`slide`) and no visual signature at all — +the *shift invisible* antipattern (book ch. 34 §14) inside the framework that +names it. The token was never the problem: sema was already stamping +`data-event-family='shift'`. What was missing was on eidos's side of the line, +and the fix stayed there — two entries in `BUILTIN_SIGNATURES`. Sema's only +addition was a new token, `data-event-direction` (`forward` | `backward`), the +SENSE of the crossing; eidos is what decides that a sense means the inline axis +and flips it under `:dir(rtl)`. Sema still knows nothing about left and right, +which is exactly the property that lets it stay DOM-agnostic. + +What `shift` has now are two signatures keyed on that token — `shift-forward` +and `shift-backward` (they name the `shift-cross-*` keyframes): the frame +arrives displaced one `--motion-distance-xl` (30 px) along the **inline** axis +and settles in 320 ms on the `emphasized` curve. An emission that stamps no +direction matches neither and stays visually silent — deliberate, since there +is no neutral sense of travel to draw. Geometry, the `--motion-shift-sign` +pair and the selector shape: [`motion.md`](./motion.md) §8. + +And the misreading that delayed the repair, because the structure invites it: +`SEMA_MAP.families.shift.activeChannels` is `['sound']`, which looks like a +family declared mute everywhere else. It is not. **"Mute by doctrine" is a +statement about a CHANNEL, never about a family** — `activeChannels` governs +only the two channels sema *executes*. `delegate` is the proof: its +`activeChannels` is literally `[]`, and it still carries two visual signatures +(`delegate-return-fulfill`, `delegate-return-loss`). No field of `SEMA_MAP` +could say otherwise, because eidos is the sole visual owner (§1) — the visual +channel is not in the map to be switched off, which is also why motion does not +live there. Read an empty `activeChannels` as "silent", and `shift` files +itself beside `sustain`, whose continuity is genuinely carried by persistent +state and not by a firma. + (The full canonical narrative lives in `CLAUDE.md` → "Sema: open channel registry".) diff --git a/docs/theming/motion.md b/docs/theming/motion.md index 9f5d9aaec..fcb9bf776 100644 --- a/docs/theming/motion.md +++ b/docs/theming/motion.md @@ -269,7 +269,7 @@ axes. | Layer | What it contributes | Moment | |---|---|---| | **Morfo** | Declares the attrs: `data-state` + states (`open`/`closed`), the **events** (`emerge`/`commit`/`signal` + `sequence`/`persistence`/`intent`), `data-side`/`data-align`, `data-starting/ending-style`. | both | -| **Sema** | Stamps `data-event-*` (`family`/`intent`/`phase`/`id`) during the `hold`; resolves the signature (sound/haptic are runtime channels; **`motion`/`color`/`presence` are materialized by eidos** reading `data-event-*`). | `--event` | +| **Sema** | Stamps `data-event-*` (`family`/`intent`/`direction`/`phase`/`id`) during the `hold`; resolves the signature (sound/haptic are runtime channels; **`motion`/`color`/`presence` are materialized by eidos** reading `data-event-*`). | `--event` | | **Soma** | Writes `data-state` via effects; `Presence` keeps the node during the exit and awaits the animation (`getAnimations()` + `Promise.all(finished)`). Fires the event with its `sequence`. | `--state` (+ fires the event) | | **Eidos** | `keyframes` + `signatures` (event-moment, over `data-event-*`) + `presets` (state-moment, over `data-state`) + **CSS generation**; delegates the JS engine to **`uix.motion`** (the service) and registers its `css` presets there at boot. **Reads both axes and animates.** | both | @@ -287,7 +287,7 @@ EidosConfig.motion ├── keyframes: { 'fade-in': {...}, 'scale-in': {...}, ... } registered @keyframes │ ├── signatures: { ← the --event MOMENT (the signature, generic per event) -│ 'emerge-present': { family:'emerge', event:'present', keyframes:['fade-in'], ... }, +│ 'emerge-present': { family:'emerge', event:'emerge-present', keyframes:['fade-in'], ... }, │ 'commit-settle': { family:'commit', keyframes:['settle'], ... }, │ 'announce-threat':{ family:'signal', intent:'threat', keyframes:['pulse'], ... } │ } → generates [data-event-*][data-event-phase='active'] rules @@ -342,7 +342,9 @@ interface CssPhase { interface EventSignature { family?: string // data-event-family ('emerge' | 'commit' | 'signal' | …) intent?: string // data-event-intent (valenced families) - event?: string | string[] // data-event name(s)/prefix(es) (['present','open'], …) + event?: string | string[] // data-event name(s)/prefix(es) (['emerge-present','emerge-open'], …) + direction?: string // data-event-direction ('forward' | 'backward') — sense of travel, + // decided per emit. A REFINEMENT, never a matcher on its own. keyframes: KeyframeName | KeyframeName[] duration?: string // token key OR raw hold ('600ms', outside the scale) ease?: EaseKey @@ -467,11 +469,19 @@ to the driver, not to `ActiveDom`. **The `--event` moment** (sema writes it during the hold; eidos reacts): ```css -[data-event='present'][data-event-phase='active'] { animation: fade-in …; } +[data-event='emerge-present'][data-event-phase='active'] { animation: fade-in …; } [data-event-family='commit'][data-event-phase='active'] { animation: settle …; } [data-event-family='signal'][data-event-intent='threat'][data-event-phase='active'] { animation: pulse …; } +[data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] { animation: shift-cross-forward …; } ``` +`data-event-direction` (`forward` | `backward`) is the sixth attr of the stamp +and the only one decided PER EMIT rather than declared in the morfo: the event +name already says `shift-enter-mode` vs `shift-exit-mode`, so the attr carries +only the sense of travel. The `shift` crossing is the first firma to read it — +its keyframes travel the inline axis via `--motion-shift-sign` (+1 `:dir(ltr)` / +−1 `:dir(rtl)`), never a physical `translateX`. + **The `--state` moment** (soma writes it; eidos reacts). The wrapper sets `data-animation-style` (the `motion` prop); the rest state comes from the recipe over the same `data-state`: diff --git a/scripts/docs-vocabularies.ts b/scripts/docs-vocabularies.ts index 62cd93f93..f4c70af5d 100644 --- a/scripts/docs-vocabularies.ts +++ b/scripts/docs-vocabularies.ts @@ -1,10 +1,10 @@ /** * docs:vocabularies — generate the canonical-vocabulary appendix from the code * consts, so agents building from the docs-book can SEE the closed sets they - * must draw from (archetypes, sema families + holds + verbs, intents, haptic - * kinds, palette scales, sizes, variants, shared strings) without any - * copy-the-list drift objection. Generated = the ONE sanctioned place these - * lists are spelled out; every other doc links here. + * must draw from (archetypes, sema families + holds + verbs, intents, + * directions, haptic kinds, palette scales, sizes, variants, shared strings) + * without any copy-the-list drift objection. Generated = the ONE sanctioned + * place these lists are spelled out; every other doc links here. * * Closes STUMBLES #1 (invisible canonical vocabularies — an agent could not * assign archetypes / holds from the docs alone). @@ -28,6 +28,7 @@ import { SEMA_HOLDS_BY_INTENT } from '../src/uix/sema/holds'; import { SEMA_VERBS } from '../src/uix/sema/verbs'; import { SOUNDS } from '../src/uix/sema/sound-names'; import { SEMA_DURATIONS } from '../src/uix/sema/durations'; +import { SEMA_DIRECTIONS } from '../src/uix/sema/types'; import { INTENTS } from '../src/uix/intent'; import { commonLangs } from '../src/uix/langs/common'; @@ -161,6 +162,26 @@ export function generateVocabulariesDoc(): string { L.push(INTENTS.map((i) => `\`${i}\``).join(' · ')); L.push(''); + // Directions + L.push(`## Directions (${SEMA_DIRECTIONS.length})`); + L.push(''); + L.push('The sense of a traversal (`SEMA_DIRECTIONS`, `src/uix/sema/types.ts`),'); + L.push('projected as `data-event-direction`. Per emission like the intent, and'); + L.push('unlike it undeclarable on the EVENT: one `shift-navigate` is the previous'); + L.push('month and the next one is the following month, so only the caller knows'); + L.push('(`TriggerOptions.direction`). Optional — most occurrences have no sense to'); + L.push('declare, and an invented one is worse than none.'); + L.push(''); + L.push('A SENSE, never an axis: eidos maps `forward` onto the inline end and'); + L.push('`backward` onto the inline start, so RTL flips through `:dir(rtl)` and'); + L.push('nothing upstream knows about it. Distinct from `SoundContour`'); + L.push('(`ascending` / `descending`, `src/uix/sema/sounds.ts`), which shapes a pitch,'); + L.push('and from `Morfo.direction`, which names the parts that carry the `dir` stamp'); + L.push('(the RTL contract — see `docs/canon/direction-contract.md`).'); + L.push(''); + L.push(SEMA_DIRECTIONS.map((d) => `\`${d}\``).join(' · ')); + L.push(''); + // Hold durations L.push('## Hold / perceptual durations'); L.push(''); diff --git a/scripts/eidos-event-vocabulary.ts b/scripts/eidos-event-vocabulary.ts new file mode 100644 index 000000000..26aacc9b2 --- /dev/null +++ b/scripts/eidos-event-vocabulary.ts @@ -0,0 +1,206 @@ +/** + * The closed vocabulary an eidos recipe may NAME through the `data-event*` + * stamp. + * + * `src/uix/sema/stamp.ts` writes `data-event` = the morfo event's `name`, plus + * `data-event-family` / `data-event-intent` / `data-event-direction` from the + * same signal. The + * structural linter (`src/uix/eidos/lint.ts`) classifies those attrs as + * "eidos-only" and never reads their VALUE — which is how + * `[data-event='commit-resize']` survived in `splitter.css` from the + * 2026-05-22 rename (`commit-resize` → `commit-set`, bd2e40366) without a + * single test complaining. A recipe hooked to a name nobody emits is dead + * CSS that looks alive. + * + * The check is CATALOGUE-wide, not per-component, and deliberately so: + * composition means a node receives stamps from another component's morfo + * (the card-group item carries `data-toggle-group-item` on the same node and + * receives `commit-block`, which toggle-group declares; the calendar + * nav-buttons receive `contact-activate` from the composed `Button`). A + * per-component ERROR would be a dozen false positives. The mismatch is + * still surfaced — as a WARN. + */ + +import { readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { INTENTS } from '../src/uix/intent'; +import { SEMA_DIRECTIONS, SEMA_FAMILY_POLICY } from '../src/uix/sema/types'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MORFO_DIRS = [ + join(__dirname, '..', 'src', 'uix', 'morfo', 'components'), + join(__dirname, '..', 'src', 'uix', 'morfo', 'internal') +]; + +const FAMILIES: readonly string[] = Object.keys(SEMA_FAMILY_POLICY); +const INTENT_VALUES: readonly string[] = INTENTS; +/** + * Imported, never restated. `scripts/` is outside the `tsconfig` graph, so a + * literal `['forward', 'backward']` written here would be unchecked prose that + * silently outlives the vocabulary it guards — the same shape of defect this + * whole file exists to prevent. `SemaDirection` is derived FROM this array + * (`sema/types.ts`), so the two cannot diverge. + */ +const DIRECTION_VALUES: readonly string[] = SEMA_DIRECTIONS; + +export interface EventVocabulary { + /** event `name` → the morfo kebabs that declare it. */ + readonly names: ReadonlyMap; +} + +/** Import every morfo in the catalogue and index its declared event names. */ +export async function loadEventVocabulary(): Promise { + const names = new Map(); + for (const dir of MORFO_DIRS) { + for (const file of readdirSync(dir)) { + if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue; + // file:// URL — Node's ESM loader rejects bare Windows paths ("g:\…"). + const mod = (await import(pathToFileURL(join(dir, file)).href)) as Record; + for (const value of Object.values(mod)) { + if (typeof value !== 'object' || value === null) continue; + const morfo = value as { kebab?: unknown; parts?: unknown; events?: unknown }; + if (typeof morfo.kebab !== 'string' || !Array.isArray(morfo.parts)) continue; + if (!Array.isArray(morfo.events)) continue; + for (const event of morfo.events as readonly { name?: unknown }[]) { + if (typeof event?.name !== 'string') continue; + const owners = names.get(event.name); + if (owners) owners.push(morfo.kebab); + else names.set(event.name, [morfo.kebab]); + } + } + } + } + return { names }; +} + +export interface EventValueFinding { + readonly level: 'error' | 'warn'; + /** The selector the attribute was authored in. */ + readonly rule: string; + readonly message: string; +} + +const COMMENT = /\/\*[\s\S]*?\*\//g; +const SELECTOR_LINE = /(?:^|\})\s*([^\s{}][^{}]*?)\s*\{/g; +const EVENT_ATTR = + /\[\s*(data-event-family|data-event-intent|data-event-direction|data-event)\s*([~^$*|]?)=\s*(?:'([^']*)'|"([^"]*)"|([^\]\s]+))/gi; + +/** + * Validate every `data-event` / `-family` / `-intent` / `-direction` VALUE in + * a CSS file. + * + * `ownKebab` (optional) turns "the name exists, but no morfo of this + * component declares it" into a WARN — the composition case. + */ +export function lintEventValues( + cssText: string, + vocabulary: EventVocabulary, + ownKebab?: string +): EventValueFinding[] { + // Comments first: splitter.css documents the hook in prose right above the + // rule, and a commented name is not a selector. + const text = cssText.replace(COMMENT, ''); + const out: EventValueFinding[] = []; + + for (const ruleMatch of text.matchAll(SELECTOR_LINE)) { + const rule = ruleMatch[1].trim().replace(/\s+/g, ' '); + for (const m of rule.matchAll(EVENT_ATTR)) { + const attr = m[1].toLowerCase(); + const op = m[2]; + const value = m[3] ?? m[4] ?? m[5]; + + if (attr === 'data-event-family') { + check(out, rule, attr, op, value, FAMILIES, 'a canon sema family'); + continue; + } + if (attr === 'data-event-intent') { + check(out, rule, attr, op, value, INTENT_VALUES, 'a canon intent'); + continue; + } + if (attr === 'data-event-direction') { + check(out, rule, attr, op, value, DIRECTION_VALUES, 'a sense of traversal'); + continue; + } + + const matched = matchable(value, op, vocabulary.names.keys()); + if (!matched) { + out.push(unmodelledOperator(rule, attr, op, value)); + continue; + } + if (matched.length === 0) { + out.push({ + level: 'error', + rule, + message: `[${attr}${op}='${value}'] — no morfo declares an event ${op === '^' ? 'starting with' : 'named'} '${value}'.` + }); + continue; + } + if (!ownKebab) continue; + const owners = [...new Set(matched.flatMap((name) => vocabulary.names.get(name) ?? []))]; + if (owners.includes(ownKebab)) continue; + out.push({ + level: 'warn', + rule, + message: `[${attr}${op}='${value}'] — declared by ${owners.join(', ')}, not by the '${ownKebab}' morfo (composition, or drift).` + }); + } + } + + return out; +} + +function check( + out: EventValueFinding[], + rule: string, + attr: string, + op: string, + value: string, + vocabulary: readonly string[], + label: string +): void { + const matched = matchable(value, op, vocabulary); + if (!matched) { + out.push(unmodelledOperator(rule, attr, op, value)); + return; + } + if (matched.length > 0) return; + out.push({ + level: 'error', + rule, + message: `[${attr}${op}='${value}'] — '${value}' is not ${label} {${vocabulary.join(', ')}}.` + }); +} + +/** + * The vocabulary entries this attribute selector can ever match. + * `undefined` — an operator this guard does not model (nothing uses one today). + */ +function matchable(value: string, op: string, vocabulary: Iterable): string[] | undefined { + if (op === '') return [...vocabulary].filter((entry) => entry === value); + if (op === '^') return [...vocabulary].filter((entry) => entry.startsWith(value)); + return undefined; +} + +function unmodelledOperator( + rule: string, + attr: string, + op: string, + value: string +): EventValueFinding { + return { + level: 'warn', + rule, + message: `[${attr}${op}='${value}'] — operator "${op}=" is not modelled; value left unvalidated.` + }; +} + +export function formatEventFindings(findings: readonly EventValueFinding[], indent = ' '): string { + const lines: string[] = []; + for (const f of findings) { + lines.push(`${indent}${f.level === 'error' ? '✗' : '·'} ${f.rule}`); + lines.push(`${indent} ${f.message}`); + } + return lines.join('\n'); +} diff --git a/scripts/eidos-lint-all.ts b/scripts/eidos-lint-all.ts index f91890b89..b2d5f75ef 100644 --- a/scripts/eidos-lint-all.ts +++ b/scripts/eidos-lint-all.ts @@ -5,6 +5,9 @@ * * Prints a per-component summary so the drift between eidos and the * morfo declarations is visible at a glance. + * + * Also validates the `data-event*` VALUES against the morfo catalogue and + * the sema canon — see `scripts/eidos-event-vocabulary.ts`. */ import { existsSync, readFileSync, readdirSync } from 'node:fs'; @@ -13,6 +16,11 @@ import { resolve } from 'node:path'; import { compileMorfo } from '../src/uix/morfo/compile'; import { lintEidosCss } from '../src/uix/eidos/lint'; import type { Morfo } from '../src/uix/morfo/types'; +import { + formatEventFindings, + lintEventValues, + loadEventVocabulary +} from './eidos-event-vocabulary'; const EIDOS_ONLY_ATTRS = new Set([ 'data-archetype', @@ -22,7 +30,11 @@ const EIDOS_ONLY_ATTRS = new Set([ 'data-color', 'data-columns', 'data-dragging', + // The sema stamp (`src/uix/sema/stamp.ts`), not a morfo part. Allowlisted + // STRUCTURALLY only — the VALUE is validated against the morfo catalogue + // by `lintEventValues` below. 'data-event', + 'data-event-direction', 'data-event-family', 'data-event-id', 'data-event-intent', @@ -157,7 +169,26 @@ async function main() { console.log('\nNo drift hotspots beyond documented Eidos-only attrs/parts.'); } - if (totalInvalid > 0) process.exit(1); + // `data-event*` VALUE check. Its own pass over the same files: it needs no + // compiled morfo, so it also covers the CSS skipped above for lack of one. + const vocabulary = await loadEventVocabulary(); + let deadEvents = 0; + const eventBlocks: string[] = []; + for (const file of cssFiles) { + const findings = lintEventValues(readFileSync(file.path, 'utf-8'), vocabulary, file.name); + if (!findings.length) continue; + deadEvents += findings.filter((f) => f.level === 'error').length; + eventBlocks.push(` ${file.name}\n${formatEventFindings(findings, ' ')}`); + } + + if (eventBlocks.length) { + console.log('\ndata-event* vocabulary (value must exist in a morfo / the canon):'); + for (const block of eventBlocks) console.log(`\n${block}`); + } else { + console.log('\nEvery data-event* value in a recipe exists in the morfo catalogue.'); + } + + if (totalInvalid > 0 || deadEvents > 0) process.exit(1); } function isAllowedEidosOnlyAttr(attr: string): boolean { diff --git a/scripts/eidos-lint.ts b/scripts/eidos-lint.ts index ba8a9a24c..c1971b2e0 100644 --- a/scripts/eidos-lint.ts +++ b/scripts/eidos-lint.ts @@ -9,6 +9,9 @@ * `components/{name}.css`), compiles the morfo, and reports which * selectors are morfo-backed, eidos-only DOM signals/wrapper attrs, or * invalid (value not in declared enum). + * + * Also validates the `data-event*` VALUES against the morfo catalogue and + * the sema canon — see `scripts/eidos-event-vocabulary.ts`. */ import { readFileSync, existsSync } from 'node:fs' @@ -17,6 +20,11 @@ import { resolve } from 'node:path' import { compileMorfo } from '../src/uix/morfo/compile' import { formatLintReport, lintEidosCss } from '../src/uix/eidos/lint' import type { Morfo } from '../src/uix/morfo/types' +import { + formatEventFindings, + lintEventValues, + loadEventVocabulary +} from './eidos-event-vocabulary' async function main() { const name = process.argv[2] @@ -51,7 +59,17 @@ async function main() { const report = lintEidosCss(css, compiled) console.log(formatLintReport(report)) - if (report.counts.invalid > 0) process.exit(1) + // The structural report allowlists `data-event*` as eidos-only and never + // reads its value — the vocabulary is checked separately. + const eventFindings = lintEventValues(css, await loadEventVocabulary(), name) + if (eventFindings.length) { + console.log('') + console.log(' data-event* vocabulary (value must exist in a morfo / the canon):') + console.log(formatEventFindings(eventFindings, ' ')) + } + + const deadEvents = eventFindings.filter((f) => f.level === 'error').length + if (report.counts.invalid > 0 || deadEvents > 0) process.exit(1) } main().catch((err) => { diff --git a/src/arts/motion/types.ts b/src/arts/motion/types.ts index 6448594a3..84c5528f5 100644 --- a/src/arts/motion/types.ts +++ b/src/arts/motion/types.ts @@ -73,6 +73,15 @@ export interface EventSignature { readonly intent?: string /** Name(s) / prefix(es) of `data-event` (`['present','open']`, …). */ readonly event?: string | readonly string[] + /** + * Sense of travel of the signal (`data-event-direction`), decided PER EMIT: + * `'forward'` / `'backward'`. A REFINEMENT, never a matcher on its own — the + * event NAME already distinguishes `shift-enter-mode` from `shift-exit-mode`; + * this carries only what the name cannot. Not to be confused with the + * sound's `SoundContour` (`ascending` / `descending`) — that one shapes a + * pitch, this one a traversal. + */ + readonly direction?: string readonly keyframes: KeyframeName | readonly KeyframeName[] /** Duration token key or raw hold (`'600ms'`). */ readonly duration?: string diff --git a/src/uix/eidos/components/alert-dialog/README.md b/src/uix/eidos/components/alert-dialog/README.md index 1b51b7923..f79b31bc8 100644 --- a/src/uix/eidos/components/alert-dialog/README.md +++ b/src/uix/eidos/components/alert-dialog/README.md @@ -78,9 +78,9 @@ Passive **at the alert-dialog morfo level** — the morfo declares only the Action / Cancel button parts (Provider is virtual). All behavioural events (open/close presence, escape, focus trap, …) come from the Dialog runtime that soma's AlertDialog delegates to. There -is no alert-dialog-specific sema event to fire; the -`commit-confirm` and `close-cancel` flows live inside the Dialog -morfo's vocabulary. +is no alert-dialog-specific sema event to fire; the confirm / cancel +flows live inside the Dialog morfo's vocabulary — the polymorphic +`emerge-close` (book §5.3) and its `data-last-action` cause. The component IS interactive from the user's perspective. "Passive" here is a contract-layer classification, not a UX one. @@ -128,7 +128,7 @@ behavioural locking. | Gap | Disposición | Detalle | | --- | --- | --- | -| Soma's `commit-confirm` / `close-cancel` events not yet wired to a per-component sema cascade | **diferir** | The `commit` family base ships sound; suffices for now. Add cascade if we want different sounds for confirm-destroy vs confirm-affirm. | +| Dialog's polymorphic `emerge-close` (and its `commit.save` concretion) not yet wired to a per-component sema cascade | **diferir** | The `commit` family base ships sound; suffices for now. Add cascade if we want different sounds for confirm-destroy vs confirm-affirm. | | No async `onAction` returning Promise to gate close | **diferir** | Soma's wrapper closes synchronously; consumers gate via a parent state machine instead. Open spec question. | | `size='xs'` / `'sm'` shaping for very small confirmations | **diferir** | Inherits Dialog's size scale (`sm/md/lg/xl/full`). Smaller would require Dialog-level changes. | diff --git a/src/uix/eidos/components/calendar/README.md b/src/uix/eidos/components/calendar/README.md index 9e9650814..4d8d02377 100644 --- a/src/uix/eidos/components/calendar/README.md +++ b/src/uix/eidos/components/calendar/README.md @@ -37,7 +37,7 @@ APG Date Picker Dialog/Grid. | ----------------- | ---------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------- | | `commit-select` | `commit.select` | `day` | `CalendarProvider.select(date, target)` after value changes | soft form commit + tap | | `commit-unselect` | `commit.remove` | `day` | same, when a date is removed/cleared | subtle form commit + tap | -| `shift-navigate` | `shift.navigate` | provider fallback, actual button/select/day target when known | `prevPage`, `nextPage`, `setMonth`, `setYear`, keyboard month boundary | subtle ascending navigation sound | +| `shift-navigate` | `shift.navigate` | `grid` | `prevPage`, `nextPage`, `setMonth`, `setYear`, keyboard month boundary | family default `slide` (arc, gain 0.06) + the directional `shift` motion firma | Calendar is not passive: a 0-event contract would hide committed selection and visible-range navigation from the semantic layer. This migration closes that gap @@ -97,9 +97,25 @@ before adding the Eidos recipe. fecha es un compromiso evaluativamente positivo (suave). Un commit destructivo correspondería a `commit-unselect` con `intent: 'neutral'`. - **`shift-navigate` cubre todo el movimiento de mes/año** (prev/next, - selects, atajos de teclado). No se subdivide por dirección — la - perceptiva de Sema se modula con `target` y `sequence`, no con - variantes de verbo. + selects, atajos de teclado). No se subdivide en variantes de verbo: el + sentido del paso viaja POR EMISIÓN en `data-event-direction` + (`forward`/`backward`), y lo pone quien da el paso — la paginación y el + teclado saben hacia dónde fueron; un salto desde el select no pasa + ninguno. +- **El sello de `shift-navigate` cae en el `grid`, no en el provider ni + en el botón**. El grid es el sujeto del cruce (es lo que cambia de mes; + la cabecera no se mueve y la flecha es sólo el instrumento) y es lo + único que puede LUCIR una firma de `shift`: un mes se desliza, un botón + no. Medido el 2026-08-11 sobre la flecha «anterior»: recibía su + `contact-activate` y arrancaba el `press-squeeze`, y 8,5 ms después + —media trama— `shift-navigate` pisaba la misma ranura y el squeeze + moría sin pintar. Una superficie, una ranura. +- **El anillo de evento perdió el provider en la receta** + (`box-shadow: var(--calendar-event-shadow)`): con el sello en el grid, + nada apunta ya a `[data-calendar]` y la regla era una selección muerta. + Se quedan el día (lo alimenta `commit-select`) y las flechas y los dos + selects, que componen `