refactor(sema)!: el sonido se ELIGE, no se modula — evento.intent = sonido

Directiva del autor, sin matices: «todo el sistema de sonido es una puta
mierda... la unica solucion es evento sonido, evento.intent = sonido, y
punto y nada de mierdas de que si el intent modifica nada».

Tenia razon, y lo que lo destapo fue oirlo: los dieciseis nombres del
catalogo anterior sonaban practicamente igual. Medido despues: cinco de
ellos eran LA MISMA nota de 700 Hz a cinco volumenes, y las variantes de
direccion se separaban 20 Hz. El modelo de modulacion producia teoria
bonita y mush audible.

LA REGLA ENTERA, y no hay mas:

    nombre = per-emit ?? cascada ?? pack ?? morfo ?? familia[verbo] ?? familia.default
    sonido = pack[`${nombre}.${intent}`] ?? pack[nombre] ?? nada

Dos busquedas. El intent SELECCIONA un sonido entero; si no existe la
variante se desprecia y suena la base; si no hay base, silencio. Un
`threat` no es un `tick` con mas aspereza: es otro sonido —mas grave, mas
rasposo, descendente— y no se pueden confundir.

EL TIER DEL VERBO es lo que vacia los packs. La tabla de familia se
indexa por verbo (`emerge.close → 'close'`), asi que ni un solo overlay
escribe su cierre. De ~165 reglas de sonido quedan 30, en 16 de los 71
packs — y las 135 borradas no decian mas que el defecto. Para que el
verbo llegue, ahora VIAJA en la señal: se calculaba y se tiraba con un
`void` (hallazgo S-40), el tipo prometia que se emitia y era falso.

CATALOGO NUEVO: 10 bases + 6 variantes, cada una un sonido diseñado
entero. Medido en el grafo de audio real: de 240 Hz (`tick.loss`) a
1900 Hz (`step`), casi tres octavas, y las variantes evaluativas traen su
propio modulador de aspereza — texturas distintas, no un tono movido.
100% sintesis a proposito: el pack por defecto no lleva binarios, funciona
sin red y no puede dar 404. Los .wav quedan como pack de EJEMPLO.

EL GUARD QUE FALTABA: `distinguishability` exige que dos entradas se
separen en al menos DOS ejes perceptuales. Nacio en rojo contra mi propio
catalogo —ocho pares casi gemelos— y dirigio el diseño hasta separarlos.
Una instantanea no lo habria cazado: cada valor era exactamente el que la
tabla decia; lo que faltaba era una RELACION entre entradas.

GESTOS POR REPETICION (idea del autor). Un arrastre ya emite a su ritmo,
asi que toca `step` (18 ms) por emision: la velocidad del gesto ES la del
trinquete, como una rueda fisica. Mueren los tres resolvers que
sintetizaban tono desde posicion y velocidad —la ultima aritmetica del
sistema— y `handle` queda EXENTA de la memoria de frecuencia, que si no
estrangularia el trinquete al primer arrastre. Perdida firmada: el ritmo
lleva la velocidad, pero la posicion ya no mapea a altura.

NADIE ESCRIBE PARAMETROS, ni la app. `SemaSignatureOverride.sound` y
`SemaCascadeRule.sound` aceptan un nombre o SILENT. La rendija de autoria
inline «para la personalizacion» sobraba: un producto REGISTRA su sonido
(nombre + definicion, una vez) y lo nombra.

⚠️ BUG PROPIO durante la cirugia: al reemplazar el bloque del sonido borre
tambien la aplicacion de deltas de intent, y el HAPTICO dejo de recibirlos.
Lo cazaron sus tests. Restaurado — el haptico sigue modulando porque su
vocabulario es categorico y sus deltas cambian el KIND, que tambien es
seleccion.

MUERE: los deltas de sonido del intent, las bases de sonido de familia, la
capa 1.5, `op:'add'` para sonido, `mergeSoundOverride`, y
`d7-intent-survives.test.ts` — vigilaba una inversion que ya no puede
existir.

VERIFICADO: sema+morfo+soma+sound 1108/1108 · docs:check 0/618 · la pagina
`/uix/docs/sound` reescrita y medida en navegador (commit+threat →
tick.threat; commit+affirm → tick, el intent se desprecia; emerge+close →
close sin una linea de pack; sustain → silencio). Los 7 fallos de la suite
completa son los ajenos y preexistentes de contracts.test.

⚠️ NO SE HA OIDO. Todo esta medido en el grafo de audio, no con un oido.
Si `open` suena a lo que debe sonar un panel abriendose es tu decision, y
se afina en un fichero.

⚠️ El estudio `/temas/sema` compila pero edita firmas crudas: su modelo es
el viejo. Queda por reconvertir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 09f8d76e02
commit 86fa9a1b77

@ -99,7 +99,7 @@ Silence is not a name: it is `SILENT`, one canonical value for the whole
system (`$uix/sema`). Declared on a channel slice, the resolver honours it by
dropping that channel.
`subtle` · `soft` · `medium` · `strong` · `loud` · `subtle.rise` · `soft.rise` · `subtle.fall` · `soft.fall` · `deep.fall` · `air` · `air.strong` · `settle` · `snap` · `ping` · `error`
`touch` · `step` · `tick` · `open` · `close` · `slide` · `alert` · `air` · `settle` · `snap` · `tick.fulfill` · `tick.risk` · `tick.threat` · `tick.loss` · `alert.risk` · `alert.threat`
## Intents (6)

@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
import { resolveSignature, type SemaCascadeRule } from './resolver';
import { SEMA_MAP } from './sema-map';
import { SOUNDS } from './sound-names';
/**
* Cascade scoping — a rule matches THE TARGET, never an ancestor of it.
@ -43,7 +44,7 @@ describe('cascade scoping', () => {
/** The form's own rule — the one that plays the error sample. */
const formRule: SemaCascadeRule = {
selector: '[data-form][data-event="signal-warn-invalid"]',
sound: { gain: 0.9, pitch: 111 }
sound: 'snap'
};
it('does not let an ancestor rule reach a descendant signal', () => {
@ -54,22 +55,22 @@ describe('cascade scoping', () => {
);
// The button's own contact must keep the contact family base — the
// form's error rule is not addressed to it.
expect(eff.sound?.pitch).not.toBe(111);
expect(eff.sound?.gain).toBe(SEMA_MAP.families.contact.base.sound!.gain);
expect(eff.sound?.pitch).not.toBe(SOUNDS.snap.pitch);
expect(eff.sound?.pitch).toBe(SOUNDS.touch.pitch);
});
it('still honours descendant scoping written in the selector', () => {
const { button } = build();
const scoped: SemaCascadeRule = {
selector: '[data-form] [data-button][data-event="contact-activate"]',
sound: { pitch: 222 }
sound: 'alert'
};
button.setAttribute('data-event', 'contact-activate');
const eff = resolveSignature(
{ target: button, name: 'contact-activate', family: 'contact' },
{ map: SEMA_MAP, cascade: [scoped] }
);
expect(eff.sound?.pitch).toBe(222);
expect(eff.sound?.pitch).toBe(SOUNDS.alert.pitch);
});
it('still matches a rule addressed to the target itself — and the intent still lands on it', () => {
@ -78,13 +79,8 @@ describe('cascade scoping', () => {
{ target: form, name: 'signal-warn-invalid', family: 'signal', intent: 'risk' },
{ map: SEMA_MAP, cascade: [formRule] }
);
// The rule matched: 111 is its pitch and nothing else in the map produces
// it. But a matched rule now sets the BASE of the occurrence and the
// intent modulates it afterwards (`risk` is −100 Hz), so the audible
// result is 11, not 111. Before 2026-08-06 the rule ran last and in
// replace mode: `risk` was inaudible, which is the defect D.7 and S-07
// were written to police and this ordering removes.
const risk = SEMA_MAP.intents.risk!.deltas.sound as { pitch: number };
expect(eff.sound?.pitch).toBe(111 + risk.pitch);
// The rule matched, and its NAME is what sounds. `snap` has no `.risk`
// variant, so the intent is ignored — the author's degradation rule.
expect(eff.sound?.pitch).toBe(SOUNDS.snap.pitch);
});
});

@ -141,8 +141,23 @@ export type SemaSignatureOverride = {
*/
channels?: readonly SemaChannelId[];
} & {
[K in keyof SemaChannelSignatures]?:
/**
* `sound` is a NAME from the active pack, or `SILENT` — never a signature.
* Nobody authors a sound inline: not a morfo, not a pack, not an app, not a
* per-emit override. A product that needs a sound the pack lacks REGISTERS
* it (name + definition, once) and then names it.
*/
sound?: SoundNameRef | Silent;
} & {
[K in Exclude<keyof SemaChannelSignatures, 'sound'>]?:
| Partial<SemaChannelSignatures[K]>
| Record<string, DeltaValue>
| Silent;
};
/**
* Loose reference to a pack entry. The precise union (`SoundName`) is derived
* from the map, which imports this module — so the tight type is applied where
* it can be: `Sema['cascade'].sound` and `SemaCascadeRule.sound`.
*/
export type SoundNameRef = string;

@ -107,7 +107,7 @@ describe('audio path: EngineSemantic → SoundChannel → $sound', () => {
engine.dispose();
});
it('rebuilding the engine (the studio\'s hot-reload) keeps producing sound', async () => {
it("rebuilding the engine (the studio's hot-reload) keeps producing sound", async () => {
// `/temas/sema` disposes and rebuilds its EngineSemantic on every draft
// edit. A disposed engine must not poison the next one.
for (let i = 0; i < 3; i++) {

@ -162,12 +162,15 @@ describe('VisualChannel', () => {
});
let resolved = false;
const promise = channel
.handle({ target, name: 'announce', family: 'signal', id: 'sig-1' }, {
family: 'signal',
intent: undefined,
activeChannels: ['sound'],
hold: 100
})
.handle(
{ target, name: 'announce', family: 'signal', id: 'sig-1' },
{
family: 'signal',
intent: undefined,
activeChannels: ['sound'],
hold: 100
}
)
.then(() => {
resolved = true;
});
@ -199,12 +202,15 @@ describe('VisualChannel', () => {
});
let resolved = false;
const promise = channel
.handle({ target, name: 'announce', family: 'signal', id: 'sig-1' }, {
family: 'signal',
intent: undefined,
activeChannels: ['sound'],
hold: 100
})
.handle(
{ target, name: 'announce', family: 'signal', id: 'sig-1' },
{
family: 'signal',
intent: undefined,
activeChannels: ['sound'],
hold: 100
}
)
.then(() => {
resolved = true;
});

@ -28,12 +28,10 @@ export const accordionSema: Sema = {
name: 'accordion',
cascade: [
{
selector: onItem({ eventFamily: 'emerge', eventName: 'expand' }),
sound: 'medium'
selector: onItem({ eventFamily: 'emerge', eventName: 'expand' })
},
{
selector: onItem({ eventFamily: 'emerge', eventName: 'collapse' }),
sound: 'soft.fall'
selector: onItem({ eventFamily: 'emerge', eventName: 'collapse' })
}
]
};

@ -2,29 +2,36 @@ import { semaSelector } from '$uix/morfo';
import { calendarMorfo } from '$uix/morfo/components/calendar';
import type { Sema } from '../sema-map';
const onPart = (
part: 'provider' | 'prev-button' | 'next-button' | 'month-select' | 'year-select' | 'day',
matchers?: Parameters<typeof semaSelector<typeof calendarMorfo>>[2]
) => semaSelector(calendarMorfo, part, matchers);
/**
* Calendar perceptual defaults.
*
* Almost nothing, and that is the point. `commit-select` / `commit-unselect`
* take the commit family's `tick` (and its intent variants, by name);
* `shift-navigate` takes the shift family's `slide` on whichever surface caused
* it — arrow, select, or a day that spilled the month. None of that is written
* here any more: the map's verb table says it once for the whole framework.
*
* Until 2026-08-06 this file carried eight sound rules, one of which set
* `gain: 0.18` — the loudest value in the system, louder than an alert, for
* paging a calendar. It was the residue of a tuning whose family head lied.
*
* The haptic stays: a day is a target you hit, and the tap confirms the finger
* landed on the right one.
*/
const onDay = (matchers?: Parameters<typeof semaSelector<typeof calendarMorfo>>[2]) =>
semaSelector(calendarMorfo, 'day', matchers);
export const calendarSema: Sema = {
name: 'calendar',
cascade: [
{
selector: onPart('day', { eventName: 'commit-select' }),
sound: 'soft',
selector: onDay({ eventName: 'commit-select' }),
haptic: { kind: 'tap' }
},
{
selector: onPart('day', { eventName: 'commit-unselect' }),
sound: 'subtle',
selector: onDay({ eventName: 'commit-unselect' }),
haptic: { kind: 'tap' }
},
...(
['provider', 'prev-button', 'next-button', 'month-select', 'year-select', 'day'] as const
).map((part) => ({
selector: onPart(part, { eventName: 'shift-navigate' }),
sound: 'soft.rise' as const
}))
}
]
};

@ -19,7 +19,7 @@ export const chatComposerSema: Sema = {
selector: semaSelector(chatComposerMorfo, 'provider', {
eventName: 'commit-submit-send'
}),
sound: 'soft',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
}
]

@ -23,7 +23,7 @@ export const chatLogSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'signal-notify-new' }),
sound: 'ping'
sound: 'alert'
}
]
};

