feat(web): /uix/docs/sound — la pagina que explica el canal del sonido

Primera de las ocho del plan, en castellano y DERIVADA del codigo vivo: las
tablas leen SEMA_MAP y el resolver real, nunca una transcripcion. Una pagina
que copiara el catalogo envejeceria al primer gain que alguien tocara — que
es exactamente la deriva que este subsistema se reconstruyo para quitar.

SIETE SECCIONES, ordenadas para responder «no se como funciona esto despues
de tantas correcciones»: (1) en una frase — un componente dice una palabra;
(2) el catalogo, los 16 nombres sonando al pulsarlos, agrupados por la FORMA
de cada entrada y no por una lista a mano; (3) el orden con los numeros de
verdad, tabla viva de familia x nombre x intent capa a capa; (4) que puede
llevar un .wav; (5) que no te toca escribir; (6) las correcciones y por que
—la historia ES la explicacion, ocultarla es lo que dejo al autor sin saber
como funciona—; (7) el tema.

EL BLOQUEANTE, PRIMERO. El interruptor Sound del topbar no silenciaba NADA:
el layout no pasaba `preferences`, asi que los canales se quedaban en 'full'
y el switch solo movia el intent de prefs (que gobierna el lado visual).
Publicar paginas que suenan con un mute que miente es peor que no
publicarlas. Ahora pasa un objeto VIVO — ambos canales leen
`preferences.{sound,haptic}` por emision, asi que mutarlo silencia al
instante sin re-registrar nada. Cierra §3.3 de la cola.

MEDIDO EN NAVEGADOR, no supuesto: 7 secciones, 17 chips, la rejilla y los
chips con su CSS aplicado, y la tabla de capas moviendose de verdad — con
`fulfill` el gain pasa de 0.05 a 0.1, el pitch de 700 a 1000 y el contour de
flat a ascending. Es decir: la pagina DEMUESTRA en vivo que el intent
sobrevive al nombre, que es la tesis del rediseño entero.

⚠️ NO he podido hacer captura: el panel del navegador no esta visible en esta
sesion. Verificado por arbol de accesibilidad, consola y estilos computados
— pero no lo he MIRADO. Queda pendiente tu ojo.

⚠️ Corregido de paso un fallo propio: la sonda del resolver usaba `document`
en el render de servidor y la pagina daba 500. Ahora cae a un stub en SSR.

El nav estrena seccion «El sistema perceptual». Quedan las otras siete
paginas, y los seis enlaces muertos que Foundations ya declaraba
(/uix/architecture, /uix/tokens, /uix/morfo, /uix/sema, /uix/eidos,
/uix/getting-started) siguen sin pagina.

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

