|
|
# Sema
|
|
|
|
|
|
`Sema` define el dominio semántico canónico de UIX y orquesta la emisión de
|
|
|
señales perceptivas.
|
|
|
|
|
|
## Qué es
|
|
|
|
|
|
- familias canónicas: `contact`, `commit`, `signal`, `handle`, `emerge`, `shift`, `sustain`
|
|
|
- intents canónicos: `neutral`, `affirm`, `fulfill`, `risk`, `threat`, `loss`
|
|
|
- política de intent por familia (`SEMA_FAMILY_POLICY`): determina cuándo
|
|
|
el intent es obligatorio (`'expected'`), opcional (`'allowed'` /
|
|
|
`'optional'`)
|
|
|
- normalización entre shape estructurado y label canónico
|
|
|
- validación mínima del dominio
|
|
|
- `EngineSemantic` como registry de canales + dispatch de ocurrencias
|
|
|
|
|
|
`Sema` no decide qué evento ocurrió. El provider lo decide. `EngineSemantic`
|
|
|
recibe la ocurrencia y la despacha a los canales perceptivos registrados.
|
|
|
Cada canal materializa la señal en su modalidad (DOM, audio, vibración).
|
|
|
|
|
|
## Handoff 2026-05-13
|
|
|
|
|
|
Sema ya tiene contrato explicito de DOM: si el canal visual esta activo,
|
|
|
`EngineSemantic` debe recibir `dom` o `projector`. Si se construye desde
|
|
|
`ActiveUix`, recibe el `dom` de `ActiveUix`; si se usa directamente fuera de
|
|
|
UIX, el integrador debe pasar un writer explicito o usar `visual:false`.
|
|
|
Sema no cae a escrituras DOM directas por defecto.
|
|
|
|
|
|
Tambien queda por decidir si `emit` debe seguir siendo secuencial estricto de
|
|
|
forma global o si la secuenciacion pertenece al evento/morfo. No cambiar esto
|
|
|
sin documentar antes la tabla de contratos minimos en
|
|
|
[`../active_architecture.md`](../active_architecture.md).
|
|
|
|
|
|
## Qué ya no es
|
|
|
|
|
|
`Sema` ya no es un runtime multimodal monolítico.
|
|
|
|
|
|
El `EngineSemantic` no contiene:
|
|
|
|
|
|
- política global de accesibilidad
|
|
|
- mapa perceptivo cross-canal
|
|
|
- decisiones sobre qué efecto modal aplicar
|
|
|
|
|
|
Eso vive en cada canal por separado:
|
|
|
|
|
|
- `EngineSemantic` mantiene un registry de canales y despacha cada signal
|
|
|
- el proyector DOM materializa la señal como `data-event*` en el DOM
|
|
|
- `SoundChannel`, `HapticChannel`, futuros engines modales se registran como
|
|
|
canales independientes que reciben la señal y deciden cómo materializarla
|
|
|
en su plano
|
|
|
|
|
|
### Registry de canales — abierto
|
|
|
|
|
|
El conjunto de canales **NO está cerrado**. El framework ships con firmas
|
|
|
canónicas para `sound` y `haptic`. El canal `visual` existe como meta-canal
|
|
|
de proyección/hold, pero no tiene slice propia en `EffectiveSignature`.
|
|
|
Apps pueden añadir canales con firma vía declaration merging:
|
|
|
|
|
|
```ts
|
|
|
// app bootstrap
|
|
|
declare module '$uix/sema' {
|
|
|
interface SemaChannelSignatures {
|
|
|
a11y: A11ySignature; // narrador / live-region
|
|
|
voice: VoiceSignature; // text-to-speech
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Sólo el visual channel es obligatorio (con escape `visual: false`).
|
|
|
Sound y haptic son opt-in. Cualquier canal nuevo registra su `Channel` y
|
|
|
recibe el dispatch.
|
|
|
|
|
|
## El contrato `emit`
|
|
|
|
|
|
```ts
|
|
|
semantic.emit(signal: SemanticSignal): Promise<void>
|
|
|
```
|
|
|
|
|
|
Una sola firma. Cubre los tres escenarios cuando se compone con `dom.apply`:
|
|
|
|
|
|
```ts
|
|
|
// Cambio estructural sin señal
|
|
|
dom.apply(change);
|
|
|
|
|
|
// Cambio estructural con señal
|
|
|
await semantic.emit(signal);
|
|
|
dom.apply(change);
|
|
|
|
|
|
// Señal sin cambio estructural
|
|
|
void semantic.emit(signal);
|
|
|
```
|
|
|
|
|
|
### Semántica de la Promise — sequential strict
|
|
|
|
|
|
`emit(signal)` resuelve cuando el `VisualChannel` ha completado su
|
|
|
materialización entera:
|
|
|
|
|
|
1. el `VisualChannel.prepare()` proyectó `data-event*` al DOM
|
|
|
2. los atributos vivieron en el DOM durante el `hold` configurado
|
|
|
3. los atributos ya fueron retirados (cleanup completo)
|
|
|
4. la Promise resuelve
|
|
|
|
|
|
Esto es **sequential strict**: el caller aplica el commit estructural
|
|
|
DESPUÉS de que la señal perceptiva haya terminado. No hay paralelismo entre
|
|
|
evento y cambio de estado.
|
|
|
|
|
|
Los canales no-visuales (sound, haptic) son fire-and-forget: arrancan en
|
|
|
paralelo con el visual pero no afectan al timing de la Promise.
|
|
|
|
|
|
### Ciclo de vida interno de `emit`
|
|
|
|
|
|
```
|
|
|
1. Engine genera id/session de la ocurrencia
|
|
|
2. Engine ejecuta `channel.prepare(...)` en los canales registrados
|
|
|
- `VisualChannel.prepare()` proyecta `data-event*` para la cascade
|
|
|
3. Engine resuelve la cascade y despacha la señal a TODOS los canales registrados
|
|
|
- canales no-visuales (sound, haptic) → fire-and-forget (no awaited)
|
|
|
- canal visual → awaited
|
|
|
4. Engine resuelve la Promise cuando el visual ha terminado
|
|
|
(semántica secuencial estricta: cleanup ANTES del resolve)
|
|
|
```
|
|
|
|
|
|
### Canales como módulos
|
|
|
|
|
|
Sema está organizada en canales perceptivos simétricos:
|
|
|
|
|
|
```
|
|
|
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
|
|
|
├── sema-map.ts SEMA_MAP data + per-component Sema packs
|
|
|
├── components/ per-component perceptual packs (CSEM)
|
|
|
│ ├── dialog.ts dialogSema — cascade rules + preloadSamples
|
|
|
│ ├── toast.ts (futuro)
|
|
|
│ └── …
|
|
|
└── chans/
|
|
|
├── types.ts interfaz Channel — 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)
|
|
|
```
|
|
|
|
|
|
El engine no muta atributos DOM directamente: ejecuta el hook genérico
|
|
|
`channel.prepare(...)`. En el canal visual, ese hook delega la proyección a un
|
|
|
`SignalProjector`. Cuando lo construye `ActiveUix`, ese proyector escribe via
|
|
|
el `ActiveDom` de UIX. El engine resuelve cada signal en una
|
|
|
`EffectiveSignature` con `hold`, `sound`, `haptic` y futuros canales tipados,
|
|
|
y dispatcha `(signal, effective)` a cada canal. Cada canal lee su slice
|
|
|
(`effective.sound` para audio, `effective.haptic` para vibración, etc.) o
|
|
|
ignora el signature si no lo usa. Solo el canal visual bloquea al caller con
|
|
|
el hold perceptivo; los demás son fire-and-forget.
|
|
|
|
|
|
### Resolver y sema-map — cascada de 6 capas
|
|
|
|
|
|
El engine, en cada `emit`:
|
|
|
|
|
|
1. Ejecuta los `prepare` de canales. El `VisualChannel` proyecta los tokens
|
|
|
semánticos `data-event`, `data-event-family`, `data-event-intent`,
|
|
|
`data-event-phase`, `data-event-id` al `signal.target`. Estos son los
|
|
|
tokens que la cascade y la CSS de eidos leen.
|
|
|
2. Llama a `resolveSignature(signal, opts)` que aplica la cascada de 6
|
|
|
capas (cada una sobreescribe la anterior):
|
|
|
|
|
|
```
|
|
|
1. FAMILY base — SEMA_MAP.families[signal.family].base
|
|
|
sound / haptic + activeChannels + hold
|
|
|
2. INTENT deltas — SEMA_MAP.intents[signal.intent] cuando exista;
|
|
|
numbers ADD por defecto — son modificadores)
|
|
|
3. soundPack URL — SEMA_MAP.soundPack[<family>-<intent>]
|
|
|
4. MORFO overrides — signal.overrides + signal.channels
|
|
|
(numbers REPLACE por defecto — son set values)
|
|
|
5. RUNTIME overrides — engineOpts.overrides.runtime (path-based globals;
|
|
|
baked into el map en el constructor; numbers REPLACE)
|
|
|
6. CASCADE rules — engineOpts.components (per-component packs prepended)
|
|
|
+ engineOpts.overrides.cascade (app-level appended;
|
|
|
gana en empate de specificity por declaration order).
|
|
|
Selectors CSS-like contra signal.target con los
|
|
|
data-event-* ya stampados; numbers REPLACE.
|
|
|
```
|
|
|
|
|
|
3. Despacha a cada canal con la signature resuelta.
|
|
|
4. Awaita el VisualChannel (que aporta el hold).
|
|
|
5. Ejecuta el cleanup de los handles devueltos por `prepare`.
|
|
|
|
|
|
**Convención de overrides vs deltas — números:**
|
|
|
|
|
|
- **Capa 2 (intent.deltas)**: `pitch: -200` significa "restar 200 al
|
|
|
pitch base". Modificadores compositivos.
|
|
|
- **Capas 4, 5, 6 (overrides)**: `pitch: 720` significa "set pitch a
|
|
|
720". Como CSS — `gain: 0.4` no añade, asigna.
|
|
|
- Para sumar explícitamente desde una capa de override: `{ op: 'add',
|
|
|
value: 100 }`.
|
|
|
- Para multiplicar: `{ op: 'multiply', factor: 1.2 }`.
|
|
|
- Para reemplazar primitivos no-numéricos: `{ op: 'replace', value: ... }`.
|
|
|
|
|
|
Si `signal.family` falta o no está en el map, devuelve un
|
|
|
`EffectiveSignature` vacío — los canales hacen no-op.
|
|
|
|
|
|
### Tokens semánticos — `data-event-*`
|
|
|
|
|
|
El `VisualChannel.prepare()` proyecta los siguientes attrs en `signal.target`
|
|
|
ANTES de resolver la cascade. Las rules con selectores sobre estos attrs
|
|
|
matchean nativamente vía `target.matches()` / `target.closest()`:
|
|
|
|
|
|
| Attr | Valor | Origen |
|
|
|
| ------------------- | -------------------- | ------------------------------- |
|
|
|
| `data-event` | `'close-after-fail'` | `signal.name` |
|
|
|
| `data-event-family` | `'signal'` | `signal.family` |
|
|
|
| `data-event-intent` | `'threat'` | `signal.intent` (cuando exista) |
|
|
|
| `data-event-phase` | `'active'` | mientras dure el hold |
|
|
|
| `data-event-id` | `'sig-42'` | id de ocurrencia |
|
|
|
|
|
|
Esos tokens son la **superficie de contacto cross-channel**: la cascade
|
|
|
de sema (`sound`, `haptic` y futuros canales) los lee con selectores CSS,
|
|
|
igual que el CSS de eidos los lee para tintar bordes / animar estados durante
|
|
|
el hold. Una sola superficie perceptiva, con dueños separados.
|
|
|
|
|
|
### Cascade rules — forma plana CSS-like
|
|
|
|
|
|
```ts
|
|
|
interface SemaCascadeRule {
|
|
|
selector: string // CSS selector — matchea state attrs + tokens evento
|
|
|
priority?: number // override de specificity CSS (opcional)
|
|
|
channels?: readonly SemaChannelId[] // restringe / silencia canales activos
|
|
|
sound?: …
|
|
|
haptic?: …
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Una rule = un selector + un block de deltas. Sin capa intermedia
|
|
|
`overrides: { eventLabel: ... }` — la identidad del evento se lee del
|
|
|
selector mediante los tokens `[data-event*]`.
|
|
|
|
|
|
### 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`
|
|
|
|
|
|
Cada componente trae su pack de defaults perceptivos en
|
|
|
`src/uix/sema/components/{name}.ts`, simétrico a soma y eidos:
|
|
|
|
|
|
```ts
|
|
|
import type { Sema } from '../sema-map';
|
|
|
|
|
|
export const dialogSema: Sema = {
|
|
|
name: 'dialog',
|
|
|
preloadSamples: ['/sounds/dialog/saved.wav', '/sounds/dialog/failed.wav'],
|
|
|
cascade: [
|
|
|
{
|
|
|
selector: '[data-dialog-content][data-event-intent="threat"]',
|
|
|
sound: { sampleUrl: '/sounds/dialog/failed.wav' },
|
|
|
haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] }
|
|
|
}
|
|
|
]
|
|
|
};
|
|
|
```
|
|
|
|
|
|
App importa los packs que use (tree-shakable):
|
|
|
|
|
|
```ts
|
|
|
import { dialogSema } from '$uix/sema/components/dialog';
|
|
|
|
|
|
defineEngineSemantic({
|
|
|
components: [dialogSema],
|
|
|
overrides: {
|
|
|
cascade: [
|
|
|
// App-level rules ganan sobre los packs en empate de specificity
|
|
|
{ selector: '#critical [data-dialog-content]', sound: { sampleUrl: '/x.wav' } }
|
|
|
]
|
|
|
}
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`preloadSamples` se concatena entre todos los packs y se le pasa a
|
|
|
`SoundChannel.preloadSamples()` al crear el engine cuando la app lo pide
|
|
|
explicitamente — los WAVs pueden quedar decodeados antes del primer emit.
|
|
|
El listener global de unlock no se registra en el constructor del canal; se
|
|
|
instala solo cuando existe un `AudioContext` que desbloquear.
|
|
|
|
|
|
### Canales y signatures
|
|
|
|
|
|
| Canal | Slice consumido | Comportamiento si no aplica |
|
|
|
| -------- | -------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
|
| `visual` | `effective.hold` para hold | Cae al family fallback table (SEMA_DURATIONS) o al `defaultHold` |
|
|
|
| `sound` | `effective.sound` (skip si `'sound'` no en activeChannels) | no-op |
|
|
|
| `haptic` | `effective.haptic` (Vibration API, respeta `prefers-reduced-motion`) | no-op si no hay `navigator.vibrate` |
|
|
|
| (custom) | declaración merging del `SemaChannelSignatures` | leído por el canal registrado |
|
|
|
|
|
|
### Namespace de attrs del canal visual
|
|
|
|
|
|
El `VisualChannel.prepare()` proyecta **solo** atributos bajo el prefijo
|
|
|
`data-event-*`:
|
|
|
|
|
|
| Attr | Cuándo |
|
|
|
| ------------------- | -------------------- |
|
|
|
| `data-event` | siempre |
|
|
|
| `data-event-id` | siempre |
|
|
|
| `data-event-phase` | siempre (`'active'`) |
|
|
|
| `data-event-family` | si `signal.family` |
|
|
|
| `data-event-intent` | si `signal.intent` |
|
|
|
|
|
|
**Regla**: el canal nunca toca state attrs (`data-state`, `data-intent`,
|
|
|
`data-disabled`, ...). El estado lo gestiona el runtime/morfo. Razón:
|
|
|
state es persistente y signal es transitorio; pisar el mismo nombre
|
|
|
fuerza al canal a borrar estado al limpiar (o a save/restore frágil si
|
|
|
el estado muta durante el hold).
|
|
|
|
|
|
Para CSS:
|
|
|
|
|
|
- `[data-event-intent='risk']` → reacciona al intent de la ocurrencia transitoria
|
|
|
- `[data-intent='risk']` → reacciona al estado persistente del componente
|
|
|
|
|
|
Ambos pueden coexistir en el mismo elemento con semánticas distintas.
|
|
|
|
|
|
### Hold — cadena de resolución del canal visual
|
|
|
|
|
|
El `VisualChannel` resuelve su `hold` (cuánto viven los `data-event-*`
|
|
|
en el DOM) en este orden:
|
|
|
|
|
|
1. `signal.hold` — override imperativo per llamada.
|
|
|
2. `effective.hold` — viene del resolver desde `SEMA_MAP.families[*].hold`.
|
|
|
3. Family fallback table (`SEMA_DURATIONS` + label per familia) — defensa
|
|
|
si una familia externa no declara `hold`.
|
|
|
4. `defaultHold` global del canal (240ms por defecto).
|
|
|
|
|
|
Ver `SEMA_MAP.families[*].hold` para los valores concretos por familia.
|
|
|
|
|
|
```ts
|
|
|
// Override per signal
|
|
|
semantic.emit({ ..., hold: 1200 })
|
|
|
|
|
|
// Override default global del canal visual
|
|
|
const semantic = new EngineSemantic({ dom, visual: { defaultHold: 400 } })
|
|
|
|
|
|
// Desactivar visual (entornos sin DOM)
|
|
|
const semantic = new EngineSemantic({ visual: false })
|
|
|
|
|
|
// Activar el canal de sonido built-in (opt-in: tiene side effect audible)
|
|
|
const semantic = new EngineSemantic({ dom, sound: true })
|
|
|
|
|
|
// Con opciones de SoundChannel
|
|
|
const semantic = new EngineSemantic({
|
|
|
dom,
|
|
|
sound: { masterGain: 0.6 }
|
|
|
})
|
|
|
|
|
|
// Activar el canal háptico built-in (opt-in: feedback del dispositivo)
|
|
|
const semantic = new EngineSemantic({ dom, haptic: true })
|
|
|
|
|
|
// Con opciones de HapticChannel
|
|
|
const semantic = new EngineSemantic({
|
|
|
dom,
|
|
|
haptic: { masterIntensity: 0.7 }
|
|
|
})
|
|
|
|
|
|
// Registrar canales a medida (a11y, voice, futuros)
|
|
|
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,
|
|
|
|
|
|
// Capa 6a — packs de componentes (defaults shipped con cada componente)
|
|
|
components: [dialogSema /* , toastSema, drawerSema, … */],
|
|
|
|
|
|
overrides: {
|
|
|
// Capa 5 — edits puntuales del SEMA_MAP, válidos a TODA la app
|
|
|
runtime: {
|
|
|
'families.commit.base.sound.pitch': 850,
|
|
|
'intents.threat.deltas.haptic.intensity': 0.4
|
|
|
},
|
|
|
|
|
|
// Capa 6b — rules CSS-like de la app, matched contra signal.target
|
|
|
// AFTER de los packs de componente
|
|
|
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
|
|
|
// Capa 4 — per-event override declarado en la propia morfo del componente.
|
|
|
// Propaga vía SomaRuntime → SemanticSignal → resolver. La app puede
|
|
|
// seguir sobreescribiendo desde la cascade (capa 6).
|
|
|
|
|
|
// 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: sin sonido para evitar competir con el live-region
|
|
|
channels: ['haptic'],
|
|
|
// Per-event override: sample propio del Dialog
|
|
|
overrides: {
|
|
|
haptic: { kind: 'error', pattern: [60, 80, 60, 80, 60] }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Specificity entre rules
|
|
|
|
|
|
Como CSS:
|
|
|
|
|
|
- **Specificity computada del selector** — IDs × 100, atributos × 10,
|
|
|
classes × 10, pseudo-classes × 10, elementos × 1.
|
|
|
- **Rules con specificity ascendente se aplican en orden** — la última
|
|
|
aplicada gana en cada conflicto puntual.
|
|
|
- **Empate** → orden de declaración. Componentes packs aparecen ANTES
|
|
|
de `overrides.cascade`, así app rules ganan en empate.
|
|
|
- **`priority?: number`** — override del valor computado para casos que
|
|
|
necesitan ganar sin contar atributos (típicamente accesibilidad
|
|
|
`priority: 100+`).
|
|
|
|
|
|
Para variar por evento, intent, family, name, etc. — todo va en el
|
|
|
selector mediante los tokens `[data-event-*]`:
|
|
|
|
|
|
```ts
|
|
|
// Variación por intent del evento
|
|
|
{ selector: '[data-toast-root][data-event-intent="threat"]', haptic: { kind: 'error' } }
|
|
|
|
|
|
// Variación por family
|
|
|
{ selector: '[data-toast-root][data-event-family="signal"]', sound: { gain: 0.4 } }
|
|
|
|
|
|
// Variación por nombre exacto de evento
|
|
|
{ selector: '[data-toast-root][data-event="close-after-fail"]', sound: { sampleUrl: '/fail.wav' } }
|
|
|
|
|
|
// Variación por prefijo de evento
|
|
|
{ selector: '[data-dialog-content][data-event^="close-"]', sound: { contour: 'descending' } }
|
|
|
```
|
|
|
|
|
|
### SoundChannel
|
|
|
|
|
|
Earcons cortos sintetizados vía Web Audio API a partir de
|
|
|
`effective.sound` (pitch / centroid / roughness / attack / decay /
|
|
|
duration / contour / gain). Detalles:
|
|
|
|
|
|
- Un único `AudioContext` con `GainNode` master por engine.
|
|
|
- **Prepare-time priming**, no side-effect en el constructor. El canal
|
|
|
crea + resume el `AudioContext` en `prepare()` cuando la señal admite
|
|
|
`sound`, de forma síncrona dentro del gesto de usuario.
|
|
|
- Despues de crear el contexto, registra un listener de `click` /
|
|
|
`touchstart` / `keydown` via la superficie DOM inyectada
|
|
|
(`ActiveDom.listen(ActiveDom.getDocument(), ...)`) para re-resume tras
|
|
|
suspends pasivos (tab switch, etc.). Si el canal nunca prepara una señal
|
|
|
sonora, no instala listeners globales.
|
|
|
- Síntesis: dos osciladores (sine + 5ª) → biquad lowpass (centroid)
|
|
|
→ envelope ADSR-lite. Si `roughness > 0.2`, modulador AM rápido.
|
|
|
- `contour` (`flat` / `ascending` / `descending` / `arc` / `bell`) se
|
|
|
aplica vía `osc.detune`.
|
|
|
- Si la signature trae `sampleUrl`, reproduce el sample (con caché de
|
|
|
`AudioBuffer`) en lugar de sintetizar.
|
|
|
- Cualquier fallo (no-AudioContext, decode failure) se absorbe — sema
|
|
|
es ornamental.
|
|
|
|
|
|
El patrón prepare-time priming aplica en general a cualquier canal cuyo
|
|
|
backend tenga restricción de "primera vez ha de ocurrir en gesture":
|
|
|
audio, vibration, fullscreen, clipboard write. Documentado como
|
|
|
**convención 12** en
|
|
|
[`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md).
|
|
|
|
|
|
### Política de errores
|
|
|
|
|
|
- Errores en canales NO-visuales se loguean pero no propagan. Sema es
|
|
|
ornamental: un fallo de audio context o vibration API no debe abortar
|
|
|
la operación del provider.
|
|
|
- Si el canal visual lanza, la Promise de `emit` rechaza. El caller decide.
|
|
|
- En fire-and-forget (`void semantic.emit(...)`), una rejection del visual
|
|
|
se propaga como unhandled promise — política consciente.
|
|
|
|
|
|
## Política de intent por familia — `SEMA_FAMILY_POLICY`
|
|
|
|
|
|
La doctrina sobre cuándo el intent es obligatorio vive en una const en
|
|
|
`src/uix/sema/types.ts`. Tres niveles:
|
|
|
|
|
|
```ts
|
|
|
export const SEMA_FAMILY_POLICY = {
|
|
|
contact: { intentPolicy: 'allowed' }, // OPCIONAL — neutral por default
|
|
|
commit: { intentPolicy: 'expected' }, // REQUERIDO — toda consumación es evaluativa
|
|
|
signal: { intentPolicy: 'expected' }, // REQUERIDO — alarmas son inherentemente evaluativas
|
|
|
handle: { intentPolicy: 'allowed' }, // OPCIONAL — drag/scrub suelen ser neutrales
|
|
|
emerge: { intentPolicy: 'optional' }, // OPCIONAL — Dialog que abre para confirmar threat sí declara
|
|
|
shift: { intentPolicy: 'optional' },
|
|
|
sustain: { intentPolicy: 'optional' }
|
|
|
} as const;
|
|
|
```
|
|
|
|
|
|
Cada entry es un objeto — anticipa otros campos doctrinales por familia
|
|
|
(default sequence, channels permitidos, hold preferences, gesture phases,
|
|
|
…). Cuando se añadan se ubicarán dentro del mismo objeto sin reestructurar.
|
|
|
|
|
|
**Cómo se aplica:**
|
|
|
|
|
|
1. **Compile time** — `SemaEvent` y `MorfoEventSemantic` son discriminated
|
|
|
unions derivadas de la const. Cambiar `'optional'` → `'expected'` en
|
|
|
`contact` (por ejemplo) hace que cada morfo de toggle/switch/etc.
|
|
|
tenga que declarar intent o falle el typecheck.
|
|
|
|
|
|
2. **Runtime** — `validateSemaEvent` lanza `SemaInvariantError` cuando
|
|
|
un evento de family `'expected'` se construye sin intent (defensa
|
|
|
contra morfos mal-formados o inputs externos).
|
|
|
|
|
|
**Por qué esta política reemplazó al split valenced/transitional:**
|
|
|
|
|
|
La doctrina canónica original asumía que sólo familias valenced
|
|
|
(contact, commit, signal, handle) podían declarar intent. Las
|
|
|
transitional (emerge, shift, sustain) eran intent-less por definición.
|
|
|
La realidad UX lo contradijo: un Dialog que se abre para confirmar
|
|
|
borrar algo destructivo carga threat en su misma aparición. La política
|
|
|
distingue por NECESIDAD práctica de intent, no por categoría taxonómica.
|
|
|
|
|
|
La distinción valenced/transitional sigue existiendo como clasificación,
|
|
|
pero ya no dicta las reglas de intent — eso lo hace la política.
|
|
|
|
|
|
## Vocabulario canónico de verbs (`SEMA_VERBS`)
|
|
|
|
|
|
Cross-component action verbs grouped by family per
|
|
|
[`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md)
|
|
|
§1.3. `morfo.events[].name` debería alinear con este vocabulario para
|
|
|
que sema/sound/haptic puedan suscribir por verb y eidos pueda escribir
|
|
|
selectores transversales (`[data-event^=dismiss]`).
|
|
|
|
|
|
```ts
|
|
|
SEMA_VERBS = {
|
|
|
contact: ['press', 'tap', 'activate', 'focus', 'trigger', 'release'],
|
|
|
commit: [
|
|
|
'select',
|
|
|
'toggle',
|
|
|
'save',
|
|
|
'submit',
|
|
|
'confirm',
|
|
|
'cancel',
|
|
|
'complete',
|
|
|
'fail',
|
|
|
'delete',
|
|
|
'restore',
|
|
|
'reset',
|
|
|
'discard',
|
|
|
'expire',
|
|
|
'acknowledge',
|
|
|
'set',
|
|
|
'remove',
|
|
|
'reorder'
|
|
|
],
|
|
|
signal: ['announce', 'notify', 'warn', 'alert', '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',
|
|
|
'end'
|
|
|
]
|
|
|
};
|
|
|
```
|
|
|
|
|
|
**Verbs que parecen de una familia pero pertenecen a otra** per la
|
|
|
canónica: `select`/`toggle`/`acknowledge` son `commit` (fijan estado);
|
|
|
`edit` se expresa como `shift.enter-mode` (cambia régimen, no hay verb
|
|
|
`edit` en handle).
|
|
|
|
|
|
Definido en [`verbs.ts:SEMA_VERBS`](./verbs.ts).
|
|
|
|
|
|
### Naming shapes
|
|
|
|
|
|
Un `morfo.events[].name` puede tomar dos formas canónicas:
|
|
|
|
|
|
```ts
|
|
|
// Forma 1: {verb}-{variant} — head es el verbo, tail explica el matiz.
|
|
|
'dismiss'; // bare verb
|
|
|
'dismiss-outside'; // verb + variant
|
|
|
'close-cancel'; // verb (close) + variant (cancel)
|
|
|
|
|
|
// Forma 2: {family}-{verb} — head es la familia, tail es el verb canónico.
|
|
|
'commit-toggle'; // family=commit, verb=toggle
|
|
|
'commit-save'; // family=commit, verb=save
|
|
|
```
|
|
|
|
|
|
`validateEventName(name)` reconoce ambas formas y devuelve `{ family,
|
|
|
verb, variant, matchesCanonical }`. Advisory — no rechaza morfos, solo
|
|
|
flagea drift para tooling y revisión.
|
|
|
|
|
|
```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 }
|
|
|
```
|
|
|
|
|
|
## Relación con Morfo y Soma
|
|
|
|
|
|
- `Morfo` declara los eventos semánticos del componente en `morfo.events`
|
|
|
- `Provider` decide cuándo ocurren y llama a `semantic.emit(...)`
|
|
|
- `SomaRuntime` orquesta la secuencia `prewrite -> emit -> handler -> effects`
|
|
|
- `Sema` aporta el vocabulario, la normalización y la validación del dominio,
|
|
|
y publica las ocurrencias
|
|
|
|
|
|
## Dependencias
|
|
|
|
|
|
- `EngineSemantic` no escribe atributos directamente. Orquesta hooks de
|
|
|
canales (`prepare`, `handle`, `cleanup`) sin conocer los attrs DOM.
|
|
|
- Cada canal gestiona su propia modalidad:
|
|
|
- `VisualChannel.prepare()` proyecta `data-event-*` y luego mantiene el
|
|
|
hold perceptivo.
|
|
|
- `DomSignalProjector` es el escritor DOM usado por el canal visual;
|
|
|
escribe mediante el `ActiveDom` recibido desde `ActiveUix`.
|
|
|
- Futuros canales (sound, haptic) accederán a sus APIs respectivas
|
|
|
(`AudioContext`, `navigator.vibrate`, etc.).
|
|
|
- En uso normal, `ActiveUix` inyecta el `ActiveDom` en `EngineSemantic`.
|
|
|
El uso directo de Sema fuera de `ActiveUix` debe pasar un `dom/projector`
|
|
|
explicito o elegir una degradacion documentada.
|
|
|
|
|
|
## Regla de arquitectura
|
|
|
|
|
|
`Morfo` autoriza la semántica del componente.
|
|
|
|
|
|
`Sema` define el vocabulario canónico y despacha señales a los canales.
|
|
|
|
|
|
`Provider` decide cuándo emitir.
|
|
|
|
|
|
Cada `Channel` materializa la señal en su modalidad.
|
|
|
|
|
|
Ver [src/uix/README.md](../README.md) §2.bis para la vista cross-layer y
|
|
|
[`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md)
|
|
|
para las convenciones doctrinales del API (single-event para
|
|
|
operaciones instantáneas, intent ↔ visual token resolution, sound prepare-time
|
|
|
priming, etc.).
|