From d05ecc1aa8904ffaac5cb620619e5ab385206a17 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 5 Aug 2026 22:50:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(sema):=20los=20toggles=20recuperan=20voz,?= =?UTF-8?q?=20y=20el=20silencio=20deja=20de=20sintetizarse=20=E2=80=94=20m?= =?UTF-8?q?uere=20el=20prefijo=20form.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tres cosas que el mismo hilo destapo, de la mas visible a la mas de fondo. 1) LOS TOGGLES SUENAN OTRA VEZ (directiva de autor 2026-08-05) switch, toggle y toggle-group llevaban SILENCIADOS desde el 2026-05-26: su pack aplicaba un tuning que restaba exactamente el gain base de la familia commit (0.3 - 0.3 = 0). Medido en navegador: pico 0.0001, el suelo de la rampa. Un silencio por defecto no se percibe como sobriedad sino como componente roto. Pasan a `commit.medium` (gain 0.1): audible, y un tercio de una pulsacion de boton (contact base 0.25) para que un flip no pese mas que una activacion. Los deltas de intent siguen montando encima (threat +0.1, fulfill +0.05). El haptico ligero no cambia. Medido despues: switch pico 0.1. Conservan el silencio, con su justificacion intacta, solo los dos donde el silencio ES la firma: tooltip (revela al pasar el raton, sonaria en cada cruce) y media-player (su propia salida ES audio — D-AP2.7, decision del hilo de audio). 2) EMITIR SILENCIO YA NO CUESTA `SoundChannel.handle` cortaba DESPUES de resolver la firma, asi que un tuning silenciador levantaba dos osciladores, un filtro y una envolvente programada para no sonar, en cada interaccion. Ahora corta antes de sintetizar cuando la ganancia resuelta es <= 0. El corte mira la ganancia RESUELTA, no el tuning, asi que un intent que la devuelve por encima de 0 (threat, fulfill) suena igual. Fijado con test propio, verificado en ROJO quitando el corto. 3) EL AFINADO SE NOMBRA POR LA FAMILIA QUE MODIFICA El catalogo mezclaba tres ejes en el primer segmento —familia (emerge.*, contact.silent), componente (tooltip.*, tabs.*) y la categoria huerfana form.*— sin regla declarada y sin guard que mirara. `form.commit.soft/subtle` entro el 2026-05-19 (09e261878, el commit que CREA sounds.ts) nombrando con verdad a siete controles de formulario; el 2026-05-26, 2d562f378 lo extendio a menus y arboles en la misma pasada que escribia la doctrina: 7 -> 29 consumidores en un commit, 50 hoy, de los que exactamente UNO era el Form. El D.6 de ese commit llego a poner el nombre correcto —commit.select— en la linea que usaba la clave incorrecta. form.commit.soft -> commit.soft form.commit.subtle -> commit.subtle form.toggle.silent -> commit.silent tooltip.silent -> emerge.silent tabs.select.soft -> commit.select.soft 131 ocurrencias en 68 ficheros, dirigido por `git ls-files` — nunca por recorrido de directorio: hay 3 worktrees vivos y build/ con 449 apariciones mas que un sed recursivo habria corrompido en silencio. La cola queda LIBRE a proposito: media-player aplica el silenciador al commit-set de un slider, no a un toggle — la cola describe la FIRMA, no el evento, asi que cerrarla contra el vocabulario de verbos fabricaria otra mentira. Por eso emerge.* no se toca pese a que `exit` no sea verbo canonico. Quedan tres silenciadores, uno por familia con base audible, con el mismo nombre: contact.silent (-0.25), commit.silent (-0.3), emerge.silent (-0.2). Eso convierte la coherencia en INVARIANTE: - `sounds-grammar.test.ts` (guard nuevo): cabeza ∈ SEMA_FAMILIES; forma {family}.{tail}; y todo {family}.silent resta EXACTAMENTE el gain base de su familia, leido de sema-map.ts —nunca copiado—. Verificado en ROJO: el guard calculo solo el -0.4 esperado de signal. - Ley escrita en sema.md § Tuning naming shapes, junto a la seccion hermana que ya legislaba los nombres de EVENTO. - vocabularies.md —"the ONE place the closed sets are spelled out"— pasa a enumerar los 14 afinados por familia; el generador los extrae del const y docs:check vigila su frescura. - D.5 gana las dos notas: el renombrado y la reversion del silencio. VERIFICACION: check 75 (linea base exacta) · sema 198/198 · docs:check 0/615 · navegador antes/despues (switch 0.0001 -> 0.1; button 0.25 sin tocar) · los 25 avisos de prettier entre lo tocado son deriva YA en HEAD, ninguno mio. Cronica NO reescrita (4 ficheros): AUDIT-blocks-2026-08-01, PLAN-audio-player, handoffs-claude-md y audit/components/date-field registran lo que ERA cierto. DESVIACION DECLARADA: sema/components/chronos.ts entra con 6 sustituciones mecanicas —chronos es de escritura excluida, pero dejarlo fuera rompia el type-check del repo. Co-Authored-By: Claude Fable 5 --- docs/architecture/sema.md | 35 ++++++ docs/canon/vocabularies.md | 16 +++ docs/decisions/book-deviations.md | 31 ++++- scripts/docs-vocabularies.ts | 21 ++++ src/uix/eidos/components/chronos/SPEC.md | 4 +- src/uix/eidos/components/mask-field/README.md | 2 +- src/uix/sema/chans/sound.test.ts | 17 +++ src/uix/sema/chans/sound.ts | 8 ++ src/uix/sema/components/calendar.ts | 6 +- src/uix/sema/components/chat-composer.ts | 2 +- src/uix/sema/components/chat-message.ts | 4 +- src/uix/sema/components/checkbox.ts | 4 +- src/uix/sema/components/chronos.ts | 12 +- src/uix/sema/components/clipboard.ts | 4 +- src/uix/sema/components/color-field.ts | 2 +- src/uix/sema/components/combobox.ts | 4 +- src/uix/sema/components/context-menu.ts | 2 +- src/uix/sema/components/cropper.ts | 2 +- src/uix/sema/components/css-field.ts | 4 +- src/uix/sema/components/date-field.ts | 4 +- src/uix/sema/components/drag-drop.ts | 2 +- src/uix/sema/components/dropdown-menu.ts | 4 +- src/uix/sema/components/editable.ts | 6 +- src/uix/sema/components/file-upload.ts | 10 +- src/uix/sema/components/form.ts | 4 +- src/uix/sema/components/image-adjustments.ts | 2 +- src/uix/sema/components/image-picker.ts | 4 +- src/uix/sema/components/listbox.ts | 4 +- src/uix/sema/components/mask-field.ts | 2 +- src/uix/sema/components/media-player.ts | 14 +-- src/uix/sema/components/menu-dial.ts | 2 +- src/uix/sema/components/menubar.ts | 4 +- src/uix/sema/components/month-grid.ts | 2 +- src/uix/sema/components/navigation-menu.ts | 4 +- src/uix/sema/components/number-field.ts | 2 +- src/uix/sema/components/onion-menu.ts | 2 +- 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/proof-of-human.ts | 2 +- src/uix/sema/components/radio-group.ts | 2 +- src/uix/sema/components/range-calendar.ts | 6 +- 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/stepper.ts | 4 +- src/uix/sema/components/switch.ts | 24 ++-- src/uix/sema/components/table.ts | 4 +- 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/toggle-group.ts | 6 +- src/uix/sema/components/toggle.ts | 4 +- src/uix/sema/components/toolbar.ts | 4 +- src/uix/sema/components/tooltip.ts | 4 +- src/uix/sema/components/tree-grid.ts | 2 +- src/uix/sema/components/tree-view.ts | 6 +- src/uix/sema/components/year-grid.ts | 2 +- src/uix/sema/sounds-grammar.test.ts | 112 ++++++++++++++++++ src/uix/sema/sounds.ts | 63 +++++++--- .../soma/components/chat-message/README.md | 2 +- src/uix/soma/components/checkbox/README.md | 2 +- .../soma/components/context-menu/README.md | 2 +- .../soma/components/dropdown-menu/README.md | 2 +- .../components/image-adjustments/README.md | 2 +- src/uix/soma/components/menubar/README.md | 2 +- .../soma/components/navigation-menu/README.md | 2 +- src/uix/soma/components/switch/README.md | 2 +- .../soma/components/toggle-group/README.md | 2 +- src/uix/soma/components/tooltip/README.md | 2 +- src/uix/soma/components/tree-grid/README.md | 2 +- src/uix/soma/components/tree-view/README.md | 2 +- 74 files changed, 423 insertions(+), 160 deletions(-) create mode 100644 src/uix/sema/sounds-grammar.test.ts diff --git a/docs/architecture/sema.md b/docs/architecture/sema.md index 4d2a58edc..614f0f036 100644 --- a/docs/architecture/sema.md +++ b/docs/architecture/sema.md @@ -384,6 +384,41 @@ sample('/sounds/uix/dialog-fail.wav', sound('handle.release.soft'), { preload: t fetch/decode fails, falls back to the synthetic signature. The rule is that components reference names; URLs and parameters live in one place. +#### Tuning naming shapes + +A `SOUND_TUNINGS` key takes one canonical shape: + +``` +{family}.{tail} +``` + +- **`{family}`** — the sema family whose base signature the tuning modifies. + Closed set (`SEMA_FAMILIES`), and the only axis that is both true and + mechanically checkable: a component name is neither. Never a component, + never a UI category. +- **`{tail}`** — free prose describing the resulting SIGNATURE, one or more + lowercase segments (`soft`, `subtle`, `silent`, `exit.deep`, `select.soft`). + Deliberately NOT the triggering event: `commit.silent` silences a slider's + `commit-set` in `media-player.ts` as legitimately as a switch's + `commit-toggle`, because it names the silence, not the gesture. This is the + one place where the tuning vocabulary and the *event* vocabulary + ([Naming shapes](#naming-shapes)) deliberately diverge. + +**Silencer invariant.** A `{family}.silent` tuning subtracts EXACTLY that +family's base gain, so neutral lands at 0 while the intent deltas (`threat` ++0.1, `fulfill` +0.05) still surface — silence by default, never silence +absolute. Three exist, one per family with an audible base: +`contact.silent` (−0.25), `commit.silent` (−0.3), `emerge.silent` (−0.2). +The families with no `base.sound` (`handle`, `sustain`, `delegate`) can have +no silencer: there is nothing to cancel. + +Both rules are enforced by `src/uix/sema/sounds-grammar.test.ts`, which reads +the families from `event.ts` and the base gains from `sema-map.ts` rather +than restating them. The guard exists because `form.*` named seven form +controls truthfully on 2026-05-19, was extended to menus and trees a week +later, and reached 50 packs — one of which was the Form — before anyone +noticed, since no check ever looked at these keys. + ### Channels and signatures | Channel | Consumed slice | Behavior when not applicable | diff --git a/docs/canon/vocabularies.md b/docs/canon/vocabularies.md index 81bf0b899..7eb649101 100644 --- a/docs/canon/vocabularies.md +++ b/docs/canon/vocabularies.md @@ -84,6 +84,22 @@ The canonical verb set an event may use for each family (`SEMA_VERBS`, | `sustain` | `start` · `progress` · `loading` · `waiting` · `syncing` · `processing` · `streaming` · `pending` · `retrying` · `upload` · `end` | | `delegate` | `offer` · `plan` · `authorize` · `act` · `review` · `escalate` · `return` | +## Sound tunings (14) + +Parametric deltas over a family base signature (`SOUND_TUNINGS`, +`src/uix/sema/sounds.ts`). A key is `{family}.{tail}`: the FIRST segment is +the sema family whose base the tuning modifies — never a component — and the +tail describes the resulting signature, not the triggering event. Every +`{family}.silent` subtracts exactly that family base gain, so neutral lands +at 0 while intent deltas still surface. Enforced by +`src/uix/sema/sounds-grammar.test.ts`. + +| Family | Tunings | +| --- | --- | +| `contact` | `contact.silent` | +| `emerge` | `emerge.soft` · `emerge.medium` · `emerge.strong` · `emerge.exit` · `emerge.exit.deep` · `emerge.exit.soft` · `emerge.dismiss.passive` · `emerge.silent` | +| `commit` | `commit.soft` · `commit.subtle` · `commit.select.soft` · `commit.medium` · `commit.silent` | + ## Intents (6) The evaluative axis (`INTENTS`, `src/uix/intent.ts`). `commit` and `signal` diff --git a/docs/decisions/book-deviations.md b/docs/decisions/book-deviations.md index 2087e0237..910330266 100644 --- a/docs/decisions/book-deviations.md +++ b/docs/decisions/book-deviations.md @@ -314,10 +314,37 @@ type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none'; - **Status**: **PROJECT_CANON** (decisión arquitectural sin necesidad de pasar al libro) - **Caso**: `switch`, `toggle`, `toggle-group` emiten `commit-toggle` en bucle (settings panels, toolbars, segmented controls). Sin tuning, heredan el `family.commit.base.gain = 0.3` que es demasiado para una sesión sostenida. -- **Solución**: nuevo tuning `form.toggle.silent = { gain: { op: 'add', value: -0.3 } }` en `src/uix/sema/sounds.ts`. Cancela exactamente el `gain` del family base, dejando el default en 0 (silencio). Los intent.deltas que añaden gain (`threat: +0.1`, `fulfill: +0.05`) siguen surgiendo, así un toggle destructivo sí emite señal audible. +- **Solución**: nuevo tuning `commit.silent = { gain: { op: 'add', value: -0.3 } }` en `src/uix/sema/sounds.ts`. Cancela exactamente el `gain` del family base, dejando el default en 0 (silencio). Los intent.deltas que añaden gain (`threat: +0.1`, `fulfill: +0.05`) siguen surgiendo, así un toggle destructivo sí emite señal audible. - **Haptic**: tap leve (`intensity: 0.3, duration: 12, delay: 0`) sustituye el tap medio del family. Replaza el `kind` para que la háptica no oscile con el intent — la carga evaluativa de un toggle se lee en `data-color` + (selectivamente) sonido, no en háptica fluctuante. - **Doctrina**: el libro habla de "componentes de baja intensidad" (cap. 22) — esta es la materialización runtime. NO ir al libro: es decisión de tuning, no de gramática. - **Packs concretos**: `src/uix/sema/components/{switch,toggle,toggle-group}.ts` — los tres comparten la misma firma porque comparten rol UX (un press → un flip). +- **REVERTIDO 2026-08-05 — los toggles recuperan voz (directiva de autor)**: el + silencio de arriba se retira para `switch`, `toggle` y `toggle-group`, que pasan a + **`commit.medium` (gain 0.1)** — audible, y un tercio de una pulsación de botón + (`contact` base 0.25) para que un flip nunca pese más que una activación. Los deltas + de intent siguen montando encima (`threat` +0.1, `fulfill` +0.05). Razón: un silencio + por defecto se percibe como componente roto, no como sobriedad; la fatiga se combate + bajando el nivel, no anulándolo. El háptico ligero de abajo NO cambia. + Se conserva el silencio, con su justificación intacta, sólo donde el silencio ES la + firma: `tooltip` (revela al pasar el ratón — sonaría en cada cruce) y `media-player` + (su propia salida ES audio, D-AP2.7). + Además, **emitir silencio dejó de costar**: `SoundChannel.handle` corta antes de + sintetizar cuando la ganancia resuelta es ≤ 0 (`chans/sound.ts`), así que un + `{family}.silent` ya no levanta dos osciladores, un filtro y una envolvente para no + sonar. Fijado por test, verificado en rojo. +- **Renombrado 2026-08-05 — el prefijo `form.` muere**: este tuning nació como + `form.toggle.silent` y hoy es **`commit.silent`**. El prefijo `form.` entró el + 2026-05-19 (`09e261878`) nombrando con verdad a siete controles de formulario, pero + ESTE MISMO commit (`2d562f378`) lo extendió a menús y árboles vía `form.commit.subtle` + — el D.6 de abajo llegó a escribir el nombre correcto (`commit.select`) en la misma + línea que usaba la clave incorrecta. Llegó a 50 packs, de los que exactamente uno era + el componente Form. La ley queda escrita en + [`sema.md` § Tuning naming shapes](../architecture/sema.md#tuning-naming-shapes) — + **el primer segmento es la familia cuya base modifica el tuning, nunca un + componente** — y la vigila `src/uix/sema/sounds-grammar.test.ts`. Renombres del + mismo barrido: `form.commit.soft` → `commit.soft`, `form.commit.subtle` → + `commit.subtle`, `tooltip.silent` → `emerge.silent`, `tabs.select.soft` → + `commit.select.soft`. Sin cambio de comportamiento: mismos deltas, mismas ganancias. ### D.6 Packs para superficies de menú y árboles @@ -326,7 +353,7 @@ type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none'; - **Solución**: packs por componente que combinan tuning emerge soft + commit subtle: - **emerge.open (menús, expand)**: `emerge.soft` (gain 0.08) — el contenido se revela sin competir con la superficie que lo invoca. - **emerge.close (menús, collapse)**: `emerge.exit.soft` (gain 0.05, descending) — disciplina de dirección compartida con dialog/drawer/popover. - - **commit.select (item de menú, nodo de árbol)**: `form.commit.subtle` (gain 0.03) + `tap` leve. Más sobrio que radio-group porque la cascada típica es "menú cierra + item commit + nueva superficie aparece" — tres señales en milisegundos, hay que repartir intensidad. + - **commit.select (item de menú, nodo de árbol)**: `commit.subtle` (gain 0.03) + `tap` leve. Más sobrio que radio-group porque la cascada típica es "menú cierra + item commit + nueva superficie aparece" — tres señales en milisegundos, hay que repartir intensidad. - **Coherencia**: dropdown-menu y context-menu comparten firma idéntica (el usuario no debe aprender dos "sonidos de menú"). Tree-view y tree-grid idem. Menubar y navigation-menu se quedan en solo `commit.subtle` porque no tienen evento de open/close declarado en el morfo (la apertura es de un dropdown adyacente). - **Caveat (`tree-view` / `tree-grid`) — RESUELTO (verificado 2026-07-12, SEM-4)**: la emisión aterriza vía `fallbackTarget` en el elemento real (`branchEl` / `rowEl`) y los diff --git a/scripts/docs-vocabularies.ts b/scripts/docs-vocabularies.ts index 76a55bfd6..9a7089e95 100644 --- a/scripts/docs-vocabularies.ts +++ b/scripts/docs-vocabularies.ts @@ -21,6 +21,7 @@ import { ARCHETYPE_VOCABULARY, ARCHETYPE_DESCRIPTIONS } from '../src/uix/morfo/t import { PALETTE_SCALES, SIZES, SIZE_PRIMITIVE_KEYS, EIDOS_VARIANTS } from '../src/uix/eidos/lib/types'; import { SEMA_HOLDS_BY_INTENT } from '../src/uix/sema/holds'; import { SEMA_VERBS } from '../src/uix/sema/verbs'; +import { SOUND_TUNINGS } from '../src/uix/sema/sounds'; import { SEMA_DURATIONS } from '../src/uix/sema/durations'; import { INTENTS } from '../src/uix/intent'; import { commonLangs } from '../src/uix/langs/common'; @@ -126,6 +127,26 @@ export function generateVocabulariesDoc(): string { } L.push(''); + // Sound tunings + const tuningKeys = Object.keys(SOUND_TUNINGS); + L.push(`## Sound tunings (${tuningKeys.length})`); + L.push(''); + L.push('Parametric deltas over a family base signature (`SOUND_TUNINGS`,'); + L.push('`src/uix/sema/sounds.ts`). A key is `{family}.{tail}`: the FIRST segment is'); + L.push('the sema family whose base the tuning modifies — never a component — and the'); + L.push('tail describes the resulting signature, not the triggering event. Every'); + L.push('`{family}.silent` subtracts exactly that family base gain, so neutral lands'); + L.push('at 0 while intent deltas still surface. Enforced by'); + L.push('`src/uix/sema/sounds-grammar.test.ts`.'); + L.push(''); + L.push('| Family | Tunings |'); + L.push('| --- | --- |'); + for (const family of families) { + const owned = tuningKeys.filter((k) => k.split('.')[0] === family); + if (owned.length > 0) L.push(`| \`${family}\` | ${owned.map((k) => `\`${k}\``).join(' · ')} |`); + } + L.push(''); + // Intents L.push(`## Intents (${INTENTS.length})`); L.push(''); diff --git a/src/uix/eidos/components/chronos/SPEC.md b/src/uix/eidos/components/chronos/SPEC.md index e3ef54b71..a0b9e465f 100644 --- a/src/uix/eidos/components/chronos/SPEC.md +++ b/src/uix/eidos/components/chronos/SPEC.md @@ -407,9 +407,9 @@ src/uix/soma/components/chronos/ Pack opcional con `cascade` construido vía `semaSelector(chronosMorfo, part, matchers)`. Sin canales nuevos (sound + haptic son los únicos canónicos). Reusa *tunings* existentes: -- `commit-create` / `commit-update` → `soundTuning('form.commit.soft')` + `haptic: tap`. +- `commit-create` / `commit-update` → `soundTuning('commit.soft')` + `haptic: tap`. - `commit-delete` → tuning de `loss` (descendente) + `haptic: error` suave. -- `shift-navigate` → `form.commit.subtle` ascendente/descendente según dirección. +- `shift-navigate` → `commit.subtle` ascendente/descendente según dirección. - `handle-drag` / `handle-resize` → solo `haptic` (la familia `handle` activa haptic; **no emitir sonido por frame** — misma doctrina que `virtual-*` `handle-scroll`). - `emerge-open` / `emerge-close` → `emerge.soft` / `emerge.dismiss.passive`. diff --git a/src/uix/eidos/components/mask-field/README.md b/src/uix/eidos/components/mask-field/README.md index 30ceadbd2..f50899590 100644 --- a/src/uix/eidos/components/mask-field/README.md +++ b/src/uix/eidos/components/mask-field/README.md @@ -126,7 +126,7 @@ seeding). None are blocking; each lands behind a real request. | `commit-set` | commit · set | neutral | post | provider | Blur / Enter, once per changed value | Typing emits no semantic event (high-frequency editing). Perceptual signature: -`form.commit.subtle` sound + `tap` haptic — identical to NumberField (both are +`commit.subtle` sound + `tap` haptic — identical to NumberField (both are field commits). Pack: `src/uix/sema/components/mask-field.ts`. ## Anatomy diff --git a/src/uix/sema/chans/sound.test.ts b/src/uix/sema/chans/sound.test.ts index d9dcc35e2..d3b02c86f 100644 --- a/src/uix/sema/chans/sound.test.ts +++ b/src/uix/sema/chans/sound.test.ts @@ -126,6 +126,23 @@ describe('SoundChannel', () => { channel.dispose(); }); + it('never synthesizes a signature resolved to zero gain', async () => { + const { dom } = createDomHarness(); + const audio = createAudioHarness(); + (audio.ctx as { state: string }).state = 'running'; + const channel = new SoundChannel({ dom, audioContextFactory: audio.factory }); + // What a `{family}.silent` tuning produces: the family base cancelled + // exactly. Silence is not a sound — before this guard every such + // emission still built two oscillators, a filter and an envelope to + // render nothing audible, on every single interaction. + await channel.handle( + { target: document.createElement('button'), name: 'x', family: 'commit' }, + { ...soundEffective, sound: { ...soundEffective.sound, gain: 0 } } as never + ); + expect(audio.factory).not.toHaveBeenCalled(); + channel.dispose(); + }); + it('reduces per signal and never moves the document master (BK-REDUCTIONS)', async () => { const { dom } = createDomHarness(); const audio = createAudioHarness(); diff --git a/src/uix/sema/chans/sound.ts b/src/uix/sema/chans/sound.ts index a327f9d16..012bb91e9 100644 --- a/src/uix/sema/chans/sound.ts +++ b/src/uix/sema/chans/sound.ts @@ -167,6 +167,14 @@ export class SoundChannel implements Channel { if (level === 'off') return; const gainScale = level === 'reduce' ? SOUND_REDUCE_GAIN_FACTOR : 1; + // Silence is not a sound to synthesize. A `{family}.silent` tuning + // cancels the family base exactly so neutral lands at 0 — without this + // guard every such emission still spun up two oscillators, a filter and + // a scheduled envelope to render nothing audible, on every interaction. + // Intent deltas that push the gain back above 0 (threat, fulfill) play + // normally: the check is on the RESOLVED gain, not on the tuning. + if (typeof sig.gain === 'number' && sig.gain * gainScale <= 0) return; + // The engine never rejects — audio is ornamental. Earcons are UI sound, // so they ride the `ui` bus (never `content`: `prefs.sound` must not // touch the work's volume) and speak with sema's registered voice. diff --git a/src/uix/sema/components/calendar.ts b/src/uix/sema/components/calendar.ts index 635acb763..e99e53f99 100644 --- a/src/uix/sema/components/calendar.ts +++ b/src/uix/sema/components/calendar.ts @@ -19,12 +19,12 @@ export const calendarSema: Sema = { cascade: [ { selector: onPart('day', { eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onPart('day', { eventName: 'commit-unselect' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, ...( @@ -38,7 +38,7 @@ export const calendarSema: Sema = { ] as const ).map((part) => ({ selector: onPart(part, { eventName: 'shift-navigate' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { gain: 0.18, contour: 'ascending', pitch: { op: 'add', value: -90 } diff --git a/src/uix/sema/components/chat-composer.ts b/src/uix/sema/components/chat-composer.ts index e3e138561..39c351fc5 100644 --- a/src/uix/sema/components/chat-composer.ts +++ b/src/uix/sema/components/chat-composer.ts @@ -20,7 +20,7 @@ export const chatComposerSema: Sema = { selector: semaSelector(chatComposerMorfo, 'provider', { eventName: 'commit-submit-send' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 } } ] diff --git a/src/uix/sema/components/chat-message.ts b/src/uix/sema/components/chat-message.ts index 910c89b48..fd88f7ac3 100644 --- a/src/uix/sema/components/chat-message.ts +++ b/src/uix/sema/components/chat-message.ts @@ -25,12 +25,12 @@ export const chatMessageSema: Sema = { cascade: [ { selector: onReaction({ eventName: 'commit-select-react' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 } }, { selector: onReaction({ eventName: 'commit-unselect-react' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.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 f29ba3bae..1e5545fd9 100644 --- a/src/uix/sema/components/checkbox.ts +++ b/src/uix/sema/components/checkbox.ts @@ -36,12 +36,12 @@ export const checkboxSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-toggle-check' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onProvider({ eventName: 'commit-toggle-uncheck' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/chronos.ts b/src/uix/sema/components/chronos.ts index babb89692..acca5b756 100644 --- a/src/uix/sema/components/chronos.ts +++ b/src/uix/sema/components/chronos.ts @@ -14,25 +14,25 @@ export const chronosSema: Sema = { // Event landed (create / edit-save / resize-save). { selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-save' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, // Event moved by drag. { selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-move' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, // Event deleted (loss). { selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-delete' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, // Month navigation. { selector: semaSelector(chronosMorfo, 'provider', { eventName: 'shift-navigate' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { gain: 0.18, contour: 'ascending', pitch: { op: 'add', value: -90 } @@ -41,11 +41,11 @@ export const chronosSema: Sema = { // Undo / redo. { selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-restore' }), - sound: soundTuning('form.commit.subtle') + sound: soundTuning('commit.subtle') }, { selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-apply' }), - sound: soundTuning('form.commit.subtle') + sound: soundTuning('commit.subtle') }, // 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 3db56e79b..e95d49ba4 100644 --- a/src/uix/sema/components/clipboard.ts +++ b/src/uix/sema/components/clipboard.ts @@ -15,12 +15,12 @@ export const clipboardSema: Sema = { cascade: [ { selector: onTrigger({ eventName: 'commit-save-copy' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onTrigger({ eventName: 'commit-fail-copy' }), - sound: soundTuning('form.commit.soft', { contour: 'descending' }) + sound: soundTuning('commit.soft', { contour: 'descending' }) } ] } diff --git a/src/uix/sema/components/color-field.ts b/src/uix/sema/components/color-field.ts index cb209b1af..5b0020722 100644 --- a/src/uix/sema/components/color-field.ts +++ b/src/uix/sema/components/color-field.ts @@ -13,7 +13,7 @@ export const colorFieldSema: Sema = { cascade: [ { selector: onInput({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/combobox.ts b/src/uix/sema/components/combobox.ts index ef16ee575..dddef505d 100644 --- a/src/uix/sema/components/combobox.ts +++ b/src/uix/sema/components/combobox.ts @@ -19,12 +19,12 @@ export const comboboxSema: Sema = { cascade: [ { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onItem({ eventName: 'commit-unselect' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/context-menu.ts b/src/uix/sema/components/context-menu.ts index f999eb9e9..15ed4a789 100644 --- a/src/uix/sema/components/context-menu.ts +++ b/src/uix/sema/components/context-menu.ts @@ -36,7 +36,7 @@ export const contextMenuSema: Sema = { }, { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.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 667ee0870..1ee083cab 100644 --- a/src/uix/sema/components/cropper.ts +++ b/src/uix/sema/components/cropper.ts @@ -40,7 +40,7 @@ export const cropperSema: Sema = { }, { selector: onProvider({ eventName: 'commit-crop' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/css-field.ts b/src/uix/sema/components/css-field.ts index ffe5ec965..b499b2adb 100644 --- a/src/uix/sema/components/css-field.ts +++ b/src/uix/sema/components/css-field.ts @@ -28,7 +28,7 @@ export const cssFieldSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { @@ -44,7 +44,7 @@ export const cssFieldSema: Sema = { }, { selector: onInput({ eventName: 'signal-warn-invalid' }), - sound: soundTuning('form.commit.soft', { + sound: soundTuning('commit.soft', { contour: 'descending', pitch: { op: 'add', value: -120 } }), diff --git a/src/uix/sema/components/date-field.ts b/src/uix/sema/components/date-field.ts index 5b4c8ccb9..825f6ad1a 100644 --- a/src/uix/sema/components/date-field.ts +++ b/src/uix/sema/components/date-field.ts @@ -14,12 +14,12 @@ export const dateFieldSema: Sema = { cascade: [ { selector: onInput({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onInput({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }), diff --git a/src/uix/sema/components/drag-drop.ts b/src/uix/sema/components/drag-drop.ts index f22a192fc..a13bd1cc7 100644 --- a/src/uix/sema/components/drag-drop.ts +++ b/src/uix/sema/components/drag-drop.ts @@ -33,7 +33,7 @@ export const dragDropSema: Sema = { }, { selector: onDraggable({ eventName: 'commit-cancel' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }) diff --git a/src/uix/sema/components/dropdown-menu.ts b/src/uix/sema/components/dropdown-menu.ts index 894d44fe3..f0071af80 100644 --- a/src/uix/sema/components/dropdown-menu.ts +++ b/src/uix/sema/components/dropdown-menu.ts @@ -21,7 +21,7 @@ import type { Sema } from '../sema-map'; * gain 0.05. Even softer than open because closing is usually * immediately followed by the item-select chime OR the surface the * user navigated to. - * - SELECT: `form.commit.subtle` (gain 0.03) + light `tap` haptic. + * - SELECT: `commit.subtle` (gain 0.03) + light `tap` haptic. * Softer than radio-group because the close emerge and the select * commit usually fire together — two signals in quick succession * would double-tap perceptually if both were at 0.05. @@ -47,7 +47,7 @@ export const dropdownMenuSema: Sema = { }, { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.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 3fd53dec6..9dacb6132 100644 --- a/src/uix/sema/components/editable.ts +++ b/src/uix/sema/components/editable.ts @@ -11,7 +11,7 @@ export const editableSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'shift-enter-mode' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'ascending', pitch: { op: 'add', value: 80 } }), @@ -19,12 +19,12 @@ export const editableSema: Sema = { }, { selector: onProvider({ eventName: 'commit-save' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'success' } }, { selector: onProvider({ eventName: 'commit-cancel' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }), diff --git a/src/uix/sema/components/file-upload.ts b/src/uix/sema/components/file-upload.ts index b08948104..347e4adb0 100644 --- a/src/uix/sema/components/file-upload.ts +++ b/src/uix/sema/components/file-upload.ts @@ -17,17 +17,17 @@ export const fileUploadSema: Sema = { cascade: [ { selector: onTrigger({ eventName: 'trigger-picker' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tick' } }, { selector: onProvider({ eventName: 'commit-set-add' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'success' } }, { selector: onProvider({ eventName: 'signal-warn-reject' }), - sound: soundTuning('form.commit.soft', { + sound: soundTuning('commit.soft', { contour: 'descending', pitch: { op: 'add', value: -120 } }), @@ -35,7 +35,7 @@ export const fileUploadSema: Sema = { }, { selector: onItem({ eventName: 'commit-remove' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }), @@ -43,7 +43,7 @@ export const fileUploadSema: Sema = { }, { selector: onProvider({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -100 } }), diff --git a/src/uix/sema/components/form.ts b/src/uix/sema/components/form.ts index beeea333f..8edf0bd5f 100644 --- a/src/uix/sema/components/form.ts +++ b/src/uix/sema/components/form.ts @@ -11,7 +11,7 @@ export const formSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-submit' }), - sound: soundTuning('form.commit.soft', { + sound: soundTuning('commit.soft', { contour: 'ascending', pitch: { op: 'add', value: 80 } }), @@ -24,7 +24,7 @@ export const formSema: Sema = { }, { selector: onProvider({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }), diff --git a/src/uix/sema/components/image-adjustments.ts b/src/uix/sema/components/image-adjustments.ts index 57cfccb05..dead93803 100644 --- a/src/uix/sema/components/image-adjustments.ts +++ b/src/uix/sema/components/image-adjustments.ts @@ -24,7 +24,7 @@ export const imageAdjustmentsSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/image-picker.ts b/src/uix/sema/components/image-picker.ts index ef5f449ba..fa8eafc0b 100644 --- a/src/uix/sema/components/image-picker.ts +++ b/src/uix/sema/components/image-picker.ts @@ -27,12 +27,12 @@ export const imagePickerSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onProvider({ eventName: 'commit-remove' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { diff --git a/src/uix/sema/components/listbox.ts b/src/uix/sema/components/listbox.ts index 168f1caee..52020900d 100644 --- a/src/uix/sema/components/listbox.ts +++ b/src/uix/sema/components/listbox.ts @@ -13,12 +13,12 @@ export const listboxSema: Sema = { cascade: [ { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onItem({ eventName: 'commit-unselect' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/mask-field.ts b/src/uix/sema/components/mask-field.ts index 901f79aa6..3b3d0d2c2 100644 --- a/src/uix/sema/components/mask-field.ts +++ b/src/uix/sema/components/mask-field.ts @@ -20,7 +20,7 @@ export const maskFieldSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/media-player.ts b/src/uix/sema/components/media-player.ts index e854016da..41b38477e 100644 --- a/src/uix/sema/components/media-player.ts +++ b/src/uix/sema/components/media-player.ts @@ -77,19 +77,19 @@ export const mediaPlayerSema: Sema = { // measured 2026-07-30, H-1/G-4). The subtraction is the commit family // base (-0.3) — the tuning name comes from D.5. selector: onPlayButton({ eventName: 'commit-toggle-play' }), - sound: soundTuning('form.toggle.silent'), + sound: soundTuning('commit.silent'), haptic: { kind: 'tap' } }, { selector: semaSelector(mediaPlayerMorfo, 'mute-button', { eventName: 'commit-toggle-mute' }), - sound: soundTuning('form.toggle.silent'), + sound: soundTuning('commit.silent'), haptic: { kind: 'tap' } }, { selector: onRateButton({ eventName: 'commit-set-rate' }), - sound: soundTuning('form.toggle.silent'), + sound: soundTuning('commit.silent'), haptic: { kind: 'tap' } }, { @@ -101,7 +101,7 @@ export const mediaPlayerSema: Sema = { { // The composed Sliders' release (scrubber + volume): same subtraction. selector: inPlayer(semaSelector(sliderMorfo, 'provider', { eventName: 'commit-set' })), - sound: soundTuning('form.toggle.silent') + sound: soundTuning('commit.silent') }, { // The Sliders' grab cue: structural, no intent — silenced by channel @@ -123,7 +123,7 @@ export const mediaPlayerSema: Sema = { // recovers its full voice. These follow it out. { selector: inVolume(semaSelector(sliderMorfo, 'provider', { eventName: 'commit-set' })), - sound: soundTuning('form.toggle.silent') + sound: soundTuning('commit.silent') }, { selector: inVolume(semaSelector(sliderMorfo, 'provider', { eventName: 'handle-pick' })), @@ -135,11 +135,11 @@ export const mediaPlayerSema: Sema = { }, { selector: onProvider({ eventName: 'commit-complete' }), - sound: soundTuning('form.commit.soft') + sound: soundTuning('commit.soft') }, { selector: onProvider({ eventName: 'commit-fail' }), - sound: soundTuning('form.commit.soft', { contour: 'descending' }) + sound: soundTuning('commit.soft', { contour: 'descending' }) } ] }; diff --git a/src/uix/sema/components/menu-dial.ts b/src/uix/sema/components/menu-dial.ts index 33c92417e..e6e4b224c 100644 --- a/src/uix/sema/components/menu-dial.ts +++ b/src/uix/sema/components/menu-dial.ts @@ -23,7 +23,7 @@ export const menuDialSema: Sema = { }, { selector: onAction({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/menubar.ts b/src/uix/sema/components/menubar.ts index 32dc3a99f..238f7f6c9 100644 --- a/src/uix/sema/components/menubar.ts +++ b/src/uix/sema/components/menubar.ts @@ -12,7 +12,7 @@ import type { Sema } from '../sema-map'; * trigger is now active". Same frequency profile as a toolbar. * * Strategy (book cap. 22 §10 — baja intensidad): - * - SOUND: `form.commit.subtle` (gain 0.03). Triggers fire as the user + * - SOUND: `commit.subtle` (gain 0.03). Triggers fire as the user * scrubs across the menubar — softer than radio-group / checkbox to * avoid fatigue during multi-trigger traversal. * - HAPTIC: light `tap` (intensity 0.3, duration 12). Same family as @@ -29,7 +29,7 @@ export const menubarSema: Sema = { cascade: [ { selector: onTrigger({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.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 b5d2c34d7..738af0218 100644 --- a/src/uix/sema/components/month-grid.ts +++ b/src/uix/sema/components/month-grid.ts @@ -13,7 +13,7 @@ export const monthGridSema: Sema = { cascade: [ { selector: onCell({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/navigation-menu.ts b/src/uix/sema/components/navigation-menu.ts index 639c133f3..7a833a535 100644 --- a/src/uix/sema/components/navigation-menu.ts +++ b/src/uix/sema/components/navigation-menu.ts @@ -11,7 +11,7 @@ import type { Sema } from '../sema-map'; * fatigue profile as Menubar. * * Strategy (book cap. 22 §10): - * - SOUND: `form.commit.subtle` (gain 0.03). Selecting a nav link is + * - SOUND: `commit.subtle` (gain 0.03). Selecting a nav link is * usually followed by a route change / focus shift, so the chime * must not compete with the next surface's signals. * - HAPTIC: light `tap`. Confirms the click landed. @@ -28,7 +28,7 @@ export const navigationMenuSema: Sema = { cascade: [ { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 } } ] diff --git a/src/uix/sema/components/number-field.ts b/src/uix/sema/components/number-field.ts index ff3468fcc..247e55aaf 100644 --- a/src/uix/sema/components/number-field.ts +++ b/src/uix/sema/components/number-field.ts @@ -24,7 +24,7 @@ export const numberFieldSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { diff --git a/src/uix/sema/components/onion-menu.ts b/src/uix/sema/components/onion-menu.ts index 8283391c6..a545293f3 100644 --- a/src/uix/sema/components/onion-menu.ts +++ b/src/uix/sema/components/onion-menu.ts @@ -24,7 +24,7 @@ export const onionMenuSema: Sema = { }, { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/pagination.ts b/src/uix/sema/components/pagination.ts index f2bab62da..7a217caf0 100644 --- a/src/uix/sema/components/pagination.ts +++ b/src/uix/sema/components/pagination.ts @@ -13,32 +13,32 @@ export const paginationSema: Sema = { cascade: [ { selector: onPart('provider', { eventName: 'commit-set' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { selector: onPart('first-trigger', { eventName: 'commit-set' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { selector: onPart('prev-trigger', { eventName: 'commit-set' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { selector: onPart('item', { eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onPart('next-trigger', { eventName: 'commit-set' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { selector: onPart('last-trigger', { eventName: 'commit-set' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/password-field.ts b/src/uix/sema/components/password-field.ts index 0d50920f2..0f01567d8 100644 --- a/src/uix/sema/components/password-field.ts +++ b/src/uix/sema/components/password-field.ts @@ -27,17 +27,17 @@ export const passwordFieldSema: Sema = { cascade: [ { selector: onPart('input', { eventName: 'commit-submit' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onPart('visibility-trigger', { eventName: 'commit-toggle-visibility' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { selector: onPart('caps-lock-indicator', { eventName: 'signal-notify-caps-state' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/picker.ts b/src/uix/sema/components/picker.ts index 9804ebaa9..fb7776807 100644 --- a/src/uix/sema/components/picker.ts +++ b/src/uix/sema/components/picker.ts @@ -8,7 +8,7 @@ import type { Sema } from '../sema-map' * (checkpoint S3b ascent, 2026-07-07; pays the morfo's old "later sema pass"). * * Accept (`commit-set` + fulfill) is the seal of the deferValue transaction: - * the fields' commit voice (`form.commit.soft`) with the fulfill intent delta + * the fields' commit voice (`commit.soft`) with the fulfill intent delta * on top — slightly resolutive, never celebratory noise. Cancel and Clear are * quieter, descending: edits abandoned / value emptied, nothing to celebrate * (undo matrix, CANON §3). Every picker composing this host inherits the @@ -24,19 +24,19 @@ export const pickerSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onProvider({ eventName: 'commit-cancel' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }) }, { selector: onProvider({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }) diff --git a/src/uix/sema/components/proof-of-human.ts b/src/uix/sema/components/proof-of-human.ts index 8c244ceb2..4df73333b 100644 --- a/src/uix/sema/components/proof-of-human.ts +++ b/src/uix/sema/components/proof-of-human.ts @@ -25,7 +25,7 @@ export const proofOfHumanSema: Sema = { // Cancel — a quiet descending retreat (mirrors drag-drop). { selector: onProvider({ eventName: 'commit-cancel' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }) diff --git a/src/uix/sema/components/radio-group.ts b/src/uix/sema/components/radio-group.ts index 6653f2ea4..bc970f353 100644 --- a/src/uix/sema/components/radio-group.ts +++ b/src/uix/sema/components/radio-group.ts @@ -28,7 +28,7 @@ export const radioGroupSema: Sema = { cascade: [ { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/range-calendar.ts b/src/uix/sema/components/range-calendar.ts index 7d03c54fe..f9d83b2f9 100644 --- a/src/uix/sema/components/range-calendar.ts +++ b/src/uix/sema/components/range-calendar.ts @@ -20,17 +20,17 @@ export const rangeCalendarSema: Sema = { cascade: [ { selector: onDay({ eventName: 'commit-select-start' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onDay({ eventName: 'commit-select-range' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onDay({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }) diff --git a/src/uix/sema/components/rating-group.ts b/src/uix/sema/components/rating-group.ts index 419828114..c63b28497 100644 --- a/src/uix/sema/components/rating-group.ts +++ b/src/uix/sema/components/rating-group.ts @@ -14,12 +14,12 @@ export const ratingGroupSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onItem({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/search-field.ts b/src/uix/sema/components/search-field.ts index 11a2c0beb..7d14226e4 100644 --- a/src/uix/sema/components/search-field.ts +++ b/src/uix/sema/components/search-field.ts @@ -18,12 +18,12 @@ export const searchFieldSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-submit' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onProvider({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/select.ts b/src/uix/sema/components/select.ts index ecd3d2cf0..75e928d38 100644 --- a/src/uix/sema/components/select.ts +++ b/src/uix/sema/components/select.ts @@ -20,12 +20,12 @@ export const selectSema: Sema = { cascade: [ { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onItem({ eventName: 'commit-unselect' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/stepper.ts b/src/uix/sema/components/stepper.ts index 36564f244..3c13f130b 100644 --- a/src/uix/sema/components/stepper.ts +++ b/src/uix/sema/components/stepper.ts @@ -11,7 +11,7 @@ export const stepperSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'shift-step' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'ascending', pitch: { op: 'add', value: 60 } }), @@ -19,7 +19,7 @@ export const stepperSema: Sema = { }, { selector: onProvider({ eventName: 'commit-complete' }), - sound: soundTuning('form.commit.soft', { + sound: soundTuning('commit.soft', { contour: 'ascending', pitch: { op: 'add', value: 120 } }), diff --git a/src/uix/sema/components/switch.ts b/src/uix/sema/components/switch.ts index 41dce9e15..330f7b21d 100644 --- a/src/uix/sema/components/switch.ts +++ b/src/uix/sema/components/switch.ts @@ -4,19 +4,19 @@ import { soundTuning } from '../sounds'; import type { Sema } from '../sema-map'; /** - * Switch perceptual defaults — SILENT-BY-DEFAULT + LIGHT HAPTIC. + * Switch perceptual defaults — RESTRAINED VOICE + LIGHT HAPTIC. * - * Switches are high-frequency form controls (settings panels, preference - * trays). Fatigue is the dominant risk: a sober signature lets the user - * flip a dozen toggles without auditory noise. Book cap. 22 §10 maps a - * single `commit.toggle` verb to both directions; the intent prop carries - * the evaluative load. + * Switches are high-frequency controls (settings panels, preference trays), + * so the signature is sober — but a flip the user cannot hear is not a + * signature, it is an absence. Book cap. 22 §10 maps a single `commit.toggle` + * verb to both directions; the intent prop carries the evaluative load. * - * Strategy (per author directive 2026-05-26): - * - SOUND: `form.toggle.silent` subtracts the family.commit gain (0.3), - * so neutral/affirm/risk land silent. Threat (+0.1 from intent.delta) - * still surfaces, so destructive toggles emit a cue. Pitch / contour - * differentiation stays computed but inaudible when gain is 0. + * Strategy (author directive 2026-08-05, superseding the silence of + * 2026-05-26 — see D.5): + * - SOUND: `commit.medium` (gain 0.1) — audible, and a third of a button + * press (contact base 0.25) so a flip never outweighs an activation. + * Intent deltas ride on top: threat (+0.1) and fulfill (+0.05) make a + * destructive or culminating toggle stand out, as before. * - HAPTIC: light `tap` (intensity 0.3 vs family's 0.5, duration 12 vs * 20, no delay). Replaces the intent-driven `kind` so toggles always * feel like a tap — keeping the gesture intelligible without @@ -33,7 +33,7 @@ export const switchSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-toggle' }), - sound: soundTuning('form.toggle.silent'), + sound: soundTuning('commit.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 c13b994fa..310ceabac 100644 --- a/src/uix/sema/components/table.ts +++ b/src/uix/sema/components/table.ts @@ -16,12 +16,12 @@ export const tableSema: Sema = { cascade: [ { selector: onRow({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onColumnHeader({ eventName: 'commit-set-sort' }), - sound: soundTuning('form.commit.subtle') + sound: soundTuning('commit.subtle') } ] } diff --git a/src/uix/sema/components/tabs.ts b/src/uix/sema/components/tabs.ts index 536c666d1..b2b8218e9 100644 --- a/src/uix/sema/components/tabs.ts +++ b/src/uix/sema/components/tabs.ts @@ -31,7 +31,7 @@ export const tabsSema: Sema = { cascade: [ { selector: onTrigger({ eventName: 'select' }), - sound: soundTuning('tabs.select.soft'), + sound: soundTuning('commit.select.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/tag-group.ts b/src/uix/sema/components/tag-group.ts index 1b8b8243d..e4ede23a4 100644 --- a/src/uix/sema/components/tag-group.ts +++ b/src/uix/sema/components/tag-group.ts @@ -14,17 +14,17 @@ export const tagGroupSema: Sema = { cascade: [ { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onItem({ eventName: 'commit-unselect' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { selector: onItem({ eventName: 'commit-remove' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }), @@ -32,7 +32,7 @@ export const tagGroupSema: Sema = { }, { selector: onProvider({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -100 } }), diff --git a/src/uix/sema/components/tags-input.ts b/src/uix/sema/components/tags-input.ts index 331f583bf..de5a49e4f 100644 --- a/src/uix/sema/components/tags-input.ts +++ b/src/uix/sema/components/tags-input.ts @@ -17,12 +17,12 @@ export const tagsInputSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-set-add' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'success' } }, { selector: onInput({ eventName: 'signal-warn-reject' }), - sound: soundTuning('form.commit.soft', { + sound: soundTuning('commit.soft', { contour: 'descending', pitch: { op: 'add', value: -120 } }), @@ -30,7 +30,7 @@ export const tagsInputSema: Sema = { }, { selector: onItem({ eventName: 'commit-remove' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -80 } }), @@ -38,7 +38,7 @@ export const tagsInputSema: Sema = { }, { selector: onProvider({ eventName: 'commit-reset' }), - sound: soundTuning('form.commit.subtle', { + sound: soundTuning('commit.subtle', { contour: 'descending', pitch: { op: 'add', value: -100 } }), diff --git a/src/uix/sema/components/textarea.ts b/src/uix/sema/components/textarea.ts index 01040e19b..0fbab89fc 100644 --- a/src/uix/sema/components/textarea.ts +++ b/src/uix/sema/components/textarea.ts @@ -19,12 +19,12 @@ export const textareaSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-submit' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } }, { selector: onProvider({ eventName: 'signal-warn-count-overflow' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/time-field.ts b/src/uix/sema/components/time-field.ts index d5c65e6a6..8a8f5546a 100644 --- a/src/uix/sema/components/time-field.ts +++ b/src/uix/sema/components/time-field.ts @@ -14,7 +14,7 @@ export const timeFieldSema: Sema = { cascade: [ { selector: onInput({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/toggle-group.ts b/src/uix/sema/components/toggle-group.ts index 94c2b1644..721d0e25a 100644 --- a/src/uix/sema/components/toggle-group.ts +++ b/src/uix/sema/components/toggle-group.ts @@ -4,7 +4,7 @@ import { soundTuning } from '../sounds'; import type { Sema } from '../sema-map'; /** - * ToggleGroup perceptual defaults — SILENT-BY-DEFAULT + LIGHT HAPTIC. + * ToggleGroup perceptual defaults — RESTRAINED VOICE + LIGHT HAPTIC. * * ToggleGroup composes N toggles under one provider (segmented controls, * formatter ribbons, filter chips). Fatigue compounds with each @@ -12,7 +12,7 @@ import type { Sema } from '../sema-map'; * than for a lone Switch / Toggle. Each item emits `commit-toggle` * targeting the `item` part. * - * Strategy mirrors Switch / Toggle — silent by default, light tap haptic. + * Strategy mirrors Switch / Toggle — `commit.medium`, light tap haptic. * The pack targets `item` (not provider) so the cascade matches the * actual element that carries the `data-event-*` stamp. */ @@ -25,7 +25,7 @@ export const toggleGroupSema: Sema = { cascade: [ { selector: onItem({ eventName: 'commit-toggle' }), - sound: soundTuning('form.toggle.silent'), + sound: soundTuning('commit.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 462dff5db..39ebaa8f0 100644 --- a/src/uix/sema/components/toggle.ts +++ b/src/uix/sema/components/toggle.ts @@ -4,7 +4,7 @@ import { soundTuning } from '../sounds'; import type { Sema } from '../sema-map'; /** - * Toggle perceptual defaults — SILENT-BY-DEFAULT + LIGHT HAPTIC. + * Toggle perceptual defaults — RESTRAINED VOICE + LIGHT HAPTIC. * * Toggles are single-button two-state controls (toolbar pressed-states, * inline filters). Same fatigue profile as Switch and Checkbox: dozens of @@ -25,7 +25,7 @@ export const toggleSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-toggle' }), - sound: soundTuning('form.toggle.silent'), + sound: soundTuning('commit.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 06f2b027f..204aa14ec 100644 --- a/src/uix/sema/components/toolbar.ts +++ b/src/uix/sema/components/toolbar.ts @@ -14,12 +14,12 @@ export const toolbarSema: Sema = { cascade: [ { selector: onProvider({ eventName: 'commit-toggle' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } }, { selector: onGroupItem({ eventName: 'commit-toggle' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/components/tooltip.ts b/src/uix/sema/components/tooltip.ts index 82d096692..8d3109f29 100644 --- a/src/uix/sema/components/tooltip.ts +++ b/src/uix/sema/components/tooltip.ts @@ -17,7 +17,7 @@ import type { Sema } from '../sema-map'; * * Strategy: * - `present` / `dismiss` / `dismiss-escape` (all emerge family on content): - * all routed through `tooltip.silent`. That tuning subtracts the + * all routed through `emerge.silent`. That tuning subtracts the * family.emerge.base.gain (0.2), so the final gain is 0 for * neutral/affirm/risk/loss. Intent threat (+0.1) and fulfill (+0.05) * still surface — a tooltip used to convey an error or success @@ -40,7 +40,7 @@ export const tooltipSema: Sema = { cascade: [ { selector: onContent({ eventFamily: 'emerge' }), - sound: soundTuning('tooltip.silent') + sound: soundTuning('emerge.silent') } ] }; diff --git a/src/uix/sema/components/tree-grid.ts b/src/uix/sema/components/tree-grid.ts index 692456c29..438e0aad1 100644 --- a/src/uix/sema/components/tree-grid.ts +++ b/src/uix/sema/components/tree-grid.ts @@ -32,7 +32,7 @@ export const treeGridSema: Sema = { }, { selector: onRow({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.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 b4b6a2d0f..8cf390adf 100644 --- a/src/uix/sema/components/tree-view.ts +++ b/src/uix/sema/components/tree-view.ts @@ -18,7 +18,7 @@ import type { Sema } from '../sema-map'; * family base. Same character as Accordion expand. * - COLLAPSE: `emerge.exit.soft` — descending pitch + gain 0.05, * same direction discipline as menus / dialogs. - * - SELECT: `form.commit.subtle` + light `tap`. Soft because tree + * - SELECT: `commit.subtle` + light `tap`. Soft because tree * exploration is often paired with a focus change (the leaf * content surfaces elsewhere), and the select chime mustn't * compete with that downstream signal. @@ -51,12 +51,12 @@ export const treeViewSema: Sema = { // consistent across leaf and branch selection. { selector: onItem({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.subtle'), haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 } }, { selector: onBranch({ eventName: 'commit-select' }), - sound: soundTuning('form.commit.subtle'), + sound: soundTuning('commit.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 627ce883f..3a68ec7d9 100644 --- a/src/uix/sema/components/year-grid.ts +++ b/src/uix/sema/components/year-grid.ts @@ -13,7 +13,7 @@ export const yearGridSema: Sema = { cascade: [ { selector: onCell({ eventName: 'commit-set' }), - sound: soundTuning('form.commit.soft'), + sound: soundTuning('commit.soft'), haptic: { kind: 'tap' } } ] diff --git a/src/uix/sema/sounds-grammar.test.ts b/src/uix/sema/sounds-grammar.test.ts new file mode 100644 index 000000000..3aac7e0e4 --- /dev/null +++ b/src/uix/sema/sounds-grammar.test.ts @@ -0,0 +1,112 @@ +/** + * Census guard — the naming grammar of `SOUND_TUNINGS`. + * + * WHY THIS EXISTS. A tuning is a parametric delta over a FAMILY's base + * signature. Its name must say which family it modifies, because that is the + * only axis that is simultaneously true and mechanically checkable: the eight + * families are a closed set (`SEMA_FAMILIES`), a component name is not. + * + * THE DRIFT THAT PAID FOR IT. `form.commit.soft` / `form.commit.subtle` + * entered on 2026-05-19 (`09e261878`, the commit that created `sounds.ts`) + * naming seven form controls — checkbox, radio-group, stepper, tags-input, + * tag-group, file-upload, editable — truthfully. One week later + * `2d562f378` (2026-05-26) added `form.toggle.silent` for switch/toggle AND + * applied `form.commit.subtle` to menubar, navigation-menu, dropdown-menu, + * context-menu, tree-view and tree-grid. Consumers went 7 → 29 in a single + * commit, and by 2026-08-05 they were 50 — of which exactly ONE was the Form + * component. The doctrine written in that same commit (D.6) even spelled the + * right name, `commit.select`, on the very line that used the wrong key. + * + * Nothing caught it for two and a half months because nothing looked: + * `SoundTuningName = keyof typeof SOUND_TUNINGS` closes the set of keys but + * says nothing about their SHAPE, and no script referenced the catalogue. + * Renamed on 2026-08-05 (`form.*` → `commit.*`, `tooltip.silent` → + * `emerge.silent`, `tabs.select.soft` → `commit.select.soft`). + * + * WHAT IS NOT CHECKED, deliberately: the tail. `commit.silent` is applied to + * a slider's `commit-set` in `media-player.ts` as legitimately as to a + * switch's `commit-toggle` — the tail describes the resulting SIGNATURE, not + * the triggering event, so closing it against the verb vocabulary would + * manufacture a lie. There is no closed set for it in the codebase, and + * inventing one here would be exactly the copied-canon this guard exists to + * prevent. + */ + +import { describe, expect, it } from 'vitest'; + +import { SEMA_FAMILIES } from './event'; +import { SEMA_MAP } from './sema-map'; +import { SOUND_TUNINGS } from './sounds'; +import type { SemaFamily } from './types'; + +/** + * Keys allowed to break the grammar, each with the reason it earned the pass. + * Growing this list is a decision, not a convenience. + */ +const EXCEPTIONS: Record = {}; + +const TUNING_KEYS = Object.keys(SOUND_TUNINGS); + +/** The family base gain, read from the map — never copied into this file. */ +function baseGain(family: SemaFamily): number | undefined { + return SEMA_MAP.families[family]?.base?.sound?.gain; +} + +describe('SOUND_TUNINGS naming grammar', () => { + it('finds the catalogue (sanity: the census is not scanning an empty const)', () => { + expect(TUNING_KEYS.length).toBeGreaterThan(8); + }); + + it('heads every key with a sema family', () => { + const families = new Set(SEMA_FAMILIES); + const offenders = TUNING_KEYS.filter((key) => key in EXCEPTIONS === false).filter( + (key) => !families.has(key.split('.')[0]) + ); + expect( + offenders, + `a tuning is a delta on a FAMILY — head these keys with one of ${[...families].join(' | ')}` + ).toEqual([]); + }); + + it('gives every key a tail describing the signature', () => { + const malformed = TUNING_KEYS.filter((key) => { + const segments = key.split('.'); + return segments.length < 2 || segments.some((s) => !/^[a-z][a-z-]*$/.test(s)); + }); + expect(malformed, 'shape is `{family}.{tail}`, lowercase segments').toEqual([]); + }); + + it('makes every silencer subtract EXACTLY its family base gain', () => { + const silencers = TUNING_KEYS.filter((key) => key.endsWith('.silent')); + // The silence trick only exists because there is a base to cancel. + expect(silencers.length, 'the catalogue lost its silencers').toBeGreaterThan(0); + + const wrong: string[] = []; + for (const key of silencers) { + const family = key.split('.')[0] as SemaFamily; + const base = baseGain(family); + const gain = (SOUND_TUNINGS as Record)[key].gain; + if (base === undefined) { + wrong.push(`${key}: family '${family}' declares no base.sound.gain to cancel`); + continue; + } + if ( + typeof gain !== 'object' || + gain === null || + (gain as { op?: string }).op !== 'add' || + (gain as { value?: number }).value !== -base + ) { + wrong.push(`${key}: expected { op: 'add', value: ${-base} }, got ${JSON.stringify(gain)}`); + } + } + expect( + wrong, + 'a `.silent` tuning must land the family at gain 0 so intent deltas still surface' + ).toEqual([]); + }); + + it('keeps the exception list honest (every entry still names a live key)', () => { + const stale = Object.keys(EXCEPTIONS).filter((key) => !TUNING_KEYS.includes(key)); + expect(stale, 'these exceptions excuse keys that no longer exist — delete them').toEqual([]); + }); +}); diff --git a/src/uix/sema/sounds.ts b/src/uix/sema/sounds.ts index 63dde8356..a2ac27d79 100644 --- a/src/uix/sema/sounds.ts +++ b/src/uix/sema/sounds.ts @@ -148,6 +148,25 @@ export const SOUND_LIBRARY = { }) } as const satisfies Record; +/** + * Tuning catalogue — parametric deltas over a family's base signature. + * + * NAMING LAW (2026-08-05): the FIRST segment is the sema family whose base + * signature the tuning modifies — never a component, never a UI category. + * A tuning is a delta on a family, so the family is the only axis that is + * both true and mechanically checkable (`SEMA_FAMILIES` is a closed set; + * a component name is not). The tail is free prose describing the resulting + * SIGNATURE, deliberately NOT the triggering event: `commit.silent` is + * applied to a slider's `commit-set` in `media-player.ts` as legitimately as + * to a switch's `commit-toggle`, because what it names is the silence, not + * the gesture. + * + * Enforced by `sounds-grammar.test.ts`. Born from a real drift: `form.*` + * entered on 2026-05-19 naming seven form controls truthfully, was extended + * to menus and trees on 2026-05-26 (`2d562f378`, the very commit that wrote + * D.5/D.6) and reached 50 packs — one of which was the Form. No guard could + * see it because none looked at these keys. + */ export const SOUND_TUNINGS = { 'emerge.soft': { gain: 0.08 }, 'emerge.medium': { gain: 0.1 }, @@ -160,37 +179,45 @@ export const SOUND_TUNINGS = { gain: 0.05 }, 'emerge.dismiss.passive': { gain: 0.06 }, - 'form.commit.soft': { gain: 0.05 }, - 'form.commit.subtle': { gain: 0.03 }, + 'commit.soft': { gain: 0.05 }, + 'commit.subtle': { gain: 0.03 }, + 'commit.select.soft': { gain: 0.04 }, + /** + * Restrained but plainly audible commit — the toggles' voice (switch, + * toggle, toggle-group; author directive 2026-08-05, superseding the + * silence D.5 gave them in May). Sits between `commit.soft` (0.05) and the + * family base (0.3), mirroring the `emerge.soft/medium/strong` ladder: a + * flip is heard without competing with a button press (contact base 0.25). + */ + 'commit.medium': { gain: 0.1 }, /** - * Silent-by-default for high-frequency toggles (switch, toggle, - * toggle-group). Subtracts the `family.commit.base.sound.gain` (0.3), - * so neutral/affirm/risk/loss land at gain 0 (silent). Threat (+0.1) + * Silent-by-default for high-frequency commits (switch, toggle, + * toggle-group; also the player's scrubber and volume `commit-set`). + * Subtracts the `family.commit.base.sound.gain` (0.3), so + * neutral/affirm/risk/loss land at gain 0 (silent). Threat (+0.1) * and fulfill (+0.05) intent deltas still surface — destructive or - * culminating toggles emit a perceptual cue while everyday on/off + * culminating commits emit a perceptual cue while everyday on/off * stays mute. Avoids fatigue without erasing evaluative loading. */ - 'form.toggle.silent': { gain: { op: 'add', value: -0.3 } }, + 'commit.silent': { gain: { op: 'add', value: -0.3 } }, /** - * Silent-by-default for tooltips. Tooltips reveal on hover and would - * "puff" on every mouse cross if the family emerge gain (0.2) reached - * audible output. Subtracts the family base so neutral tooltips land - * at gain 0. Intent threat (+0.1) and fulfill (+0.05) still surface - * — a tooltip used as an error / success hint can still be heard. - * Tooltip is auxiliary presence, not signal; the silence is its - * canonical signature. + * Silent-by-default emergence, the tooltip's canonical signature. + * Tooltips reveal on hover and would "puff" on every mouse cross if the + * family emerge gain (0.2) reached audible output. Subtracts the family + * base so neutral emergences land at gain 0. Intent threat (+0.1) and + * fulfill (+0.05) still surface — a tooltip used as an error / success + * hint can still be heard. Auxiliary presence is not signal. */ - 'tooltip.silent': { gain: { op: 'add', value: -0.2 } }, + 'emerge.silent': { gain: { op: 'add', value: -0.2 } }, /** * Silent-by-default for contact inside sound-emitting components (the * media player's transport buttons — signed 2026-07-31, D-AP2.7 v2): the * component's own output IS audio, so a UI tick competes with the work by * definition (D.7). Subtracts the contact family base (0.25) so the press * lands at gain 0; intent deltas still surface, same shape as - * `form.toggle.silent` / `tooltip.silent`. + * `commit.silent` / `emerge.silent`. */ - 'contact.silent': { gain: { op: 'add', value: -0.25 } }, - 'tabs.select.soft': { gain: 0.04 } + 'contact.silent': { gain: { op: 'add', value: -0.25 } } } as const satisfies Record; export type SoundName = keyof typeof SOUND_LIBRARY; diff --git a/src/uix/soma/components/chat-message/README.md b/src/uix/soma/components/chat-message/README.md index e416b3ad7..e60ec5657 100644 --- a/src/uix/soma/components/chat-message/README.md +++ b/src/uix/soma/components/chat-message/README.md @@ -76,7 +76,7 @@ Both react directions are `affirm` per the undo matrix (CANON §3): removing an item from a collection is as active a choice as adding it. Delivery changes are app STATE (`data-delivery` via effects), not events. Pack: `sema/components/chat-message.ts` — high-frequency sobriety (D.5): -`form.commit.subtle` + light tap on both react directions; retry keeps the +`commit.subtle` + light tap on both react directions; retry keeps the family base. `expression: 'pack'`. ## Data attributes diff --git a/src/uix/soma/components/checkbox/README.md b/src/uix/soma/components/checkbox/README.md index 5a39191c1..0197bbefd 100644 --- a/src/uix/soma/components/checkbox/README.md +++ b/src/uix/soma/components/checkbox/README.md @@ -61,7 +61,7 @@ A control that allows the user to toggle between checked, unchecked, and optiona Two events instead of a single `commit-toggle`: the direction matters perceptually. `affirm` (the user confirms inclusion / agreement) maps to a brighter, slightly higher cue; `neutral` (revoking a state) has no evaluative load. Per book cap. 22 §10 (literal: "Toggle: contact.press → commit.toggle + affirm — el press registra; el toggle fija") — both share the canonical verb `commit.toggle`; direction is communicated by intent + `data-state`. -**Pack**: [`src/uix/sema/components/checkbox.ts`](../../../sema/components/checkbox.ts) — `form.commit.soft` (gain 0.05) + tap haptic on check; `form.commit.subtle` (gain 0.03) + tap on uncheck. Discrimination rides on the family/intent base — the pack does NOT override pitch / contour / gain from intent.deltas to preserve the per-intent perceptual signature. +**Pack**: [`src/uix/sema/components/checkbox.ts`](../../../sema/components/checkbox.ts) — `commit.soft` (gain 0.05) + tap haptic on check; `commit.subtle` (gain 0.03) + tap on uncheck. Discrimination rides on the family/intent base — the pack does NOT override pitch / contour / gain from intent.deltas to preserve the per-intent perceptual signature. ## Usage diff --git a/src/uix/soma/components/context-menu/README.md b/src/uix/soma/components/context-menu/README.md index 102600d7f..6ae89b8c4 100644 --- a/src/uix/soma/components/context-menu/README.md +++ b/src/uix/soma/components/context-menu/README.md @@ -166,7 +166,7 @@ Contract: [`docs/canon/direction-contract.md`](../../../../../docs/canon/directi Identical perceptual signature to `dropdown-menu` so the system reads as one consistent "menu sound" regardless of how the menu was triggered. Emission lives in `openAt()`, `handleClose()`, and `ContextMenuItemProvider.onclick/onkeydown`. -**Pack**: [`src/uix/sema/components/context-menu.ts`](../../../sema/components/context-menu.ts) — mirrors dropdown-menu (open: `emerge.soft`, close: `emerge.exit.soft`, item: `form.commit.subtle` + tap). +**Pack**: [`src/uix/sema/components/context-menu.ts`](../../../sema/components/context-menu.ts) — mirrors dropdown-menu (open: `emerge.soft`, close: `emerge.exit.soft`, item: `commit.subtle` + tap). ## Usage diff --git a/src/uix/soma/components/dropdown-menu/README.md b/src/uix/soma/components/dropdown-menu/README.md index 6bd94760f..3425d24c2 100644 --- a/src/uix/soma/components/dropdown-menu/README.md +++ b/src/uix/soma/components/dropdown-menu/README.md @@ -181,7 +181,7 @@ The open/close **sema firma** (`emerge`) plays on the content during the hold (s **Pack**: [`src/uix/sema/components/dropdown-menu.ts`](../../../sema/components/dropdown-menu.ts): - `open` → `emerge.soft` (soft chime, gain 0.08). - `close` → `emerge.exit.soft` (descending pitch + gain 0.05). -- `commit-select` → `form.commit.subtle` + light `tap` haptic. Softer than radio-group because close-emerge + select-commit + downstream-surface fire in quick succession; spreading intensity avoids double-tap fatigue. +- `commit-select` → `commit.subtle` + light `tap` haptic. Softer than radio-group because close-emerge + select-commit + downstream-surface fire in quick succession; spreading intensity avoids double-tap fatigue. ## Usage diff --git a/src/uix/soma/components/image-adjustments/README.md b/src/uix/soma/components/image-adjustments/README.md index 20547d9bb..f45cc5eda 100644 --- a/src/uix/soma/components/image-adjustments/README.md +++ b/src/uix/soma/components/image-adjustments/README.md @@ -63,7 +63,7 @@ intentionally out of scope. Per-slider drag feedback (`handle-drag`) is owned by the composed `` pack — ImageAdjustments doesn't re-emit it. Pack: `src/uix/sema/components/image-adjustments.ts` -(`form.commit.soft` + a `tap`). `sequence: 'post'` — the values mutate first, then the +(`commit.soft` + a `tap`). `sequence: 'post'` — the values mutate first, then the pulse. ## Props (Provider) diff --git a/src/uix/soma/components/menubar/README.md b/src/uix/soma/components/menubar/README.md index 12ba220bb..10e7cd3c6 100644 --- a/src/uix/soma/components/menubar/README.md +++ b/src/uix/soma/components/menubar/README.md @@ -223,7 +223,7 @@ Triggers use roving tabindex: one trigger has `tabindex="0"`, all others have `t Emission is centralized in `MenubarProvider.open(value)` so every path (mouse, keyboard, hover-follow) produces one stamp. The fallback target is the trigger element resolved through `triggerRegistry`. -**Pack**: [`src/uix/sema/components/menubar.ts`](../../../sema/components/menubar.ts) — `form.commit.subtle` (gain 0.03) + light `tap` haptic. High-frequency top-level traversal; softer than radio-group to avoid fatigue during multi-trigger scrubbing. See `LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.6. +**Pack**: [`src/uix/sema/components/menubar.ts`](../../../sema/components/menubar.ts) — `commit.subtle` (gain 0.03) + light `tap` haptic. High-frequency top-level traversal; softer than radio-group to avoid fatigue during multi-trigger scrubbing. See `LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.6. The menu's open/close and item selection live in the nested `DropdownMenu` (see its own README) — those events fire independently from this morfo's emission. diff --git a/src/uix/soma/components/navigation-menu/README.md b/src/uix/soma/components/navigation-menu/README.md index 975540b06..7521575b8 100644 --- a/src/uix/soma/components/navigation-menu/README.md +++ b/src/uix/soma/components/navigation-menu/README.md @@ -229,7 +229,7 @@ Disabled triggers and Links with no interactivity are included in navigation ord Emission lives in `NavigationMenuProvider.openNow(value)` — the central state-mutator covers every path (click, keyboard, hover-follow). Fallback target is the trigger element from `triggerRefs`; the cascade selector `[data-navigation-menu-item]` matches via `closest()`. -**Pack**: [`src/uix/sema/components/navigation-menu.ts`](../../../sema/components/navigation-menu.ts) — `form.commit.subtle` + light `tap` haptic. Same soft signature as `menubar` and items in `dropdown-menu` / `context-menu` for system-wide menu coherence. +**Pack**: [`src/uix/sema/components/navigation-menu.ts`](../../../sema/components/navigation-menu.ts) — `commit.subtle` + light `tap` haptic. Same soft signature as `menubar` and items in `dropdown-menu` / `context-menu` for system-wide menu coherence. ## i18n diff --git a/src/uix/soma/components/switch/README.md b/src/uix/soma/components/switch/README.md index 270863a28..2c7a8c229 100644 --- a/src/uix/soma/components/switch/README.md +++ b/src/uix/soma/components/switch/README.md @@ -60,7 +60,7 @@ Contract: [`docs/canon/direction-contract.md`](../../../../../docs/canon/directi | --------------- | -------- | -------- | ---------- | --------------------------------------------------------------------- | | `commit-toggle` | `commit` | `toggle` | `provider` | Switch toggled on/off. Intent from prop: `neutral`, `affirm`, `risk`, `threat`. | -**Pack**: [`src/uix/sema/components/switch.ts`](../../../sema/components/switch.ts) — silent-by-default (`form.toggle.silent`) + light `tap` haptic. High-frequency settings panels; intent-loaded toggles (threat) still surface audibly via `intent.deltas`. +**Pack**: [`src/uix/sema/components/switch.ts`](../../../sema/components/switch.ts) — silent-by-default (`commit.silent`) + light `tap` haptic. High-frequency settings panels; intent-loaded toggles (threat) still surface audibly via `intent.deltas`. ## Usage diff --git a/src/uix/soma/components/toggle-group/README.md b/src/uix/soma/components/toggle-group/README.md index 9aeb4f173..deae8227f 100644 --- a/src/uix/soma/components/toggle-group/README.md +++ b/src/uix/soma/components/toggle-group/README.md @@ -66,7 +66,7 @@ Contract: [`docs/canon/direction-contract.md`](../../../../../docs/canon/directi | --------------- | -------- | -------- | ------ | ------------------------------------------------------------------- | | `commit-toggle` | `commit` | `toggle` | `item` | Item clicked. Intent from prop: `neutral`, `affirm`, `risk`, `threat`. | -**Pack**: [`src/uix/sema/components/toggle-group.ts`](../../../sema/components/toggle-group.ts) — silent-by-default sound (`form.toggle.silent`) + light `tap` haptic. High-frequency segmented controls; intent-driven gain still surfaces (threat +0.1 audible). See `LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.5. +**Pack**: [`src/uix/sema/components/toggle-group.ts`](../../../sema/components/toggle-group.ts) — silent-by-default sound (`commit.silent`) + light `tap` haptic. High-frequency segmented controls; intent-driven gain still surfaces (threat +0.1 audible). See `LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.5. ## Usage diff --git a/src/uix/soma/components/tooltip/README.md b/src/uix/soma/components/tooltip/README.md index e7fe6e5c6..883e28c3d 100644 --- a/src/uix/soma/components/tooltip/README.md +++ b/src/uix/soma/components/tooltip/README.md @@ -83,7 +83,7 @@ Contract: [`docs/canon/direction-contract.md`](../../../../../docs/canon/directi | `close` | `emerge` | `close` | `content` | `pre` | Pointer-leave or blur closed it. | | `close-dismiss` | `emerge` | `dismiss` | `content` | `pre` | Escape key dismissal. | -**Pack**: [`src/uix/sema/components/tooltip.ts`](../../../sema/components/tooltip.ts) — **silent-by-default**. The pack tuning (`tooltip.silent`) subtracts the family.emerge gain entirely, so neutral tooltips emit at gain 0 (no sound) and no haptic. Intent threat / fulfill still surface via `intent.deltas` for the rare error/success tooltip variant. +**Pack**: [`src/uix/sema/components/tooltip.ts`](../../../sema/components/tooltip.ts) — **silent-by-default**. The pack tuning (`emerge.silent`) subtracts the family.emerge gain entirely, so neutral tooltips emit at gain 0 (no sound) and no haptic. Intent threat / fulfill still surface via `intent.deltas` for the rare error/success tooltip variant. Tooltips are auxiliary presence (not signal). Hovering is a continuous, ambient gesture — emitting audible feedback on every cursor sweep across a dense UI would cause immediate fatigue. The silence is doctrinal, not absence (see `LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.8 — tooltips as canonical example of "auxiliary, not signal"). diff --git a/src/uix/soma/components/tree-grid/README.md b/src/uix/soma/components/tree-grid/README.md index 1362979a9..03c903fe9 100644 --- a/src/uix/soma/components/tree-grid/README.md +++ b/src/uix/soma/components/tree-grid/README.md @@ -152,7 +152,7 @@ Per the [APG treegrid spec](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/): Unlike tree-view, tree-grid uses a single `Row` part for both leaves and branches — `data-has-children` + `data-expanded` disambiguate. The row element is resolved via `querySelector('[data-tree-grid-row][data-value="..."]')`. -**Pack**: [`src/uix/sema/components/tree-grid.ts`](../../../sema/components/tree-grid.ts) — same signature as tree-view (emerge.soft expand / emerge.exit.soft collapse / form.commit.subtle + tap on select). +**Pack**: [`src/uix/sema/components/tree-grid.ts`](../../../sema/components/tree-grid.ts) — same signature as tree-view (emerge.soft expand / emerge.exit.soft collapse / commit.subtle + tap on select). ## Comparison diff --git a/src/uix/soma/components/tree-view/README.md b/src/uix/soma/components/tree-view/README.md index d6765c8d4..5591de859 100644 --- a/src/uix/soma/components/tree-view/README.md +++ b/src/uix/soma/components/tree-view/README.md @@ -133,7 +133,7 @@ Contract: [`docs/canon/direction-contract.md`](../../../../../docs/canon/directi **Pack**: [`src/uix/sema/components/tree-view.ts`](../../../sema/components/tree-view.ts): - `emerge-expand` → `emerge.soft` (gain 0.08), same as Accordion expand. - `emerge-collapse` → `emerge.exit.soft` (descending pitch + gain 0.05). -- `commit-select` → `form.commit.subtle` + light `tap` haptic. Two cascade rules (one on `item`, one on `branch`) so both leaf and branch selection share the perceptual signature. +- `commit-select` → `commit.subtle` + light `tap` haptic. Two cascade rules (one on `item`, one on `branch`) so both leaf and branch selection share the perceptual signature. ## Behavior