From 86fa9a1b7735934b52e280125b898f6fde48928c Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 6 Aug 2026 21:57:46 +0200 Subject: [PATCH] =?UTF-8?q?refactor(sema)!:=20el=20sonido=20se=20ELIGE,=20?= =?UTF-8?q?no=20se=20modula=20=E2=80=94=20evento.intent=20=3D=20sonido?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/canon/vocabularies.md | 2 +- src/uix/sema/cascade-scope.test.ts | 22 +- src/uix/sema/channels.ts | 17 +- src/uix/sema/chans/sound-e2e.test.ts | 2 +- src/uix/sema/chans/visual.test.ts | 30 +- src/uix/sema/components/accordion.ts | 6 +- src/uix/sema/components/calendar.ts | 37 +- src/uix/sema/components/chat-composer.ts | 2 +- src/uix/sema/components/chat-log.ts | 2 +- src/uix/sema/components/chat-message.ts | 4 +- src/uix/sema/components/checkbox.ts | 4 +- src/uix/sema/components/chronos.ts | 15 +- src/uix/sema/components/clipboard.ts | 5 +- src/uix/sema/components/collapsible.ts | 6 +- src/uix/sema/components/color-field.ts | 2 +- src/uix/sema/components/color-picker.ts | 2 +- src/uix/sema/components/combobox.ts | 4 +- src/uix/sema/components/context-menu.ts | 8 +- src/uix/sema/components/cropper.ts | 2 +- src/uix/sema/components/css-field.ts | 6 +- src/uix/sema/components/date-field.ts | 4 +- src/uix/sema/components/dialog.ts | 12 +- src/uix/sema/components/drag-drop.ts | 3 +- src/uix/sema/components/drawer.ts | 3 +- src/uix/sema/components/dropdown-menu.ts | 8 +- src/uix/sema/components/editable.ts | 6 +- src/uix/sema/components/file-upload.ts | 10 +- src/uix/sema/components/float-panel.ts | 3 +- src/uix/sema/components/form.ts | 6 +- src/uix/sema/components/gradient-builder.ts | 2 +- src/uix/sema/components/image-adjustments.ts | 2 +- src/uix/sema/components/image-picker.ts | 4 +- src/uix/sema/components/knob.ts | 2 +- src/uix/sema/components/listbox.ts | 4 +- src/uix/sema/components/mask-field.ts | 2 +- src/uix/sema/components/media-player.ts | 6 +- src/uix/sema/components/menu-dial.ts | 8 +- src/uix/sema/components/menubar.ts | 2 +- src/uix/sema/components/month-grid.ts | 2 +- src/uix/sema/components/nav-tree.ts | 6 +- src/uix/sema/components/navigation-menu.ts | 8 +- src/uix/sema/components/number-field.ts | 4 +- src/uix/sema/components/onion-menu.ts | 8 +- src/uix/sema/components/pagination.ts | 12 +- src/uix/sema/components/password-field.ts | 6 +- src/uix/sema/components/picker.ts | 8 +- src/uix/sema/components/popover.ts | 9 +- src/uix/sema/components/proof-of-human.ts | 7 +- src/uix/sema/components/radio-group.ts | 2 +- src/uix/sema/components/range-calendar.ts | 7 +- src/uix/sema/components/rating-group.ts | 4 +- src/uix/sema/components/search-field.ts | 4 +- src/uix/sema/components/select.ts | 4 +- src/uix/sema/components/sidebar.ts | 6 +- src/uix/sema/components/slider.ts | 2 +- src/uix/sema/components/splitter.ts | 2 +- src/uix/sema/components/stepper.ts | 4 +- src/uix/sema/components/switch.ts | 2 +- src/uix/sema/components/table.ts | 5 +- src/uix/sema/components/tabs.ts | 2 +- src/uix/sema/components/tag-group.ts | 8 +- src/uix/sema/components/tags-input.ts | 8 +- src/uix/sema/components/textarea.ts | 4 +- src/uix/sema/components/time-field.ts | 2 +- src/uix/sema/components/timeline.ts | 2 +- src/uix/sema/components/toast.ts | 6 +- src/uix/sema/components/toggle-group.ts | 2 +- src/uix/sema/components/toggle.ts | 2 +- src/uix/sema/components/toolbar.ts | 4 +- src/uix/sema/components/tree-grid.ts | 8 +- src/uix/sema/components/tree-view.ts | 10 +- src/uix/sema/components/year-grid.ts | 2 +- src/uix/sema/d7-intent-survives.test.ts | 67 --- src/uix/sema/define-engine-semantic.ts | 14 +- src/uix/sema/durations.test.ts | 36 +- src/uix/sema/durations.ts | 12 +- src/uix/sema/engine.test.ts | 18 +- src/uix/sema/engine.ts | 6 + src/uix/sema/event.test.ts | 6 +- src/uix/sema/event.ts | 106 ++--- src/uix/sema/index.ts | 2 +- src/uix/sema/resolver.test.ts | 318 +++++--------- src/uix/sema/resolver.ts | 157 ++++--- src/uix/sema/sema-map.ts | 381 +++++++++++------ src/uix/sema/signal.ts | 14 + src/uix/sema/sound-names.test.ts | 183 ++++---- src/uix/sema/sound-names.ts | 51 ++- src/uix/sema/sounds.test.ts | 16 +- src/uix/sema/sounds.ts | 5 - src/uix/sema/stamp.ts | 14 +- src/uix/sema/theming.test.ts | 48 +-- src/uix/sema/timers.ts | 16 +- src/uix/sema/validation.ts | 35 +- src/uix/sema/verbs.test.ts | 194 ++++----- src/uix/sema/verbs.ts | 21 +- .../drawer/drawer-provider.svelte.ts | 14 +- .../components/knob/knob-provider.svelte.ts | 14 +- .../slider/slider-provider.svelte.ts | 14 +- .../splitter/splitter-provider.svelte.ts | 14 +- src/uix/soma/runtime.svelte.ts | 13 +- web/routes/uix/docs/sound/+page.svelte | 399 +++++++----------- 101 files changed, 1236 insertions(+), 1389 deletions(-) delete mode 100644 src/uix/sema/d7-intent-survives.test.ts diff --git a/docs/canon/vocabularies.md b/docs/canon/vocabularies.md index 2c3092551..da2b1018a 100644 --- a/docs/canon/vocabularies.md +++ b/docs/canon/vocabularies.md @@ -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) diff --git a/src/uix/sema/cascade-scope.test.ts b/src/uix/sema/cascade-scope.test.ts index 98d16580f..52e90ab66 100644 --- a/src/uix/sema/cascade-scope.test.ts +++ b/src/uix/sema/cascade-scope.test.ts @@ -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); }); }); diff --git a/src/uix/sema/channels.ts b/src/uix/sema/channels.ts index a2cca2d06..5c7886c65 100644 --- a/src/uix/sema/channels.ts +++ b/src/uix/sema/channels.ts @@ -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]?: | Partial | Record | 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; diff --git a/src/uix/sema/chans/sound-e2e.test.ts b/src/uix/sema/chans/sound-e2e.test.ts index 7c02f7df5..11d104838 100644 --- a/src/uix/sema/chans/sound-e2e.test.ts +++ b/src/uix/sema/chans/sound-e2e.test.ts @@ -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++) { diff --git a/src/uix/sema/chans/visual.test.ts b/src/uix/sema/chans/visual.test.ts index 52c583e1d..a2f33d021 100644 --- a/src/uix/sema/chans/visual.test.ts +++ b/src/uix/sema/chans/visual.test.ts @@ -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; }); diff --git a/src/uix/sema/components/accordion.ts b/src/uix/sema/components/accordion.ts index 21392b47e..014cebab2 100644 --- a/src/uix/sema/components/accordion.ts +++ b/src/uix/sema/components/accordion.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/calendar.ts b/src/uix/sema/components/calendar.ts index 02a91b326..c0fe7fb65 100644 --- a/src/uix/sema/components/calendar.ts +++ b/src/uix/sema/components/calendar.ts @@ -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>[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>[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 - })) + } ] }; diff --git a/src/uix/sema/components/chat-composer.ts b/src/uix/sema/components/chat-composer.ts index 2aba4c562..e8148cefb 100644 --- a/src/uix/sema/components/chat-composer.ts +++ b/src/uix/sema/components/chat-composer.ts @@ -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 } } ] diff --git a/src/uix/sema/components/chat-log.ts b/src/uix/sema/components/chat-log.ts index c080293f2..0679b3349 100644 --- a/src/uix/sema/components/chat-log.ts +++ b/src/uix/sema/components/chat-log.ts @@ -23,7 +23,7 @@ export const chatLogSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'signal-notify-new' }), - sound: 'ping' + sound: 'alert' } ] }; diff --git a/src/uix/sema/components/chat-message.ts b/src/uix/sema/components/chat-message.ts index fb98b4bfe..9d63841c6 100644 --- a/src/uix/sema/components/chat-message.ts +++ b/src/uix/sema/components/chat-message.ts @@ -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 } } ] diff --git a/src/uix/sema/components/checkbox.ts b/src/uix/sema/components/checkbox.ts index 29c374d82..d294e6184 100644 --- a/src/uix/sema/components/checkbox.ts +++ b/src/uix/sema/components/checkbox.ts @@ -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' } } ] diff --git a/src/uix/sema/components/chronos.ts b/src/uix/sema/components/chronos.ts index 1fd2054bb..8f16cb89d 100644 --- a/src/uix/sema/components/chronos.ts +++ b/src/uix/sema/components/chronos.ts @@ -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). { diff --git a/src/uix/sema/components/clipboard.ts b/src/uix/sema/components/clipboard.ts index 86a59be41..f46f0df12 100644 --- a/src/uix/sema/components/clipboard.ts +++ b/src/uix/sema/components/clipboard.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/collapsible.ts b/src/uix/sema/components/collapsible.ts index 298c05632..19befb281 100644 --- a/src/uix/sema/components/collapsible.ts +++ b/src/uix/sema/components/collapsible.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/color-field.ts b/src/uix/sema/components/color-field.ts index b8bbd84bb..a2b0fce60 100644 --- a/src/uix/sema/components/color-field.ts +++ b/src/uix/sema/components/color-field.ts @@ -12,7 +12,7 @@ export const colorFieldSema: Sema = { cascade: [ { selector: onInput({ eventName: 'commit-set' }), - sound: 'soft', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/color-picker.ts b/src/uix/sema/components/color-picker.ts index 6082563f0..eabed1f7b 100644 --- a/src/uix/sema/components/color-picker.ts +++ b/src/uix/sema/components/color-picker.ts @@ -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 } }, { diff --git a/src/uix/sema/components/combobox.ts b/src/uix/sema/components/combobox.ts index fe1e3e9a4..53b30c0a5 100644 --- a/src/uix/sema/components/combobox.ts +++ b/src/uix/sema/components/combobox.ts @@ -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' } } ] diff --git a/src/uix/sema/components/context-menu.ts b/src/uix/sema/components/context-menu.ts index f62b5c8d5..fc73b6380 100644 --- a/src/uix/sema/components/context-menu.ts +++ b/src/uix/sema/components/context-menu.ts @@ -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 } } ] diff --git a/src/uix/sema/components/cropper.ts b/src/uix/sema/components/cropper.ts index 3468bebe9..044a97435 100644 --- a/src/uix/sema/components/cropper.ts +++ b/src/uix/sema/components/cropper.ts @@ -39,7 +39,7 @@ export const cropperSema: Sema = { }, { selector: onProvider({ eventName: 'commit-crop' }), - sound: 'soft', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/css-field.ts b/src/uix/sema/components/css-field.ts index 2b5ba3ecb..d60c5bc9b 100644 --- a/src/uix/sema/components/css-field.ts +++ b/src/uix/sema/components/css-field.ts @@ -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' } } ] diff --git a/src/uix/sema/components/date-field.ts b/src/uix/sema/components/date-field.ts index 76da240e2..6278b3fbb 100644 --- a/src/uix/sema/components/date-field.ts +++ b/src/uix/sema/components/date-field.ts @@ -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' } } ] diff --git a/src/uix/sema/components/dialog.ts b/src/uix/sema/components/dialog.ts index 9c700d2dd..0239282bb 100644 --- a/src/uix/sema/components/dialog.ts +++ b/src/uix/sema/components/dialog.ts @@ -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) ───────────────────────────────────────── diff --git a/src/uix/sema/components/drag-drop.ts b/src/uix/sema/components/drag-drop.ts index ad1b2a247..411ec8cb3 100644 --- a/src/uix/sema/components/drag-drop.ts +++ b/src/uix/sema/components/drag-drop.ts @@ -31,8 +31,7 @@ export const dragDropSema: Sema = { haptic: { kind: 'tap' } }, { - selector: onDraggable({ eventName: 'commit-cancel' }), - sound: 'subtle.fall' + selector: onDraggable({ eventName: 'commit-cancel' }) } ] }; diff --git a/src/uix/sema/components/drawer.ts b/src/uix/sema/components/drawer.ts index 37c0f194c..0aada4dc0 100644 --- a/src/uix/sema/components/drawer.ts +++ b/src/uix/sema/components/drawer.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/dropdown-menu.ts b/src/uix/sema/components/dropdown-menu.ts index 0a258b659..734a71fe2 100644 --- a/src/uix/sema/components/dropdown-menu.ts +++ b/src/uix/sema/components/dropdown-menu.ts @@ -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 } } ] diff --git a/src/uix/sema/components/editable.ts b/src/uix/sema/components/editable.ts index ba9046579..3eba316b1 100644 --- a/src/uix/sema/components/editable.ts +++ b/src/uix/sema/components/editable.ts @@ -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' } } ] diff --git a/src/uix/sema/components/file-upload.ts b/src/uix/sema/components/file-upload.ts index ce0916acd..88af11799 100644 --- a/src/uix/sema/components/file-upload.ts +++ b/src/uix/sema/components/file-upload.ts @@ -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' } } ] diff --git a/src/uix/sema/components/float-panel.ts b/src/uix/sema/components/float-panel.ts index a6aec7883..bdba462c5 100644 --- a/src/uix/sema/components/float-panel.ts +++ b/src/uix/sema/components/float-panel.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/form.ts b/src/uix/sema/components/form.ts index 969489517..cd86b3ddb 100644 --- a/src/uix/sema/components/form.ts +++ b/src/uix/sema/components/form.ts @@ -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' } } ] diff --git a/src/uix/sema/components/gradient-builder.ts b/src/uix/sema/components/gradient-builder.ts index dc203ae5a..fd40f8ef9 100644 --- a/src/uix/sema/components/gradient-builder.ts +++ b/src/uix/sema/components/gradient-builder.ts @@ -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 } }, { diff --git a/src/uix/sema/components/image-adjustments.ts b/src/uix/sema/components/image-adjustments.ts index cbcae31aa..f32d00d7b 100644 --- a/src/uix/sema/components/image-adjustments.ts +++ b/src/uix/sema/components/image-adjustments.ts @@ -23,7 +23,7 @@ export const imageAdjustmentsSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-reset' }), - sound: 'soft', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/image-picker.ts b/src/uix/sema/components/image-picker.ts index 01991da26..db65d021c 100644 --- a/src/uix/sema/components/image-picker.ts +++ b/src/uix/sema/components/image-picker.ts @@ -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' } }, { diff --git a/src/uix/sema/components/knob.ts b/src/uix/sema/components/knob.ts index 386ba1937..6e842cd73 100644 --- a/src/uix/sema/components/knob.ts +++ b/src/uix/sema/components/knob.ts @@ -37,7 +37,7 @@ export const knobSema: Sema = { { selector: onControl({ eventName: 'commit-set', eventIntent: 'risk' }), channels: ['sound', 'haptic'], - sound: 'deep.fall', + haptic: { kind: 'warning' } } ] diff --git a/src/uix/sema/components/listbox.ts b/src/uix/sema/components/listbox.ts index 3eba706a4..44239987b 100644 --- a/src/uix/sema/components/listbox.ts +++ b/src/uix/sema/components/listbox.ts @@ -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' } } ] diff --git a/src/uix/sema/components/mask-field.ts b/src/uix/sema/components/mask-field.ts index b5d105b6c..86d292c1f 100644 --- a/src/uix/sema/components/mask-field.ts +++ b/src/uix/sema/components/mask-field.ts @@ -19,7 +19,7 @@ export const maskFieldSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-set' }), - sound: 'subtle', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/media-player.ts b/src/uix/sema/components/media-player.ts index 895e665c8..b0f16267e 100644 --- a/src/uix/sema/components/media-player.ts +++ b/src/uix/sema/components/media-player.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/menu-dial.ts b/src/uix/sema/components/menu-dial.ts index 902f172cf..b9bf51d93 100644 --- a/src/uix/sema/components/menu-dial.ts +++ b/src/uix/sema/components/menu-dial.ts @@ -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' } } ] diff --git a/src/uix/sema/components/menubar.ts b/src/uix/sema/components/menubar.ts index dcc02c6f3..3f1c6b6a7 100644 --- a/src/uix/sema/components/menubar.ts +++ b/src/uix/sema/components/menubar.ts @@ -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 } } ] diff --git a/src/uix/sema/components/month-grid.ts b/src/uix/sema/components/month-grid.ts index 29eb20f4a..7475f4176 100644 --- a/src/uix/sema/components/month-grid.ts +++ b/src/uix/sema/components/month-grid.ts @@ -12,7 +12,7 @@ export const monthGridSema: Sema = { cascade: [ { selector: onCell({ eventName: 'commit-set' }), - sound: 'soft', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/nav-tree.ts b/src/uix/sema/components/nav-tree.ts index e90cdd695..cf9deff1a 100644 --- a/src/uix/sema/components/nav-tree.ts +++ b/src/uix/sema/components/nav-tree.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/navigation-menu.ts b/src/uix/sema/components/navigation-menu.ts index a41192c21..8699d21b5 100644 --- a/src/uix/sema/components/navigation-menu.ts +++ b/src/uix/sema/components/navigation-menu.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/number-field.ts b/src/uix/sema/components/number-field.ts index 6ef5eccd1..a80f3e549 100644 --- a/src/uix/sema/components/number-field.ts +++ b/src/uix/sema/components/number-field.ts @@ -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 } }, { diff --git a/src/uix/sema/components/onion-menu.ts b/src/uix/sema/components/onion-menu.ts index 31c75e25e..0c3c5a6a6 100644 --- a/src/uix/sema/components/onion-menu.ts +++ b/src/uix/sema/components/onion-menu.ts @@ -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' } } ] diff --git a/src/uix/sema/components/pagination.ts b/src/uix/sema/components/pagination.ts index 00102a60b..b1a7af4f3 100644 --- a/src/uix/sema/components/pagination.ts +++ b/src/uix/sema/components/pagination.ts @@ -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' } } ] diff --git a/src/uix/sema/components/password-field.ts b/src/uix/sema/components/password-field.ts index 6d8362de7..cddd09dc1 100644 --- a/src/uix/sema/components/password-field.ts +++ b/src/uix/sema/components/password-field.ts @@ -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' } } ] diff --git a/src/uix/sema/components/picker.ts b/src/uix/sema/components/picker.ts index 0231b3695..50f7ad35b 100644 --- a/src/uix/sema/components/picker.ts +++ b/src/uix/sema/components/picker.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/popover.ts b/src/uix/sema/components/popover.ts index a87c4a022..8539e1988 100644 --- a/src/uix/sema/components/popover.ts +++ b/src/uix/sema/components/popover.ts @@ -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' + }) } ] }; diff --git a/src/uix/sema/components/proof-of-human.ts b/src/uix/sema/components/proof-of-human.ts index dbadb0ea5..b1bc8f8e9 100644 --- a/src/uix/sema/components/proof-of-human.ts +++ b/src/uix/sema/components/proof-of-human.ts @@ -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] } } ] diff --git a/src/uix/sema/components/radio-group.ts b/src/uix/sema/components/radio-group.ts index 81241351f..d04ee89a5 100644 --- a/src/uix/sema/components/radio-group.ts +++ b/src/uix/sema/components/radio-group.ts @@ -27,7 +27,7 @@ export const radioGroupSema: Sema = { cascade: [ { selector: onItem({ eventName: 'commit-select' }), - sound: 'soft', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/range-calendar.ts b/src/uix/sema/components/range-calendar.ts index 7c2e5d3d1..005981312 100644 --- a/src/uix/sema/components/range-calendar.ts +++ b/src/uix/sema/components/range-calendar.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/rating-group.ts b/src/uix/sema/components/rating-group.ts index 116cef3cb..0745f2eea 100644 --- a/src/uix/sema/components/rating-group.ts +++ b/src/uix/sema/components/rating-group.ts @@ -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' } } ] diff --git a/src/uix/sema/components/search-field.ts b/src/uix/sema/components/search-field.ts index bf72fa8d6..183d91260 100644 --- a/src/uix/sema/components/search-field.ts +++ b/src/uix/sema/components/search-field.ts @@ -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' } } ] diff --git a/src/uix/sema/components/select.ts b/src/uix/sema/components/select.ts index 335120f4b..41ad37e7e 100644 --- a/src/uix/sema/components/select.ts +++ b/src/uix/sema/components/select.ts @@ -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' } } ] diff --git a/src/uix/sema/components/sidebar.ts b/src/uix/sema/components/sidebar.ts index 7b1bde408..41f16961d 100644 --- a/src/uix/sema/components/sidebar.ts +++ b/src/uix/sema/components/sidebar.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/slider.ts b/src/uix/sema/components/slider.ts index a27e61b85..7436d73e2 100644 --- a/src/uix/sema/components/slider.ts +++ b/src/uix/sema/components/slider.ts @@ -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 } }, { diff --git a/src/uix/sema/components/splitter.ts b/src/uix/sema/components/splitter.ts index 0c409400b..92d998edd 100644 --- a/src/uix/sema/components/splitter.ts +++ b/src/uix/sema/components/splitter.ts @@ -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 } }, { diff --git a/src/uix/sema/components/stepper.ts b/src/uix/sema/components/stepper.ts index 8da3ce193..b323af516 100644 --- a/src/uix/sema/components/stepper.ts +++ b/src/uix/sema/components/stepper.ts @@ -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' } } ] diff --git a/src/uix/sema/components/switch.ts b/src/uix/sema/components/switch.ts index 6a350d484..df5f56668 100644 --- a/src/uix/sema/components/switch.ts +++ b/src/uix/sema/components/switch.ts @@ -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 } }, { diff --git a/src/uix/sema/components/table.ts b/src/uix/sema/components/table.ts index ce2fb2532..69a56fcae 100644 --- a/src/uix/sema/components/table.ts +++ b/src/uix/sema/components/table.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/tabs.ts b/src/uix/sema/components/tabs.ts index 49d96e97d..2d053e2cf 100644 --- a/src/uix/sema/components/tabs.ts +++ b/src/uix/sema/components/tabs.ts @@ -30,7 +30,7 @@ export const tabsSema: Sema = { cascade: [ { selector: onTrigger({ eventName: 'select' }), - sound: 'subtle', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/tag-group.ts b/src/uix/sema/components/tag-group.ts index 303d889c0..55a8db794 100644 --- a/src/uix/sema/components/tag-group.ts +++ b/src/uix/sema/components/tag-group.ts @@ -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' } } ] diff --git a/src/uix/sema/components/tags-input.ts b/src/uix/sema/components/tags-input.ts index 018b072ca..e54f199ac 100644 --- a/src/uix/sema/components/tags-input.ts +++ b/src/uix/sema/components/tags-input.ts @@ -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' } } ] diff --git a/src/uix/sema/components/textarea.ts b/src/uix/sema/components/textarea.ts index c5a618671..f72aca6a9 100644 --- a/src/uix/sema/components/textarea.ts +++ b/src/uix/sema/components/textarea.ts @@ -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' } } ] diff --git a/src/uix/sema/components/time-field.ts b/src/uix/sema/components/time-field.ts index bd8ee280d..985fb8c79 100644 --- a/src/uix/sema/components/time-field.ts +++ b/src/uix/sema/components/time-field.ts @@ -13,7 +13,7 @@ export const timeFieldSema: Sema = { cascade: [ { selector: onInput({ eventName: 'commit-set' }), - sound: 'soft', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/timeline.ts b/src/uix/sema/components/timeline.ts index 9a0a948f4..c0cbb0043 100644 --- a/src/uix/sema/components/timeline.ts +++ b/src/uix/sema/components/timeline.ts @@ -29,7 +29,7 @@ export const timelineSema: Sema = { cascade: [ { selector: onItem({ eventName: 'emerge-reveal' }), - sound: 'strong', + haptic: { kind: 'tick' } } ] diff --git a/src/uix/sema/components/toast.ts b/src/uix/sema/components/toast.ts index 190cbda43..9c355e2ea 100644 --- a/src/uix/sema/components/toast.ts +++ b/src/uix/sema/components/toast.ts @@ -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' }) } ] }; diff --git a/src/uix/sema/components/toggle-group.ts b/src/uix/sema/components/toggle-group.ts index 32b68e139..0ab50e182 100644 --- a/src/uix/sema/components/toggle-group.ts +++ b/src/uix/sema/components/toggle-group.ts @@ -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 } }, { diff --git a/src/uix/sema/components/toggle.ts b/src/uix/sema/components/toggle.ts index f025f0f27..3c8d1a45e 100644 --- a/src/uix/sema/components/toggle.ts +++ b/src/uix/sema/components/toggle.ts @@ -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 } }, { diff --git a/src/uix/sema/components/toolbar.ts b/src/uix/sema/components/toolbar.ts index 5ae487530..b9ea13bb2 100644 --- a/src/uix/sema/components/toolbar.ts +++ b/src/uix/sema/components/toolbar.ts @@ -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' } } ] diff --git a/src/uix/sema/components/tree-grid.ts b/src/uix/sema/components/tree-grid.ts index 3ddc9ec9f..a3a402c3b 100644 --- a/src/uix/sema/components/tree-grid.ts +++ b/src/uix/sema/components/tree-grid.ts @@ -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 } } ] diff --git a/src/uix/sema/components/tree-view.ts b/src/uix/sema/components/tree-view.ts index 7c5e2fb36..3a15ba5a4 100644 --- a/src/uix/sema/components/tree-view.ts +++ b/src/uix/sema/components/tree-view.ts @@ -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 } } ] diff --git a/src/uix/sema/components/year-grid.ts b/src/uix/sema/components/year-grid.ts index 28fff2eea..3b97117eb 100644 --- a/src/uix/sema/components/year-grid.ts +++ b/src/uix/sema/components/year-grid.ts @@ -12,7 +12,7 @@ export const yearGridSema: Sema = { cascade: [ { selector: onCell({ eventName: 'commit-set' }), - sound: 'soft', + haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/d7-intent-survives.test.ts b/src/uix/sema/d7-intent-survives.test.ts deleted file mode 100644 index 05a30a48f..000000000 --- a/src/uix/sema/d7-intent-survives.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/uix/sema/define-engine-semantic.ts b/src/uix/sema/define-engine-semantic.ts index 519f9e965..9281c29eb 100644 --- a/src/uix/sema/define-engine-semantic.ts +++ b/src/uix/sema/define-engine-semantic.ts @@ -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(); } - } + }; } diff --git a/src/uix/sema/durations.test.ts b/src/uix/sema/durations.test.ts index 9cbfa38f7..a90523938 100644 --- a/src/uix/sema/durations.test.ts +++ b/src/uix/sema/durations.test.ts @@ -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(); + }); +}); diff --git a/src/uix/sema/durations.ts b/src/uix/sema/durations.ts index 94666b282..95bac02be 100644 --- a/src/uix/sema/durations.ts +++ b/src/uix/sema/durations.ts @@ -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]; } diff --git a/src/uix/sema/engine.test.ts b/src/uix/sema/engine.test.ts index 10f685957..05fdccf7c 100644 --- a/src/uix/sema/engine.test.ts +++ b/src/uix/sema/engine.test.ts @@ -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) ─────────────────────────────────────────────── diff --git a/src/uix/sema/engine.ts b/src/uix/sema/engine.ts index 32e29aedb..64d70904b 100644 --- a/src/uix/sema/engine.ts +++ b/src/uix/sema/engine.ts @@ -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 }; diff --git a/src/uix/sema/event.test.ts b/src/uix/sema/event.test.ts index 2317adf3e..fdd175dff 100644 --- a/src/uix/sema/event.test.ts +++ b/src/uix/sema/event.test.ts @@ -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); }); diff --git a/src/uix/sema/event.ts b/src/uix/sema/event.ts index b07f4cd0b..1a211b37c 100644 --- a/src/uix/sema/event.ts +++ b/src/uix/sema/event.ts @@ -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(SEMA_FAMILIES) -const VALENCED_FAMILY_SET = new Set(SEMA_VALENCED_FAMILIES) -const TRANSITIONAL_FAMILY_SET = new Set(SEMA_TRANSITIONAL_FAMILIES) -const INTENT_SET = new Set(INTENTS) -const LABEL_SET = new Set(SEMA_EVENT_LABELS) +const FAMILY_SET = new Set(SEMA_FAMILIES); +const VALENCED_FAMILY_SET = new Set(SEMA_VALENCED_FAMILIES); +const TRANSITIONAL_FAMILY_SET = new Set(SEMA_TRANSITIONAL_FAMILIES); +const INTENT_SET = new Set(INTENTS); +const LABEL_SET = new Set(SEMA_EVENT_LABELS); function isRecord(value: unknown): value is Record { - 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 ): 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 ): { - 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): SemaEventLabel { - return normalizeSemaEvent(event, props).label +export function toSemaEventLabel( + event: SemaActionEvent, + props?: Record +): SemaEventLabel { + return normalizeSemaEvent(event, props).label; } diff --git a/src/uix/sema/index.ts b/src/uix/sema/index.ts index b44737ae2..8570a426a 100644 --- a/src/uix/sema/index.ts +++ b/src/uix/sema/index.ts @@ -1 +1 @@ -export * from './exports' +export * from './exports'; diff --git a/src/uix/sema/resolver.test.ts b/src/uix/sema/resolver.test.ts index c084aa6b1..d99f57482 100644 --- a/src/uix/sema/resolver.test.ts +++ b/src/uix/sema/resolver.test.ts @@ -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 `` - // 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); }); }); diff --git a/src/uix/sema/resolver.ts b/src/uix/sema/resolver.ts index 5a783e244..8d249bea6 100644 --- a/src/uix/sema/resolver.ts +++ b/src/uix/sema/resolver.ts @@ -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 { 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 `` 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 -): NonNullable { - // 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. */ diff --git a/src/uix/sema/sema-map.ts b/src/uix/sema/sema-map.ts index 29626c626..e59f76d42 100644 --- a/src/uix/sema/sema-map.ts +++ b/src/uix/sema/sema-map.ts @@ -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; + /** + * 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; 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, diff --git a/src/uix/sema/signal.ts b/src/uix/sema/signal.ts index fa48b1927..8b16b85b6 100644 --- a/src/uix/sema/signal.ts +++ b/src/uix/sema/signal.ts @@ -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 diff --git a/src/uix/sema/sound-names.test.ts b/src/uix/sema/sound-names.test.ts index b5b2afc55..35aa4775b 100644 --- a/src/uix/sema/sound-names.test.ts +++ b/src/uix/sema/sound-names.test.ts @@ -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([]); }); }); diff --git a/src/uix/sema/sound-names.ts b/src/uix/sema/sound-names.ts index 7fb0dd9ee..f6defc885 100644 --- a/src/uix/sema/sound-names.ts +++ b/src/uix/sema/sound-names.ts @@ -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`) 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 { return ( typeof entry === 'object' && entry !== null && (entry as { kind?: unknown }).kind === 'sample' @@ -76,32 +87,30 @@ function isSample(entry: NamedSound): entry is Extract = { ...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) } 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[]) { diff --git a/src/uix/sema/sounds.test.ts b/src/uix/sema/sounds.test.ts index 8ab21780a..28b090cfc 100644 --- a/src/uix/sema/sounds.test.ts +++ b/src/uix/sema/sounds.test.ts @@ -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', () => { diff --git a/src/uix/sema/sounds.ts b/src/uix/sema/sounds.ts index be45c6db9..6ff5888aa 100644 --- a/src/uix/sema/sounds.ts +++ b/src/uix/sema/sounds.ts @@ -151,11 +151,6 @@ function mergeSound(base: SoundSignature, overrides?: Partial): 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); } diff --git a/src/uix/sema/stamp.ts b/src/uix/sema/stamp.ts index 59d73544f..dcb241760 100644 --- a/src/uix/sema/stamp.ts +++ b/src/uix/sema/stamp.ts @@ -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 'data-event-phase': 'active', 'data-event-family': signal.family, 'data-event-intent': signal.intent - } + }; - return attrs + return attrs; } export function clearEventAttrs(): Record { @@ -42,7 +42,7 @@ export function clearEventAttrs(): Record { '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() - }) + }); } diff --git a/src/uix/sema/theming.test.ts b/src/uix/sema/theming.test.ts index 90d78eebe..285013275 100644 --- a/src/uix/sema/theming.test.ts +++ b/src/uix/sema/theming.test.ts @@ -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); }); }); diff --git a/src/uix/sema/timers.ts b/src/uix/sema/timers.ts index 11fa908e7..bc4ea700f 100644 --- a/src/uix/sema/timers.ts +++ b/src/uix/sema/timers.ts @@ -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 +export type SemaTimerScheduler = Pick; export interface SemaTimerHandle { - cancel(): boolean + cancel(): boolean; } /** @@ -38,13 +38,13 @@ export function semaDelay( meta?: Readonly> ): 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; } - } + }; } diff --git a/src/uix/sema/validation.ts b/src/uix/sema/validation.ts index a54ec7a93..01e7372f4 100644 --- a/src/uix/sema/validation.ts +++ b/src/uix/sema/validation.ts @@ -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`); } diff --git a/src/uix/sema/verbs.test.ts b/src/uix/sema/verbs.test.ts index c4ee63683..c872c4482 100644 --- a/src/uix/sema/verbs.test.ts +++ b/src/uix/sema/verbs.test.ts @@ -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); } - }) -}) + }); +}); diff --git a/src/uix/sema/verbs.ts b/src/uix/sema/verbs.ts index 1ea3d7ff2..6f1201555 100644 --- a/src/uix/sema/verbs.ts +++ b/src/uix/sema/verbs.ts @@ -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'], diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.ts index 8a6eded8c..5b857fab3 100644 --- a/src/uix/soma/components/drawer/drawer-provider.svelte.ts +++ b/src/uix/soma/components/drawer/drawer-provider.svelte.ts @@ -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 = () => { diff --git a/src/uix/soma/components/knob/knob-provider.svelte.ts b/src/uix/soma/components/knob/knob-provider.svelte.ts index 1984898da..6eb5c9a1f 100644 --- a/src/uix/soma/components/knob/knob-provider.svelte.ts +++ b/src/uix/soma/components/knob/knob-provider.svelte.ts @@ -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)), diff --git a/src/uix/soma/components/slider/slider-provider.svelte.ts b/src/uix/soma/components/slider/slider-provider.svelte.ts index a137c7d3a..67f8885cd 100644 --- a/src/uix/soma/components/slider/slider-provider.svelte.ts +++ b/src/uix/soma/components/slider/slider-provider.svelte.ts @@ -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)), diff --git a/src/uix/soma/components/splitter/splitter-provider.svelte.ts b/src/uix/soma/components/splitter/splitter-provider.svelte.ts index 8b28b88df..2d71f6856 100644 --- a/src/uix/soma/components/splitter/splitter-provider.svelte.ts +++ b/src/uix/soma/components/splitter/splitter-provider.svelte.ts @@ -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)), diff --git a/src/uix/soma/runtime.svelte.ts b/src/uix/soma/runtime.svelte.ts index 217ccec60..b1a4a666e 100644 --- a/src/uix/soma/runtime.svelte.ts +++ b/src/uix/soma/runtime.svelte.ts @@ -721,7 +721,10 @@ export function createSomaRuntime( 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( 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( // (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( target, name: action.name, family, + ...(resolvedVerb ? { verb: resolvedVerb } : {}), ...(intent ? { intent } : {}), ...(hold !== undefined ? { hold } : {}), ...(channels !== undefined ? { channels } : {}), diff --git a/web/routes/uix/docs/sound/+page.svelte b/web/routes/uix/docs/sound/+page.svelte index a34c8cfd9..ffe1c3382 100644 --- a/web/routes/uix/docs/sound/+page.svelte +++ b/web/routes/uix/docs/sound/+page.svelte @@ -1,216 +1,165 @@
Sema · el canal del sonido
-

Un componente dice una palabra. Nada más.

+

Un evento y un intent eligen un sonido. Y ya está.

- Toda la superficie de autoría del sonido es sound: 'soft' — un nombre del catálogo, - o SILENT. Que ese nombre sea una receta sintetizada o un - .wav no es asunto del componente: es del catálogo. Esta página está - derivada del código vivo, no transcrita: las tablas leen - SEMA_MAP y el resolver real. + Nada modula nada. El sonido no se deforma, se elige: hay un catálogo de sonidos + completos, y el par (evento, intent) señala uno. Si no existe la variante, se desprecia el + intent. Si no hay ninguno, no suena nada. Esta página lee + SEMA_MAP y el resolver reales — no es una transcripción.

- nombres{NAMES.length} - familias{FAMILIES.length} - intents{INTENTS.length} + bases{BASES.length} + variantes{VARIANTS.length} + reglas en 71 packs30
-
-

1 · En una frase

-

- Un pack de componente no autora una firma: nombra una entrada del catálogo. El - tipo no acepta otra cosa, así que la ley no es una convención que alguien deba recordar — la impone - el compilador. -

+

1 · La regla entera

{`// src/uix/sema/components/textarea.ts
-export const textareaSema: Sema = {
-	name: 'textarea',
-	cascade: [
-		{ selector: onInput({ eventName: 'commit-submit' }), sound: 'soft' },
-		{ selector: onInput({ eventName: 'signal-warn-count-overflow' }), sound: 'subtle' }
-	]
-};`}{`nombre = per-emit ?? cascada-app ?? pack ?? morfo ?? familia[verbo] ?? familia.default
+sonido = pack[nombre.intent] ?? pack[nombre] ?? nada`}
+

+ Dos búsquedas. No hay capas que se pisen, ni deltas, ni aritmética. Y el caso normal no escribe nada: el mapa ya dice que un + commit hace tick y que un emerge con verbo + close hace close. Un pack sólo habla cuando + difiere — por eso de las ~165 reglas de sonido que había quedan + 30, en 16 de los 71 packs. +

2 · El catálogo

- Los {NAMES.length} nombres que existen. Pulsa cualquiera para oírlo con la familia y el intent que - tengas seleccionados abajo. Requiere el interruptor «Sound» del topbar encendido. + Cada entrada es un sonido diseñado entero, no un modificador. Un guard exige + que dos entradas se separen en al menos dos ejes + perceptuales — es el test que faltaba cuando todos sonaban igual. +

+

Bases

+
+ {#each BASES as name} + + {/each} +
+

Variantes por intent

+
+ {#each VARIANTS as name} + + {/each} +
+

+ Un pack que sólo trae bases funciona entero: donde no haya variante, el intent se ignora. Un + pack cuidado añade variantes sólo donde le importa.

- {#each ['level', 'direction', 'character', 'sample'] as const as group} - {@const names = NAMES.filter((n) => groupOf(n) === group)} - {#if names.length > 0} -

{GROUP_LABEL[group]}

-
- {#each names as name} - - {/each} -
- {/if} - {/each}
-

3 · El orden, con los números de verdad

+

3 · Pruébalo

- Esto es lo que hace legible todo lo demás. La familia pone la - identidad, el nombre pone - nivel, dirección y carácter, y el - intent llega el último — por eso su carga evaluativa siempre se oye. Cambia los - tres y mira cómo se mueve cada eje. + Elige familia, verbo e intent, y mira cómo se resuelve el nombre y qué entrada acaba sonando.

-
@@ -228,126 +177,78 @@ export const textareaSema: Sema = {
- - - {#each AXES as axis}{/each} - + - {#each layers as layer} - - - {#each AXES as axis} - - {/each} - - {/each} + + + + + + + + + + + {#each AXES as axis} + + {/each} + + + + {#each AXES as axis} + + {/each} +
capa{axis}
pasoresultado
- {layer.label} - {layer.tone} - {layer.sig ? (layer.sig[axis] ?? '—') : '—'}
+ 1 · el nombre + familia[verbo] ?? familia.default + {chosenName ?? '— esta familia no nombra sonido'}
+ 2 · la entrada + nombre.intent ?? nombre + {chosenEntry ?? '— no suena nada'}
lo que suena{resolved ? (resolved[axis] ?? '—') : '—'}
eje{axis}

- Si la familia no declara sonido (handle, sustain, - delegate) las tres filas salen vacías: ahí el gesto suena por otra vía, no por el - catálogo. -

-
- -
-

4 · Qué puede llevar un .wav

-

- Esto es física, no política. playSample lee - exactamente dos campos de la firma: sampleUrl y - gain. No hay playbackRate ni detune. Luego sobre una - muestra el intent sólo puede mover el volumen: un - risk es indistinguible de un neutral, porque lo único que aporta es - roughness y la ruta de sample lo ignora. -

-

- Compruébalo: elige {sampleNames[0]} arriba y ve cambiando el intent. El - gain se mueve; el resto de ejes se resuelven pero no se oyen. -

-

- Consecuencia de diseño: una muestra que deba llevar carga evaluativa necesita - un fichero por intent. Es lo que hace la familia signal con - ping - frente a error. -

-
- -
-

5 · Lo que no te toca escribir

-

- Ningún parámetro. Nunca. Ni un gain, ni un centroid, ni un - op: 'add'. Si un componente necesita algo que el catálogo no lleva, eso es una - decisión del catálogo: se toma una vez, en un fichero, y el siguiente - componente la reutiliza. -

-
{`// ✗ lo que ya no compila
-sound: { gain: { op: 'add', value: -0.25 }, centroid: { op: 'add', value: 2800 } }
-
-// ✓ lo único que se escribe
-sound: 'settle'`}
-

- Añadir un sonido es añadir un nombre al catálogo, jamás ensanchar una regla. + Prueba commit + threat: no es un tick con más aspereza, + es otro sonido — más grave, más rasposo y descendente. Y prueba + commit + + affirm: no hay variante, así que el intent se desprecia y suena el + tick de siempre.

-

6 · Por qué es así — las correcciones

+

4 · Los gestos suenan por repetición

- El diseño actual no se entiende sin lo que reemplaza. Hasta el 6 de agosto de 2026 una regla - de pack llevaba una bolsa abierta de ocho ejes continuos. Medido sobre los 71 packs: -

-
    -
  • - 214 reglas autoraban 33 firmas distintas, y 92 de ellas (el - 43 %) no decían más que un volumen. -
  • -
  • - 69 reglas pisaban el contour del intent — y no podían hacer otra - cosa: es un enum, sólo admite reemplazo. -
  • -
  • - Cinco packs llevaban un centroid +2800 copiado que dejaba su - commit en 4600 Hz, más brillante que signal, la familia más - brillante del mapa. Era el residuo aritmético de una muestra borrada. -
  • -
-

- Y la pieza que lo explica todo: los packs corrían los últimos y en modo reemplazo, así que el sonido elegido por el componente borraba el perfil evaluativo que el intent - acababa de poner — un diálogo con threat sonaba exactamente igual que uno neutro. - Existían tres mecanismos para vigilar esa inversión. Los tres desaparecieron: con el nombre - debajo - del intent, la inversión no está prohibida, es inexpresable. + Un arrastre no calcula nada: emite a su propio ritmo, y cada emisión toca + step — 18 ms. La + velocidad del gesto es la velocidad del trinquete, como una rueda física. Eso + sustituyó a tres resolvers que sintetizaban el tono desde posición y velocidad, lo último que + quedaba de aritmética. Y la familia handle está exenta de la memoria de frecuencia: + si no, el anti-fatiga estrangularía el trinquete al primer arrastre.

-

7 · Y el tema

+

5 · Nadie escribe parámetros

- El catálogo vive dentro de SEMA_MAP, que es la estructura que un tema direcciona. - Así que la voz del sistema se puede cambiar en caliente y revertir: + Ni un componente, ni un pack, ni un tema, ni la app. El tipo acepta un + nombre o SILENT. Si un producto necesita un sonido que el pack + no trae, lo registra — declara el nombre y su definición, una vez — y luego lo + nombra.

{`eidos.applyTheme({ color: '#3b5bdb', sound: { soft: { gain: 0.08 } } });
-eidos.clearTheme();   // revierte los siete ejes
-
-// o sólo el eje perceptual
-uix.events.applySounds({ soft: { gain: 0.08 } });`}{`// 1 · declarar el nombre  ·  2 · definirlo (síntesis, wav o mp3)  ·  3 · nombrarlo
+declare module '$uix/sema' { interface SemaSoundNames { laser: true } }
+createActiveUix({ events: { sounds: { laser: sample('/sounds/laser.mp3', reserva) } } })
+{ selector: onProvider({ eventName: 'contact-activate' }), sound: 'laser' }`}

- Lo que no se abre es el vocabulario: SoundName es - keyof del catálogo, así que un tema cambia - a qué suena un nombre y no puede inventar uno que ningún componente pueda referenciar. - La voz se abre; las palabras no. + Un sound pack es una implementación completa del vocabulario: mezcla libre de + síntesis, .wav y .mp3. El pack por defecto del framework es 100 % + síntesis a propósito — funciona sin red y no puede dar 404.