|
|
---
|
|
|
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<string>
|
|
|
```
|
|
|
|
|
|
The resolved value is the **occurrence id**. Transient signals can ignore it;
|
|
|
a non-transient one (`untilFix` / `untilAction` / `stateBound`, see
|
|
|
[persistence](#persistence)) must be held so the caller can end it with
|
|
|
`clear(id)` or `clearTarget(el)` — there is no other handle on it.
|
|
|
|
|
|
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 (gate + reduction policy; the Web Audio
|
|
|
│ runtime lives in the `$sound` art, injected)
|
|
|
└── 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):
|
|
|
|
|
|
```
|
|
|
THE SOUND — two lookups, and nothing modulates:
|
|
|
|
|
|
nombre = per-emit ?? cascada ?? pack ?? morfo ?? familia[verbo] ?? familia.default
|
|
|
sonido = pack[`${nombre}.${intent}`] ?? pack[nombre] ?? nada
|
|
|
|
|
|
EVERYTHING ELSE — a CSS-like cascade, each layer over the previous:
|
|
|
|
|
|
1. FAMILY base — SEMA_MAP.families[f].base (haptic + activeChannels + hold)
|
|
|
2. INTENT deltas — SEMA_MAP.intents[i].deltas; HAPTIC ONLY since 2026-08-06
|
|
|
3. MORFO overrides — signal.overrides + signal.channels (minus `sound`)
|
|
|
4. RUNTIME overrides — engineOpts.overrides.runtime, baked into the map
|
|
|
5a. PACK cascade — engineOpts.components (minus their `sound`)
|
|
|
5b. APP cascade — engineOpts.overrides.cascade
|
|
|
```
|
|
|
|
|
|
**The sound does not participate in that cascade, and that is the design.**
|
|
|
An intent SELECTS a whole sound; it never bends one. A pack NAMES a sound; it
|
|
|
never authors one. There is nothing to override because there are no
|
|
|
parameters to override — a rule carries a name, and a name replaces a name.
|
|
|
|
|
|
What that removed, and why it is worth knowing before you go looking for the
|
|
|
machinery that used to be here: sound used to be resolved through all six
|
|
|
layers, with intent deltas adding to a family base signature and pack rules
|
|
|
replacing primitives afterwards. Measured across the 71 packs, that produced
|
|
|
214 rules authoring 33 distinct signatures, of which five were the same 700 Hz
|
|
|
note at five volumes and the «direction» variants differed by 20 Hz. The theory
|
|
|
was expressive; the output was mush. It also generated a whole class of defect
|
|
|
(D.7, the S-07 finding, three guards) whose only job was to police arithmetic
|
|
|
that should never have been in a component.
|
|
|
|
|
|
**Precedence among the things that NAME.** Most specific wins: a name passed at
|
|
|
trigger-time beats the app cascade, which beats a pack rule, which beats the
|
|
|
morfo event, which beats the family's verb table. This INVERTED the old order,
|
|
|
in which the cascade beat the per-emit override — whoever fires the occurrence
|
|
|
knows it best.
|
|
|
|
|
|
**The verb tier is what empties the packs.** `SEMA_MAP.families[f].sounds` is
|
|
|
keyed by the event's canonical VERB, with `default` as the fallback cell, so
|
|
|
`emerge` + `close` finds `close` without one line in any overlay's pack. The
|
|
|
verb travels in the signal for exactly this reason (it used to be computed and
|
|
|
discarded — audit S-40). A pack only speaks when it DIFFERS from the default:
|
|
|
the framework's 71 packs carry 30 sound rules between them.
|
|
|
|
|
|
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.** Applies to the HAPTIC channel
|
|
|
and to `hold`; the sound channel has no numbers to converge on any more.
|
|
|
|
|
|
- **Layer 2 (intent.deltas)**: `intensity: 0.2` adds to the base. Modifiers.
|
|
|
- **Layers 3, 4, 5a/5b (overrides)**: `intensity: 0.7` sets. Like CSS.
|
|
|
- To add explicitly from an override layer: `{ op: 'add', value: 0.1 }`.
|
|
|
- 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 { semaSelector } from '$uix/morfo';
|
|
|
import { dialogMorfo } from '$uix/morfo/components/dialog';
|
|
|
import type { Sema } from '../sema-map';
|
|
|
import { sound, soundSampleUrls } from '../sounds';
|
|
|
|
|
|
export const dialogSema: Sema = {
|
|
|
name: 'dialog',
|
|
|
preloadSamples: soundSampleUrls(['notification.ping']),
|
|
|
cascade: [
|
|
|
{
|
|
|
// `[data-dialog-content][data-event-intent="threat"]`, built from
|
|
|
// the morfo contract — a part rename breaks at compile time.
|
|
|
selector: semaSelector(dialogMorfo, 'content', { eventIntent: '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. Apps use the
|
|
|
// SAME typed builder; instance scoping goes in `ancestor`.
|
|
|
{
|
|
|
selector: semaSelector(dialogMorfo, 'content', { ancestor: '#critical' }),
|
|
|
sound: { sampleUrl: '/x.wav' }
|
|
|
}
|
|
|
]
|
|
|
}
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`preloadSamples` is concatenated across all packs and passed to
|
|
|
`SoundChannel.preloadSamples()` when the engine is created, together with every
|
|
|
sample URL the map itself can reach — so installing a pack at runtime
|
|
|
(`applySoundPack`) warms it too, instead of leaving the first play of each name
|
|
|
to pay its own round trip. Preloading does NOT wait for the unlock gesture:
|
|
|
`decodeAudioData` works on a suspended context, while awaiting `resume()` before
|
|
|
the first click stays pending forever and downloaded nothing.
|
|
|
|
|
|
With `preferences.sound === 'off'` it downloads nothing at all — a pack that
|
|
|
cannot be heard is bytes spent on silence. The request is REMEMBERED, not
|
|
|
dropped: the level is read at dispatch, so the first audible play flushes the
|
|
|
deferred warm, and turning sound on does not hand back a cold cache.
|
|
|
|
|
|
The global unlock listener is not registered in the channel's constructor; it is
|
|
|
installed only once there is an `AudioContext` to unlock.
|
|
|
|
|
|
### When a component deserves a pack — participation doctrine (2026-07-07)
|
|
|
|
|
|
`Morfo.expression` (`SemaExpressionMode`, [`morfo/types.ts`](../../src/uix/morfo/types.ts),
|
|
|
author decision 2026-05-26) declares HOW a sema-scoped morfo contributes its
|
|
|
perceptual signature. THIS section is the canonical doctrine; it was
|
|
|
canonized at the component-audit checkpoint (verdicts S3a/S11 — historical
|
|
|
record in [`docs/audit/components/_veredictos.md`](../audit/components/_veredictos.md)):
|
|
|
four legitimate stances, each with shipped exemplars —
|
|
|
|
|
|
1. **`'pack'`** — a cascade in `sema/components/{kebab}.ts` NAMES the sound
|
|
|
per event. Justified when the pattern ADDS meaning beyond family + intent
|
|
|
deltas (date-field naming a fall for its clear; menubar's in-place note:
|
|
|
"subtle commit + tap haptic for high-frequency top-level triggers"). A pack
|
|
|
that only restates the family default is not a pack — it is noise, and the
|
|
|
type makes that cheap to see: one word, or nothing.
|
|
|
2. **`'family-default'`** — family base + intent deltas suffice. Legitimate
|
|
|
when (a) the gesture is generic contact (button), (b) the book itself
|
|
|
counsels restraint (command, citing ch. 22 §8: celebrating at the click is
|
|
|
*"celebrate before time"* — the outcome fires downstream), or (c) a
|
|
|
composed child supplies the character (field-langs: the embedded
|
|
|
ToggleGroup's pack puts the tap). The reason is written IN the morfo.
|
|
|
3. **`'delegated'`** — composite; expression lives in the children's packs.
|
|
|
The composite declares ONLY the events its children don't own (date-picker:
|
|
|
its own `commit-reset`; selection/commit sound through the embedded
|
|
|
date-field/calendar). The anti-duplication rationale is radio-cards'
|
|
|
morfo header: *"declaring them here would duplicate the contract"*.
|
|
|
4. **Declared debt** — `events: []` with an honest "yet" comment (the generic
|
|
|
Picker) is better than silence but MUST carry a deadline or become a
|
|
|
decision: an undated "yet" is a hole in disguise.
|
|
|
|
|
|
Coherence is guarded: `morfo-vocabulary-check` fails when a pack file exists
|
|
|
and `expression` declares anything other than `'pack'`, and warns when a pack
|
|
|
exists with no `expression` at all (verdict S11d).
|
|
|
|
|
|
### The sound pack — `SEMA_MAP.sounds`
|
|
|
|
|
|
**A component names a sound. It never writes a parameter.** The whole authoring
|
|
|
surface of the sound channel is one word, and only when the component differs
|
|
|
from its family's default:
|
|
|
|
|
|
```ts
|
|
|
{ selector: onTrigger({ eventName: 'contact-activate' }), sound: 'snap' }
|
|
|
```
|
|
|
|
|
|
`Sema['cascade'].sound` accepts a `SoundName` or `SILENT`, **and nothing else** —
|
|
|
the law is a type, not a convention. The same holds for the morfo's per-event
|
|
|
`overrides.sound`, for `overrides.cascade` (the app's door) and for the per-emit
|
|
|
option. Nobody authors a signature inline, not even the product: bringing a new
|
|
|
sound means REGISTERING it (declare the name, define it once) and then naming it.
|
|
|
|
|
|
#### The two kinds of entry
|
|
|
|
|
|
The pack has BASES (`tick`) and VARIANTS (`tick.threat`). Resolution is two
|
|
|
lookups: `name.intent`, then bare `name`, then silence. So a pack that ships only
|
|
|
bases works whole — the intent is ignored where no variant exists — and a pack
|
|
|
that cares adds variants only where it matters.
|
|
|
|
|
|
The default pack is **pure synthesis**: it works offline, pays no fetch and
|
|
|
cannot 404. A sample entry (`wav` / `mp3` / `ogg` / `aac`) carries its URL plus a
|
|
|
synthetic FALLBACK, so a missing file degrades to a designed sound rather than to
|
|
|
silence. Whether a name is a recipe or a file is not the component's business.
|
|
|
|
|
|
##### Two ways to DELIVER a file-based pack
|
|
|
|
|
|
The delivery is not part of the contract. A pack is
|
|
|
`Partial<Record<SoundName, NamedSound>>` either way, hot-swappable either way,
|
|
|
and a name that is not a `SoundName` is a compile error either way.
|
|
|
|
|
|
- **One file per name.** The pack points at URLs (`sample('/sounds/tick.mp3', …)`).
|
|
|
The engine's cache is keyed by URL, so each file is fetched and decoded once;
|
|
|
the browser caches each independently, and `preload: false` on an entry keeps
|
|
|
it out of the warm-up so rare sounds load on demand.
|
|
|
- **One file for the whole pack.** A JSON of base64 payloads keyed by sound
|
|
|
name, turned into a pack by `packFromBase64()` — the entries become `data:`
|
|
|
URIs. Measured on `static/sounds/ui-inline.json`: **one** request instead of
|
|
|
fifteen, and 69.082 B under brotli against 79.332 B for the loose files,
|
|
|
because base64's +33 % is undone by compression while `audio/mpeg` is not
|
|
|
compressed by anyone. The cost is granularity: the pack caches as one object,
|
|
|
so one changed sound re-downloads all of them.
|
|
|
|
|
|
A `data:` URI is decoded IN PLACE — never fetched. A `connect-src 'self'` CSP
|
|
|
BLOCKS `fetch('data:…')` (the directive matches by scheme, and `'self'` does not
|
|
|
cover `data:`) while letting a same-origin path through, so routing an inline
|
|
|
pack through `fetch` made it degrade silently to synthesis in exactly the
|
|
|
environment that made someone inline it. It is also ~6x slower.
|
|
|
|
|
|
#### Designed to be told apart
|
|
|
|
|
|
`sound-names.test.ts` fails when two entries are not separated on at least TWO
|
|
|
perceptual axes — a fifth of pitch, a third of brightness, half again as long,
|
|
|
a different contour, double the level, or 0.2 of roughness.
|
|
|
|
|
|
That guard exists because its absence was measured, by ear, by the author: a
|
|
|
previous catalogue had five names that were the same 700 Hz note at five
|
|
|
volumes, and every test passed. A snapshot could not have caught it — each value
|
|
|
was exactly what the table said. **The missing property was a RELATION between
|
|
|
entries**, and that is what the guard asserts.
|
|
|
|
|
|
#### Verbs, not components
|
|
|
|
|
|
Names describe an occurrence, never a widget: `open`, `tick`, `alert`. A library
|
|
|
organised by component (`button_hard`, `panel_expand`, `window_open`) needs
|
|
|
TRANSLATING into this vocabulary, and several of its files will compete for one
|
|
|
name — that is normal, and the choosing is where a pack author's ear works. See
|
|
|
`packs/ui-mp3.ts` for a worked example, including what it could not use.
|
|
|
|
|
|
#### Samples: what a file can and cannot carry
|
|
|
|
|
|
`playSample` reads **exactly two** fields of the resolved signature: `sampleUrl`
|
|
|
and `gain`. There is no `playbackRate` and no `detune`. So over a recording the
|
|
|
intent can only move the LEVEL.
|
|
|
|
|
|
Consequence, and it is physics rather than policy: **a sample that must carry
|
|
|
evaluative weight needs one file per intent** — `tick.mp3` AND
|
|
|
`tick.threat.mp3`, not one file bent two ways. With synthesis the problem does
|
|
|
not arise, because each variant is designed whole.
|
|
|
|
|
|
#### Silence is a value, not a name
|
|
|
|
|
|
`SILENT` is THE canonical silence — one value for the whole system, exported
|
|
|
from `$uix/sema`. Set a channel slice to it and **the channel that owns the
|
|
|
slice ignores it and dispatches nothing downstream**: no signature is
|
|
|
resolved, no engine is called, no context is opened.
|
|
|
|
|
|
It admits no family, no verb and no intent, because there is nothing to
|
|
|
modulate. That is why it is not a key: a per-family silencer would need to
|
|
|
know which base it is cancelling, which is how the catalogue ended up with
|
|
|
three of them.
|
|
|
|
|
|
```ts
|
|
|
// a pack rule that does not speak in sound
|
|
|
{ selector: onProvider({ eventName: 'emerge-open' }), sound: SILENT }
|
|
|
```
|
|
|
|
|
|
**Absorbing by construction.** Intent deltas do not apply on top of `SILENT`
|
|
|
(layer 2 cannot un-silence). A later override CAN lift it by REPLACING the
|
|
|
slice with a full signature — declaring sound is a decision, not a delta.
|
|
|
|
|
|
**Why it is a value and not a zero.** Until 2026-08-06 silence was arithmetic:
|
|
|
`{family}.silent` subtracted the family's base gain so neutral landed at 0.
|
|
|
A zero gain is a NUMBER, and later layers move numbers. Measured on a
|
|
|
"silenced" toggle: an intent delta that raises `roughness` past the AM
|
|
|
threshold (`risk` +0.2, `threat` +0.4) turned the signature into a raw tremolo
|
|
|
at **−13.9 dBFS** (`risk`) and **−6.7 dBFS** (`threat`) — louder than a real
|
|
|
button press at −9.3. The engine had already written the rule it was breaking:
|
|
|
*«muting DROPS the channel rather than scaling to zero, because a `0` still
|
|
|
buzzes»* (`engine.ts`).
|
|
|
|
|
|
The grammar and the ban on silence-as-arithmetic are enforced by
|
|
|
`src/uix/sema/sounds-grammar.test.ts`, which reads the families from `event.ts`
|
|
|
and the base gains from `sema-map.ts` rather than restating them; the runtime
|
|
|
half (`SILENT` reaches no engine) is pinned in `chans/sound.test.ts`. The guard
|
|
|
exists because `form.*` named seven form controls truthfully on 2026-05-19, was
|
|
|
extended to menus and trees a week later, and reached 50 packs — one of which
|
|
|
was the Form — before anyone noticed, since no check ever looked at these keys.
|
|
|
|
|
|
### 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` — composed by the resolver from
|
|
|
`resolveHoldsByIntent(family, intent)` (`holds.ts`,
|
|
|
`SEMA_HOLDS_BY_INTENT` — the ONE canonical hold source; the per-family
|
|
|
`SEMA_MAP.families[*].hold` field was REMOVED 2026-07-06 because it
|
|
|
duplicated this table and had drifted from the book's regions).
|
|
|
3. The same canonical table, consulted directly by the channel — defense
|
|
|
when a custom map's resolver path didn't compose a hold.
|
|
|
4. The channel's global `defaultHold` (`brief`, 240ms by default).
|
|
|
|
|
|
See `holds.ts` (`SEMA_HOLDS_BY_INTENT`) for the concrete family+intent 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: [
|
|
|
{
|
|
|
// Typed builder here too — `ancestor` carries the instance id
|
|
|
// (it lives outside the morfo's contract: plain string by design).
|
|
|
selector: semaSelector(dialogMorfo, 'content', {
|
|
|
eventIntent: 'threat',
|
|
|
ancestor: '#delete-confirm-dialog'
|
|
|
}),
|
|
|
sound: { sampleUrl: '/sounds/scary.wav', gain: 0.25 },
|
|
|
haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] }
|
|
|
},
|
|
|
{
|
|
|
// Targets NO morfo-emitted attr (a global reduce gate over the
|
|
|
// engine's own `data-event-*` stamps) — legitimately hand-written;
|
|
|
// the builder mandate covers morfo-targeting selectors only.
|
|
|
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 through the
|
|
|
typed builder's matchers, which stamp the `[data-event-*]` tokens. (The
|
|
|
hand-written examples this section used to show targeted `[data-toast-root]`
|
|
|
— a part that never existed in the toast morfo: exactly the silent drift
|
|
|
`semaSelector` turns into a compile error.)
|
|
|
|
|
|
```ts
|
|
|
// Vary by the event's intent
|
|
|
{ selector: semaSelector(toastMorfo, 'item', { eventIntent: 'threat' }), haptic: { kind: 'error' } }
|
|
|
|
|
|
// Vary by family
|
|
|
{ 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' } }
|
|
|
|
|
|
// Vary by event prefix
|
|
|
{ selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-' }), sound: { contour: 'descending' } }
|
|
|
```
|
|
|
|
|
|
### SoundChannel — doctrine here, machine in `$sound`
|
|
|
|
|
|
> **The Web Audio machinery does NOT live in sema** (since 2026-07-30). The
|
|
|
> context lifecycle, the synthesis graph, the unlock-on-gesture and the sample
|
|
|
> path were extracted to the art [`$sound`](../../src/arts/sound/README.md)
|
|
|
> (`EngineSound`) — they were ~63% of a 407-line "channel" and are machinery,
|
|
|
> not perceptual doctrine. Same move `$motion` made out of eidos, for the same
|
|
|
> reason and with the same result: the art owns the RUNTIME, the layer keeps its
|
|
|
> DATA and doctrine.
|
|
|
|
|
|
What the channel keeps — all of it doctrine:
|
|
|
|
|
|
- the `prepare` gate (does this signal admit sound at all?),
|
|
|
- the per-channel reduction policy (BK-REDUCTIONS): `off` silences (meaning
|
|
|
migrates via `SEMA_MIGRATION.sound` → presence / live region), `reduce`
|
|
|
attenuates,
|
|
|
- **its voice** — `SEMA_SOUND_VOICE` in `sounds.ts`, the synthesis calibration
|
|
|
sema's vocabulary was tuned against, registered on the engine at channel
|
|
|
construction (redesign 2026-07-31, D-SR.3 in `PLAN-sound-redesign.md`; it was
|
|
|
baked into the art as constants before). Earcons play on the engine's **`ui`
|
|
|
bus** with that voice — so UI-sound policy (BK-REDUCTIONS, resolved here and
|
|
|
handed over as `gainScale`) can never touch the content's volume; the `ui`
|
|
|
bus itself is the art's graph-side lever, with no direct prefs wiring today,
|
|
|
- and the division of labour: **the channel resolves the LEVEL, the engine
|
|
|
applies the gain**.
|
|
|
|
|
|
Everything else — `SOUNDS` (the named catalogue), the gesture resolvers, the
|
|
|
cascade — is unchanged and still sema's.
|
|
|
|
|
|
**How it reaches the engine.** `EngineSemantic` takes a `soundEngine` option and
|
|
|
forwards it. Both boot modes fill it without the integrator doing anything:
|
|
|
standalone `ActiveUix` creates the engine and passes it in (exposing it as
|
|
|
`uix.sound`); in attach, `defineEngineSemantic()` declares `sound` as a service
|
|
|
dependency and forwards whatever `defineUixServices()` registered. The engine
|
|
|
arrives as a structural port, the `MotionDom` / `SceneDom` pattern — sema never
|
|
|
reaches for it. (**Precision**: the *instance* is injected, but the module graph
|
|
|
is not free of the art — `chans/sound.ts` imports `createEngineSound` as a value
|
|
|
for the private-engine fallback, so an app that builds an `EngineSemantic` still
|
|
|
bundles the synthesizer with sound off. See `PLAN-sound-engine.md` §16.)
|
|
|
Without an injected engine the channel creates a private one and owns
|
|
|
its lifecycle — *whoever creates, disposes*; a shared engine is never closed by
|
|
|
a consumer. That last rule is what lets a page rebuild its `EngineSemantic`
|
|
|
freely: the sema studio does it on every draft edit, and the context survives.
|
|
|
|
|
|
**Why one engine matters.** Browsers cap concurrent `AudioContext`s and the
|
|
|
autoplay unlock gesture is per-context, so a second context leaves one of the
|
|
|
two mute. An app that also plays content (a media player, a waveform) must take
|
|
|
`uix.sound` — or pass `soundEngine` to its own `EngineSemantic` — instead of
|
|
|
opening its own. The art warns when a second context goes live.
|
|
|
|
|
|
Behaviour, unchanged by the extraction:
|
|
|
|
|
|
- **Prepare-time priming**, no constructor side effect: the context is created +
|
|
|
resumed in `prepare()` when the signal admits `sound`, synchronously inside
|
|
|
the user gesture.
|
|
|
- The unlock listener (`pointerdown` / `mousedown` / `click` / `touchstart` /
|
|
|
`keydown`) is registered only AFTER the context exists, through the injected
|
|
|
DOM surface. An engine that never plays installs no global listeners.
|
|
|
- Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) →
|
|
|
`roughness > 0.2` inserts a tremolo stage → ADSR-lite envelope; `contour`
|
|
|
(`flat` / `ascending` / `descending` / `arc` / `bell`) rides `osc.detune`.
|
|
|
The tremolo is a gain node IN SERIES at `1 - depth`, modulated on its own
|
|
|
`.gain`, so the factor peaks at 1 and the trill scales WITH the envelope.
|
|
|
It used to be patched onto `envelope.gain`, where a connection ADDS to the
|
|
|
automation instead of scaling it: the depth stayed constant while the
|
|
|
envelope moved, closing every `signal` note on a non-zero sample (an audible
|
|
|
tick) and inflating a `commit.subtle` charged with `threat` to seven times
|
|
|
its declared level. Pinned by `engine-sound.test.ts`.
|
|
|
- A signature carrying `sampleUrl` plays the sample (with an `AudioBuffer`
|
|
|
cache) and **falls back to synthesis** on fetch / decode failure.
|
|
|
- Any 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-semantica-historica.md`](../decisions/guia-semantica-historica.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.
|
|
|
|
|
|
## Perceptual arbitration, reductions and accessibility (C-series, 2026-07-04)
|
|
|
|
|
|
Landed from the Sema↔book audit (`sema-findings.md`, a process registry since
|
|
|
removed from the tree); anchors in [`book-map.md`](../book-map.md). All
|
|
|
opt-outable, all covered by unit tests.
|
|
|
|
|
|
### Frequency memory — `engine.frequencyMemory` (default on)
|
|
|
|
|
|
`BK-FREQ-MEMORY` (ch. 32 §11): _"la gramática debe tener memoria de frecuencia"_.
|
|
|
Repeated occurrences of the **same** event identity (`signal.name`, else
|
|
|
`family:intent`) attenuate their non-visual channels — `sound.gain` and
|
|
|
`haptic.intensity` — past a small threshold, down to a floor, so the 50th
|
|
|
autosave doesn't sound like the first. `threat` is exempt; the visual hold is
|
|
|
never touched. A window of silence resets the key to full. Distinct from the
|
|
|
pointer-rate throttle (that samples ONE continuous gesture; this damps _repeated_
|
|
|
discrete signals). Disable with `frequencyMemory: false`.
|
|
|
|
|
|
### Dominance arbiter — `engine.dominance` (default on)
|
|
|
|
|
|
`BK-DOMINANCE` (ch. 30 §7): when signals overlap, the engine ranks each active
|
|
|
occurrence (evaluable `commit`/`signal` > structural; higher activation from
|
|
|
intent; recency breaks ties for the newcomer) and **mutes the non-visual
|
|
|
channels** of an incoming signal a still-active occurrence out-ranks — "lo
|
|
|
evaluable calla a lo estructural". `threat` is never muted; the visual hold and
|
|
|
the `announce` channel survive (a dominated event still reads structurally and
|
|
|
remains accessible). Occurrences stay active for a short overlap window. Disable
|
|
|
with `dominance: false`.
|
|
|
|
|
|
### Announce channel — `engine.announce` (opt-in)
|
|
|
|
|
|
`BK-SIGNAL-A11Y` / `BK-A11Y-CRITICAL`: the accessible live region is now a
|
|
|
first-class **channel** (`AnnounceChannel`, id `announce`), opt-in like sound /
|
|
|
haptic. It reads the human-facing text from `signal.message` (never inferred from
|
|
|
the technical event name), derives priority from intent (`threat`/`loss` →
|
|
|
`assertive`, else `polite`), and materializes it either through an injected
|
|
|
announcer (`{ announce }`, e.g. `ActiveUix.announce`, one shared region) or a
|
|
|
self-owned pair of live regions built from the injected `dom`. Fire-and-forget,
|
|
|
never rejects. Supersedes `book-deviations.md` D.8's deferral.
|
|
|
|
|
|
**Live-region doctrine — who owns what (AUX-1 / SEM-1, 2026-07-11).** The
|
|
|
framework has ONE sink, two emitters, and an app-level component:
|
|
|
|
|
|
- `uix.announce` (ActiveUix) owns the SHARED pair of live regions — the
|
|
|
low-level sink (its zwsp-toggle re-announce is an implementation detail of
|
|
|
that sink, not a third policy).
|
|
|
- The two framework **emitters** both prefer delegating to it: the soma
|
|
|
runtime's a11y commitments (`a11ySemantic.requiresLiveRegion` →
|
|
|
`sources.announce`) and this channel; its self-owned clear-then-set regions
|
|
|
are ONLY the no-uix fallback.
|
|
|
|
|
|
⚠️ **The two signatures do not meet, so the delegation needs an adapter.**
|
|
|
`AnnounceFn` takes the priority in an options bag and `uix.announce` takes it
|
|
|
positionally, so the literal `{ announce: uix.announce }` this section used to
|
|
|
prescribe does not compile:
|
|
|
|
|
|
```ts
|
|
|
announce: (message, { priority }) => uix.announce(message, priority);
|
|
|
```
|
|
|
|
|
|
And **no composition root wires it today** — neither `createActiveUix` nor
|
|
|
`attachActiveUix` passes `announce` to the engine, so enabling the channel
|
|
|
currently lands on the self-owned fallback, which adds a SECOND pair of
|
|
|
`role=status` / `role=alert` regions next to the shared one. Fixing this is a
|
|
|
code decision (adapter at the root, or reshape `AnnounceFn`) that has not been
|
|
|
taken — audited 2026-08-05.
|
|
|
- The soma `<Announce>` component is different in kind: an app-authored,
|
|
|
declarative region for app-level messages — not a vehicle for framework
|
|
|
event announcements.
|
|
|
|
|
|
Priority is ONE policy everywhere (SEM-1 — the runtime used to add a
|
|
|
`family === 'signal'` conjunct, making a threat-tinted commit announce
|
|
|
polite on one path and assertive on the other): the evaluative INTENT alone
|
|
|
— `threat` / `loss` → `assertive`, else `polite` — implemented identically
|
|
|
in `priorityForIntent` (`chans/announce.ts`) and the runtime's a11y step
|
|
|
(`runtime.svelte.ts`), pinned by tests on both sides
|
|
|
(`announce.test.ts` · `runtime.svelte.test.ts` SEM-1).
|
|
|
|
|
|
### Per-channel reductions — `engine.preferences`
|
|
|
|
|
|
`BK-REDUCTIONS` (ch. 32 §12 / ch. 33 §7): a symmetric reduction story per
|
|
|
channel. `SemaPreferences { sound?, haptic?: 'full' | 'reduce' | 'off' }` is read
|
|
|
at dispatch (back it with reactive state to make it live). Sound gains a
|
|
|
reduce/off path (attenuate master gain / no-op) symmetric to haptic; haptic
|
|
|
_also_ keeps honoring `prefers-reduced-motion`. For sound, `off` governs the
|
|
|
NETWORK as well as the output: no sample is downloaded while it holds (see
|
|
|
`preloadSamples` above). Visual-motion reduction stays in
|
|
|
eidos CSS; when a channel is `off`, meaning migrates via the table below.
|
|
|
|
|
|
### Migration table — `sema/migration.ts`
|
|
|
|
|
|
`BK-A11Y-MIGRATION` (ch. 33): _"cuando un canal falla, migra el significado, no
|
|
|
el adorno"_ — as **data**, not just prose. `SEMA_MIGRATION` gives per-modality
|
|
|
fallback surfaces (motion → state/focus/text; color → shape/icon/text; sound →
|
|
|
presence/text/liveRegion; haptic → motion/shape/state); `SEMA_MIGRATION_BY_FAMILY`
|
|
|
refines the cases the book calls out (signal-sound → live region; commit-color →
|
|
|
footprint). `resolveFallback(modality, family?)` is the accessor a11y / eidos
|
|
|
consult.
|
|
|
|
|
|
### Frame-intent guardrail (morfo)
|
|
|
|
|
|
`BK-FRAME-NO-INTENT` (Apéndice A): a structural family (`contact` / `emerge` /
|
|
|
`shift` / `sustain` / `delegate`) that declares a non-neutral intent must justify
|
|
|
it with `MorfoEventSemantic.intentRationale`, or `validateMorfo` rejects it. This
|
|
|
makes the "appearance that is the warning" exception ([book-updates
|
|
|
A-1](../audit/book-updates.md)) explicit and auditable rather than a silent
|
|
|
loophole. `handle` is exempt (the book puts intent on `handle.drop`);
|
|
|
`commit`/`signal` require intent anyway.
|
|
|
|
|
|
## Continuous components (drag, swipe, hold)
|
|
|
|
|
|
Most events are discrete: one gesture, one emit, one hold. Direct-manipulation
|
|
|
components — Slider, Drawer, and any future knob / swipe surface — are
|
|
|
**continuous**: the pointer moves at sample rate (dozens to hundreds of events
|
|
|
per second) while the value updates the whole time. Emitting one perceptual
|
|
|
signal per pointer sample would flood the channels and desynchronise the hold.
|
|
|
The rules below keep a continuous gesture perceptually legible without breaking
|
|
|
the emit contract.
|
|
|
|
|
|
### One door: `runtime.trigger(eventName, opts?)`
|
|
|
|
|
|
There is no `emitEvent`. Providers reach the engine through a single method,
|
|
|
`runtime.trigger(name)`, which resolves the morfo's compiled action, applies
|
|
|
polymorphic overrides, runs the handler in the declared `sequence`, awaits the
|
|
|
perceptual emit, and honours the event's a11y commitments. It returns a
|
|
|
`Promise` that represents **the whole occurrence, including the visual hold** —
|
|
|
the same sequential-strict window described in _Promise semantics_.
|
|
|
|
|
|
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_).
|
|
|
|
|
|
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`.
|
|
|
|
|
|
### `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 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`;
|
|
|
the two are just declared indivisible.)
|
|
|
- **The commit** (`commit-set`) is `sequence: 'post'` — the handler settles the
|
|
|
resolved value first, then the signal celebrates it. The move is felt
|
|
|
continuously; the commit is felt once, after. (`post` is handler-then-emit;
|
|
|
see the `sequence` comment in `runtime.svelte.ts` and the overlay note under
|
|
|
_Hold_.)
|
|
|
|
|
|
Slider is the worked example: its morfo declares `handle-pick` / `handle-drag`
|
|
|
as `handle` · `coincident` and `commit-set` as `commit` · `post`.
|
|
|
|
|
|
### Throttle continuous pointer emits; leave keyboard alone
|
|
|
|
|
|
`onpointermove` fires far faster than the eye or ear resolves. Continuous emits
|
|
|
are therefore **coalesced to animation frames** and floored to a
|
|
|
component-tuned minimum interval — both via `ActiveDom`'s `requestFrame` (the
|
|
|
sanctioned rAF vehicle; never a raw `requestAnimationFrame` or `setTimeout`):
|
|
|
|
|
|
- Slider's `queueHandleDrag` stores the pending value, schedules one
|
|
|
`requestFrame`, and inside it skips the emit when less than
|
|
|
`SliderProvider.DRAG_SIGNAL_MS` has elapsed since the last signal.
|
|
|
- Drawer's `flushDragProgress` does the same with `DRAG_PROGRESS_SIGNAL_MS`,
|
|
|
deliberately set _below_ rAF cadence (sparser than one signal per frame).
|
|
|
|
|
|
The concrete millisecond floors are perceptual tuning, not architecture — they
|
|
|
live at those constants in the providers, never copied here.
|
|
|
|
|
|
Keyboard is the opposite case and is **not** throttled: Slider's `onkeydown`
|
|
|
calls `commitValue()` → `trigger('commit-set')` on every arrow / Page / Home /
|
|
|
End press. OS key-repeat is already human-paced and every repeat is a discrete
|
|
|
committed value, so one emit per keydown is correct. Throttling is a property of
|
|
|
_sub-perceptual pointer sampling_, not of continuous components in general.
|
|
|
|
|
|
### The per-emit payload owns the primitives; the cascade only adds character
|
|
|
|
|
|
`handle-drag` carries a dynamic sound payload — pitch / gain / contour derived
|
|
|
from pointer position and velocity — passed per-emit through `trigger`'s
|
|
|
`overrides`. For that live payload to survive frame after frame, the
|
|
|
component's sema pack for the continuous event sets **only**
|
|
|
`channels: ['sound', 'haptic']` and overrides nothing else
|
|
|
(`sema/components/slider.ts`).
|
|
|
|
|
|
> A cascade rule for a continuous event MUST NOT set `pitch` / `gain` /
|
|
|
> `contour` (nor per-intent haptic `intensity`). Those are exactly what the
|
|
|
> per-emit `overrides` control; a rule that also set them would clobber the live
|
|
|
> payload on every frame and flatten the gesture to a constant tone. The rule
|
|
|
> enables the channels and adds character — it never fixes the evaluative
|
|
|
> primitives. (Same division as _Override layers_ and the canon rule "cascade
|
|
|
> rules add character, never the intent's evaluative profile".)
|
|
|
|
|
|
## 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"]`).
|
|
|
|
|
|
> **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
|
|
|
> by real morfos. A copy of a closed set is a copy that goes stale; the
|
|
|
> generated enumeration in
|
|
|
> [`canon/vocabularies.md`](../canon/vocabularies.md#sema-verbs-by-family)
|
|
|
> is regenerated from the const by `npm run docs:vocabularies` and guarded for
|
|
|
> freshness by `npm run docs:check`. Removed 2026-08-06.
|
|
|
|
|
|
|
|
|
**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-semantica-historica.md`](../decisions/guia-semantica-historica.md);
|
|
|
the ruling vocabulary is [`CANON.md`](../CANON.md).
|