|
|
---
|
|
|
title: Sema — the perceptual engine and channels
|
|
|
type: reference
|
|
|
audience: human + agent
|
|
|
authority: E1 architecture — the semantic domain, the emit contract, the resolution cascade and the channels
|
|
|
status: current
|
|
|
source: migrated from src/uix/sema/README.md (2026-07-02, docs-book F7.2)
|
|
|
---
|
|
|
|
|
|
# Sema
|
|
|
|
|
|
`Sema` defines UIX's canonical semantic domain and orchestrates the emission
|
|
|
of perceptual signals.
|
|
|
|
|
|
## What it is
|
|
|
|
|
|
- canonical families (8): `contact`, `commit`, `signal`, `handle`, `emerge`,
|
|
|
`shift`, `sustain`, `delegate`
|
|
|
- canonical intents: `neutral`, `affirm`, `fulfill`, `risk`, `threat`, `loss`
|
|
|
- a per-family intent policy (`SEMA_FAMILY_POLICY`) with **two axes**:
|
|
|
`intentRequirement` (`'required'` | `'optional'` | `'forbidden'`) — the
|
|
|
compile-time gate that shapes the discriminated union — and
|
|
|
`intentGuidance` (`'expected'` | `'contextual'` | `'discouraged'`) — a
|
|
|
doctrinal hint for lint/tooling
|
|
|
- normalization between the structured shape and the canonical label
|
|
|
- minimal domain validation
|
|
|
- `EngineSemantic` as the channel registry + occurrence dispatch
|
|
|
|
|
|
`Sema` does not decide which event happened. The provider decides.
|
|
|
`EngineSemantic` receives the occurrence and dispatches it to the registered
|
|
|
perceptual channels. Each channel materializes the signal in its modality
|
|
|
(DOM, audio, vibration).
|
|
|
|
|
|
## What it no longer is
|
|
|
|
|
|
`Sema` is no longer a monolithic multimodal runtime.
|
|
|
|
|
|
`EngineSemantic` does not contain:
|
|
|
|
|
|
- a global accessibility policy
|
|
|
- a cross-channel perceptual map
|
|
|
- decisions about which modal effect to apply
|
|
|
|
|
|
Those live in each channel separately:
|
|
|
|
|
|
- `EngineSemantic` keeps a channel registry and dispatches each signal
|
|
|
- the DOM projector materializes the signal as `data-event*` in the DOM
|
|
|
- `SoundChannel`, `HapticChannel` and future modal engines register as
|
|
|
independent channels that receive the signal and decide how to materialize
|
|
|
it on their plane
|
|
|
|
|
|
### Channel registry — open
|
|
|
|
|
|
The channel set is **NOT closed**. The framework ships canonical signatures
|
|
|
for `sound` and `haptic`. The `visual` channel exists as the projection/hold
|
|
|
meta-channel but has no slice of its own in `EffectiveSignature`. Apps can add
|
|
|
channels with a signature via declaration merging:
|
|
|
|
|
|
```ts
|
|
|
// app bootstrap
|
|
|
declare module '$uix/sema' {
|
|
|
interface SemaChannelSignatures {
|
|
|
a11y: A11ySignature; // narrator / live-region
|
|
|
voice: VoiceSignature; // text-to-speech
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Only the visual channel is mandatory (with the `visual: false` escape).
|
|
|
Sound and haptic are opt-in. Any new channel registers its `Channel` and
|
|
|
receives the dispatch.
|
|
|
|
|
|
## The `emit` contract
|
|
|
|
|
|
```ts
|
|
|
semantic.emit(signal: SemanticSignal): Promise<void>
|
|
|
```
|
|
|
|
|
|
One signature. It covers the three scenarios when composed with `dom.apply`:
|
|
|
|
|
|
```ts
|
|
|
// Structural change without a signal
|
|
|
dom.apply(change);
|
|
|
|
|
|
// Structural change with a signal
|
|
|
await semantic.emit(signal);
|
|
|
dom.apply(change);
|
|
|
|
|
|
// Signal without structural change
|
|
|
void semantic.emit(signal);
|
|
|
```
|
|
|
|
|
|
### Promise semantics — sequential strict
|
|
|
|
|
|
`emit(signal)` resolves when the `VisualChannel` has completed its whole
|
|
|
materialization:
|
|
|
|
|
|
1. `VisualChannel.prepare()` projected `data-event*` onto the DOM
|
|
|
2. the attributes lived in the DOM for the configured `hold`
|
|
|
3. the attributes were already removed (cleanup complete)
|
|
|
4. the Promise resolves
|
|
|
|
|
|
This is **sequential strict**: the caller applies the structural commit
|
|
|
AFTER the perceptual signal has finished. There is no parallelism between the
|
|
|
event and the state change.
|
|
|
|
|
|
Non-visual channels (sound, haptic) are fire-and-forget: they start in
|
|
|
parallel with the visual one but do not affect the Promise's timing.
|
|
|
|
|
|
### `emit`'s internal lifecycle
|
|
|
|
|
|
```
|
|
|
1. The engine generates the occurrence's id/session
|
|
|
2. The engine runs `channel.prepare(...)` on the registered channels
|
|
|
- `VisualChannel.prepare()` projects `data-event*` for the cascade
|
|
|
3. The engine resolves the cascade and dispatches the signal to ALL registered channels
|
|
|
- non-visual channels (sound, haptic) → fire-and-forget (not awaited)
|
|
|
- the visual channel → awaited
|
|
|
4. The engine resolves the Promise when the visual channel has finished
|
|
|
(strict sequential semantics: cleanup BEFORE the resolve)
|
|
|
```
|
|
|
|
|
|
### Channels as modules
|
|
|
|
|
|
Sema is organized in symmetric perceptual channels:
|
|
|
|
|
|
```
|
|
|
src/uix/sema/
|
|
|
├── engine.ts registry + channel prepare/dispatch + cascade composition
|
|
|
├── resolver.ts resolveSignature(signal, opts): EffectiveSignature
|
|
|
├── stamp.ts stampEventAttrs / unstampEventAttrs (data-event-*)
|
|
|
├── channels.ts channel ids, signatures and override types
|
|
|
├── sounds.ts nominal sound repository + dynamic recipes
|
|
|
├── sema-map.ts SEMA_MAP data + per-component Sema packs
|
|
|
├── components/ per-component perceptual packs (CSEM)
|
|
|
│ ├── dialog.ts dialogSema — cascade rules + preloadSamples
|
|
|
│ ├── toast.ts (future)
|
|
|
│ └── …
|
|
|
└── chans/
|
|
|
├── types.ts Channel interface — handle(signal, effective)
|
|
|
├── visual.ts VisualChannel (data-event projection + hold)
|
|
|
├── sound.ts SoundChannel (Web Audio earcons + sample playback)
|
|
|
└── haptic.ts HapticChannel (Vibration API + categorical kinds)
|
|
|
```
|
|
|
|
|
|
The engine does not mutate DOM attributes directly: it runs the generic
|
|
|
`channel.prepare(...)` hook. In the visual channel that hook delegates the
|
|
|
projection to a `SignalProjector`. When `ActiveUix` builds it, that projector
|
|
|
writes through UIX's `ActiveDom`. The engine resolves each signal into an
|
|
|
`EffectiveSignature` with `hold`, `sound`, `haptic` and future typed
|
|
|
channels, and dispatches `(signal, effective)` to each channel. Each channel
|
|
|
reads its slice (`effective.sound` for audio, `effective.haptic` for
|
|
|
vibration, etc.) or ignores the signature if it doesn't use it. Only the
|
|
|
visual channel blocks the caller with the perceptual hold; the rest are
|
|
|
fire-and-forget.
|
|
|
|
|
|
### Resolver and sema-map — the resolution cascade
|
|
|
|
|
|
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.
|
|
|
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
|
|
|
previous):
|
|
|
|
|
|
```
|
|
|
1. FAMILY base — SEMA_MAP.families[signal.family].base
|
|
|
sound / haptic + activeChannels + hold
|
|
|
2. INTENT deltas — SEMA_MAP.intents[signal.intent] when present;
|
|
|
numbers ADD by default — they are modifiers
|
|
|
3. MORFO overrides — signal.overrides + signal.channels
|
|
|
(numbers REPLACE by default — they are set values)
|
|
|
4. RUNTIME overrides — engineOpts.overrides.runtime (path-based globals;
|
|
|
baked into the map in the constructor; numbers REPLACE)
|
|
|
5a. PACK cascade — engineOpts.components (per-component packs)
|
|
|
5b. APP cascade — engineOpts.overrides.cascade (appended after the
|
|
|
packs; wins specificity ties by declaration order).
|
|
|
CSS-like selectors against signal.target with the
|
|
|
data-event-* already stamped; numbers REPLACE.
|
|
|
```
|
|
|
|
|
|
3. Dispatches to each channel with the resolved signature.
|
|
|
4. Awaits the VisualChannel (which contributes the hold).
|
|
|
5. Runs the cleanup of the handles returned by `prepare`.
|
|
|
|
|
|
**Overrides vs deltas convention — numbers:**
|
|
|
|
|
|
- **Layer 2 (intent.deltas)**: `pitch: -200` means "subtract 200 from the
|
|
|
base pitch". Compositional modifiers.
|
|
|
- **Layers 3, 4, 5a/5b (overrides)**: `pitch: 720` means "set pitch to
|
|
|
720". Like CSS — `gain: 0.4` doesn't add, it assigns.
|
|
|
- To add explicitly from an override layer: `{ op: 'add', value: 100 }`.
|
|
|
- To multiply: `{ op: 'multiply', factor: 1.2 }`.
|
|
|
- To replace non-numeric primitives: `{ op: 'replace', value: ... }`.
|
|
|
|
|
|
If `signal.family` is missing or not in the map, it returns an empty
|
|
|
`EffectiveSignature` — the channels no-op.
|
|
|
|
|
|
### Semantic tokens — `data-event-*`
|
|
|
|
|
|
`VisualChannel.prepare()` projects the following attrs onto `signal.target`
|
|
|
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 |
|
|
|
|
|
|
Those tokens are the **cross-channel contact surface**: sema's cascade
|
|
|
(`sound`, `haptic` and future channels) reads them with CSS selectors, the
|
|
|
same way eidos's CSS reads them to tint borders / animate states during the
|
|
|
hold. One perceptual surface, separate owners.
|
|
|
|
|
|
### Cascade rules — flat CSS-like shape
|
|
|
|
|
|
```ts
|
|
|
interface SemaCascadeRule {
|
|
|
selector: string // CSS selector — matches state attrs + event tokens
|
|
|
priority?: number // CSS-specificity override (optional)
|
|
|
channels?: readonly SemaChannelId[] // restricts / silences active channels
|
|
|
sound?: …
|
|
|
haptic?: …
|
|
|
}
|
|
|
```
|
|
|
|
|
|
One rule = one selector + one block of deltas. No intermediate
|
|
|
`overrides: { eventLabel: ... }` layer — the event's identity is read from
|
|
|
the selector via the `[data-event*]` tokens.
|
|
|
|
|
|
### Cascade selectors — typed builder, no hand-written strings
|
|
|
|
|
|
Cascade rules in `sema/components/*.ts` MUST build their `selector` via
|
|
|
`semaSelector(morfo, partKebab, matchers?)` from `$uix/morfo`. The helper
|
|
|
closes the loop between morfo's part/event contract and the selectors the
|
|
|
cascade evaluates.
|
|
|
|
|
|
```ts
|
|
|
import { semaSelector } from '$uix/morfo';
|
|
|
import { dialogMorfo } from '$uix/morfo/components/dialog';
|
|
|
|
|
|
const onContent = (matchers?: Parameters<typeof semaSelector<typeof dialogMorfo>>[2]) =>
|
|
|
semaSelector(dialogMorfo, 'content', matchers);
|
|
|
|
|
|
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^="close-"][data-event-family="emerge"]
|
|
|
{
|
|
|
selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }),
|
|
|
sound: { contour: 'descending', pitch: { op: 'add', value: -150 } }
|
|
|
}
|
|
|
];
|
|
|
```
|
|
|
|
|
|
Compile-time guarantees:
|
|
|
|
|
|
- `partKebab` is checked against `morfo.parts[].kebab`.
|
|
|
- `eventName` is checked against `morfo.events[].name`.
|
|
|
- `eventFamily` / `eventIntent` are typed against the canonical sema unions.
|
|
|
- `state` / `aria` / `pseudo` accept plain strings (the data-attr vocabulary
|
|
|
is per-component and not yet typed-derived).
|
|
|
|
|
|
Renaming a part or event in morfo breaks the cascade at type-check time, not
|
|
|
silently in production. **Hand-written selector strings in cascade rules are
|
|
|
a code smell** — review them as drift.
|
|
|
|
|
|
### Per-component packs — `sema/components/{name}.ts`
|
|
|
|
|
|
Each component ships its perceptual-defaults pack in
|
|
|
`src/uix/sema/components/{name}.ts`, symmetric to soma and eidos:
|
|
|
|
|
|
```ts
|
|
|
import type { Sema } from '../sema-map';
|
|
|
import { sound, soundSampleUrls } from '../sounds';
|
|
|
|
|
|
export const dialogSema: Sema = {
|
|
|
name: 'dialog',
|
|
|
preloadSamples: soundSampleUrls(['notification.ping']),
|
|
|
cascade: [
|
|
|
{
|
|
|
selector: '[data-dialog-content][data-event-intent="threat"]',
|
|
|
sound: sound('notification.ping'),
|
|
|
haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] }
|
|
|
}
|
|
|
]
|
|
|
};
|
|
|
```
|
|
|
|
|
|
The app imports the packs it uses (tree-shakable):
|
|
|
|
|
|
```ts
|
|
|
import { dialogSema } from '$uix/sema/components/dialog';
|
|
|
|
|
|
defineEngineSemantic({
|
|
|
components: [dialogSema],
|
|
|
overrides: {
|
|
|
cascade: [
|
|
|
// App-level rules win over packs on specificity ties
|
|
|
{ selector: '#critical [data-dialog-content]', sound: { sampleUrl: '/x.wav' } }
|
|
|
]
|
|
|
}
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`preloadSamples` is concatenated across all packs and passed to
|
|
|
`SoundChannel.preloadSamples()` when the engine is created, when the app asks
|
|
|
for it explicitly — the WAVs can be decoded before the first emit. The global
|
|
|
unlock listener is not registered in the channel's constructor; it is
|
|
|
installed only once there is an `AudioContext` to unlock.
|
|
|
|
|
|
### The sound repository — `sounds.ts`
|
|
|
|
|
|
Components must not declare full sound signatures in every pack. Sema has a
|
|
|
nominal repository:
|
|
|
|
|
|
```ts
|
|
|
import { sample, sound, soundTuning } from '$uix/sema';
|
|
|
|
|
|
sound('handle.pickup.air');
|
|
|
soundTuning('emerge.exit.deep');
|
|
|
```
|
|
|
|
|
|
An entry can be synthetic or point to an external `.wav` file with a
|
|
|
synthetic fallback:
|
|
|
|
|
|
```ts
|
|
|
sample('/sounds/uix/dialog-fail.wav', sound('handle.release.soft'), { preload: true });
|
|
|
```
|
|
|
|
|
|
`SoundChannel` understands `sampleUrl`: it tries to play the WAV and, if
|
|
|
fetch/decode fails, falls back to the synthetic signature. The rule is that
|
|
|
components reference names; URLs and parameters live in one place.
|
|
|
|
|
|
### Channels and signatures
|
|
|
|
|
|
| Channel | Consumed slice | Behavior when not applicable |
|
|
|
| -------- | --------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
|
| `visual` | `effective.hold` for the hold | Falls back to the family table (SEMA_DURATIONS) or `defaultHold` |
|
|
|
| `sound` | `effective.sound` (skips if `'sound'` not in activeChannels) | no-op |
|
|
|
| `haptic` | `effective.haptic` (Vibration API, honors `prefers-reduced-motion`) | no-op if there is no `navigator.vibrate` |
|
|
|
| (custom) | declaration merging of `SemaChannelSignatures` | read by the registered channel |
|
|
|
|
|
|
### The visual channel's attr namespace
|
|
|
|
|
|
`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` |
|
|
|
|
|
|
**Rule**: the channel never touches state attrs (`data-state`,
|
|
|
`data-intent`, `data-disabled`, ...). State is managed by the runtime/morfo.
|
|
|
Reason: state is persistent and a signal is transient; reusing the same name
|
|
|
would force the channel to erase state on cleanup (or into fragile
|
|
|
save/restore if state mutates during the hold).
|
|
|
|
|
|
For CSS:
|
|
|
|
|
|
- `[data-event-intent='risk']` → reacts to the transient occurrence's intent
|
|
|
- `[data-intent='risk']` → reacts to the component's persistent state
|
|
|
|
|
|
Both can coexist on the same element with distinct semantics.
|
|
|
|
|
|
### Hold — the visual channel's resolution chain
|
|
|
|
|
|
The `VisualChannel` resolves its `hold` (how long the `data-event-*` live in
|
|
|
the DOM) in this order:
|
|
|
|
|
|
1. `signal.hold` — imperative per-call override.
|
|
|
2. `effective.hold` — comes from the resolver via `SEMA_MAP.families[*].hold`.
|
|
|
3. The family fallback table (`SEMA_DURATIONS` + per-family label) — defense
|
|
|
when an external family declares no `hold`.
|
|
|
4. The channel's global `defaultHold` (240ms by default).
|
|
|
|
|
|
See `SEMA_MAP.families[*].hold` for the concrete per-family values.
|
|
|
|
|
|
**The hold timer runs on the managed scheduler, not on raw `setTimeout`.**
|
|
|
The engine receives `timers` (in `ActiveUix` it is `uix.timers`) and forwards
|
|
|
it to the three channels: the `VisualChannel`'s hold, the `HapticChannel`'s
|
|
|
`delay` and the `SoundChannel`'s earcon duration are scheduled via
|
|
|
`uix.timers.schedule(...)` (the `semaDelay` helper in
|
|
|
`src/uix/sema/timers.ts`). That makes perceptual timing cancelable on
|
|
|
`dispose`, observable, and deterministic under a fake clock in tests. It only
|
|
|
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
|
|
|
> 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
|
|
|
> and the signal MUST precede the unmount.
|
|
|
|
|
|
```ts
|
|
|
// Per-signal override
|
|
|
semantic.emit({ ..., hold: 1200 })
|
|
|
|
|
|
// Override the visual channel's global default
|
|
|
const semantic = new EngineSemantic({ dom, visual: { defaultHold: 400 } })
|
|
|
|
|
|
// Disable visual (DOM-less environments)
|
|
|
const semantic = new EngineSemantic({ visual: false })
|
|
|
|
|
|
// Enable the built-in sound channel (opt-in: audible side effect)
|
|
|
const semantic = new EngineSemantic({ dom, sound: true })
|
|
|
|
|
|
// With SoundChannel options
|
|
|
const semantic = new EngineSemantic({
|
|
|
dom,
|
|
|
sound: { masterGain: 0.6 }
|
|
|
})
|
|
|
|
|
|
// Enable the built-in haptic channel (opt-in: device feedback)
|
|
|
const semantic = new EngineSemantic({ dom, haptic: true })
|
|
|
|
|
|
// With HapticChannel options
|
|
|
const semantic = new EngineSemantic({
|
|
|
dom,
|
|
|
haptic: { masterIntensity: 0.7 }
|
|
|
})
|
|
|
|
|
|
// Register custom channels (a11y, voice, future)
|
|
|
class A11yChannel implements Channel {
|
|
|
readonly id = 'a11y'
|
|
|
async handle(signal, effective) { /* live-region updates, etc. */ }
|
|
|
}
|
|
|
semantic.register(new A11yChannel())
|
|
|
```
|
|
|
|
|
|
### Override layers — recipes
|
|
|
|
|
|
```ts
|
|
|
import { dialogSema } from '$uix/sema/components/dialog';
|
|
|
|
|
|
const semantic = new EngineSemantic({
|
|
|
dom,
|
|
|
sound: true,
|
|
|
haptic: true,
|
|
|
|
|
|
// Layer 5a — component packs (defaults shipped with each component)
|
|
|
components: [dialogSema /* , toastSema, drawerSema, … */],
|
|
|
|
|
|
overrides: {
|
|
|
// Layer 4 — targeted SEMA_MAP edits, valid app-wide
|
|
|
runtime: {
|
|
|
'families.commit.base.sound.pitch': 850,
|
|
|
'intents.threat.deltas.haptic.intensity': 0.4
|
|
|
},
|
|
|
|
|
|
// Layer 5b — app CSS-like rules, matched against signal.target
|
|
|
// AFTER the component packs
|
|
|
cascade: [
|
|
|
{
|
|
|
selector: '#delete-confirm-dialog [data-dialog-content][data-event-intent="threat"]',
|
|
|
sound: { sampleUrl: '/sounds/scary.wav', gain: 0.25 },
|
|
|
haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] }
|
|
|
},
|
|
|
{
|
|
|
selector: ':root[data-sound="reduce"] [data-event-phase="active"]',
|
|
|
priority: 100,
|
|
|
sound: { gain: 0.05 },
|
|
|
channels: ['sound']
|
|
|
}
|
|
|
]
|
|
|
}
|
|
|
});
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
// Layer 3 — per-event override declared in the component's own morfo.
|
|
|
// Propagates via SomaRuntime → SemanticSignal → resolver. The app can
|
|
|
// still override from the cascade (layers 5a/5b).
|
|
|
|
|
|
// src/uix/morfo/components/dialog.ts
|
|
|
{
|
|
|
name: 'close-after-fail',
|
|
|
semantic: {
|
|
|
family: 'signal',
|
|
|
verb: 'alert',
|
|
|
target: v.partRef('content'),
|
|
|
sequence: 'pre',
|
|
|
intent: 'threat',
|
|
|
// Per-event silencing: no sound, to avoid competing with the live region
|
|
|
channels: ['haptic'],
|
|
|
// Per-event override: the Dialog's own sample
|
|
|
overrides: {
|
|
|
haptic: { kind: 'error', pattern: [60, 80, 60, 80, 60] }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Specificity between rules
|
|
|
|
|
|
Like CSS:
|
|
|
|
|
|
- **Computed selector specificity** — IDs × 100, attributes × 10, classes ×
|
|
|
10, pseudo-classes × 10, elements × 1.
|
|
|
- **Rules apply in ascending specificity order** — the last applied wins each
|
|
|
pointwise conflict.
|
|
|
- **Tie** → declaration order. Component packs appear BEFORE
|
|
|
`overrides.cascade`, so app rules win ties.
|
|
|
- **`priority?: number`** — overrides the computed value for cases that must
|
|
|
win without counting attributes (typically accessibility, `priority: 100+`).
|
|
|
|
|
|
To vary by event, intent, family, name, etc. — everything goes in the
|
|
|
selector via the `[data-event-*]` tokens:
|
|
|
|
|
|
```ts
|
|
|
// Vary by the event's intent
|
|
|
{ selector: '[data-toast-root][data-event-intent="threat"]', haptic: { kind: 'error' } }
|
|
|
|
|
|
// Vary by family
|
|
|
{ selector: '[data-toast-root][data-event-family="signal"]', sound: { gain: 0.4 } }
|
|
|
|
|
|
// Vary by exact event name
|
|
|
{ selector: '[data-toast-root][data-event="close-after-fail"]', sound: { sampleUrl: '/fail.wav' } }
|
|
|
|
|
|
// Vary by event prefix
|
|
|
{ selector: '[data-dialog-content][data-event^="close-"]', sound: { contour: 'descending' } }
|
|
|
```
|
|
|
|
|
|
### SoundChannel
|
|
|
|
|
|
Short earcons synthesized via the Web Audio API from `effective.sound`
|
|
|
(pitch / centroid / roughness / attack / decay / duration / contour / gain).
|
|
|
Details:
|
|
|
|
|
|
- A single `AudioContext` with a master `GainNode` per engine.
|
|
|
- **Prepare-time priming**, no constructor side effect. The channel creates +
|
|
|
resumes the `AudioContext` in `prepare()` when the signal admits `sound`,
|
|
|
synchronously inside the user gesture.
|
|
|
- After creating the context, it registers a `click` / `touchstart` /
|
|
|
`keydown` listener via the injected DOM surface
|
|
|
(`ActiveDom.listen(ActiveDom.getDocument(), ...)`) to re-resume after
|
|
|
passive suspends (tab switch, etc.). If the channel never prepares an
|
|
|
audible signal, it installs no global listeners.
|
|
|
- Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) →
|
|
|
ADSR-lite envelope. If `roughness > 0.2`, a fast AM modulator.
|
|
|
- `contour` (`flat` / `ascending` / `descending` / `arc` / `bell`) is applied
|
|
|
via `osc.detune`.
|
|
|
- If the signature carries a `sampleUrl`, it plays the sample (with an
|
|
|
`AudioBuffer` cache) instead of synthesizing.
|
|
|
- Any failure (no AudioContext, decode failure) is absorbed — sema is
|
|
|
ornamental.
|
|
|
|
|
|
The prepare-time priming pattern applies in general to any channel whose
|
|
|
backend has a "first time must happen inside a gesture" restriction: audio,
|
|
|
vibration, fullscreen, clipboard write. Documented as **convention 12** in
|
|
|
[`GUIA_IMPLEMENTACION_SEMAUIX.md`](../../src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md)
|
|
|
(historical seed).
|
|
|
|
|
|
### Error policy
|
|
|
|
|
|
- Errors in NON-visual channels are logged but never propagate. Sema is
|
|
|
ornamental: an audio-context or vibration-API failure must not abort the
|
|
|
provider's operation.
|
|
|
- If the visual channel throws, the `emit` Promise rejects. The caller
|
|
|
decides.
|
|
|
- In fire-and-forget (`void semantic.emit(...)`), a visual rejection
|
|
|
propagates as an unhandled promise — a conscious policy.
|
|
|
|
|
|
## The per-family intent policy — `SEMA_FAMILY_POLICY`
|
|
|
|
|
|
> **Canonical:** [`CANON.md`](../CANON.md) §4 is the single source for this
|
|
|
> policy. The shape below mirrors `SEMA_FAMILY_POLICY` in `types.ts`; if they
|
|
|
> ever disagree, the code + canon win.
|
|
|
|
|
|
The doctrine on when intent is mandatory lives in a const in
|
|
|
`src/uix/sema/types.ts`. Each family declares **two independent axes**:
|
|
|
|
|
|
```ts
|
|
|
export const SEMA_FAMILY_POLICY = {
|
|
|
contact: { intentRequirement: 'optional', intentGuidance: 'discouraged' },
|
|
|
commit: { intentRequirement: 'required', intentGuidance: 'expected' },
|
|
|
signal: { intentRequirement: 'required', intentGuidance: 'expected' },
|
|
|
handle: { intentRequirement: 'optional', intentGuidance: 'contextual' },
|
|
|
emerge: { intentRequirement: 'optional', intentGuidance: 'contextual' },
|
|
|
shift: { intentRequirement: 'optional', intentGuidance: 'contextual' },
|
|
|
sustain: { intentRequirement: 'optional', intentGuidance: 'contextual' },
|
|
|
delegate: { intentRequirement: 'optional', intentGuidance: 'contextual' }
|
|
|
} as const;
|
|
|
```
|
|
|
|
|
|
- **`intentRequirement`** (`'required' | 'optional' | 'forbidden'`) — the
|
|
|
compile-time gate that shapes the discriminated union. `'required'` (only
|
|
|
`commit` and `signal`) → `intent` is MANDATORY in `MorfoEventSemantic`.
|
|
|
`'forbidden'` is reserved; no family uses it today.
|
|
|
- **`intentGuidance`** (`'expected' | 'contextual' | 'discouraged'`) — a
|
|
|
doctrinal hint with no type effect. `commit`/`signal` are `'expected'`;
|
|
|
`contact` is `'discouraged'` (book ch. 22 §11: "strong intent should not
|
|
|
live in the contact"); the rest are `'contextual'`.
|
|
|
|
|
|
**How it is enforced:**
|
|
|
|
|
|
1. **Compile time** — `SemaEvent` and `MorfoEventSemantic` are discriminated
|
|
|
unions derived from the `intentRequirement` axis. Flipping a family from
|
|
|
`'optional'` to `'required'` forces every morfo of that family to declare
|
|
|
an intent or fail the typecheck.
|
|
|
|
|
|
2. **Runtime** — `validateSemaEvent` throws when an event of a family with
|
|
|
`intentRequirement: 'required'` is built without intent (a defense against
|
|
|
malformed morfos or external inputs).
|
|
|
|
|
|
**Why this policy replaced the valenced/transitional split:**
|
|
|
|
|
|
The original canonical doctrine assumed only valenced families (contact,
|
|
|
commit, signal, handle) could declare intent. Transitional ones (emerge,
|
|
|
shift, sustain) were intent-less by definition. UX reality contradicted it: a
|
|
|
Dialog opening to confirm a destructive delete carries threat in its very
|
|
|
appearance. The policy distinguishes by the practical NEED for intent, not by
|
|
|
taxonomic category.
|
|
|
|
|
|
The valenced/transitional distinction still exists as a classification, but
|
|
|
it no longer dictates the intent rules — the policy does.
|
|
|
|
|
|
## The canonical verb vocabulary (`SEMA_VERBS`)
|
|
|
|
|
|
> **Canonical:** [`CANON.md`](../CANON.md) §6 +
|
|
|
> [`verbs.ts`](../../src/uix/sema/verbs.ts) are the source of truth for the
|
|
|
> verb vocabulary. This section documents how sema *consumes* it (validation,
|
|
|
> naming shapes).
|
|
|
|
|
|
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"]`).
|
|
|
|
|
|
```ts
|
|
|
SEMA_VERBS = {
|
|
|
contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'],
|
|
|
commit: [
|
|
|
'select', 'toggle', 'save', 'submit', 'confirm', 'complete', 'fail',
|
|
|
'cancel', 'reset', 'discard', 'delete', 'restore', 'expire',
|
|
|
'acknowledge',
|
|
|
// Contextual verbs from the book (ch. 29 + ch. 30 + appendix C):
|
|
|
'apply', 'partial', 'block', 'move', 'set', 'remove', 'reorder', 'upload'
|
|
|
],
|
|
|
signal: ['announce', 'notify', 'warn', 'alert', 'inform', 'emphasize', 'remind'],
|
|
|
handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'],
|
|
|
emerge: ['present', 'dismiss', 'open', 'close', 'expand', 'collapse', 'reveal', 'hide'],
|
|
|
shift: ['enter-mode', 'exit-mode', 'navigate', 'route', 'step', 'return', 'context'],
|
|
|
sustain: [
|
|
|
'start', 'progress', 'loading', 'waiting', 'syncing', 'processing',
|
|
|
'streaming', 'pending', 'retrying', 'upload', 'end'
|
|
|
],
|
|
|
// The delegate family (book ch. 29): events where the initiative changes
|
|
|
// hands. Doesn't require AI — workflows, macros, approvals.
|
|
|
delegate: ['offer', 'plan', 'authorize', 'act', 'review', 'escalate', 'return']
|
|
|
};
|
|
|
```
|
|
|
|
|
|
**Verbs that look like one family but belong to another** per the canon:
|
|
|
`select`/`toggle`/`acknowledge` are `commit` (they fix state); `edit` is
|
|
|
expressed as `shift.enter-mode` (it changes the regime — there is no `edit`
|
|
|
verb in handle).
|
|
|
|
|
|
Defined in [`verbs.ts:SEMA_VERBS`](../../src/uix/sema/verbs.ts).
|
|
|
|
|
|
### Naming shapes
|
|
|
|
|
|
A `morfo.events[].name` can take two canonical shapes:
|
|
|
|
|
|
```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.
|
|
|
'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
|
|
|
`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
|
|
|
allowlist in the script covers deliberate divergences.
|
|
|
|
|
|
```ts
|
|
|
import { validateEventName } from '$uix/sema';
|
|
|
|
|
|
validateEventName('commit-toggle');
|
|
|
// { name: 'commit-toggle', head: 'commit', matchesCanonical: true,
|
|
|
// variant: 'toggle', family: 'commit', verb: 'toggle' }
|
|
|
|
|
|
validateEventName('dismiss-outside');
|
|
|
// { name: 'dismiss-outside', head: 'dismiss', matchesCanonical: true,
|
|
|
// variant: 'outside', family: 'emerge', verb: 'dismiss' }
|
|
|
|
|
|
validateEventName('frob-glob');
|
|
|
// { name: 'frob-glob', head: 'frob', matchesCanonical: false,
|
|
|
// variant: 'glob', family: undefined, verb: undefined }
|
|
|
```
|
|
|
|
|
|
## Relationship with Morfo and Soma
|
|
|
|
|
|
- `Morfo` declares the component's semantic events in `morfo.events`
|
|
|
- `Provider` decides when they occur and calls `semantic.emit(...)`
|
|
|
- `SomaRuntime` orchestrates the `prewrite -> emit -> handler -> effects`
|
|
|
sequence
|
|
|
- `Sema` contributes the vocabulary, normalization and domain validation, and
|
|
|
publishes the occurrences
|
|
|
|
|
|
## Dependencies
|
|
|
|
|
|
- `EngineSemantic` writes no attributes directly. It orchestrates channel
|
|
|
hooks (`prepare`, `handle`, `cleanup`) without knowing the DOM attrs.
|
|
|
- Each channel manages its own modality:
|
|
|
- `VisualChannel.prepare()` projects `data-event-*` and then holds the
|
|
|
perceptual window.
|
|
|
- `DomSignalProjector` is the DOM writer used by the visual channel; it
|
|
|
writes through the `ActiveDom` received from `ActiveUix`.
|
|
|
- Other channels (sound, haptic) access their respective APIs
|
|
|
(`AudioContext`, `navigator.vibrate`, etc.).
|
|
|
- In normal use, `ActiveUix` injects the `ActiveDom` into `EngineSemantic`.
|
|
|
Using Sema directly outside `ActiveUix` must pass an explicit
|
|
|
`dom/projector` or choose a documented degradation.
|
|
|
|
|
|
## Architecture rule
|
|
|
|
|
|
`Morfo` authorizes the component's semantics.
|
|
|
|
|
|
`Sema` defines the canonical vocabulary and dispatches signals to channels.
|
|
|
|
|
|
`Provider` decides when to emit.
|
|
|
|
|
|
Each `Channel` materializes the signal in its modality.
|
|
|
|
|
|
See [`architecture/overview.md`](./overview.md) §2.bis for the cross-layer
|
|
|
view. The original Spanish API conventions (single-event for instantaneous
|
|
|
operations, intent ↔ visual token resolution, sound prepare-time priming)
|
|
|
survive as a historical seed in
|
|
|
[`GUIA_IMPLEMENTACION_SEMAUIX.md`](../../src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md);
|
|
|
the ruling vocabulary is [`CANON.md`](../CANON.md).
|