@ -23,6 +23,7 @@
import { imagePickerSema } from '$uix/sema/components/image-picker';
import { knobSema } from '$uix/sema/components/knob';
import { maskFieldSema } from '$uix/sema/components/mask-field';
import type { SemaPreferences } from '$uix/sema';
import { navTreeSema } from '$uix/sema/components/nav-tree';
import { sidebarSema } from '$uix/sema/components/sidebar';
import { numberFieldSema } from '$uix/sema/components/number-field';
@ -116,12 +117,25 @@
}
});
/**
* The perceptual reduction levels the sema channels read. A LIVE object:
* both channels read `preferences.{sound,haptic}` on every emit, so
* mutating it silences immediately with no channel re-registration.
*
* Until 2026-08-06 this was not passed at all, so the channels defaulted to
* `'full'` and the topbar's Sound switch silenced NOTHING — it set the prefs
* intent (which drives the visual/CSS side) and stopped there. A mute that
* does not mute is worse on a page that teaches sound than no mute at all.
*/
const semaPreferences: SemaPreferences = $state({ sound: 'full', haptic: 'full' });
const uix = createActiveUix({
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
prefs: { schema: docsPrefsSchema },
events: {
sound: true,
haptic: true,
preferences: semaPreferences,
components: [
accordionSema,
calendarSema,
@ -226,6 +240,11 @@
{ slug: '/uix/eidos', label: 'Eidos · visual layer' }
]
},
{
kind: 'group',
heading: 'El sistema perceptual',
items: [{ slug: '/uix/docs/sound', label: 'El sonido' }]
},
{
kind: 'group',
heading: 'Controls',
@ -665,6 +684,10 @@
uix.prefs.setIntent('sound', soundOn ? 'allow' : 'reduce');
uix.prefs.setIntent('motion', motionOn ? 'allow' : 'reduce');
uix.prefs.setIntent('haptic', hapticOn ? 'allow' : 'reduce');
// And the channels themselves: the prefs intent drives the visual side,
// this drives whether anything is heard or felt at all.
semaPreferences.sound = soundOn ? 'full' : 'off';
semaPreferences.haptic = hapticOn ? 'full' : 'off';
});
// Project the display axes onto :root through a managed <style> (id-keyed, so

@ -0,0 +1,333 @@
<script lang="ts">
/**
* `/uix/docs/sound` — how the sound channel actually works.
*
* DERIVED, NEVER TRANSCRIBED. Every table on this page reads the live
* `SEMA_MAP` and the real `resolveSignature`. A page that copied the
* catalogue would go stale the first time someone edited a gain — which is
* exactly the drift this whole subsystem was rebuilt to remove.
*
* Audible: the ▶ buttons emit through the real engine, so the topbar's
* Sound switch silences them (it now passes `preferences` to the channels).
*/
import { getActiveUix } from '$active-uix';
import { SEMA_MAP, resolveSignature, type SoundName } from '$uix/sema';
const uix = getActiveUix();
const NAMES = Object.keys(SEMA_MAP.sounds) as SoundName[];
const FAMILIES = Object.keys(SEMA_MAP.families) as (keyof typeof SEMA_MAP.families)[];
const INTENTS = Object.keys(SEMA_MAP.intents) as (keyof typeof SEMA_MAP.intents)[];
/** Groups, read off the catalogue's own shape — no hand-kept list. */
function groupOf(name: string): 'level' | 'direction' | 'character' | 'sample' {
const entry = SEMA_MAP.sounds[name] as Record<string, unknown>;
if (entry.kind === 'sample') return 'sample';
if ('contour' in entry && 'pitch' in entry) return 'direction';
return Object.keys(entry).length === 1 ? 'level' : 'character';
}
const GROUP_LABEL = {
level: 'Nivel — cuánto pesa',
direction: 'Dirección — sube o baja',
character: 'Carácter — el timbre del gesto',
sample: 'Muestras — un fichero, no una receta'
} as const;
// ── The live probe ───────────────────────────────────────────────────────
let family = $state<string>('commit');
let sound = $state<string>('soft');
let intent = $state<string>('neutral');
let stage = $state<HTMLElement | null>(null);
/**
* Resolve with the REAL resolver, layer by layer, so the table below shows
* the same arithmetic the engine does rather than a retelling of it.
*/
function resolveAt(step: 'base' | 'name' | 'intent') {
// SSR-safe: the resolver only needs a target to run `matches()` against,
// and there is no DOM on the server. A stub keeps the table rendering
// server-side (the numbers are the same — the cascade below matches `*`).
const target = (stage ??
(typeof document !== 'undefined'
? document.createElement('span')
: ({ matches: () => true, closest: () => null } as unknown))) as HTMLElement;
const cascade = step === 'base' ? [] : [{ selector: '*', sound: sound as SoundName }];
return resolveSignature(
{
name: 'probe',
family: family as never,
target,
...(step === 'intent' && intent !== 'neutral' ? { intent: intent as never } : {})
},
{ map: SEMA_MAP, cascade: cascade as never }
).sound;
}
const layers = $derived([
{
label: '1 · base de familia',
tone: 'La identidad: su timbre, su altura.',
sig: resolveAt('base')
},
{
label: `1.5 · el nombre «${sound}»`,
tone: 'Lo único que escribe el componente.',
sig: resolveAt('name')
},
{
label: `2 · intent «${intent}»`,
tone: 'La carga evaluativa. Llega la última.',
sig: resolveAt('intent')
}
]);
const AXES = ['gain', 'pitch', 'contour', 'centroid', 'roughness', 'duration'] as const;
function play(name: string) {
if (!stage) return;
void uix.events?.emit({
name: 'probe',
family: family as never,
target: stage,
...(intent !== 'neutral' ? { intent: intent as never } : {})
});
void name;
}
function playName(name: string) {
sound = name;
play(name);
}
/** The two sample entries, for the §4 A/B. */
const sampleNames = NAMES.filter((n) => groupOf(n) === 'sample');
</script>
<div data-uix-canvas-inner>
<div data-uix-eyebrow>Sema · el canal del sonido</div>
<h1 data-uix-page-title>Un componente dice una palabra. Nada más.</h1>
<p data-uix-page-lede>
Toda la superficie de autoría del sonido es <code>sound: 'soft'</code> — un nombre del catálogo,
o <code>SILENT</code>. Que ese nombre sea una receta sintetizada o un
<code>.wav</code> no es asunto del componente: es del catálogo. Esta página está
<strong>derivada del código vivo</strong>, no transcrita: las tablas leen
<code>SEMA_MAP</code> y el resolver real.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>nombres</span>{NAMES.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>familias</span>{FAMILIES.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>intents</span>{INTENTS.length}</span>
</div>
<!-- The emit target. Everything audible on this page fires against it. -->
<div bind:this={stage} data-uix-sound-stage aria-hidden="true"></div>
<section data-uix-section>
<h2 data-uix-section-title>1 · En una frase</h2>
<p data-uix-section-desc>
Un pack de componente no autora una firma: <strong>nombra</strong> 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.
</p>
<pre data-uix-code><code
>{`// 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' }
]
};`}</code
></pre>
</section>
<section data-uix-section>
<h2 data-uix-section-title>2 · El catálogo</h2>
<p data-uix-section-desc>
Los {NAMES.length} nombres que existen. Pulsa cualquiera para oírlo con la familia y el intent que
tengas seleccionados abajo. <em>Requiere el interruptor «Sound» del topbar encendido.</em>
</p>
{#each ['level', 'direction', 'character', 'sample'] as const as group}
{@const names = NAMES.filter((n) => groupOf(n) === group)}
{#if names.length > 0}
<h3 data-uix-sound-group>{GROUP_LABEL[group]}</h3>
<div data-uix-sound-grid>
{#each names as name}
<button
type="button"
data-uix-sound-chip
data-active={name === sound ? '' : undefined}
onclick={() => playName(name)}
>
<span data-uix-sound-chip-name>{name}</span>
<span data-uix-sound-chip-play>▶</span>
</button>
{/each}
</div>
{/if}
{/each}
</section>
<section data-uix-section>
<h2 data-uix-section-title>3 · El orden, con los números de verdad</h2>
<p data-uix-section-desc>
Esto es lo que hace legible todo lo demás. La familia pone la
<strong>identidad</strong>, el nombre pone
<strong>nivel, dirección y carácter</strong>, y el
<strong>intent llega el último</strong> — por eso su carga evaluativa siempre se oye. Cambia los
tres y mira cómo se mueve cada eje.
</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>familia</span>
<select bind:value={family}>
{#each FAMILIES as f}<option value={f}>{f}</option>{/each}
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>nombre</span>
<select bind:value={sound}>
{#each NAMES as n}<option value={n}>{n}</option>{/each}
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>intent</span>
<select bind:value={intent}>
{#each INTENTS as i}<option value={i}>{i}</option>{/each}
</select>
</label>
<button type="button" data-uix-sound-chip onclick={() => play(sound)}>
<span data-uix-sound-chip-name>oír el resultado</span>
<span data-uix-sound-chip-play>▶</span>
</button>
</div>
<div data-uix-sound-table>
<table>
<thead>
<tr>
<th>capa</th>
{#each AXES as axis}<th>{axis}</th>{/each}
</tr>
</thead>
<tbody>
{#each layers as layer}
<tr>
<th scope="row">
<span data-uix-sound-layer>{layer.label}</span>
<span data-uix-sound-layer-note>{layer.tone}</span>
</th>
{#each AXES as axis}
<td>{layer.sig ? (layer.sig[axis] ?? '—') : '—'}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc>
Si la familia no declara sonido (<code>handle</code>, <code>sustain</code>,
<code>delegate</code>) las tres filas salen vacías: ahí el gesto suena por otra vía, no por el
catálogo.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>4 · Qué puede llevar un <code>.wav</code></h2>
<p data-uix-section-desc>
Esto es física, no política. <code>playSample</code> lee
<strong>exactamente dos campos</strong> de la firma: <code>sampleUrl</code> y
<code>gain</code>. No hay <code>playbackRate</code> ni <code>detune</code>. Luego sobre una
muestra el intent <strong>sólo puede mover el volumen</strong>: un
<code>risk</code> es indistinguible de un <code>neutral</code>, porque lo único que aporta es
<code>roughness</code> y la ruta de sample lo ignora.
</p>
<p data-uix-section-desc>
Compruébalo: elige <code>{sampleNames[0]}</code> arriba y ve cambiando el intent. El
<code>gain</code> se mueve; el resto de ejes se resuelven pero no se oyen.
</p>
<p data-uix-section-desc>
<strong>Consecuencia de diseño</strong>: una muestra que deba llevar carga evaluativa necesita
<strong>un fichero por intent</strong>. Es lo que hace la familia <code>signal</code> con
<code>ping</code>
frente a <code>error</code>.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>5 · Lo que no te toca escribir</h2>
<p data-uix-section-desc>
Ningún parámetro. Nunca. Ni un <code>gain</code>, ni un <code>centroid</code>, ni un
<code>op: 'add'</code>. Si un componente necesita algo que el catálogo no lleva, eso es una
decisión <strong>del catálogo</strong>: se toma una vez, en un fichero, y el siguiente
componente la reutiliza.
</p>
<pre data-uix-code><code
>{`// ✗ lo que ya no compila
sound: { gain: { op: 'add', value: -0.25 }, centroid: { op: 'add', value: 2800 } }
// ✓ lo único que se escribe
sound: 'settle'`}</code
></pre>
<p data-uix-section-desc>
Añadir un sonido es <strong>añadir un nombre al catálogo</strong>, jamás ensanchar una regla.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>6 · Por qué es así — las correcciones</h2>
<p data-uix-section-desc>
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:
</p>
<ul data-uix-prose-list>
<li>
<strong>214 reglas autoraban 33 firmas distintas</strong>, y <strong>92</strong> de ellas (el
43 %) no decían más que un volumen.
</li>
<li>
<strong>69 reglas pisaban el <code>contour</code> del intent</strong> — y no podían hacer otra
cosa: es un enum, sólo admite reemplazo.
</li>
<li>
Cinco packs llevaban un <code>centroid +2800</code> copiado que dejaba su
<code>commit</code> en 4600 Hz, más brillante que <code>signal</code>, la familia más
brillante del mapa. Era el residuo aritmético de una muestra borrada.
</li>
</ul>
<p data-uix-section-desc>
Y la pieza que lo explica todo: <strong
>los packs corrían los últimos y en modo reemplazo</strong
>, así que el sonido elegido por el componente borraba el perfil evaluativo que el intent
acababa de poner — un diálogo con <code>threat</code> sonaba exactamente igual que uno neutro.
Existían tres mecanismos para vigilar esa inversión. Los tres desaparecieron: con el nombre
<em>debajo</em>
del intent, la inversión no está prohibida, es <strong>inexpresable</strong>.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>7 · Y el tema</h2>
<p data-uix-section-desc>
El catálogo vive dentro de <code>SEMA_MAP</code>, que es la estructura que un tema direcciona.
Así que la voz del sistema se puede cambiar en caliente y revertir:
</p>
<pre data-uix-code><code
>{`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 } });`}</code
></pre>
<p data-uix-section-desc>
Lo que <strong>no</strong> se abre es el vocabulario: <code>SoundName</code> es
<code>keyof</code> del catálogo, así que un tema cambia
<em>a qué suena</em> un nombre y no puede inventar uno que ningún componente pueda referenciar.
La voz se abre; las palabras no.
</p>
</section>
</div>

@ -1765,3 +1765,127 @@ samp {
grid-template-columns: minmax(0, 1fr);
gap: var(--uix-space-2);
}
/* ── /uix/docs/sound — the catalogue grid + the layer table ───────────────
Hooks for the sound documentation page. Same token vocabulary as the rest
of the shell, so the page inherits density, scaling and theme for free. */
[data-uix-sound-stage] {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
[data-uix-sound-group] {
margin-block: var(--uix-space-6) var(--uix-space-3);
font-size: var(--uix-text-2xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--uix-text-muted);
}
[data-uix-sound-grid] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
gap: var(--uix-space-2);
}
[data-uix-sound-chip] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--uix-space-3);
padding: var(--uix-space-3) var(--uix-space-4);
border: 1px solid var(--uix-line);
border-radius: var(--uix-radius-md);
background: var(--uix-surface);
color: var(--uix-text);
font-family: var(--uix-font-mono);
font-size: var(--uix-text-2xs);
cursor: pointer;
transition:
border-color var(--uix-duration-fast) var(--uix-ease),
background var(--uix-duration-fast) var(--uix-ease);
}
[data-uix-sound-chip]:hover {
border-color: var(--uix-line-strong);
background: var(--uix-surface-overlay);
}
[data-uix-sound-chip][data-active] {
border-color: var(--uix-accent-line);
background: var(--uix-accent-soft);
color: var(--uix-accent-text);
}
[data-uix-sound-chip-play] {
color: var(--uix-text-faint);
}
[data-uix-sound-chip]:hover [data-uix-sound-chip-play] {
color: var(--uix-accent);
}
[data-uix-sound-table] {
margin-block-start: var(--uix-space-5);
overflow-x: auto;
border: 1px solid var(--uix-line);
border-radius: var(--uix-radius-lg);
}
[data-uix-sound-table] table {
width: 100%;
border-collapse: collapse;
font-family: var(--uix-font-mono);
font-size: var(--uix-text-2xs);
}
[data-uix-sound-table] th,
[data-uix-sound-table] td {
padding: var(--uix-space-3) var(--uix-space-4);
text-align: start;
border-block-end: 1px solid var(--uix-line-soft);
}
[data-uix-sound-table] thead th {
color: var(--uix-text-muted);
font-weight: 600;
background: var(--uix-surface-sunken);
}
[data-uix-sound-table] tbody tr:last-child th,
[data-uix-sound-table] tbody tr:last-child td {
border-block-end: 0;
}
[data-uix-sound-table] tbody th {
font-weight: 400;
white-space: nowrap;
}
[data-uix-sound-layer] {
display: block;
color: var(--uix-text);
}
[data-uix-sound-layer-note] {
display: block;
margin-block-start: var(--uix-space-1);
font-family: var(--uix-font-sans);
color: var(--uix-text-faint);
}
[data-uix-prose-list] {
margin-block: var(--uix-space-4);
padding-inline-start: var(--uix-space-6);
line-height: var(--uix-leading-prose);
color: var(--uix-text-muted);
}
[data-uix-prose-list] li {
margin-block-end: var(--uix-space-2);
}

Loading…
Cancel
Save

Powered by TurnKey Linux.