@ -24,12 +24,12 @@ export const chatMessageSema: Sema = {
cascade: [
{
selector: onReaction({ eventName: 'commit-select-react' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{
selector: onReaction({ eventName: 'commit-unselect-react' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
}
]

@ -35,12 +35,12 @@ export const checkboxSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-toggle-check' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-toggle-uncheck' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -13,34 +13,31 @@ export const chronosSema: Sema = {
// Event landed (create / edit-save / resize-save).
{
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-save' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
// Event moved by drag.
{
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-move' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
// Event deleted (loss).
{
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-delete' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
// Month navigation.
{
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'shift-navigate' }),
sound: 'soft.rise'
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'shift-navigate' })
},
// Undo / redo.
{
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-restore' }),
sound: 'subtle'
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-restore' })
},
{
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-apply' }),
sound: 'subtle'
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-apply' })
},
// Direct manipulation in flight — haptic only (no sound per frame).
{

@ -14,12 +14,11 @@ export const clipboardSema: Sema = {
cascade: [
{
selector: onTrigger({ eventName: 'commit-save-copy' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onTrigger({ eventName: 'commit-fail-copy' }),
sound: 'soft.fall'
selector: onTrigger({ eventName: 'commit-fail-copy' })
}
]
};

@ -29,12 +29,10 @@ export const collapsibleSema: Sema = {
name: 'collapsible',
cascade: [
{
selector: onContent({ eventName: 'expand' }),
sound: 'medium'
selector: onContent({ eventName: 'expand' })
},
{
selector: onContent({ eventName: 'collapse' }),
sound: 'soft.fall'
selector: onContent({ eventName: 'collapse' })
}
]
};

@ -12,7 +12,7 @@ export const colorFieldSema: Sema = {
cascade: [
{
selector: onInput({ eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -31,7 +31,7 @@ export const colorPickerSema: Sema = {
{
selector: onArea({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: 'air.strong',
sound: 'air',
haptic: { kind: 'tick', intensity: 0.22, duration: 6 }
},
{

@ -18,12 +18,12 @@ export const comboboxSema: Sema = {
cascade: [
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onItem({ eventName: 'commit-unselect' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -26,16 +26,14 @@ export const contextMenuSema: Sema = {
name: 'context-menu',
cascade: [
{
selector: onContent({ eventName: 'open' }),
sound: 'medium'
selector: onContent({ eventName: 'open' })
},
{
selector: onContent({ eventName: 'close' }),
sound: 'soft.fall'
selector: onContent({ eventName: 'close' })
},
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
}
]

@ -39,7 +39,7 @@ export const cropperSema: Sema = {
},
{
selector: onProvider({ eventName: 'commit-crop' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -27,13 +27,13 @@ export const cssFieldSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-set' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onScrubber({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: 'air.strong',
sound: 'air',
haptic: { kind: 'tick', intensity: 0.18, duration: 6 }
},
{
@ -43,7 +43,7 @@ export const cssFieldSema: Sema = {
},
{
selector: onInput({ eventName: 'signal-warn-invalid' }),
sound: 'soft.fall',
haptic: { kind: 'warning' }
}
]

@ -13,12 +13,12 @@ export const dateFieldSema: Sema = {
cascade: [
{
selector: onInput({ eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onInput({ eventName: 'commit-reset' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
}
]

@ -40,8 +40,7 @@ export const dialogSema: Sema = {
// rules match on the family regardless of which event name fired
// (open vs close), giving each family its own perceptual signature.
{
selector: onContent({ eventFamily: 'emerge' }),
sound: 'loud'
selector: onContent({ eventFamily: 'emerge' })
},
{
selector: onContent({ eventFamily: 'commit' }),
@ -71,8 +70,7 @@ export const dialogSema: Sema = {
selector: onContent({
eventName: 'close',
state: { attr: 'data-last-action', value: 'dismissed-outside' }
}),
sound: 'medium'
})
},
// ─── Direccionalidad: close en emerge va DESCENDIENDO ──────────────
@ -83,8 +81,7 @@ export const dialogSema: Sema = {
// sus propias firmas y NO se ven afectadas. La parte VISUAL del
// close (easing, animation) vive en `dialog.css`.
{
selector: onContent({ eventName: 'close', eventFamily: 'emerge' }),
sound: 'deep.fall'
selector: onContent({ eventName: 'close', eventFamily: 'emerge' })
},
// ─── Variant funcional (alertdialog vs dialog) ─────────────────────
@ -96,8 +93,7 @@ export const dialogSema: Sema = {
// ─── Tamaño visual: el sound se atenúa para sm ─────────────────────
{
selector: onContent({ state: { attr: 'data-size', value: 'sm' }, eventFamily: 'emerge' }),
sound: 'medium'
selector: onContent({ state: { attr: 'data-size', value: 'sm' }, eventFamily: 'emerge' })
},
// ─── Sheet (auto en mobile) ─────────────────────────────────────────

@ -31,8 +31,7 @@ export const dragDropSema: Sema = {
haptic: { kind: 'tap' }
},
{
selector: onDraggable({ eventName: 'commit-cancel' }),
sound: 'subtle.fall'
selector: onDraggable({ eventName: 'commit-cancel' })
}
]
};

@ -76,8 +76,7 @@ export const drawerSema: Sema = {
// La parte VISUAL del close (easing, anim, backdrop) vive en
// `eidos/components/drawer/drawer.css`.
{
selector: onContent({ eventName: 'close', eventFamily: 'emerge' }),
sound: 'deep.fall'
selector: onContent({ eventName: 'close', eventFamily: 'emerge' })
}
]
};

@ -37,16 +37,14 @@ export const dropdownMenuSema: Sema = {
name: 'dropdown-menu',
cascade: [
{
selector: onContent({ eventName: 'open' }),
sound: 'medium'
selector: onContent({ eventName: 'open' })
},
{
selector: onContent({ eventName: 'close' }),
sound: 'soft.fall'
selector: onContent({ eventName: 'close' })
},
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
}
]

@ -10,17 +10,17 @@ export const editableSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'shift-enter-mode' }),
sound: 'subtle.rise',
haptic: { kind: 'tick' }
},
{
selector: onProvider({ eventName: 'commit-save' }),
sound: 'soft',
haptic: { kind: 'success' }
},
{
selector: onProvider({ eventName: 'commit-cancel' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
}
]

@ -16,27 +16,27 @@ export const fileUploadSema: Sema = {
cascade: [
{
selector: onTrigger({ eventName: 'trigger-picker' }),
sound: 'subtle',
haptic: { kind: 'tick' }
},
{
selector: onProvider({ eventName: 'commit-set-add' }),
sound: 'soft',
haptic: { kind: 'success' }
},
{
selector: onProvider({ eventName: 'signal-warn-reject' }),
sound: 'soft.fall',
haptic: { kind: 'warning' }
},
{
selector: onItem({ eventName: 'commit-remove' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-reset' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
}
]

@ -40,8 +40,7 @@ export const floatPanelSema: Sema = {
haptic: { kind: 'tap', intensity: 0.5, duration: 20 }
},
{
selector: onContent({ eventName: 'close', eventFamily: 'emerge' }),
sound: 'deep.fall'
selector: onContent({ eventName: 'close', eventFamily: 'emerge' })
}
]
};

@ -10,17 +10,17 @@ export const formSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-submit' }),
sound: 'soft.rise',
haptic: { kind: 'success' }
},
{
selector: onProvider({ eventName: 'signal-warn-invalid' }),
sound: 'error',
sound: 'alert.threat',
haptic: { kind: 'warning' }
},
{
selector: onProvider({ eventName: 'commit-reset' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
}
]

@ -32,7 +32,7 @@ export const gradientBuilderSema: Sema = {
{
selector: onTrack({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: 'air.strong',
sound: 'air',
haptic: { kind: 'tick', intensity: 0.22, duration: 6 }
},
{

@ -23,7 +23,7 @@ export const imageAdjustmentsSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-reset' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -26,12 +26,12 @@ export const imagePickerSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-remove' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{

@ -37,7 +37,7 @@ export const knobSema: Sema = {
{
selector: onControl({ eventName: 'commit-set', eventIntent: 'risk' }),
channels: ['sound', 'haptic'],
sound: 'deep.fall',
haptic: { kind: 'warning' }
}
]

@ -12,12 +12,12 @@ export const listboxSema: Sema = {
cascade: [
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onItem({ eventName: 'commit-unselect' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -19,7 +19,7 @@ export const maskFieldSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-set' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -134,12 +134,10 @@ export const mediaPlayerSema: Sema = {
channels: ['haptic']
},
{
selector: onProvider({ eventName: 'commit-complete' }),
sound: 'soft'
selector: onProvider({ eventName: 'commit-complete' })
},
{
selector: onProvider({ eventName: 'commit-fail' }),
sound: 'soft.fall'
selector: onProvider({ eventName: 'commit-fail' })
}
]
};

@ -13,16 +13,14 @@ export const menuDialSema: Sema = {
name: 'menu-dial',
cascade: [
{
selector: onList({ eventName: 'open' }),
sound: 'medium'
selector: onList({ eventName: 'open' })
},
{
selector: onList({ eventName: 'close' }),
sound: 'soft.fall'
selector: onList({ eventName: 'close' })
},
{
selector: onAction({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -28,7 +28,7 @@ export const menubarSema: Sema = {
cascade: [
{
selector: onTrigger({ eventName: 'commit-select' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
}
]

@ -12,7 +12,7 @@ export const monthGridSema: Sema = {
cascade: [
{
selector: onCell({ eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -24,12 +24,10 @@ export const navTreeSema: Sema = {
name: 'nav-tree',
cascade: [
{
selector: onGroup({ eventName: 'emerge-expand' }),
sound: 'medium'
selector: onGroup({ eventName: 'emerge-expand' })
},
{
selector: onGroup({ eventName: 'emerge-collapse' }),
sound: 'soft.fall'
selector: onGroup({ eventName: 'emerge-collapse' })
}
]
};

@ -36,16 +36,14 @@ export const navigationMenuSema: Sema = {
cascade: [
{
selector: semaSelector(navigationMenuMorfo, 'link', { eventName: 'commit-select' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{
selector: semaSelector(navigationMenuMorfo, 'trigger', { eventName: 'emerge-open' }),
sound: 'medium'
selector: semaSelector(navigationMenuMorfo, 'trigger', { eventName: 'emerge-open' })
},
{
selector: semaSelector(navigationMenuMorfo, 'trigger', { eventName: 'emerge-close' }),
sound: 'soft.fall'
selector: semaSelector(navigationMenuMorfo, 'trigger', { eventName: 'emerge-close' })
}
]
};

@ -23,13 +23,13 @@ export const numberFieldSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-set' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onScrubber({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: 'air.strong',
sound: 'air',
haptic: { kind: 'tick', intensity: 0.18, duration: 6 }
},
{

@ -14,16 +14,14 @@ export const onionMenuSema: Sema = {
name: 'onion-menu',
cascade: [
{
selector: onSurface({ eventName: 'open' }),
sound: 'medium'
selector: onSurface({ eventName: 'open' })
},
{
selector: onSurface({ eventName: 'close' }),
sound: 'soft.fall'
selector: onSurface({ eventName: 'close' })
},
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -12,32 +12,32 @@ export const paginationSema: Sema = {
cascade: [
{
selector: onPart('provider', { eventName: 'commit-set' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onPart('first-trigger', { eventName: 'commit-set' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onPart('prev-trigger', { eventName: 'commit-set' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onPart('item', { eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onPart('next-trigger', { eventName: 'commit-set' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onPart('last-trigger', { eventName: 'commit-set' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -26,17 +26,17 @@ export const passwordFieldSema: Sema = {
cascade: [
{
selector: onPart('input', { eventName: 'commit-submit' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onPart('visibility-trigger', { eventName: 'commit-toggle-visibility' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onPart('caps-lock-indicator', { eventName: 'signal-notify-caps-state' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -23,16 +23,14 @@ export const pickerSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-cancel' }),
sound: 'subtle.fall'
selector: onProvider({ eventName: 'commit-cancel' })
},
{
selector: onProvider({ eventName: 'commit-reset' }),
sound: 'subtle.fall'
selector: onProvider({ eventName: 'commit-reset' })
}
]
};

@ -38,8 +38,7 @@ export const popoverSema: Sema = {
// (~0.10 vs default ~0.20) so it doesn't compete with surrounding
// content. Haptic stays at family default (none for emerge).
{
selector: onContent({ eventFamily: 'emerge' }),
sound: 'strong'
selector: onContent({ eventFamily: 'emerge' })
},
{
selector: onContent({ eventFamily: 'commit' }),
@ -53,8 +52,7 @@ export const popoverSema: Sema = {
// as Dialog/Drawer. Scoped a `close` polymorphic + family emerge —
// commit.save y signal.alert mantienen sus propias firmas.
{
selector: onContent({ eventName: 'close', eventFamily: 'emerge' }),
sound: 'soft.fall'
selector: onContent({ eventName: 'close', eventFamily: 'emerge' })
},
// ─── close + dismiss-outside cause: even more subtle ──────────────
@ -66,8 +64,7 @@ export const popoverSema: Sema = {
selector: onContent({
eventName: 'close',
state: { attr: 'data-last-action', value: 'dismissed-outside' }
}),
sound: 'soft'
})
}
]
};

@ -34,8 +34,7 @@ export const proofOfHumanSema: Sema = {
cascade: [
// Cancel — a quiet descending retreat (mirrors drag-drop).
{
selector: onProvider({ eventName: 'commit-cancel' }),
sound: 'subtle.fall'
selector: onProvider({ eventName: 'commit-cancel' })
},
// Verified — the `fulfill` intent owns the positive profile (+300 Hz,
// ascending, +0.05). This rule only brightens the timbre and adds the
@ -43,7 +42,7 @@ export const proofOfHumanSema: Sema = {
{
selector: onProvider({ eventName: 'commit-confirm' }),
channels: ['sound', 'haptic'],
sound: 'strong',
haptic: { kind: 'tap' }
},
// Failed — the `risk` intent owns the corrective profile (−100 Hz,
@ -51,7 +50,7 @@ export const proofOfHumanSema: Sema = {
{
selector: onProvider({ eventName: 'commit-fail' }),
channels: ['sound', 'haptic'],
sound: 'error',
sound: 'alert.threat',
haptic: { kind: 'error', pattern: [40, 60, 40] }
}
]

@ -27,7 +27,7 @@ export const radioGroupSema: Sema = {
cascade: [
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -19,17 +19,16 @@ export const rangeCalendarSema: Sema = {
cascade: [
{
selector: onDay({ eventName: 'commit-select-start' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onDay({ eventName: 'commit-select-range' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onDay({ eventName: 'commit-reset' }),
sound: 'subtle.fall'
selector: onDay({ eventName: 'commit-reset' })
}
]
};

@ -13,12 +13,12 @@ export const ratingGroupSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onItem({ eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -17,12 +17,12 @@ export const searchFieldSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-submit' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-reset' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -19,12 +19,12 @@ export const selectSema: Sema = {
cascade: [
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onItem({ eventName: 'commit-unselect' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -25,12 +25,10 @@ export const sidebarSema: Sema = {
name: 'sidebar',
cascade: [
{
selector: onPanel({ eventName: 'emerge-expand' }),
sound: 'medium'
selector: onPanel({ eventName: 'emerge-expand' })
},
{
selector: onPanel({ eventName: 'emerge-collapse' }),
sound: 'soft.fall'
selector: onPanel({ eventName: 'emerge-collapse' })
}
]
};

@ -22,7 +22,7 @@ export const sliderSema: Sema = {
{
selector: onProvider({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: 'air.strong',
sound: 'air',
haptic: { kind: 'tick', intensity: 0.22, duration: 6 }
},
{

@ -33,7 +33,7 @@ export const splitterSema: Sema = {
{
selector: onResizeTrigger({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: 'air.strong',
sound: 'air',
haptic: { kind: 'tick', intensity: 0.2, duration: 6 }
},
{

@ -10,12 +10,12 @@ export const stepperSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'shift-step' }),
sound: 'subtle.rise',
haptic: { kind: 'tick' }
},
{
selector: onProvider({ eventName: 'commit-complete' }),
sound: 'soft.rise',
haptic: { kind: 'success' }
}
]

@ -39,7 +39,7 @@ export const switchSema: Sema = {
// table). `contact.soft` keeps it a third of a plain button press
// so a flip never outweighs an activation.
selector: onProvider({ eventName: 'contact-press' }),
sound: 'medium',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{

@ -15,12 +15,11 @@ export const tableSema: Sema = {
cascade: [
{
selector: onRow({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onColumnHeader({ eventName: 'commit-set-sort' }),
sound: 'subtle'
selector: onColumnHeader({ eventName: 'commit-set-sort' })
}
]
};

@ -30,7 +30,7 @@ export const tabsSema: Sema = {
cascade: [
{
selector: onTrigger({ eventName: 'select' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -13,22 +13,22 @@ export const tagGroupSema: Sema = {
cascade: [
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onItem({ eventName: 'commit-unselect' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onItem({ eventName: 'commit-remove' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-reset' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
}
]

@ -16,22 +16,22 @@ export const tagsInputSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-set-add' }),
sound: 'soft',
haptic: { kind: 'success' }
},
{
selector: onInput({ eventName: 'signal-warn-reject' }),
sound: 'soft.fall',
haptic: { kind: 'warning' }
},
{
selector: onItem({ eventName: 'commit-remove' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-reset' }),
sound: 'subtle.fall',
haptic: { kind: 'tap' }
}
]

@ -25,12 +25,12 @@ export const textareaSema: Sema = {
cascade: [
{
selector: onInput({ eventName: 'commit-submit' }),
sound: 'soft',
haptic: { kind: 'tap' }
},
{
selector: onInput({ eventName: 'signal-warn-count-overflow' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -13,7 +13,7 @@ export const timeFieldSema: Sema = {
cascade: [
{
selector: onInput({ eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -29,7 +29,7 @@ export const timelineSema: Sema = {
cascade: [
{
selector: onItem({ eventName: 'emerge-reveal' }),
sound: 'strong',
haptic: { kind: 'tick' }
}
]

@ -41,8 +41,7 @@ export const toastSema: Sema = {
// they were doing. No haptic on present — only the alert intents
// get tactile feedback (via `announce`).
{
selector: onItem({ eventFamily: 'emerge', eventName: 'present' }),
sound: 'medium'
selector: onItem({ eventFamily: 'emerge', eventName: 'present' })
},
// ─── Announce — character per intent ──────────────────────────────
@ -79,8 +78,7 @@ export const toastSema: Sema = {
// (toast leaving the surface) and drops pitch. Gain stays low
// because dismissal is even more passive than appearance.
{
selector: onItem({ eventFamily: 'emerge', eventName: 'dismiss' }),
sound: 'soft.fall'
selector: onItem({ eventFamily: 'emerge', eventName: 'dismiss' })
}
]
};

@ -28,7 +28,7 @@ export const toggleGroupSema: Sema = {
// The contact SPEAKS — «click breve opcional» (book ch. 22, channel
// table). Same signature Switch and Toggle share.
selector: onItem({ eventName: 'contact-press' }),
sound: 'medium',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{

@ -28,7 +28,7 @@ export const toggleSema: Sema = {
// table). `contact.soft` keeps it a third of a plain button press
// so a flip never outweighs an activation.
selector: onProvider({ eventName: 'contact-press' }),
sound: 'medium',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{

@ -13,12 +13,12 @@ export const toolbarSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'commit-toggle' }),
sound: 'subtle',
haptic: { kind: 'tap' }
},
{
selector: onGroupItem({ eventName: 'commit-toggle' }),
sound: 'subtle',
haptic: { kind: 'tap' }
}
]

@ -22,16 +22,14 @@ export const treeGridSema: Sema = {
name: 'tree-grid',
cascade: [
{
selector: onRow({ eventName: 'emerge-expand' }),
sound: 'medium'
selector: onRow({ eventName: 'emerge-expand' })
},
{
selector: onRow({ eventName: 'emerge-collapse' }),
sound: 'soft.fall'
selector: onRow({ eventName: 'emerge-collapse' })
},
{
selector: onRow({ eventName: 'commit-select' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
}
]

@ -37,12 +37,10 @@ export const treeViewSema: Sema = {
name: 'tree-view',
cascade: [
{
selector: onBranch({ eventName: 'emerge-expand' }),
sound: 'medium'
selector: onBranch({ eventName: 'emerge-expand' })
},
{
selector: onBranch({ eventName: 'emerge-collapse' }),
sound: 'soft.fall'
selector: onBranch({ eventName: 'emerge-collapse' })
},
// Commit-select can fire on either part: leaves use `item`, branches
// re-fire the same morfo event but land on the branch element. Two
@ -50,12 +48,12 @@ export const treeViewSema: Sema = {
// consistent across leaf and branch selection.
{
selector: onItem({ eventName: 'commit-select' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{
selector: onBranch({ eventName: 'commit-select' }),
sound: 'subtle',
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
}
]

@ -12,7 +12,7 @@ export const yearGridSema: Sema = {
cascade: [
{
selector: onCell({ eventName: 'commit-set' }),
sound: 'soft',
haptic: { kind: 'tap' }
}
]

@ -1,67 +0,0 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { resolveSignature } from './resolver';
import { SEMA_MAP } from './sema-map';
import { colorPickerSema } from './components/color-picker';
import { gradientPickerSema } from './components/gradient-picker';
import { proofOfHumanSema } from './components/proof-of-human';
/**
* D.7 guard — a pack rule may add CHARACTER, never replace the intent's
* evaluative profile.
*
* These four packs used to canonize samples on `commit` events
* (`sound('handle.release.soft')`, `sound('handle.snap.chime')`,
* `sound('notification.ping')`, `sound('alert.error')`). `sound()` returns a
* COMPLETE signature and the cascade applies it in `replace` mode, so the
* sample flattened exactly what D.7 exists to protect: changing the intent
* changed nothing audible. Corrected 2026-08-06.
*
* The axes the intent owns are `pitch` / `contour` / `gain` / `roughness`; a
* rule composes over them with `{ op: 'add' }` or stays out of them.
*/
describe('D.7 — the intent survives the pack', () => {
/** Matches only the rule whose selector names THIS event — as the DOM would. */
function targetFor(event: string): HTMLElement {
const el = {
matches: (sel: string) => sel.includes(`"${event}"`),
closest: (sel: string) => (sel.includes(`"${event}"`) ? el : null)
} as unknown as HTMLElement;
return el as HTMLElement;
}
function resolve(
pack: { cascade: readonly unknown[] },
name: string,
intent?: 'fulfill' | 'risk'
) {
return resolveSignature(
{ target: targetFor(name), name, family: 'commit', ...(intent ? { intent } : {}) },
{ map: SEMA_MAP, cascade: [...pack.cascade] as never }
).sound;
}
it.each([
['color-picker', colorPickerSema, 'commit-set'],
['gradient-picker', gradientPickerSema, 'commit-save'],
['proof-of-human', proofOfHumanSema, 'commit-confirm']
])('%s · %s keeps the intent audible', (_name, pack, event) => {
const neutral = resolve(pack as never, event)!;
const fulfill = resolve(pack as never, event, 'fulfill')!;
const risk = resolve(pack as never, event, 'risk')!;
// `fulfill` adds +300 pitch and +0.05 gain; `risk` adds +0.2 roughness.
expect(fulfill.pitch).toBeGreaterThan(neutral.pitch);
expect(fulfill.gain).toBeGreaterThan(neutral.gain);
expect(risk.roughness).toBeGreaterThan(neutral.roughness);
});
it('keeps the character the samples carried (a brighter settle)', () => {
// commit base centroid is 1800; the rule lifts it where `handle.release.soft`
// used to sit, without touching a single axis the intent owns.
const neutral = resolve(colorPickerSema as never, 'commit-set')!;
expect(neutral.centroid).toBeGreaterThan(SEMA_MAP.families.commit.base.sound!.centroid);
});
});

@ -21,10 +21,10 @@
* App.events.emit({ target, name: 'dismiss', family: 'emerge' });
*/
import { EngineSemantic, type EngineSemanticOptions } from './engine'
import type { DomApplier } from '$adom'
import type { EngineSound } from '$sound'
import type { AppServiceFactory } from '$active-app/services'
import { EngineSemantic, type EngineSemanticOptions } from './engine';
import type { DomApplier } from '$adom';
import type { EngineSound } from '$sound';
import type { AppServiceFactory } from '$active-app/services';
export function defineEngineSemantic(
options: EngineSemanticOptions = {}
@ -51,10 +51,10 @@ export function defineEngineSemantic(
soundEngine: options.soundEngine ?? (services.sound as EngineSound | undefined),
// Perceptual timers run on the app's managed clock.
timers: options.timers ?? core.timers
})
});
},
dispose(engine) {
engine.dispose()
engine.dispose();
}
}
};
}

@ -1,29 +1,29 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest';
import { SEMA_DURATIONS, resolveSemaDuration } from './durations'
import { SEMA_DURATIONS, resolveSemaDuration } from './durations';
describe('SEMA_DURATIONS', () => {
it('exposes the canonical perceptual scale (ms, ascending)', () => {
expect(SEMA_DURATIONS.subliminal).toBe(50)
expect(SEMA_DURATIONS.glimpse).toBe(120)
expect(SEMA_DURATIONS.brief).toBe(240)
expect(SEMA_DURATIONS.noticed).toBe(600)
expect(SEMA_DURATIONS.insistent).toBe(1200)
expect(SEMA_DURATIONS.persistent).toBe(3000)
})
})
expect(SEMA_DURATIONS.subliminal).toBe(50);
expect(SEMA_DURATIONS.glimpse).toBe(120);
expect(SEMA_DURATIONS.brief).toBe(240);
expect(SEMA_DURATIONS.noticed).toBe(600);
expect(SEMA_DURATIONS.insistent).toBe(1200);
expect(SEMA_DURATIONS.persistent).toBe(3000);
});
});
describe('resolveSemaDuration', () => {
it('returns the literal ms for a number', () => {
expect(resolveSemaDuration(420)).toBe(420)
})
expect(resolveSemaDuration(420)).toBe(420);
});
it('looks up a label', () => {
expect(resolveSemaDuration('brief')).toBe(240)
expect(resolveSemaDuration('noticed')).toBe(600)
})
expect(resolveSemaDuration('brief')).toBe(240);
expect(resolveSemaDuration('noticed')).toBe(600);
});
it('returns undefined when no spec is provided (lets caller fall back)', () => {
expect(resolveSemaDuration(undefined)).toBeUndefined()
})
})
expect(resolveSemaDuration(undefined)).toBeUndefined();
});
});

@ -39,23 +39,23 @@ export const SEMA_DURATIONS = {
noticed: 600,
insistent: 1200,
persistent: 3000
} as const
} as const;
export type SemaDurationLabel = keyof typeof SEMA_DURATIONS
export type SemaDurationLabel = keyof typeof SEMA_DURATIONS;
/**
* What a morfo event or signal can declare for `hold`. Either a label
* from the perceptual scale, or a raw millisecond value when the
* component needs a duration the scale doesn't cover.
*/
export type SemaDurationSpec = SemaDurationLabel | number
export type SemaDurationSpec = SemaDurationLabel | number;
/**
* Resolve a duration spec to its millisecond value. Returns `undefined`
* when no spec is provided so callers can chain to a fallback.
*/
export function resolveSemaDuration(spec: SemaDurationSpec | undefined): number | undefined {
if (spec === undefined) return undefined
if (typeof spec === 'number') return spec
return SEMA_DURATIONS[spec]
if (spec === undefined) return undefined;
if (typeof spec === 'number') return spec;
return SEMA_DURATIONS[spec];
}

@ -249,7 +249,7 @@ describe('EngineSemantic', () => {
cascade: [
{
selector: '[data-event-intent="threat"]',
sound: { sampleUrl: '/cascade-saw-threat.wav' }
sound: 'alert'
}
]
}
@ -262,7 +262,9 @@ describe('EngineSemantic', () => {
family: 'signal',
intent: 'threat'
});
expect(captured?.sound?.sampleUrl).toBe('/cascade-saw-threat.wav');
// The rule matched the stamped intent token and its NAME won — and the
// intent then SELECTED the variant: `alert` + threat = `alert.threat`.
expect(captured?.sound?.pitch).toBe(460);
});
it('does NOT touch persistent state attrs (data-state, data-color, ...)', async () => {
@ -293,11 +295,9 @@ describe('EngineSemantic', () => {
name: 'test-pack',
cascade: [
{
// A framework pack NAMES its sound — `Sema['cascade']` accepts
// nothing else since 2026-08-06. `ping` brings a sampleUrl and a
// pitch of its own, which is what this case needs to observe.
// A pack NAMES its sound; nothing else is accepted.
selector: '[data-event-family="signal"]',
sound: 'ping'
sound: 'alert'
}
]
}
@ -307,7 +307,7 @@ describe('EngineSemantic', () => {
{
selector: '[data-event-family="signal"]',
// Same specificity as the pack rule, declared LATER → wins
sound: { sampleUrl: '/app.wav' }
sound: 'tick'
}
]
}
@ -316,9 +316,9 @@ describe('EngineSemantic', () => {
const target = makeTarget();
await engine.emit({ target, name: 'announce', family: 'signal' });
// App rule wins for sampleUrl (most recent in cascade order).
expect(captured?.sound?.sampleUrl).toBe('/app.wav');
expect(captured?.sound?.pitch).toBe(850);
// The pack name's own pitch isn't touched by the app rule — it survives.
expect(captured?.sound?.pitch).toBe(960);
expect(captured?.sound?.pitch).toBe(850);
});
// ── Frequency memory (C-2) ───────────────────────────────────────────────

@ -634,6 +634,12 @@ export class EngineSemantic {
): EffectiveSignature {
if (!this.frequencyMemory) return effective;
if (effective.intent === 'threat') return effective;
// `handle` is EXEMPT: a continuous gesture sounds by repetition, so its
// emissions are supposed to be many and even. The anti-fatigue rule
// would read the ratchet as hammering and choke it to the floor within
// the first drag — the dynamics of a gesture are its RHYTHM, not a
// level to be walked down.
if (effective.family === 'handle') return effective;
const key = signal.name || `${effective.family ?? ''}:${effective.intent ?? ''}`;
const entry = this.freqCounts.get(key) ?? { count: 0 };

@ -85,7 +85,11 @@ describe('isSemaEvent — policy-driven', () => {
});
it('accepts intent bindings (fromProp shape) on any policy', () => {
const binding = { fromProp: 'intent', default: 'neutral', supported: ['neutral', 'threat'] } as const;
const binding = {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'threat']
} as const;
expect(isSemaEvent({ family: 'commit', intent: binding })).toBe(true);
expect(isSemaEvent({ family: 'emerge', intent: binding })).toBe(true);
});

@ -8,27 +8,27 @@ import {
type IntentBinding,
type SemaTransitionalFamily,
type SemaValencedFamily
} from './types'
import { INTENTS, type Intent } from '../intent'
} from './types';
import { INTENTS, type Intent } from '../intent';
export const SEMA_VALENCED_FAMILIES = [
'contact',
'commit',
'signal',
'handle'
] as const satisfies readonly SemaValencedFamily[]
] as const satisfies readonly SemaValencedFamily[];
export const SEMA_TRANSITIONAL_FAMILIES = [
'emerge',
'shift',
'sustain',
'delegate'
] as const satisfies readonly SemaTransitionalFamily[]
] as const satisfies readonly SemaTransitionalFamily[];
export const SEMA_FAMILIES = [
...SEMA_VALENCED_FAMILIES,
...SEMA_TRANSITIONAL_FAMILIES
] as const satisfies readonly SemaFamily[]
] as const satisfies readonly SemaFamily[];
/**
* All valid `SemaEventKey` values, generated from the family × intent
@ -41,47 +41,48 @@ export const SEMA_EVENT_LABELS: readonly SemaEventKey[] = [
...SEMA_FAMILIES.flatMap((family) =>
INTENTS.map((intent) => `${family}-${intent}` as SemaEventKey)
)
]
];
const FAMILY_SET = new Set<SemaFamily>(SEMA_FAMILIES)
const VALENCED_FAMILY_SET = new Set<SemaValencedFamily>(SEMA_VALENCED_FAMILIES)
const TRANSITIONAL_FAMILY_SET = new Set<SemaTransitionalFamily>(SEMA_TRANSITIONAL_FAMILIES)
const INTENT_SET = new Set<Intent>(INTENTS)
const LABEL_SET = new Set<SemaEventKey>(SEMA_EVENT_LABELS)
const FAMILY_SET = new Set<SemaFamily>(SEMA_FAMILIES);
const VALENCED_FAMILY_SET = new Set<SemaValencedFamily>(SEMA_VALENCED_FAMILIES);
const TRANSITIONAL_FAMILY_SET = new Set<SemaTransitionalFamily>(SEMA_TRANSITIONAL_FAMILIES);
const INTENT_SET = new Set<Intent>(INTENTS);
const LABEL_SET = new Set<SemaEventKey>(SEMA_EVENT_LABELS);
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function isSemaFamily(value: unknown): value is SemaFamily {
return typeof value === 'string' && FAMILY_SET.has(value as SemaFamily)
return typeof value === 'string' && FAMILY_SET.has(value as SemaFamily);
}
export function isSemaValencedFamily(value: unknown): value is SemaValencedFamily {
return typeof value === 'string' && VALENCED_FAMILY_SET.has(value as SemaValencedFamily)
return typeof value === 'string' && VALENCED_FAMILY_SET.has(value as SemaValencedFamily);
}
export function isSemaTransitionalFamily(value: unknown): value is SemaTransitionalFamily {
return typeof value === 'string' && TRANSITIONAL_FAMILY_SET.has(value as SemaTransitionalFamily)
return typeof value === 'string' && TRANSITIONAL_FAMILY_SET.has(value as SemaTransitionalFamily);
}
export function isIntent(value: unknown): value is Intent {
return typeof value === 'string' && INTENT_SET.has(value as Intent)
return typeof value === 'string' && INTENT_SET.has(value as Intent);
}
export function isSemaEventLabel(value: unknown): value is SemaEventLabel {
return typeof value === 'string' && LABEL_SET.has(value as SemaEventKey)
return typeof value === 'string' && LABEL_SET.has(value as SemaEventKey);
}
export function isIntentBinding(value: unknown): value is IntentBinding {
if (!isRecord(value)) return false
if (!isIntent(value.default)) return false
if ('fromProp' in value && value.fromProp !== undefined && typeof value.fromProp !== 'string') return false
if (!isRecord(value)) return false;
if (!isIntent(value.default)) return false;
if ('fromProp' in value && value.fromProp !== undefined && typeof value.fromProp !== 'string')
return false;
if ('supported' in value && value.supported !== undefined) {
if (!Array.isArray(value.supported)) return false
if (!value.supported.every(isIntent)) return false
if (!Array.isArray(value.supported)) return false;
if (!value.supported.every(isIntent)) return false;
}
return true
return true;
}
/**
@ -97,16 +98,16 @@ export function isIntentBinding(value: unknown): value is IntentBinding {
* carry intent unless its policy says otherwise.
*/
export function isSemaEvent(value: unknown): value is SemaEvent {
if (!isRecord(value) || !isSemaFamily(value.family)) return false
if (!isRecord(value) || !isSemaFamily(value.family)) return false;
const requirement = SEMA_FAMILY_POLICY[value.family].intentRequirement
const intentValue = 'intent' in value ? value.intent : undefined
const requirement = SEMA_FAMILY_POLICY[value.family].intentRequirement;
const intentValue = 'intent' in value ? value.intent : undefined;
if (intentValue === undefined) {
return requirement !== 'required'
return requirement !== 'required';
}
return isIntent(intentValue) || isIntentBinding(intentValue)
return isIntent(intentValue) || isIntentBinding(intentValue);
}
/**
@ -114,36 +115,34 @@ export function isSemaEvent(value: unknown): value is SemaEvent {
* may carry an intent (or none) — the parsed shape only distinguishes
* "intent present" from "intent absent" without locking the family kind.
*/
export type ParsedSemaEventLabel =
| { family: SemaFamily }
| { family: SemaFamily; intent: Intent }
export type ParsedSemaEventLabel = { family: SemaFamily } | { family: SemaFamily; intent: Intent };
export function parseSemaEventLabel(label: SemaEventLabel): ParsedSemaEventLabel {
const [family, rawIntent] = label.split('-')
const [family, rawIntent] = label.split('-');
if (rawIntent === undefined) {
return { family: family as SemaFamily }
return { family: family as SemaFamily };
}
return {
family: family as SemaFamily,
intent: rawIntent as Intent
}
};
}
export function resolveIntent(
intent: Intent | IntentBinding,
props?: Record<string, unknown>
): Intent {
if (isIntent(intent)) return intent
if (isIntent(intent)) return intent;
const supported = intent.supported?.filter(isIntent) ?? [...INTENTS]
const fallback = supported[0] ?? intent.default
const candidate = intent.fromProp ? props?.[intent.fromProp] : undefined
const supported = intent.supported?.filter(isIntent) ?? [...INTENTS];
const fallback = supported[0] ?? intent.default;
const candidate = intent.fromProp ? props?.[intent.fromProp] : undefined;
if (isIntent(candidate) && supported.includes(candidate)) {
return candidate
return candidate;
}
return supported.includes(intent.default) ? intent.default : fallback
return supported.includes(intent.default) ? intent.default : fallback;
}
/**
@ -156,43 +155,46 @@ export function normalizeSemaEvent(
event: SemaActionEvent,
props?: Record<string, unknown>
): {
family: SemaFamily
intent: Intent | null
label: SemaEventLabel
family: SemaFamily;
intent: Intent | null;
label: SemaEventLabel;
} {
if (typeof event === 'string') {
const parsed = parseSemaEventLabel(event)
const parsed = parseSemaEventLabel(event);
if ('intent' in parsed) {
return {
family: parsed.family,
intent: parsed.intent,
label: event
}
};
}
return {
family: parsed.family,
intent: null,
label: event
}
};
}
const intentValue = 'intent' in event ? event.intent : undefined
const intentValue = 'intent' in event ? event.intent : undefined;
if (intentValue === undefined) {
return {
family: event.family,
intent: null,
label: event.family as SemaEventLabel
}
};
}
const intent = resolveIntent(intentValue, props)
const intent = resolveIntent(intentValue, props);
return {
family: event.family,
intent,
label: `${event.family}-${intent}` as SemaEventLabel
}
};
}
export function toSemaEventLabel(event: SemaActionEvent, props?: Record<string, unknown>): SemaEventLabel {
return normalizeSemaEvent(event, props).label
export function toSemaEventLabel(
event: SemaActionEvent,
props?: Record<string, unknown>
): SemaEventLabel {
return normalizeSemaEvent(event, props).label;
}

@ -1 +1 @@
export * from './exports'
export * from './exports';

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { applyMapOverrides, resolveSignature, type SemaCascadeRule } from './resolver';
import { SEMA_MAP } from './sema-map';
import { SOUNDS } from './sound-names';
import { SILENT } from './channels';
import { editableSema } from './components/editable';
import { fileUploadSema } from './components/file-upload';
@ -74,21 +75,19 @@ describe('resolveSignature', () => {
expect(eff.activeChannels).toContain('sound');
});
it('can be lifted by a later rule that REPLACES the slice', () => {
// Declaring sound is a decision, not a delta: an app-level rule that
// provides a full signature wins over the pack's silence.
it('can be lifted by a later rule that NAMES a sound', () => {
// Silence is a decision, and so is breaking it: a later rule that
// names a sound (and re-activates the channel) wins over the
// earlier SILENT.
const eff = resolveSignature(
{ target, name: 'commit-toggle', family: 'commit' },
{
map: SEMA_MAP,
cascade: [
rule('sound'),
{ selector: '*', channels: ['sound', 'haptic'], sound: { gain: 0.2, pitch: 600 } }
]
cascade: [rule('sound'), { selector: '*', channels: ['sound', 'haptic'], sound: 'snap' }]
}
);
expect(eff.activeChannels).toContain('sound');
expect(eff.sound?.gain).toBe(0.2);
expect(eff.sound?.pitch).toBe(SOUNDS.snap.pitch);
});
});
@ -100,32 +99,93 @@ describe('resolveSignature', () => {
expect(eff.haptic).toBeUndefined();
});
it('returns the family base for a transitional family (no intent applied)', () => {
// ── THE SOUND: two lookups, nothing modulates ─────────────────────────
//
// «evento.intent = sonido, y punto». The family's verb table names a sound;
// the intent picks a variant of that name if the pack designed one, and is
// ignored if it did not. No arithmetic anywhere.
it('takes the family default when the event has no verb-specific cell', () => {
const eff = resolveSignature({ target: fakeTarget(), name: 'present', family: 'emerge' });
expect(eff.family).toBe('emerge');
expect(eff.activeChannels).toEqual(['sound']);
expect(eff.hold).toBe(240);
expect(eff.sound?.pitch).toBe(600);
// emerge.sounds.default = 'open'
expect(eff.sound?.pitch).toBe(SOUNDS.open.pitch);
expect(eff.sound?.contour).toBe('ascending');
});
it('returns the family base for valenced family without intent', () => {
const eff = resolveSignature({ target: fakeTarget(), name: 'click', family: 'contact' });
expect(eff.activeChannels).toEqual(['sound', 'haptic']);
expect(eff.hold).toBe(120);
expect(eff.sound?.pitch).toBe(800);
expect(eff.sound?.gain).toBe(0.25);
it('takes the VERB cell when the map has one — the tier that empties the packs', () => {
// Not one overlay pack names its close any more: the map does it once.
const eff = resolveSignature({
target: fakeTarget(),
name: 'close',
family: 'emerge',
verb: 'close'
});
expect(eff.sound?.pitch).toBe(SOUNDS.close.pitch);
expect(eff.sound?.contour).toBe('descending');
});
it('SELECTS a variant by intent — it does not modulate the base', () => {
const neutral = resolveSignature({ target: fakeTarget(), name: 'x', family: 'commit' });
const threat = resolveSignature({
target: fakeTarget(),
name: 'x',
family: 'commit',
intent: 'threat'
});
expect(neutral.sound?.pitch).toBe(SOUNDS.tick.pitch);
expect(threat.sound?.pitch).toBe(SOUNDS['tick.threat'].pitch);
// A different SOUND, not the same one bent: lower, rougher and falling.
expect(threat.sound?.contour).not.toBe(neutral.sound?.contour);
expect(threat.sound!.roughness).toBeGreaterThan(neutral.sound!.roughness);
});
it('IGNORES the intent when the pack designed no variant for it', () => {
// The author's rule: «si no existe ese sonido se desprecia el intent y
// se busca por nombre». `tick.affirm` does not exist → plain `tick`.
const affirm = resolveSignature({
target: fakeTarget(),
name: 'x',
family: 'commit',
intent: 'affirm'
});
expect(affirm.sound?.pitch).toBe(SOUNDS.tick.pitch);
});
it('says NOTHING when neither variant nor base exists', () => {
// «si no hay ninguno no suena nada». `sustain` names no sound at all.
const eff = resolveSignature({ target: fakeTarget(), name: 'loading', family: 'sustain' });
expect(eff.sound).toBeUndefined();
});
it('applies intent deltas (numeric add) on top of the base', () => {
// `affirm` adds +100 to sound.pitch (base 700 → 800) and replaces haptic.kind.
it('lets the closest author win: per-emit > cascade > family', () => {
const target = { matches: () => true, closest: () => null } as unknown as HTMLElement;
const cascade = [{ selector: '*', sound: 'snap' as const }];
const fromCascade = resolveSignature(
{ target, name: 'x', family: 'commit' },
{ map: SEMA_MAP, cascade }
);
expect(fromCascade.sound?.pitch).toBe(SOUNDS.snap.pitch);
// A name passed at trigger-time beats a rule written elsewhere: whoever
// fires the occurrence knows it best. This INVERTED the old order.
const fromEmit = resolveSignature(
{ target, name: 'x', family: 'commit', overrides: { sound: 'alert' } },
{ map: SEMA_MAP, cascade }
);
expect(fromEmit.sound?.pitch).toBe(SOUNDS.alert.pitch);
});
it('applies intent deltas to the HAPTIC — the channel that still modulates', () => {
const eff = resolveSignature({
target: fakeTarget(),
name: 'commit-affirm',
family: 'commit',
intent: 'affirm'
});
expect(eff.sound?.pitch).toBe(800);
expect(eff.haptic?.kind).toBe('success');
});
@ -140,30 +200,12 @@ describe('resolveSignature', () => {
expect(eff.haptic?.intensity).toBeCloseTo(0.7, 1);
});
it('applies intent deltas to transitional families when the signal carries intent', () => {
// Updated doctrine: transitional families (emerge / shift / sustain)
// CAN carry intent — the consumer's intent prop on the component
// root flows through SomaRuntime to the signal, even on events the
// morfo declares as transitional. Resolver applies the intent
// deltas regardless of family. This makes a `<Dialog intent="threat">`
// sound threatening on its `open` event (emerge family) too, not
// just on close-after-fail (signal family).
const eff = resolveSignature({
target: fakeTarget(),
name: 'present',
family: 'emerge',
intent: 'affirm'
});
// emerge.sound.pitch is 600; affirm adds +100 → 700.
expect(eff.sound?.pitch).toBe(700);
});
it('returns deep-cloned structures (mutating the result does not poison the map)', () => {
const a = resolveSignature({ target: fakeTarget(), name: 'click', family: 'contact' });
expect(a.sound?.pitch).toBe(800);
expect(a.sound?.pitch).toBe(SOUNDS.touch.pitch);
if (a.sound) a.sound.pitch = 9999;
const b = resolveSignature({ target: fakeTarget(), name: 'click', family: 'contact' });
expect(b.sound?.pitch).toBe(800);
expect(b.sound?.pitch).toBe(SOUNDS.touch.pitch);
});
// ── Channel registry — haptic ─────────────────────────────────────────
@ -217,12 +259,13 @@ describe('resolveSignature', () => {
family: 'signal',
intent: 'threat',
overrides: {
sound: { sampleUrl: '/sounds/dialog-fail.wav' }
sound: 'alert'
}
});
expect(eff.sound?.sampleUrl).toBe('/sounds/dialog-fail.wav');
// Other delta fields from intent.threat preserved
expect(eff.sound?.contour).toBe('descending');
// `alert` named, `threat` carried → the variant `alert.threat` is chosen.
expect(eff.sound?.pitch).toBe(SOUNDS['alert.threat'].pitch);
// A WHOLE sound, not a bent one: the variant brings its own contour.
expect(eff.sound?.contour).toBe(SOUNDS['alert.threat'].contour);
});
it('per-event override empty channels silences the signal entirely', () => {
@ -291,14 +334,15 @@ describe('resolveSignature', () => {
const cascade: SemaCascadeRule[] = [
{
selector: '[data-dialog-content]',
sound: { sampleUrl: '/sounds/dialog-fail.wav' }
sound: 'alert'
}
];
const eff = resolveSignature(
{ target, name: 'close-after-fail', family: 'signal', intent: 'threat' },
{ cascade }
);
expect(eff.sound?.sampleUrl).toBe('/sounds/dialog-fail.wav');
// `alert` named, `threat` carried → the variant `alert.threat` is chosen.
expect(eff.sound?.pitch).toBe(SOUNDS['alert.threat'].pitch);
});
it('cascade rule matches via target.closest (ancestor selector)', () => {
@ -321,217 +365,67 @@ describe('resolveSignature', () => {
const cascade: SemaCascadeRule[] = [
{
selector: '[data-dialog-content][data-event-intent="threat"]',
sound: { sampleUrl: '/threat.wav' }
sound: 'tick.threat'
}
];
const eff = resolveSignature(
{ target, name: 'close-after-fail', family: 'signal', intent: 'threat' },
{ cascade }
);
expect(eff.sound?.sampleUrl).toBe('/threat.wav');
expect(eff.sound?.pitch).toBe(330);
});
it('cascade priority decides ordering — higher priority wins', () => {
const target = makeMockTarget((sel) => sel === '[data-dialog-content]' || sel === '#x');
const cascade: SemaCascadeRule[] = [
{ selector: '[data-dialog-content]', sound: { sampleUrl: '/general.wav' } },
{ selector: '#x', priority: 10, sound: { sampleUrl: '/specific.wav' } }
{ selector: '[data-dialog-content]', sound: 'tick' },
{ selector: '#x', priority: 10, sound: 'snap' }
];
const eff = resolveSignature(
{ target, name: 'close-after-fail', family: 'signal', intent: 'threat' },
{ cascade }
);
expect(eff.sound?.sampleUrl).toBe('/specific.wav');
expect(eff.sound?.pitch).toBe(1450);
});
it('CSS specificity orders rules — more attrs wins on equal selector role', () => {
// 1 attr vs 2 attrs vs 3 attrs — strictly ascending specificity.
const target = makeMockTarget(() => true);
const cascade: SemaCascadeRule[] = [
{ selector: '[data-dialog-content]', sound: { sampleUrl: '/1.wav' } },
{ selector: '[data-dialog-content]', sound: 'tick' },
{
selector: '[data-dialog-content][data-event-family="signal"]',
sound: { sampleUrl: '/2.wav' }
sound: 'open'
},
{
selector: '[data-dialog-content][data-event-family="signal"][data-event-intent="threat"]',
sound: { sampleUrl: '/3.wav' }
sound: 'snap'
}
];
const eff = resolveSignature(
{ target, name: 'close-after-fail', family: 'signal', intent: 'threat' },
{ cascade }
);
expect(eff.sound?.sampleUrl).toBe('/3.wav');
expect(eff.sound?.pitch).toBe(1450);
});
it('layer ordering: morfo override (4) → cascade (6) — cascade wins', () => {
it('precedence: a name passed at trigger-time beats a cascade rule', () => {
const target = makeMockTarget((sel) => sel === '[data-dialog-content]');
const cascade: SemaCascadeRule[] = [
{ selector: '[data-dialog-content]', sound: { sampleUrl: '/cascade.wav' } }
];
const cascade: SemaCascadeRule[] = [{ selector: '[data-dialog-content]', sound: 'snap' }];
const eff = resolveSignature(
{
target,
name: 'close-after-fail',
family: 'signal',
intent: 'threat',
overrides: { sound: { sampleUrl: '/morfo.wav' } }
overrides: { sound: 'open' }
},
{ cascade }
);
expect(eff.sound?.sampleUrl).toBe('/cascade.wav');
});
it('slider handle-drag activates sound while preserving per-emit sound payload', () => {
const target = makeMockTarget(
(sel) => sel === '[data-slider][data-event="handle-drag"]' || sel === '[data-slider]'
);
const eff = resolveSignature(
{
target,
name: 'handle-drag',
family: 'handle',
overrides: {
sound: {
pitch: 920,
centroid: 4200,
roughness: 0.003,
attack: 8,
decay: 44,
duration: 46,
contour: 'ascending',
gain: 0.02
}
}
},
{ cascade: sliderSema.cascade }
);
expect(eff.activeChannels).toEqual(['sound', 'haptic']);
expect(eff.sound?.pitch).toBe(920);
expect(eff.sound?.gain).toBe(0.02);
});
it('slider handle-pick uses the shared handle pickup sound', () => {
const target = makeMockTarget(
(sel) => sel === '[data-slider][data-event="handle-pick"]' || sel === '[data-slider]'
);
const eff = resolveSignature(
{
target,
name: 'handle-pick',
family: 'handle'
},
{ cascade: sliderSema.cascade }
);
expect(eff.activeChannels).toEqual(['sound', 'haptic']);
expect(eff.sound?.pitch).toBe(1080);
expect(eff.sound?.contour).toBe('ascending');
expect(eff.sound?.gain).toBe(0.12);
expect(eff.haptic?.kind).toBe('tick');
});
it('tag-group removal uses the shared subtle form cue', () => {
const target = makeMockTarget(
(sel) =>
sel === '[data-tag-group-item][data-event="commit-remove"]' ||
sel === '[data-tag-group-item]'
);
const eff = resolveSignature(
{
target,
name: 'commit-remove',
family: 'commit',
intent: 'neutral'
},
{ cascade: tagGroupSema.cascade }
);
expect(eff.activeChannels).toEqual(['sound', 'haptic']);
expect(eff.sound?.gain).toBe(0.03);
expect(eff.sound?.contour).toBe('descending');
expect(eff.haptic?.kind).toBe('tap');
});
it('file-upload rejection emits a risk warning cue', () => {
const target = makeMockTarget(
(sel) =>
sel === '[data-file-upload][data-event="signal-warn-reject"]' ||
sel === '[data-file-upload]'
);
const eff = resolveSignature(
{
target,
name: 'signal-warn-reject',
family: 'signal',
intent: 'risk'
},
{ cascade: fileUploadSema.cascade }
);
expect(eff.activeChannels).toEqual(['sound', 'haptic']);
expect(eff.sound?.contour).toBe('descending');
expect(eff.haptic?.kind).toBe('warning');
});
it('tags-input rejection emits a risk warning cue on the input', () => {
const target = makeMockTarget(
(sel) =>
sel === '[data-tags-input-input][data-event="signal-warn-reject"]' ||
sel === '[data-tags-input-input]'
);
const eff = resolveSignature(
{
target,
name: 'signal-warn-reject',
family: 'signal',
intent: 'risk'
},
{ cascade: tagsInputSema.cascade }
);
expect(eff.activeChannels).toEqual(['sound', 'haptic']);
expect(eff.sound?.contour).toBe('descending');
expect(eff.haptic?.kind).toBe('warning');
});
it('editable save uses an affirmative commit cue', () => {
const target = makeMockTarget(
(sel) => sel === '[data-editable][data-event="commit-save"]' || sel === '[data-editable]'
);
const eff = resolveSignature(
{
target,
name: 'commit-save',
family: 'commit',
intent: 'affirm'
},
{ cascade: editableSema.cascade }
);
expect(eff.activeChannels).toEqual(['sound', 'haptic']);
expect(eff.sound?.gain).toBe(0.05);
expect(eff.haptic?.kind).toBe('success');
});
it('stepper completion uses a fulfill cue', () => {
const target = makeMockTarget(
(sel) => sel === '[data-stepper][data-event="commit-complete"]' || sel === '[data-stepper]'
);
const eff = resolveSignature(
{
target,
name: 'commit-complete',
family: 'commit',
intent: 'fulfill'
},
{ cascade: stepperSema.cascade }
);
expect(eff.activeChannels).toEqual(['sound', 'haptic']);
expect(eff.sound?.contour).toBe('ascending');
expect(eff.haptic?.kind).toBe('success');
// INVERTED on 2026-08-06: the cascade used to win. Whoever fires the
// occurrence knows it best, so the per-emit name is the closest author.
// `open` has no `.threat` variant, so the intent is ignored — the
// author's degradation rule, visible here.
expect(eff.sound?.pitch).toBe(SOUNDS.open.pitch);
});
});

@ -2,37 +2,15 @@
* resolveSignature(signal, opts) — turns a `SemanticSignal` into the
* per-channel `EffectiveSignature` used by every channel.
*
* Resolution cascade (each layer overrides the previous):
* THE MODEL (2026-08-06, the author's directive: «evento.intent = sonido, y
* punto»). Sound resolves in TWO lookups and nothing modulates anything:
*
* 1. FAMILY base — `SEMA_MAP.families[signal.family].base`
* 1.5 THE SOUND — the NAME the morfo event and the cascade select,
* resolved through `sound-names.ts`. Hoisted ABOVE the
* intent on 2026-08-06: everything that AUTHORS a
* sound runs here, keeping its old relative precedence
* (morfo event first, then cascade), so the intent is
* always the last word on the evaluative axes. That is
* what made D.7 / S-07 inexpressible rather than
* merely forbidden.
* 2. INTENT deltas — `SEMA_MAP.intents[signal.intent]` when present
* 3. MORFO overrides — `signal.overrides` / `signal.channels`
* (from the morfo event, copied by SomaRuntime;
* its `sound` already ran at 1.5)
* 4. RUNTIME overrides — `options.runtimeMap` already has
* `engineOpts.overrides.runtime` baked in by the
* engine via `applyMapOverrides()` at construction
* 5a. PACK cascade — per-component packs (`engineOpts.components`),
* 5b. APP cascade — app-level rules (`engineOpts.overrides.cascade`),
* appended after the packs so they win on equal
* specificity. Both arrive pre-flattened in
* `options.cascade`: CSS-style rules matched
* against `signal.target` AFTER the engine has
* stamped `data-event-*` on it (see `stamp.ts`).
* Rules are flat: `{ selector, priority?,
* channels?, sound?, haptic? }`, and their `sound` is
* a catalogue NAME applied at 1.5 — only `channels` /
* `haptic` / `hold` land here. The selector is
* matched natively via `target.matches()` /
* `target.closest()`.
* name = per-emit / morfo ?? cascade ?? family[verb] ?? family.default
* sound = pack[`${name}.${intent}`] ?? pack[name] ?? nothing
*
* The remaining channels (haptic, channels, hold) still resolve as a CSS-like
* cascade: family base → intent deltas → morfo overrides → runtime path
* overrides → pack rules → app rules.
*
* CSS-like specificity orders cascade rules: ascending order — later
* applied wins. Authors can override with `priority?: number` (replaces
@ -91,13 +69,17 @@ export type {
export interface SemaCascadeRule extends Omit<SemaSignatureOverride, 'sound'> {
selector: string;
/**
* A catalogue NAME, `SILENT`, or — for app-authored rules only — a raw
* slice. Framework packs are held to names by `Sema['cascade']`, which is
* strictly narrower; this shape stays open because `overrides.cascade` is
* the app's customisation door and an app may legitimately author a sound
* the catalogue does not carry.
* A NAME from the active pack, or `SILENT`. Nobody authors a signature
* inline — not even the app.
*
* This used to stay open «because `overrides.cascade` is the app's
* customisation door». It is not needed: a product that wants a sound the
* pack does not carry REGISTERS it (declaring the name and its definition
* once) and then names it, exactly like the framework does. Leaving the
* door open only preserved the one path through which parameters could
* still leak back into a rule.
*/
sound?: SoundName | SemaSignatureOverride['sound'];
sound?: SoundName | Silent;
/**
* Override CSS-computed specificity. When omitted, specificity is
* computed from the selector (IDs × 100, attrs / classes /
@ -168,56 +150,63 @@ export function resolveSignature(
...cloneBase(familyEntry.base)
};
// Layer 1.5 — the NAMED SOUND, matched from the cascade and applied over the
// family base BEFORE the intent.
// ── THE SOUND ────────────────────────────────────────────────────────────
//
// «evento.intent = sonido, y punto» — the author's directive of 2026-08-06.
// Two lookups and nothing else. NOTHING modulates a sound: the pair
// (name, intent) picks a whole, designed entry out of the pack.
//
// This ordering is the whole design. Until 2026-08-06 packs ran LAST and in
// replace mode, so a component's chosen sound erased the intent's evaluative
// profile — measured: a dialog with `threat` sounded exactly like a neutral
// one. D.7, S-07 and `d7-intent-survives.test.ts` all existed to police that
// inversion. With the name UNDER the intent, the inversion cannot occur:
// the family gives identity, the name gives level / direction / character,
// and the intent lands last and stays audible. Not fixed — made impossible.
// name = per-emit / morfo ?? cascade ?? family[verb] ?? family.default
// sound = pack[`${name}.${intent}`] ?? pack[name] ?? nothing
//
// The rules still match by selector exactly as before; what changed is WHERE
// their result is applied, and that a rule now names a sound instead of
// authoring one (`sound-names.ts`).
// Everything that AUTHORS a sound runs here, keeping its old relative
// precedence (morfo per-event first, then the cascade, which still wins);
// only their position with respect to the intent changed.
// What this replaced, and why: a five-layer pipeline where a family base
// signature was modified by a named delta, then by intent deltas, then by
// event overrides, then by the cascade. Its audible output was measured and
// condemned — five of sixteen names were the same 700 Hz note at five
// volumes, and an intent moved a pitch by 20 Hz nobody could hear. A whole
// class of defect (D.7, S-07, S-39, the flattening, three guards) existed
// only to police arithmetic that should never have been there.
const matchedRules =
options.cascade && options.cascade.length > 0 && signal.target
? collectCascadeMatches(options.cascade, signal.target)
: [];
// Precedence, most specific first. This INVERTS the old order, in which the
// cascade beat the per-emit override: whoever fires the occurrence knows it
// best, so a name passed at trigger-time wins over a rule written elsewhere.
let soundName: SoundName | Silent | undefined;
for (const rule of matchedRules) {
if (rule.sound !== undefined) soundName = rule.sound as SoundName | Silent;
}
if (signal.overrides?.sound !== undefined) {
signature = applyOverride(signature, { sound: resolveSoundValue(map, signal.overrides.sound) });
soundName = signal.overrides.sound as SoundName | Silent;
}
for (const rule of matchedRules) {
if (rule.sound === undefined) continue;
signature = applyOverride(signature, {
sound: resolveSoundValue(map, rule.sound)
} as SemaSignatureOverride);
if (soundName === undefined) {
const table = familyEntry.sounds;
soundName = (signal.verb ? table?.[signal.verb] : undefined) ?? table?.default;
}
// Layer 2 — intent deltas. Applied whenever the signal carries an
// intent, regardless of family. The doctrine originally restricted
// this to valenced families (contact / commit / signal / handle) but
// real UX needs intent on transitional events too: a `<Dialog
// intent="threat">` should sound threatening even on the `open` event
// (emerge family). SomaRuntime forwards the consumer's intent prop
// when the morfo doesn't declare its own, and the resolver applies
// the deltas to whichever channels the family's base populated.
if (soundName === SILENT) {
// SILENT is not a quiet sound: the channel leaves. Declared silence has
// to be absorbing, or a later layer's number resurrects it.
signature.activeChannels = signature.activeChannels.filter((c) => c !== 'sound');
} else if (soundName !== undefined) {
const resolved = lookupSound(map, soundName, signal.intent);
if (resolved) signature.sound = resolved;
}
// Intent deltas — HAPTIC only. Sound left this layer on 2026-08-06: an
// intent now SELECTS a whole sound by name instead of bending one. The
// haptic keeps modulating because its vocabulary is categorical and its
// deltas swap the KIND (affirm → success, threat → error), which is
// selection too, expressed in the shape that channel already had.
if (signal.intent) {
const intentEntry = map.intents[signal.intent];
if (intentEntry) {
signature = applyChannelDeltas(signature, intentEntry.deltas);
}
if (intentEntry) signature = applyChannelDeltas(signature, intentEntry.deltas);
}
// Layer 3 — per-event morfo overrides (carried by the signal). Its `sound`
// already ran above, before the intent; what remains here is everything
// else it may declare.
// Per-event morfo overrides (carried by the signal). Its `sound` is a NAME
// and was consumed above; what remains here is everything else it declares.
if (signal.channels !== undefined) {
signature.activeChannels = [...signal.channels];
}
@ -300,18 +289,24 @@ function cssSpecificity(selector: string): number {
}
/**
* A rule's `sound` is a NAME from the catalogue (`sound-names.ts`) — the only
* thing a framework pack may write. `SILENT` passes through untouched so the
* channel can drop itself, and an app-authored raw slice still works: apps keep
* the open shape on `overrides.cascade`, the framework holds itself to names.
* The two lookups. `name.intent` first — a pack that designed a variant for
* this intent wins — then the bare `name`, which is the author's requested
* degradation: «si no existe ese sonido se desprecia el intent y se busca por
* nombre». Neither present ⇒ nothing sounds, and that is a valid answer.
*
* Read against the MAP it is given, never a module const, so a themed pack
* (`applySoundPack`) takes effect on the next emission.
*/
function resolveSoundValue(
function lookupSound(
map: SemaMap,
value: NonNullable<SemaCascadeRule['sound']>
): NonNullable<SemaSignatureOverride['sound']> {
// Against the MAP, never a module const: the engine hands its own map on
// every emit, so a theme that retuned `soft` takes effect here.
return isSoundName(value) ? resolveNamedSound(map, value) : value;
name: SoundName,
intent: Intent | undefined
): SoundSignature | undefined {
if (intent && intent !== 'neutral') {
const variant = resolveNamedSound(map, `${name}.${intent}` as SoundName);
if (variant) return variant;
}
return resolveNamedSound(map, name);
}
/** Everything a rule says except its `sound`, which layer 1.5 already applied. */

@ -23,18 +23,38 @@ import type { Intent } from '../intent';
import type { SemaFamily } from './types';
import type {
DeltaValue,
SoundSignature,
SemaChannelId,
SemaChannelSignatures,
SemaSignatureOverride,
Silent
} from './channels';
import { sample, type SampleSoundDefinition, type SoundOverride } from './sounds';
import { sample, type SampleSoundDefinition } from './sounds';
// ── Map shape ──────────────────────────────────────────────────────────────
export interface FamilyMapEntry {
/** Per-channel base signatures. Channels not present default to undefined. */
base: Partial<SemaChannelSignatures>;
/**
* The family's DEFAULT sound name, keyed by the event's canonical VERB —
* `default` when no verb-specific cell matches.
*
* This is what makes a component write nothing at all in the common case:
* an `emerge-close` finds `close` here, so the fifteen overlays that used
* to repeat that rule in their own pack now carry no sound rule whatsoever.
* A pack only speaks when it DIFFERS from the default; if it does not
* differ, it does not exist. The 43 % of rules measured to say nothing but
* a volume died here.
*
* A family with no table is silent by default (`sustain`, `delegate`).
*
* The intent does NOT appear in this table: it is applied afterwards, by
* name (`${name}.${intent}` → `${name}` → nothing). Design of 2026-08-06,
* the author's: a sound is a SOUND, and the pair (event, intent) picks one
* whole — nothing modulates anything.
*/
sounds?: { readonly default?: SoundName; readonly [verb: string]: SoundName | undefined };
/** Channels participating in the family's signal. */
activeChannels: readonly SemaChannelId[];
// NOTE (2026-07-06): the per-family `hold` field was REMOVED. It
@ -100,7 +120,7 @@ export interface Sema {
* Lives in the MAP because that is the structure a theme addresses — see
* `SEMA_MAP.sounds`.
*/
export type NamedSound = SampleSoundDefinition | SoundOverride;
export type NamedSound = SampleSoundDefinition | SoundSignature;
export interface SemaMap {
readonly version: string;
@ -161,32 +181,118 @@ export interface HapticKindProfile {
* `SILENT`). Three groups, because three questions exhaust what a component
* needs to say: how loud, in which direction, and with what character.
*/
const SOUND_CATALOGUE = {
// ── Level ───────────────────────────────────────────────────────────
// One scale for the whole system. It deliberately does NOT vary per
// family: the family already carries its identity in its base, and a
// `soft` meaning 0.05 for a commit and 0.08 for an emerge was the
// ambiguity the old `{family}.{tail}` grammar existed to paper over.
subtle: { gain: 0.03 },
soft: { gain: 0.05 },
medium: { gain: 0.08 },
strong: { gain: 0.1 },
loud: { gain: 0.15 },
/**
* THE DEFAULT SOUND PACK — every entry a complete, designed sound.
*
* Two kinds of key:
* - a BASE (`tick`) — the sound of a kind of occurrence.
* - a VARIANT (`tick.threat`) — the same occurrence under an intent.
*
* Resolution is two lookups and nothing else: `name.intent`, then `name`, then
* silence. An intent SELECTS a sound; it never modulates one. A pack that only
* ships bases works whole — the intent is simply ignored where no variant
* exists, which is the degradation the author asked for.
*
* ALL SYNTHESIS ON PURPOSE. The framework's own pack ships no binaries: it
* works offline, with no fetch, and cannot 404. The bundled `.wav`s are an
* EXAMPLE pack (`example-sound-pack.ts`) that demonstrates swapping entries
* for files — a product brings its own in wav / mp3 / synthesis, mixed freely.
*
* DESIGNED TO BE TOLD APART, and guarded: `sound-names.test.ts` fails when two
* entries are not separated on at least two axes. The catalogue this replaced
* had five names that were one 700 Hz note at five volumes — measured, and the
* reason the author threw the modulation model out.
*/
export const SOUND_CATALOGUE = {
// ── Bases ───────────────────────────────────────────────────────────────
/** A press. Bright and immediate — the shortest thing the system says. */
touch: {
pitch: 1300,
centroid: 6500,
roughness: 0,
attack: 1,
decay: 24,
duration: 30,
contour: 'flat',
gain: 0.06
},
/**
* The ratchet of a continuous gesture. Repeated at the gesture's own rhythm
* (~72 ms while dragging), so the SPEED of the gesture is the speed of the
* clicking — no parameter is calculated. Must be tiny: anything longer
* smears into mush at that rate.
*/
step: {
pitch: 1900,
centroid: 7600,
roughness: 0,
attack: 1,
decay: 14,
duration: 18,
contour: 'flat',
gain: 0.025
},
/** A commit. Mid register, a knock rather than a click. */
tick: {
pitch: 850,
centroid: 3200,
roughness: 0,
attack: 2,
decay: 60,
duration: 70,
contour: 'flat',
gain: 0.08
},
/** Something appears. Breathes upward. */
open: {
pitch: 520,
centroid: 2400,
roughness: 0,
attack: 12,
decay: 150,
duration: 180,
contour: 'ascending',
gain: 0.09
},
/** Something leaves. Answers the opening, downward. */
close: {
pitch: 380,
centroid: 1700,
roughness: 0,
attack: 6,
decay: 130,
duration: 150,
contour: 'descending',
gain: 0.07
},
/** The frame moves — tabs, steps, navigation. Lateral arc. */
slide: {
pitch: 300,
centroid: 1400,
roughness: 0,
attack: 10,
decay: 100,
duration: 120,
contour: 'arc',
gain: 0.06
},
/** The system speaks unprompted. The one that claims attention. */
alert: {
pitch: 1000,
centroid: 4000,
roughness: 0.05,
attack: 3,
decay: 160,
duration: 190,
contour: 'arc',
gain: 0.12
},
// ── Direction ───────────────────────────────────────────────────────
// A close falls, an open rises. Today this is a component's word; it is
// really a fact about the VERB, and hoisting it into this map is the
// next axis (PLAN-sound-names.md §9).
'subtle.rise': { contour: 'ascending', pitch: { op: 'add', value: 60 }, gain: 0.03 },
'soft.rise': { contour: 'ascending', pitch: { op: 'add', value: 80 }, gain: 0.05 },
'subtle.fall': { contour: 'descending', pitch: { op: 'add', value: -80 }, gain: 0.03 },
'soft.fall': { contour: 'descending', pitch: { op: 'add', value: -120 }, gain: 0.05 },
'deep.fall': { contour: 'descending', pitch: { op: 'add', value: -150 }, gain: 0.1 },
// ── Gestos ──────────────────────────────────────────────────────────────
// Kept from the original library: these were real designs, not arithmetic.
// ── Character ───────────────────────────────────────────────────────
// Full recipes: these override the family's timbre on purpose, because
// the gesture they dress (grabbing, releasing, snapping) has an identity
// that outranks the family's. They are the ONLY entries allowed to.
/** Picking something up. */
air: {
pitch: 1080,
centroid: 5200,
@ -197,61 +303,100 @@ const SOUND_CATALOGUE = {
contour: 'ascending',
gain: 0.045
},
'air.strong': {
pitch: 1080,
centroid: 5200,
roughness: 0.004,
attack: 18,
decay: 120,
duration: 118,
contour: 'ascending',
gain: 0.12
},
/** Letting it settle. */
settle: {
pitch: 920,
centroid: 4600,
pitch: 780,
centroid: 3600,
roughness: 0.004,
attack: 18,
decay: 140,
duration: 136,
decay: 145,
duration: 165,
contour: 'descending',
gain: 0.04
gain: 0.05
},
/** It clicks into place. */
snap: {
pitch: 1040,
centroid: 5400,
pitch: 1450,
centroid: 7100,
roughness: 0.005,
attack: 16,
decay: 130,
duration: 128,
attack: 4,
decay: 45,
duration: 55,
contour: 'arc',
gain: 0.05
gain: 0.06
},
// ── Samples ─────────────────────────────────────────────────────────
// The `signal` family's cultural marks: here the evaluative weight is
// WHICH FILE plays. Over a `.wav` the intent can only move the level —
// `playSample` reads `sampleUrl` and `gain` and nothing else.
ping: sample('/sounds/ping.wav', {
pitch: 960,
centroid: 3000,
roughness: 0.05,
// ── Variantes por intent ────────────────────────────────────────────────
// Whole sounds, not deformations. A `threat` is not a `tick` with more
// roughness — it is lower, raspier, longer and falls. You cannot mistake
// one for the other, which is the entire point.
/** It worked. Bright, rising, allowed to last. */
'tick.fulfill': {
pitch: 1250,
centroid: 5600,
roughness: 0,
attack: 4,
decay: 120,
duration: 160,
contour: 'arc',
decay: 200,
duration: 240,
contour: 'ascending',
gain: 0.12
}),
error: sample('/sounds/error.wav', {
},
/** Careful. Mid register and raspy — the AM sits above the voice threshold. */
'tick.risk': {
pitch: 520,
centroid: 1400,
centroid: 1700,
roughness: 0.3,
attack: 8,
decay: 150,
duration: 190,
contour: 'flat',
gain: 0.11
},
/** Stop. Low, rough and falling. */
'tick.threat': {
pitch: 330,
centroid: 1100,
roughness: 0.45,
attack: 4,
decay: 180,
duration: 220,
decay: 200,
duration: 250,
contour: 'descending',
gain: 0.15
},
/** It is gone. A dark drop with no rasp — loss is not danger. */
'tick.loss': {
pitch: 240,
centroid: 800,
roughness: 0.05,
attack: 3,
decay: 160,
duration: 180,
contour: 'descending',
gain: 0.13
},
/** A warning the system raises on its own. */
'alert.risk': {
pitch: 700,
centroid: 2300,
roughness: 0.35,
attack: 5,
decay: 190,
duration: 230,
contour: 'arc',
gain: 0.14
},
/** An error the system raises on its own. */
'alert.threat': {
pitch: 460,
centroid: 1500,
roughness: 0.55,
attack: 3,
decay: 240,
duration: 300,
contour: 'arc',
gain: 0.18
})
}
} as const satisfies Record<string, NamedSound>;
export const SEMA_MAP: SemaMap = {
@ -259,16 +404,6 @@ export const SEMA_MAP: SemaMap = {
families: {
contact: {
base: {
sound: {
pitch: 800,
centroid: 2000,
roughness: 0.1,
attack: 4,
decay: 40,
duration: 60,
contour: 'flat',
gain: 0.25
},
haptic: {
kind: 'tick',
intensity: 0.3,
@ -276,20 +411,13 @@ export const SEMA_MAP: SemaMap = {
delay: 0
}
},
// Every contact verb (press / tap / activate / trigger / …) is the
// same occurrence: something was touched.
sounds: { default: 'touch' },
activeChannels: ['sound', 'haptic']
},
commit: {
base: {
sound: {
pitch: 700,
centroid: 1800,
roughness: 0.1,
attack: 8,
decay: 120,
duration: 100,
contour: 'flat',
gain: 0.3
},
haptic: {
kind: 'tap',
intensity: 0.5,
@ -297,20 +425,19 @@ export const SEMA_MAP: SemaMap = {
delay: 60
}
},
// One knock for every commit. The evaluative colour comes from the
// intent by name (`tick.fulfill`, `tick.threat`, …), not from here.
// The three verbs that ARE a loss get it without needing an intent.
sounds: {
default: 'tick',
remove: 'tick.loss',
delete: 'tick.loss',
discard: 'tick.loss'
},
activeChannels: ['sound', 'haptic']
},
signal: {
base: {
sound: {
pitch: 900,
centroid: 2400,
roughness: 0.3,
attack: 3,
decay: 150,
duration: 180,
contour: 'arc',
gain: 0.4
},
haptic: {
kind: 'pulse',
intensity: 0.7,
@ -318,20 +445,21 @@ export const SEMA_MAP: SemaMap = {
delay: 0
}
},
// The system speaking unprompted. `alert.risk` / `alert.threat` carry
// the two that must be unmistakable.
sounds: { default: 'alert' },
activeChannels: ['sound', 'haptic']
},
emerge: {
base: {
sound: {
pitch: 600,
centroid: 1500,
roughness: 0.05,
attack: 12,
decay: 200,
duration: 150,
contour: 'ascending',
gain: 0.2
}
base: {},
// The verb tier earning its keep: an appearance rises, a
// disappearance falls, and NOT ONE overlay pack has to say so.
sounds: {
default: 'open',
close: 'close',
collapse: 'close',
hide: 'close',
dismiss: 'close'
},
activeChannels: ['sound']
},
@ -342,18 +470,8 @@ export const SEMA_MAP: SemaMap = {
// "cruce de umbral", no solo "aparición". Sound es más sutil que
// emerge para no competir con el signal que el marco pueda
// contener (regla del libro: el marco no absorbe el mensaje).
base: {
sound: {
pitch: 500,
centroid: 1400,
roughness: 0.05,
attack: 14,
decay: 220,
duration: 160,
contour: 'ascending',
gain: 0.18
}
},
base: {},
sounds: { default: 'slide' },
activeChannels: ['sound']
},
handle: {
@ -365,7 +483,14 @@ export const SEMA_MAP: SemaMap = {
delay: 0
}
},
activeChannels: ['haptic']
// A continuous gesture sounds by REPETITION: the provider already
// emits at the gesture's own rhythm, so a 26 ms `step` per emission
// makes the speed of the drag the speed of the clicking — a ratchet,
// like a physical wheel. This replaced three bespoke resolvers that
// synthesised pitch from position and velocity: the last arithmetic
// left in the sound path.
sounds: { default: 'step' },
activeChannels: ['sound', 'haptic']
},
sustain: {
base: {},
@ -384,12 +509,6 @@ export const SEMA_MAP: SemaMap = {
intents: {
threat: {
deltas: {
sound: {
pitch: -200,
roughness: 0.4,
contour: 'descending',
gain: 0.1
},
haptic: {
kind: 'error',
pattern: [40, 60, 40, 60, 40]
@ -398,10 +517,6 @@ export const SEMA_MAP: SemaMap = {
},
risk: {
deltas: {
sound: {
pitch: -100,
roughness: 0.2
},
haptic: {
kind: 'warning',
pattern: [30, 50, 30]
@ -413,7 +528,6 @@ export const SEMA_MAP: SemaMap = {
},
affirm: {
deltas: {
sound: { pitch: 100 },
haptic: {
kind: 'success'
}
@ -421,11 +535,6 @@ export const SEMA_MAP: SemaMap = {
},
fulfill: {
deltas: {
sound: {
pitch: 300,
contour: 'ascending',
gain: 0.05
},
haptic: {
kind: 'success',
intensity: { op: 'add', value: 0.2 }
@ -438,10 +547,6 @@ export const SEMA_MAP: SemaMap = {
// convoca acción): loss registra, no convoca. Por eso desciende
// auditivamente y cae en haptic, sin arrastrar visual concerns.
deltas: {
sound: {
pitch: -150,
contour: 'descending'
},
haptic: {
kind: 'thud',
intensity: 0.8,

@ -36,6 +36,20 @@ export interface SemanticSignal {
/** Familia semántica del signal. */
family?: SemaFamily;
/**
* Canonical verb of the occurrence (`SEMA_VERBS[family]`), carried from the
* morfo declaration or from a polymorphic concretion.
*
* It selects the family's default sound: `SEMA_MAP.families[f].sounds[verb]`
* falling back to `.default`. That is what lets an overlay's `close` fall
* without a single line in its pack.
*
* Until 2026-08-06 the runtime computed this and threw it away with a
* `void` (audit S-40) — the type promised it travelled and it did not. Now
* it does, and it has a consumer.
*/
verb?: string;
/**
* Human-facing announcement text for the accessible `AnnounceChannel`
* (opt-in). When present, the channel pushes it to an ARIA live region with

@ -1,124 +1,137 @@
// @vitest-environment jsdom
/**
* The perceptual snapshot — what every NAME sounds like, under every intent.
* The sound pack — a snapshot of what each entry sounds like, and the guard
* that would have caught the failure the author reported by ear.
*
* WHY THIS AND NOT A SNAPSHOT OF THE PACKS. The queue's §3.5 proposed freezing
* the resolved signature of every (component, event, intent) so that any change
* a user can perceive would show up in a diff. That was designed for a system
* that was staying as it was. It is the wrong instrument for a system whose
* MODEL is being replaced, and freezing today's output would freeze a measured
* defect: 92 of 214 rules were accidental volume trims, 69 erased the intent's
* contour, and TextArea ran 6×/13× above its written signature until 2026-08-06.
* The old values are not lost either way — git holds them forever.
* WHAT HAPPENED. The previous catalogue was built by preserving the resolved
* values of the model it replaced, and that model was measured to be 43 %
* pure volume. The result: five of sixteen names were the SAME 700 Hz note at
* five different gains, and the two `rise` entries differed by 20 Hz. Every
* test passed. Nothing looked at whether two sounds could be told apart,
* because the system was designed around modulating one sound rather than
* choosing between several.
*
* So the snapshot moved to where the decisions actually live: the CATALOGUE.
* Sixteen names × the intents that modulate them. Editing a name's level or
* character breaks this table, which is exactly the property §3.5 wanted — the
* change appears in the diff instead of hiding inside a pack.
* The author heard it in ten seconds: «todos suenan exactamente igual … no hay
* variación ni percepción diferente de un sonido a otro». So the model changed
* — an intent SELECTS a whole sound, it does not modulate one — and this file
* gained `distinguishability`, which fails when two entries are not separated
* on at least two perceptual axes.
*
* IT ALSO CERTIFIES THE REORDER. A name is applied BEFORE the intent deltas, so
* the evaluative profile survives it. `fulfill` must stay louder and higher than
* `neutral`, `risk` rougher — for every name in the catalogue, not for the three
* packs `d7-intent-survives.test.ts` happened to sample. That is what turns D.7
* from a rule somebody must remember into a property of the arrangement.
* A snapshot alone would not have caught it: every value was exactly what the
* table said. The property that was missing is a RELATION between entries.
*/
import { describe, expect, it } from 'vitest';
import { resolveSignature } from './resolver';
import { SEMA_MAP } from './sema-map';
import { SOUNDS, namedSound, namedSampleUrls, type SoundName } from './sound-names';
import { namedSampleUrls, namedSound, type SoundName } from './sound-names';
const NAMES = Object.keys(SOUNDS) as SoundName[];
const NAMES = Object.keys(SEMA_MAP.sounds) as SoundName[];
/** Resolve a name over a family base, with an optional intent, as the engine would. */
function resolve(name: SoundName, family: 'commit' | 'emerge', intent?: 'fulfill' | 'risk') {
const el = {
matches: () => true,
closest: () => null
} as unknown as HTMLElement;
return resolveSignature(
{ target: el, name: 'probe', family, ...(intent ? { intent } : {}) } as never,
{
map: SEMA_MAP,
cascade: [{ selector: '*', sound: namedSound(name) }] as never
}
).sound!;
}
/** Entries that are a base, i.e. carry no `.intent` suffix. */
const BASES = NAMES.filter((n) => !n.includes('.'));
describe('sound catalogue — the snapshot of what each name sounds like', () => {
it('holds the catalogue (sanity: the snapshot is not scanning an empty const)', () => {
expect(NAMES.length).toBeGreaterThan(12);
describe('the sound pack', () => {
it('holds a pack (sanity: the census is not scanning an empty const)', () => {
expect(NAMES.length).toBeGreaterThan(8);
});
it('every name resolves to a complete, playable signature', () => {
it('gives every entry a complete, playable signature', () => {
const broken: string[] = [];
for (const name of NAMES) {
const sig = resolve(name, 'commit');
const sig = namedSound(name);
if (!sig) {
broken.push(`${name}: resolves to nothing`);
continue;
}
for (const axis of ['pitch', 'centroid', 'gain', 'duration', 'contour'] as const) {
const v = sig[axis];
if (v === undefined || (typeof v === 'number' && !Number.isFinite(v))) {
broken.push(`${name}.${axis} = ${String(v)}`);
}
}
if (typeof sig.gain === 'number' && sig.gain <= 0) broken.push(`${name}: gain ${sig.gain}`);
if (sig.gain <= 0) broken.push(`${name}: gain ${sig.gain}`);
}
expect(broken, 'an entry that cannot be played is not a sound').toEqual([]);
});
it('DISTINGUISHABILITY — no two entries are near-twins', () => {
// The guard that was missing. Two sounds must differ on at least TWO
// axes, with a margin wide enough to hear: a fifth of pitch, a third of
// brightness, half again as long, a different contour, or double the
// level. Anything closer is the same sound wearing two names.
const sigs = NAMES.map((n) => ({ name: n, sig: namedSound(n)! }));
const twins: string[] = [];
for (let i = 0; i < sigs.length; i++) {
for (let j = i + 1; j < sigs.length; j++) {
const a = sigs[i].sig;
const b = sigs[j].sig;
const axes = [
Math.max(a.pitch, b.pitch) / Math.min(a.pitch, b.pitch) >= 1.25,
Math.max(a.centroid, b.centroid) / Math.min(a.centroid, b.centroid) >= 1.35,
Math.max(a.duration, b.duration) / Math.min(a.duration, b.duration) >= 1.5,
a.contour !== b.contour,
Math.max(a.gain, b.gain) / Math.min(a.gain, b.gain) >= 2,
Math.abs(a.roughness - b.roughness) >= 0.2
].filter(Boolean).length;
if (axes < 2) {
twins.push(`${sigs[i].name} ≈ ${sigs[j].name} (separated on ${axes} axis)`);
}
}
}
expect(broken, 'a name that resolves to an incomplete signature cannot be played').toEqual([]);
expect(
twins,
'two entries this close are one sound with two names — the defect the author heard'
).toEqual([]);
});
it('SNAPSHOT — the level and direction of every name over `commit`', () => {
it('SNAPSHOT — what each entry sounds like', () => {
const table = NAMES.map((name) => {
const s = resolve(name, 'commit');
return `${name.padEnd(12)} gain=${s.gain} pitch=${s.pitch} contour=${s.contour} centroid=${s.centroid}${s.sampleUrl ? ` url=${s.sampleUrl}` : ''}`;
const s = namedSound(name)!;
return `${name.padEnd(13)} pitch=${s.pitch} centroid=${s.centroid} dur=${s.duration} contour=${s.contour} rough=${s.roughness} gain=${s.gain}`;
});
expect(table).toEqual([
'subtle gain=0.03 pitch=700 contour=flat centroid=1800',
'soft gain=0.05 pitch=700 contour=flat centroid=1800',
'medium gain=0.08 pitch=700 contour=flat centroid=1800',
'strong gain=0.1 pitch=700 contour=flat centroid=1800',
'loud gain=0.15 pitch=700 contour=flat centroid=1800',
'subtle.rise gain=0.03 pitch=760 contour=ascending centroid=1800',
'soft.rise gain=0.05 pitch=780 contour=ascending centroid=1800',
'subtle.fall gain=0.03 pitch=620 contour=descending centroid=1800',
'soft.fall gain=0.05 pitch=580 contour=descending centroid=1800',
'deep.fall gain=0.1 pitch=550 contour=descending centroid=1800',
'air gain=0.045 pitch=1080 contour=ascending centroid=5200',
'air.strong gain=0.12 pitch=1080 contour=ascending centroid=5200',
'settle gain=0.04 pitch=920 contour=descending centroid=4600',
'snap gain=0.05 pitch=1040 contour=arc centroid=5400',
'ping gain=0.12 pitch=960 contour=arc centroid=3000 url=/sounds/ping.wav',
'error gain=0.18 pitch=520 contour=descending centroid=1400 url=/sounds/error.wav'
'touch pitch=1300 centroid=6500 dur=30 contour=flat rough=0 gain=0.06',
'step pitch=1900 centroid=7600 dur=18 contour=flat rough=0 gain=0.025',
'tick pitch=850 centroid=3200 dur=70 contour=flat rough=0 gain=0.08',
'open pitch=520 centroid=2400 dur=180 contour=ascending rough=0 gain=0.09',
'close pitch=380 centroid=1700 dur=150 contour=descending rough=0 gain=0.07',
'slide pitch=300 centroid=1400 dur=120 contour=arc rough=0 gain=0.06',
'alert pitch=1000 centroid=4000 dur=190 contour=arc rough=0.05 gain=0.12',
'air pitch=1080 centroid=5200 dur=118 contour=ascending rough=0.004 gain=0.045',
'settle pitch=780 centroid=3600 dur=165 contour=descending rough=0.004 gain=0.05',
'snap pitch=1450 centroid=7100 dur=55 contour=arc rough=0.005 gain=0.06',
'tick.fulfill pitch=1250 centroid=5600 dur=240 contour=ascending rough=0 gain=0.12',
'tick.risk pitch=520 centroid=1700 dur=190 contour=flat rough=0.3 gain=0.11',
'tick.threat pitch=330 centroid=1100 dur=250 contour=descending rough=0.45 gain=0.15',
'tick.loss pitch=240 centroid=800 dur=180 contour=descending rough=0.05 gain=0.13',
'alert.risk pitch=700 centroid=2300 dur=230 contour=arc rough=0.35 gain=0.14',
'alert.threat pitch=460 centroid=1500 dur=300 contour=arc rough=0.55 gain=0.18'
]);
});
it('lets the INTENT through, for every name — the reorder, certified', () => {
const deaf: string[] = [];
for (const name of NAMES) {
const neutral = resolve(name, 'commit');
const fulfill = resolve(name, 'commit', 'fulfill');
const risk = resolve(name, 'commit', 'risk');
// `fulfill` is +300 pitch / +0.05 gain; `risk` is +0.2 roughness.
if (!(fulfill.gain > neutral.gain)) deaf.push(`${name}: fulfill did not raise the level`);
if (!(fulfill.pitch > neutral.pitch)) deaf.push(`${name}: fulfill did not raise the pitch`);
if (!(risk.roughness > neutral.roughness)) deaf.push(`${name}: risk did not roughen`);
}
expect(
deaf,
'a name is applied BEFORE the intent deltas precisely so the evaluative profile survives it'
).toEqual([]);
it('hangs every variant off a live base', () => {
// `tick.threat` without a `tick` would be unreachable the moment the
// intent is absent — the fallback has nothing to fall back to.
const orphans = NAMES.filter((n) => n.includes('.')).filter(
(n) => !BASES.includes(n.split('.')[0] as SoundName)
);
expect(orphans, 'a variant needs its base: `name.intent` degrades to `name`').toEqual([]);
});
it('keeps the family audible under the same name', () => {
// The level is the name's; the timbre stays the family's. A `soft` commit
// and a `soft` emerge must not be the same sound.
const commit = resolve('soft', 'commit');
const emerge = resolve('soft', 'emerge');
expect(commit.gain).toBe(emerge.gain);
expect(commit.centroid).not.toBe(emerge.centroid);
it('suffixes variants with a real intent, never an invented word', () => {
const intents = new Set(Object.keys(SEMA_MAP.intents));
const bad = NAMES.filter((n) => n.includes('.')).filter((n) => !intents.has(n.split('.')[1]));
expect(bad, 'the suffix IS the intent — the resolver looks it up by that exact key').toEqual(
[]
);
});
it('publishes every sample it can reach, for preload', () => {
expect(namedSampleUrls().sort()).toEqual(['/sounds/error.wav', '/sounds/ping.wav']);
it('ships no binaries in the framework pack', () => {
// The default pack is pure synthesis on purpose: it works offline, needs
// no fetch and cannot 404. The bundled `.wav`s are an EXAMPLE pack.
expect(namedSampleUrls()).toEqual([]);
});
});

@ -1,6 +1,12 @@
import type { SoundSignature } from './channels';
import type { SoundOverride } from './sounds';
import { SEMA_MAP, type NamedSound, type SemaMap, type SoundName } from './sema-map';
import {
SEMA_MAP,
SOUND_CATALOGUE,
type NamedSound,
type SemaMap,
type SoundName
} from './sema-map';
export type { NamedSound, SoundName } from './sema-map';
@ -60,15 +66,20 @@ export type { NamedSound, SoundName } from './sema-map';
* already does with `ping` versus `error`. Physics, not policy.
*/
/** THE catalogue, from the canonical map. */
export const SOUNDS = SEMA_MAP.sounds;
/**
* THE default pack, with its literal types — so a consumer can read
* `SOUNDS.tick.pitch`. The map's own `sounds` field is deliberately wide
* (`Record<string, NamedSound>`) so a THEME can replace entries; this is the
* authored shape.
*/
export const SOUNDS = SOUND_CATALOGUE;
/** Type guard — the resolver needs to tell a name from `SILENT`. */
/** Type guard — is this string an entry of the ACTIVE pack? */
export function isSoundName(value: unknown): value is SoundName {
return typeof value === 'string' && value in SEMA_MAP.sounds;
}
/** A sample entry — the only shape in the catalogue that carries a `kind`. */
/** A sample entry — the only shape in a pack that carries a `kind`. */
function isSample(entry: NamedSound): entry is Extract<NamedSound, { kind: 'sample' }> {
return (
typeof entry === 'object' && entry !== null && (entry as { kind?: unknown }).kind === 'sample'
@ -76,32 +87,30 @@ function isSample(entry: NamedSound): entry is Extract<NamedSound, { kind: 'samp
}
/**
* Resolve a name to the modifier the resolver applies over the family base,
* **against the map it is given** — which is what makes a theme take effect:
* the engine hands its own (possibly retuned) map on every emit.
* Resolve one name against a pack. Returns `undefined` when the pack has no
* such entry — which is a normal answer, not an error: it is how the intent
* variant degrades to the base, and how the base degrades to silence.
*
* A sample definition contributes its URL plus its fallback recipe; a plain
* modifier contributes itself.
* A sample contributes its URL plus its synthetic fallback (played when
* fetch / decode fails); a synth entry contributes itself. Always a COPY: the
* pack is shared by every emission in the session, and handing out the live
* object would let one consumer's mutation redefine a sound for everyone.
*/
export function resolveNamedSound(map: SemaMap, name: SoundName): SoundOverride {
export function resolveNamedSound(map: SemaMap, name: SoundName): SoundSignature | undefined {
const entry = map.sounds[name] as NamedSound | undefined;
if (entry === undefined) return {};
if (entry === undefined) return undefined;
if (isSample(entry)) {
const sig: Partial<SoundSignature> = { ...entry.fallback, sampleUrl: entry.sampleUrl };
return sig as SoundOverride;
return { ...entry.fallback, sampleUrl: entry.sampleUrl };
}
// Always a COPY. The catalogue is shared by every emission in the session;
// handing out the live object would let one consumer's mutation redefine
// what a name means for everyone.
return { ...(entry as Record<string, unknown>) } as SoundOverride;
return { ...(entry as unknown as SoundSignature) };
}
/** The same, against the canonical map — for tests and tooling. */
export function namedSound(name: SoundName): SoundOverride {
/** The same, against the canonical pack — for tests and tooling. */
export function namedSound(name: SoundName): SoundSignature | undefined {
return resolveNamedSound(SEMA_MAP, name);
}
/** Every sample URL the catalogue can reach — the preload manifest. */
/** Every sample URL the active pack can reach — the preload manifest. */
export function namedSampleUrls(map: SemaMap = SEMA_MAP): string[] {
const urls: string[] = [];
for (const entry of Object.values(map.sounds) as NamedSound[]) {

@ -17,11 +17,17 @@ describe('sema sound registry', () => {
expect(next.pitch).toBe(1080);
});
it('resolves a sampled name to its url plus its synthetic fallback', () => {
const resolved = namedSound('ping') as { sampleUrl: string; pitch: number };
it('resolves a base name to its whole designed signature', () => {
const resolved = namedSound('alert')!;
expect(resolved.sampleUrl).toBe('/sounds/ping.wav');
expect(resolved.pitch).toBe(960);
expect(resolved.pitch).toBe(1000);
expect(resolved.centroid).toBe(4000);
});
it('returns undefined for a name the active pack does not carry', () => {
// Not an error: it is how `name.intent` degrades to `name`, and `name`
// to silence. The author's rule — «si no hay ninguno no suena nada».
expect(namedSound('tick.affirm' as never)).toBeUndefined();
});
it('still resolves an ad-hoc sample definition (the app-authored door)', () => {
@ -29,7 +35,7 @@ describe('sema sound registry', () => {
const resolved = resolveSoundDefinition(sample('/sounds/custom.wav', fallback));
expect(resolved.sampleUrl).toBe('/sounds/custom.wav');
expect(resolved.pitch).toBe(920);
expect(resolved.pitch).toBe(780);
});
it('builds drawer drag signatures from normalized gesture state', () => {

@ -151,11 +151,6 @@ function mergeSound(base: SoundSignature, overrides?: Partial<SoundSignature>):
return overrides ? { ...next, ...overrides } : next;
}
function mergeSoundOverride(base: SoundOverride, overrides?: SoundOverride): SoundOverride {
const next = structuredClone(base);
return overrides ? { ...next, ...structuredClone(overrides) } : next;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

@ -1,5 +1,5 @@
import type { DomApplier, DomAttrValue } from '$adom'
import type { SemanticSignal } from './signal'
import type { DomApplier, DomAttrValue } from '$adom';
import type { SemanticSignal } from './signal';
/**
* Project a `SemanticSignal` onto its target as `data-event-*` attrs.
@ -30,9 +30,9 @@ export function eventAttrs(signal: SemanticSignal): Record<string, DomAttrValue>
'data-event-phase': 'active',
'data-event-family': signal.family,
'data-event-intent': signal.intent
}
};
return attrs
return attrs;
}
export function clearEventAttrs(): Record<string, DomAttrValue> {
@ -42,7 +42,7 @@ export function clearEventAttrs(): Record<string, DomAttrValue> {
'data-event-phase': undefined,
'data-event-family': undefined,
'data-event-intent': undefined
}
};
}
export function stampEventAttrs(
@ -53,7 +53,7 @@ export function stampEventAttrs(
dom.apply({
target,
attrs: eventAttrs(signal)
})
});
}
export function unstampEventAttrs(
@ -64,5 +64,5 @@ export function unstampEventAttrs(
dom.apply({
target,
attrs: clearEventAttrs()
})
});
}

@ -27,6 +27,7 @@ import { describe, expect, it } from 'vitest';
import { applyChange } from '$adom';
import { EngineSemantic } from './engine';
import { SEMA_MAP } from './sema-map';
import { SOUNDS } from './sound-names';
import { SemaConfigError } from './errors';
import type { Channel } from './chans/types';
import type { EffectiveSignature } from './resolver';
@ -72,7 +73,7 @@ describe('theming the perceptual map', () => {
const { engine, capture } = makeEngine();
const pack = {
name: 't',
cascade: [{ selector: '[data-event]', sound: 'soft' as const }]
cascade: [{ selector: '[data-event]', sound: 'tick' as const }]
};
const themed = new EngineSemantic({
dom: TEST_DOM,
@ -88,63 +89,62 @@ describe('theming the perceptual map', () => {
const el = target();
el.setAttribute('data-event', 'x');
await themed.emit({ target: el, name: 'x', family: 'commit', intent: 'neutral' });
expect(cap2.last()?.sound?.gain).toBe(0.05);
expect(cap2.last()?.sound?.gain).toBe(0.08);
themed.applySounds({ soft: { gain: 0.2 } });
themed.applySounds({ tick: { gain: 0.2 } });
await themed.emit({ target: el, name: 'x', family: 'commit', intent: 'neutral' });
expect(cap2.last()?.sound?.gain).toBe(0.2);
themed.clearMap();
await themed.emit({ target: el, name: 'x', family: 'commit', intent: 'neutral' });
expect(cap2.last()?.sound?.gain, 'clearMap reverts to the authored catalogue').toBe(0.05);
expect(cap2.last()?.sound?.gain, 'clearMap reverts to the authored catalogue').toBe(0.08);
});
it('re-voices a FAMILY live, and reverts', async () => {
it('re-voices a family DEFAULT by pointing it at another entry', async () => {
const { engine, capture } = makeEngine();
const el = target();
await engine.emit({ target: el, name: 'x', family: 'commit', intent: 'neutral' });
const authored = capture.last()?.sound?.gain;
expect(authored).toBe(SEMA_MAP.families.commit.base.sound!.gain);
// commit → 'tick' by default. No pack, no rule: the map's verb table.
await engine.emit({ target: el, name: 'x', family: 'commit' });
expect(capture.last()?.sound?.pitch).toBe(SOUNDS.tick.pitch);
engine.applyMap({ 'families.commit.base.sound.gain': 0.9 });
await engine.emit({ target: el, name: 'x', family: 'commit', intent: 'neutral' });
expect(capture.last()?.sound?.gain).toBe(0.9);
// A theme can point the whole family somewhere else — one path.
engine.applyMap({ 'families.commit.sounds.default': 'touch' });
await engine.emit({ target: el, name: 'x', family: 'commit' });
expect(capture.last()?.sound?.pitch).toBe(SOUNDS.touch.pitch);
engine.clearMap();
await engine.emit({ target: el, name: 'x', family: 'commit', intent: 'neutral' });
expect(capture.last()?.sound?.gain).toBe(authored);
await engine.emit({ target: el, name: 'x', family: 'commit' });
expect(capture.last()?.sound?.pitch).toBe(SOUNDS.tick.pitch);
});
it('is idempotent, not cumulative — a retune rebuilds from the authored map', async () => {
const { engine, capture } = makeEngine();
const el = target();
engine.applyMap({ 'families.commit.base.sound.gain': 0.5 });
engine.applyMap({ 'families.commit.base.sound.gain': 0.5 });
await engine.emit({ target: el, name: 'x', family: 'commit', intent: 'neutral' });
engine.applyMap({ 'sounds.tick.gain': 0.5 });
engine.applyMap({ 'sounds.tick.gain': 0.5 });
await engine.emit({ target: el, name: 'x', family: 'commit' });
expect(capture.last()?.sound?.gain, 'applying twice is applying once').toBe(0.5);
});
it('leaves the canonical map untouched — a theme clones, never mutates', () => {
const authored = SEMA_MAP.families.commit.base.sound!.gain;
const authored = SOUNDS.tick.gain;
const { engine } = makeEngine();
engine.applyMap({ 'families.commit.base.sound.gain': 0.77 });
expect(SEMA_MAP.families.commit.base.sound!.gain).toBe(authored);
engine.applyMap({ 'sounds.tick.gain': 0.77 });
expect(SOUNDS.tick.gain).toBe(authored);
});
it('REFUSES a path that does not exist, instead of growing a phantom branch', () => {
const { engine } = makeEngine();
// The audit's exact case (S-09): one wrong letter used to leave the real
// value untouched and create `families.commmit` beside it, in silence.
expect(() => engine.applyMap({ 'families.commmit.base.sound.gain': 0.5 })).toThrow(
expect(() => engine.applyMap({ 'families.commmit.sounds.default': 'tick' })).toThrow(
SemaConfigError
);
// And the worse one: descending THROUGH a primitive leaf, which used to
// replace the real leaf with an object.
expect(() => engine.applyMap({ 'families.commit.base.sound.gain.deep': 1 })).toThrow(
SemaConfigError
);
expect(() => engine.applyMap({ 'sounds.tick.gain.deep': 1 })).toThrow(SemaConfigError);
});
it('refuses a bad path at construction too, not only on retune', () => {
@ -183,7 +183,7 @@ describe('theming the perceptual map', () => {
// intent survives a refactor that widened the type by accident.
const names = Object.keys(SEMA_MAP.sounds);
const { engine } = makeEngine();
engine.applySounds({ soft: { gain: 0.2 } });
engine.applySounds({ tick: { gain: 0.2 } });
expect(Object.keys(SEMA_MAP.sounds), 'no name appears or disappears').toEqual(names);
});
});

@ -13,12 +13,12 @@
* `uix.timers`; the engine forwards it to every channel.
*/
import type { TimerScheduler } from '$libs/timer'
import type { TimerScheduler } from '$libs/timer';
export type SemaTimerScheduler = Pick<TimerScheduler, 'schedule'>
export type SemaTimerScheduler = Pick<TimerScheduler, 'schedule'>;
export interface SemaTimerHandle {
cancel(): boolean
cancel(): boolean;
}
/**
@ -38,13 +38,13 @@ export function semaDelay(
meta?: Readonly<Record<string, unknown>>
): SemaTimerHandle {
if (timers) {
return timers.schedule(null, delayMs, task, meta ? { meta } : undefined)
return timers.schedule(null, delayMs, task, meta ? { meta } : undefined);
}
const id = setTimeout(task, delayMs)
const id = setTimeout(task, delayMs);
return {
cancel() {
clearTimeout(id)
return true
clearTimeout(id);
return true;
}
}
};
}

@ -1,52 +1,49 @@
import { isSemaEvent, isSemaEventLabel, isIntent, isIntentBinding } from './event'
import { SemaInvariantError } from './errors'
import { SEMA_FAMILY_POLICY, type SemaActionEvent, type IntentBinding } from './types'
import { isSemaEvent, isSemaEventLabel, isIntent, isIntentBinding } from './event';
import { SemaInvariantError } from './errors';
import { SEMA_FAMILY_POLICY, type SemaActionEvent, type IntentBinding } from './types';
export { SemaInvariantError } from './errors'
export { SemaInvariantError } from './errors';
export function validateIntentBinding(binding: IntentBinding, ctx = 'sema.intent'): void {
if (!isIntentBinding(binding)) {
throw new SemaInvariantError(`${ctx} is not a valid IntentBinding`)
throw new SemaInvariantError(`${ctx} is not a valid IntentBinding`);
}
const supported = binding.supported ?? []
if (supported.length === 0) return
const supported = binding.supported ?? [];
if (supported.length === 0) return;
for (const intent of supported) {
if (!isIntent(intent)) {
throw new SemaInvariantError(`${ctx}: intent "${String(intent)}" is not a valid Intent`)
throw new SemaInvariantError(`${ctx}: intent "${String(intent)}" is not a valid Intent`);
}
}
if (!supported.includes(binding.default)) {
throw new SemaInvariantError(
`${ctx}: default intent "${binding.default}" must be included in supported intents`
)
);
}
}
export function validateSemaEvent(event: SemaActionEvent, ctx = 'sema.event'): void {
if (isSemaEventLabel(event)) return
if (isSemaEventLabel(event)) return;
if (!isSemaEvent(event)) {
throw new SemaInvariantError(`${ctx} is not a valid canonical semantic event`)
throw new SemaInvariantError(`${ctx} is not a valid canonical semantic event`);
}
const intent = 'intent' in event ? event.intent : undefined
const intent = 'intent' in event ? event.intent : undefined;
// Policy enforcement: families marked `intentRequirement: 'required'` MUST
// declare intent.
if (
intent === undefined &&
SEMA_FAMILY_POLICY[event.family].intentRequirement === 'required'
) {
if (intent === undefined && SEMA_FAMILY_POLICY[event.family].intentRequirement === 'required') {
throw new SemaInvariantError(
`${ctx}: family "${event.family}" requires intent (intentRequirement: 'required')`
)
);
}
// Bindings need structural validation; literals + absence are fine.
if (intent === undefined || typeof intent === 'string') return
if (intent === undefined || typeof intent === 'string') return;
validateIntentBinding(intent, `${ctx}.intent`)
validateIntentBinding(intent, `${ctx}.intent`);
}

@ -1,105 +1,105 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest';
import { SEMA_VERBS, isSemaVerb, validateEventName, familyForVerb } from './verbs'
import { SEMA_VERBS, isSemaVerb, validateEventName, familyForVerb } from './verbs';
describe('SEMA_VERBS', () => {
it('groups verbs by family per book canon', () => {
// Canonical verbs per «Diseñando lo que ocurre» cap. 22-29
expect(SEMA_VERBS.contact).toContain('press')
expect(SEMA_VERBS.commit).toContain('toggle')
expect(SEMA_VERBS.commit).toContain('save')
expect(SEMA_VERBS.signal).toContain('announce')
expect(SEMA_VERBS.signal).toContain('warn')
expect(SEMA_VERBS.signal).toContain('inform') // cap. 24
expect(SEMA_VERBS.handle).toContain('drag')
expect(SEMA_VERBS.emerge).toContain('present')
expect(SEMA_VERBS.emerge).toContain('dismiss')
expect(SEMA_VERBS.shift).toContain('enter-mode')
expect(SEMA_VERBS.sustain).toContain('progress')
})
expect(SEMA_VERBS.contact).toContain('press');
expect(SEMA_VERBS.commit).toContain('toggle');
expect(SEMA_VERBS.commit).toContain('save');
expect(SEMA_VERBS.signal).toContain('announce');
expect(SEMA_VERBS.signal).toContain('warn');
expect(SEMA_VERBS.signal).toContain('inform'); // cap. 24
expect(SEMA_VERBS.handle).toContain('drag');
expect(SEMA_VERBS.emerge).toContain('present');
expect(SEMA_VERBS.emerge).toContain('dismiss');
expect(SEMA_VERBS.shift).toContain('enter-mode');
expect(SEMA_VERBS.sustain).toContain('progress');
});
it('exposes the delegate family (book cap. 29)', () => {
// Eighth family: who acts. Required for workflows, IA, macros,
// rule-based automation. Per book Apéndice C case 3.
expect(SEMA_VERBS.delegate).toContain('offer')
expect(SEMA_VERBS.delegate).toContain('plan')
expect(SEMA_VERBS.delegate).toContain('authorize')
expect(SEMA_VERBS.delegate).toContain('act')
expect(SEMA_VERBS.delegate).toContain('review')
expect(SEMA_VERBS.delegate).toContain('escalate')
expect(SEMA_VERBS.delegate).toContain('return')
})
expect(SEMA_VERBS.delegate).toContain('offer');
expect(SEMA_VERBS.delegate).toContain('plan');
expect(SEMA_VERBS.delegate).toContain('authorize');
expect(SEMA_VERBS.delegate).toContain('act');
expect(SEMA_VERBS.delegate).toContain('review');
expect(SEMA_VERBS.delegate).toContain('escalate');
expect(SEMA_VERBS.delegate).toContain('return');
});
it('exposes contextual commit verbs used in the book case studies', () => {
// cap. 29 + cap. 30 + apéndice C
expect(SEMA_VERBS.commit).toContain('apply')
expect(SEMA_VERBS.commit).toContain('partial')
expect(SEMA_VERBS.commit).toContain('block')
expect(SEMA_VERBS.commit).toContain('upload')
})
expect(SEMA_VERBS.commit).toContain('apply');
expect(SEMA_VERBS.commit).toContain('partial');
expect(SEMA_VERBS.commit).toContain('block');
expect(SEMA_VERBS.commit).toContain('upload');
});
it('has no internal duplicates per family', () => {
for (const verbs of Object.values(SEMA_VERBS)) {
expect(new Set(verbs).size).toBe(verbs.length)
expect(new Set(verbs).size).toBe(verbs.length);
}
})
});
it('moved select / toggle / acknowledge to commit (per doctrine)', () => {
// These were `contact` / `handle` in pre-doctrina shape.
expect(SEMA_VERBS.commit).toContain('select')
expect(SEMA_VERBS.commit).toContain('toggle')
expect(SEMA_VERBS.commit).toContain('acknowledge')
expect(SEMA_VERBS.contact as readonly string[]).not.toContain('select')
expect(SEMA_VERBS.contact as readonly string[]).not.toContain('toggle')
expect(SEMA_VERBS.handle as readonly string[]).not.toContain('acknowledge')
})
})
expect(SEMA_VERBS.commit).toContain('select');
expect(SEMA_VERBS.commit).toContain('toggle');
expect(SEMA_VERBS.commit).toContain('acknowledge');
expect(SEMA_VERBS.contact as readonly string[]).not.toContain('select');
expect(SEMA_VERBS.contact as readonly string[]).not.toContain('toggle');
expect(SEMA_VERBS.handle as readonly string[]).not.toContain('acknowledge');
});
});
describe('isSemaVerb', () => {
it('accepts canonical verbs', () => {
expect(isSemaVerb('present')).toBe(true)
expect(isSemaVerb('toggle')).toBe(true)
expect(isSemaVerb('save')).toBe(true)
})
expect(isSemaVerb('present')).toBe(true);
expect(isSemaVerb('toggle')).toBe(true);
expect(isSemaVerb('save')).toBe(true);
});
it('rejects non-canonical strings', () => {
expect(isSemaVerb('frob')).toBe(false)
expect(isSemaVerb('commit-save')).toBe(false) // composite, not a verb
})
expect(isSemaVerb('frob')).toBe(false);
expect(isSemaVerb('commit-save')).toBe(false); // composite, not a verb
});
it('rejects family names (they are not verbs)', () => {
expect(isSemaVerb('commit')).toBe(false)
expect(isSemaVerb('emerge')).toBe(false)
expect(isSemaVerb('signal')).toBe(false)
expect(isSemaVerb('delegate')).toBe(false)
})
expect(isSemaVerb('commit')).toBe(false);
expect(isSemaVerb('emerge')).toBe(false);
expect(isSemaVerb('signal')).toBe(false);
expect(isSemaVerb('delegate')).toBe(false);
});
it('rejects non-strings', () => {
expect(isSemaVerb(undefined)).toBe(false)
expect(isSemaVerb(42)).toBe(false)
expect(isSemaVerb(null)).toBe(false)
})
})
expect(isSemaVerb(undefined)).toBe(false);
expect(isSemaVerb(42)).toBe(false);
expect(isSemaVerb(null)).toBe(false);
});
});
describe('familyForVerb', () => {
it('resolves the family of a canonical verb', () => {
expect(familyForVerb('press')).toBe('contact')
expect(familyForVerb('toggle')).toBe('commit')
expect(familyForVerb('dismiss')).toBe('emerge')
expect(familyForVerb('warn')).toBe('signal')
expect(familyForVerb('inform')).toBe('signal')
expect(familyForVerb('drag')).toBe('handle')
expect(familyForVerb('enter-mode')).toBe('shift')
expect(familyForVerb('progress')).toBe('sustain')
expect(familyForVerb('authorize')).toBe('delegate')
expect(familyForVerb('escalate')).toBe('delegate')
})
expect(familyForVerb('press')).toBe('contact');
expect(familyForVerb('toggle')).toBe('commit');
expect(familyForVerb('dismiss')).toBe('emerge');
expect(familyForVerb('warn')).toBe('signal');
expect(familyForVerb('inform')).toBe('signal');
expect(familyForVerb('drag')).toBe('handle');
expect(familyForVerb('enter-mode')).toBe('shift');
expect(familyForVerb('progress')).toBe('sustain');
expect(familyForVerb('authorize')).toBe('delegate');
expect(familyForVerb('escalate')).toBe('delegate');
});
it('returns undefined for non-canonical strings', () => {
expect(familyForVerb('frob')).toBeUndefined()
expect(familyForVerb('commit')).toBeUndefined() // family name, not a verb
})
})
expect(familyForVerb('frob')).toBeUndefined();
expect(familyForVerb('commit')).toBeUndefined(); // family name, not a verb
});
});
describe('validateEventName', () => {
it('matches a bare canonical verb', () => {
@ -110,8 +110,8 @@ describe('validateEventName', () => {
variant: undefined,
family: 'emerge',
verb: 'dismiss'
})
})
});
});
it('extracts head + variant from verb-variant composites', () => {
expect(validateEventName('dismiss-outside')).toEqual({
@ -121,7 +121,7 @@ describe('validateEventName', () => {
variant: 'outside',
family: 'emerge',
verb: 'dismiss'
})
});
expect(validateEventName('close-cancel')).toEqual({
name: 'close-cancel',
head: 'close',
@ -129,8 +129,8 @@ describe('validateEventName', () => {
variant: 'cancel',
family: 'emerge',
verb: 'close'
})
})
});
});
it('parses family-verb naming (doctrinal shape)', () => {
expect(validateEventName('commit-toggle')).toEqual({
@ -140,7 +140,7 @@ describe('validateEventName', () => {
variant: 'toggle',
family: 'commit',
verb: 'toggle'
})
});
expect(validateEventName('commit-save')).toEqual({
name: 'commit-save',
head: 'commit',
@ -148,8 +148,8 @@ describe('validateEventName', () => {
variant: 'save',
family: 'commit',
verb: 'save'
})
})
});
});
it('flags drift when neither head nor family.verb is canonical', () => {
expect(validateEventName('frob-glob')).toEqual({
@ -159,13 +159,13 @@ describe('validateEventName', () => {
variant: 'glob',
family: undefined,
verb: undefined
})
})
});
});
it('flags drift when family is canonical but the verb is not', () => {
expect(validateEventName('commit-frob').matchesCanonical).toBe(false)
expect(validateEventName('commit-frob').family).toBe('commit')
})
expect(validateEventName('commit-frob').matchesCanonical).toBe(false);
expect(validateEventName('commit-frob').family).toBe('commit');
});
it('recognises multi-word canonical verbs (enter-mode, exit-mode)', () => {
// Family-verb shape with multi-word verb.
@ -176,16 +176,16 @@ describe('validateEventName', () => {
variant: 'enter-mode',
family: 'shift',
verb: 'enter-mode'
})
expect(validateEventName('shift-exit-mode').matchesCanonical).toBe(true)
});
expect(validateEventName('shift-exit-mode').matchesCanonical).toBe(true);
// With trailing variant.
expect(validateEventName('shift-enter-mode-edit').matchesCanonical).toBe(true)
expect(validateEventName('shift-enter-mode-edit').verb).toBe('enter-mode')
expect(validateEventName('shift-enter-mode-edit').matchesCanonical).toBe(true);
expect(validateEventName('shift-enter-mode-edit').verb).toBe('enter-mode');
// Bare verb shape — `enter-mode` alone is canonical.
expect(validateEventName('enter-mode').matchesCanonical).toBe(true)
expect(validateEventName('enter-mode').verb).toBe('enter-mode')
expect(validateEventName('enter-mode').family).toBe('shift')
})
expect(validateEventName('enter-mode').matchesCanonical).toBe(true);
expect(validateEventName('enter-mode').verb).toBe('enter-mode');
expect(validateEventName('enter-mode').family).toBe('shift');
});
it('reports the existing dialog event names against the catalog', () => {
const dialogEvents = [
@ -195,16 +195,16 @@ describe('validateEventName', () => {
'close-dismiss',
'close-dismiss-outside',
'close-after-fail'
]
];
for (const name of dialogEvents) {
const v = validateEventName(name)
expect(v.matchesCanonical).toBe(true)
const v = validateEventName(name);
expect(v.matchesCanonical).toBe(true);
}
})
});
it('reports the existing toast event names against the catalog', () => {
for (const name of ['present', 'announce', 'dismiss']) {
expect(validateEventName(name).matchesCanonical).toBe(true)
expect(validateEventName(name).matchesCanonical).toBe(true);
}
})
})
});
});

@ -104,30 +104,13 @@ export const SEMA_VERBS = {
'upload'
],
signal: [
'announce',
'notify',
'warn',
'alert',
'inform',
'emphasize',
'remind'
],
signal: ['announce', 'notify', 'warn', 'alert', 'inform', 'emphasize', 'remind'],
// `zoom` (extensión del autor 2026-06-10): manipulación directa de la escala
// del contenido (rueda / pinch), junto a drag / resize / rotate / scroll.
handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll', 'zoom'],
emerge: [
'present',
'dismiss',
'open',
'close',
'expand',
'collapse',
'reveal',
'hide'
],
emerge: ['present', 'dismiss', 'open', 'close', 'expand', 'collapse', 'reveal', 'hide'],
shift: ['enter-mode', 'exit-mode', 'navigate', 'route', 'step', 'return', 'context'],

@ -27,7 +27,7 @@ import { DRAWER_LANGS } from './langs';
import { drawerMorfo } from '../../../morfo/components/drawer';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { resolveHandleDragSound, type SemaSignatureOverride } from '$uix/sema';
import type { SemaSignatureOverride } from '$uix/sema';
import type { TimerHandle } from '$timer';
// ── Dismiss cause map (book §5.3 polymorphic close) ─────────────────────────
@ -608,11 +608,13 @@ export class DrawerContentProvider {
lastAxisOffset = axisOffsetToward;
return {
sound: resolveHandleDragSound({
value01,
velocity01,
direction: directionToward > 0 ? 'ascending' : 'descending'
})
// The gesture sounds by REPETITION: this fires at the drag's own
// rhythm, so `step` per emission makes the speed of the gesture the
// speed of the clicking — a ratchet. It replaced a resolver that
// synthesised pitch from position and velocity, the last arithmetic
// in the sound path. Trade-off signed on 2026-08-06: the rhythm
// carries the speed, but position no longer maps to pitch.
sound: 'step' as const,
};
};
const flushDragProgress = () => {

@ -13,7 +13,7 @@ import { Gesture, type GestureRotateState } from '../../layers/gesture';
import { boolToEmptyStrOrUndef } from '$adom';
import { readableActive, state, type State } from '$libs/reactive';
import { knobMorfo } from '../../../morfo/components/knob';
import { resolveHandleDragSound, type SemaSignatureOverride } from '$uix/sema';
import type { SemaSignatureOverride } from '$uix/sema';
import type { KnobStateFields, KnobActiveFields } from './types';
import type { StateProps, ActiveProps } from '$libs/reactive';
@ -233,11 +233,13 @@ export class KnobProvider {
this.lastDragPercent = percent;
return {
sound: resolveHandleDragSound({
value01: percent,
velocity01,
direction: delta >= 0 ? 'ascending' : 'descending'
}),
// The gesture sounds by REPETITION: this fires at the drag's own
// rhythm, so `step` per emission makes the speed of the gesture the
// speed of the clicking — a ratchet. It replaced a resolver that
// synthesised pitch from position and velocity, the last arithmetic
// in the sound path. Trade-off signed on 2026-08-06: the rhythm
// carries the speed, but position no longer maps to pitch.
sound: 'step' as const,
haptic: {
kind: 'tick',
intensity: Number((0.18 + velocity01 * 0.14).toFixed(3)),

@ -9,7 +9,7 @@ import { sliderMorfo } from '../../../morfo/components/slider';
import { Soma } from '../../core/soma.svelte';
import { FieldProvider } from '../field';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { resolveSliderDragSound, type SemaSignatureOverride } from '$uix/sema';
import type { SemaSignatureOverride } from '$uix/sema';
import type { SliderType } from './types';
// ── Helpers ──────────────────────────────────────────────────────────────────
@ -247,11 +247,13 @@ export class SliderProvider {
this.lastDragPercent = percent;
return {
sound: resolveSliderDragSound({
value01: percent,
velocity01,
direction: delta >= 0 ? 'ascending' : 'descending'
}),
// The gesture sounds by REPETITION: this fires at the drag's own
// rhythm, so `step` per emission makes the speed of the gesture the
// speed of the clicking — a ratchet. It replaced a resolver that
// synthesised pitch from position and velocity, the last arithmetic
// in the sound path. Trade-off signed on 2026-08-06: the rhythm
// carries the speed, but position no longer maps to pitch.
sound: 'step' as const,
haptic: {
kind: 'tick',
intensity: Number((0.18 + velocity01 * 0.14).toFixed(3)),

@ -8,7 +8,7 @@ import { Soma } from '../../core/soma.svelte';
import { splitterMorfo } from '../../../morfo/components/splitter';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { resolveSplitterDragSound, type SemaSignatureOverride } from '$uix/sema';
import type { SemaSignatureOverride } from '$uix/sema';
function nowMs(): number {
return typeof performance !== 'undefined' ? performance.now() : Date.now();
@ -457,11 +457,13 @@ export class SplitterResizeTriggerProvider {
this.lastDragPercent = percent;
return {
sound: resolveSplitterDragSound({
value01: percent,
velocity01,
direction: delta >= 0 ? 'ascending' : 'descending'
}),
// The gesture sounds by REPETITION: this fires at the drag's own
// rhythm, so `step` per emission makes the speed of the gesture the
// speed of the clicking — a ratchet. It replaced a resolver that
// synthesised pitch from position and velocity, the last arithmetic
// in the sound path. Trade-off signed on 2026-08-06: the rhythm
// carries the speed, but position no longer maps to pitch.
sound: 'step' as const,
haptic: {
kind: 'tick',
intensity: Number((0.16 + velocity01 * 0.16).toFixed(3)),

@ -721,7 +721,10 @@ export function createSomaRuntime<M extends Morfo>(
resolvedIntent = provided.intent;
resolvedVerb = provided.verb;
}
void resolvedVerb; // currently unused at trigger-time; reserved for tooling/logging.
// The verb TRAVELS (fixed 2026-08-06, audit S-40 — it used to be computed
// and discarded here while the type promised it was emitted). It selects
// the family's default sound: an `emerge` + `close` finds `close` in the
// map's verb table, so no overlay pack has to name it.
// `MorfoEventSequence` doc (morfo/types.ts §280): "default 'pre'
// preserves current runtime semantics". Polymorphic events use the
@ -753,8 +756,8 @@ export function createSomaRuntime<M extends Morfo>(
const runEmit = async () => {
if (!sources.eventEngine) return;
const targetReg = registrations.get(action.target);
const target = opts.fallbackTarget ?? targetReg?.ref?.current ?? null;
const targetReg = registrations.get(action.target);
const target = opts.fallbackTarget ?? targetReg?.ref?.current ?? null;
if (!target) {
throw new SomaRuntimeTargetError(eventName, action.target);
}
@ -776,8 +779,7 @@ export function createSomaRuntime<M extends Morfo>(
// (per-call channel slices win wholesale on conflict). a11y
// reduced-motion override has the LOWEST precedence so authors
// can still force motion when they know the context warrants it.
const channels =
opts.channels ?? action.semantic.channels ?? a11yChannelsOverride;
const channels = opts.channels ?? action.semantic.channels ?? a11yChannelsOverride;
const morfoOverrides = action.semantic.overrides;
const overrides = opts.overrides
? ({ ...(morfoOverrides ?? {}), ...opts.overrides } as SemaSignatureOverride)
@ -786,6 +788,7 @@ export function createSomaRuntime<M extends Morfo>(
target,
name: action.name,
family,
...(resolvedVerb ? { verb: resolvedVerb } : {}),
...(intent ? { intent } : {}),
...(hold !== undefined ? { hold } : {}),
...(channels !== undefined ? { channels } : {}),

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.