docs(web): cuatro paginas del sonido, escritas para quien llega de nuevas

Correccion del autor sobre la primera version: «la redaccion esta mal,
empieza como si la gente supiera ya que es lo que has pasado, la redaccion
tiene que ser didactica para un nuevo desarrollador». Tenia razon — estaban
escritas como notas de version para quien vivio el rediseño: se abrian
presumiendo el vocabulario y presumian de cuantas reglas se habian borrado,
que es mi historia, no documentacion.

Reescritas desde cero, en ese orden: primero la escena («guardas un
documento y oyes un repique»), luego el vocabulario construido pieza a
pieza (familia, verbo, intent) y solo entonces la maquinaria. Cada
concepto se explica antes de usar su palabra.

  /uix/docs/sound            como suena una interfaz — el modelo, con la
                             resolucion en vivo (familia + verbo + intent)
  /uix/docs/sound/catalogo   cada sonido que existe, audible, con la razon
                             de por que suena asi y que significa cada eje
  /uix/docs/sound/packs      traer tu propia voz: sintesis, wav, mp3,
                             registrar nombres nuevos, cambiar en caliente
  /uix/docs/sound/gestos     por que un arrastre suena por repeticion, con
                             un simulador de tres velocidades para oirlo

DERIVADAS DEL CODIGO: los sonidos, familias, intents y verbos salen de
`SEMA_MAP` y del resolver real. Solo la prosa esta escrita a mano, asi que
añadir una entrada al catalogo la hace aparecer con sus medidas.

Lo historico no desaparece, cambia de sitio: donde aporta —por que los
sonidos son tan distintos entre si, que se gana y que se pierde con el
trinquete— va al final de su seccion como razon, nunca como apertura.

VERIFICADO: las cuatro responden 200 en SSR con sus secciones · check sin
errores nuevos en las paginas.

⚠️ Sigo sin poder mirarlas: el panel del navegador no compone en esta
sesion, asi que la composicion visual queda pendiente de tu ojo.

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

@ -242,8 +242,13 @@
},
{
kind: 'group',
heading: 'El sistema perceptual',
items: [{ slug: '/uix/docs/sound', label: 'El sonido' }]
heading: 'El sonido',
items: [
{ slug: '/uix/docs/sound', label: 'Cómo suena una interfaz' },
{ slug: '/uix/docs/sound/catalogo', label: 'El catálogo' },
{ slug: '/uix/docs/sound/packs', label: 'Sound packs' },
{ slug: '/uix/docs/sound/gestos', label: 'Gestos' }
]
},
{
kind: 'group',

@ -502,7 +502,7 @@
Three events: <code>commit-select-react</code> / <code>commit-unselect-react</code>
(both <strong>affirm</strong> — the undo matrix: removing a reaction is as active a
choice as adding it) and <code>commit-submit-retry</code> (neutral). The pack keeps
reactions subtle (high-frequency doctrine): <code>form.commit.subtle</code> + a light
reactions subtle (high-frequency doctrine): <code>commit.subtle</code> + a light
tap.
</p>
<SemaPanel

@ -1,12 +1,11 @@
<script lang="ts">
/**
* `/uix/docs/sound` — how the sound channel works.
* `/uix/docs/sound` — the entry point for the sound channel.
*
* DERIVED, NEVER TRANSCRIBED: every table reads the live `SEMA_MAP` and the
* real `resolveSignature`.
*
* Audible: the ▶ buttons emit through the real engine, so the topbar's Sound
* switch silences them (the layout passes `preferences` to the channels).
* Written for someone who has never seen this framework: it introduces the
* vocabulary (family, verb, intent) BEFORE using it, and only names the
* machinery once the reader needs the word. Tables are derived from the
* live `SEMA_MAP` and the real resolver, so they cannot go stale.
*/
import { getActiveUix } from '$active-uix';
import { SEMA_MAP, resolveSignature } from '$uix/sema';
@ -14,8 +13,6 @@
const uix = getActiveUix();
const NAMES = Object.keys(SEMA_MAP.sounds);
const BASES = NAMES.filter((n) => !n.includes('.'));
const VARIANTS = NAMES.filter((n) => n.includes('.'));
const FAMILIES = Object.keys(SEMA_MAP.families);
const INTENTS = Object.keys(SEMA_MAP.intents);
@ -24,12 +21,10 @@
let intent = $state('neutral');
let stage = $state<HTMLElement | null>(null);
/** The verbs the map gives a cell of its own, for the family in hand. */
const verbCells = $derived(
Object.keys(SEMA_MAP.families[family as never]?.sounds ?? {}).filter((k) => k !== 'default')
);
/** Step 1 — which NAME does this occurrence get? */
const chosenName = $derived.by(() => {
const table = SEMA_MAP.families[family as never]?.sounds as
| Record<string, string | undefined>
@ -37,7 +32,6 @@
return (verb ? table?.[verb] : undefined) ?? table?.default;
});
/** Step 2 — which ENTRY does the intent select? */
const chosenEntry = $derived.by(() => {
if (!chosenName) return undefined;
const withIntent = `${chosenName}.${intent}`;
@ -62,8 +56,6 @@
).sound;
});
const AXES = ['pitch', 'centroid', 'duration', 'contour', 'roughness', 'gain'] as const;
function play(name?: string) {
if (!stage) return;
void uix.events?.emit({
@ -79,73 +71,96 @@
<div data-uix-canvas-inner>
<div data-uix-eyebrow>Sema · el canal del sonido</div>
<h1 data-uix-page-title>Un evento y un intent eligen un sonido. Y ya está.</h1>
<h1 data-uix-page-title>Cómo suena una interfaz.</h1>
<p data-uix-page-lede>
Nada modula nada. El sonido no se deforma, se <strong>elige</strong>: hay un catálogo de sonidos
completos, y el par (evento, intent) señala uno. Si no existe la variante, se desprecia el
intent. Si no hay ninguno, no suena nada. Esta página lee
<code>SEMA_MAP</code> y el resolver reales — no es una transcripción.
Guardas un documento y oyes un pequeño repique. Lo borras y oyes algo más grave. Nadie escribió
«reproduce este fichero» en el botón de guardar: el componente sólo
<strong>describió lo que estaba pasando</strong>, y el sistema eligió el sonido. Esta página
explica cómo, desde cero.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>bases</span>{BASES.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>variantes</span>{VARIANTS.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>reglas en 71 packs</span>30</span>
</div>
<div bind:this={stage} data-uix-sound-stage aria-hidden="true"></div>
<section data-uix-section>
<h2 data-uix-section-title>1 · La regla entera</h2>
<h2 data-uix-section-title>1 · Un componente describe, no decide</h2>
<p data-uix-section-desc>
Cuando algo ocurre en un componente —se pulsa, se guarda, se abre un panel— ese componente <strong
>declara qué clase de suceso es</strong
>. No dice cómo suena, ni de qué color se pone, ni cómo se anima. Sólo lo describe, con tres
palabras:
</p>
<ul data-uix-prose-list>
<li>
<strong>La familia</strong> — a qué clase de suceso pertenece. Hay
{FAMILIES.length}, y son un conjunto cerrado: <code>contact</code> (algo se ha tocado),
<code>commit</code>
(algo ha quedado hecho), <code>signal</code> (el sistema avisa), <code>emerge</code> (algo
aparece o desaparece),
<code>shift</code> (el marco cambia), <code>handle</code> (un gesto continuo), y dos más que no
suenan.
</li>
<li>
<strong>El verbo</strong> — qué se hizo exactamente dentro de esa familia:
<code>save</code>, <code>delete</code>, <code>open</code>, <code>close</code>…
</li>
<li>
<strong>El intent</strong> — la carga: ¿salió bien, es arriesgado, es peligroso, se ha
perdido algo? Hay {INTENTS.length}, empezando por <code>neutral</code>.
</li>
</ul>
<pre data-uix-code><code
>{`nombre = per-emit ?? cascada-app ?? pack ?? morfo ?? familia[verbo] ?? familia.default
sonido = pack[nombre.intent] ?? pack[nombre] ?? nada`}</code
>{`// El contrato de un componente. No hay ni un sonido aquí.
{
name: 'commit-save',
semantic: { family: 'commit', verb: 'save', intent: 'neutral' }
}`}</code
></pre>
<p data-uix-section-desc>
Dos búsquedas. No hay capas que se pisen, ni deltas, ni aritmética. Y el caso normal <strong
>no escribe nada</strong
>: el mapa ya dice que un
<code>commit</code> hace <code>tick</code> y que un <code>emerge</code> con verbo
<code>close</code> hace <code>close</code>. Un pack sólo habla cuando
<em>difiere</em> — por eso de las ~165 reglas de sonido que había quedan
<strong>30</strong>, en 16 de los 71 packs.
Esa separación es la idea entera: el componente sabe <em>qué pasó</em>, y el sistema sabe
<em>a qué suena eso</em>. Cambiar la voz de toda la aplicación no obliga a tocar ni un
componente.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>2 · El catálogo</h2>
<h2 data-uix-section-title>2 · El sistema elige — dos pasos</h2>
<p data-uix-section-desc>
Cada entrada es un sonido <strong>diseñado entero</strong>, no un modificador. Un guard exige
que dos entradas se separen en al menos <strong>dos ejes</strong>
perceptuales — es el test que faltaba cuando todos sonaban igual.
Con esa descripción en la mano, el sistema hace <strong>dos búsquedas</strong> y nada más.
</p>
<h3 data-uix-sound-group>Bases</h3>
<div data-uix-sound-grid>
{#each BASES as name}
<button type="button" data-uix-sound-chip onclick={() => play(name)}>
<span data-uix-sound-chip-name>{name}</span>
<span data-uix-sound-chip-play>▶</span>
</button>
{/each}
</div>
<h3 data-uix-sound-group>Variantes por intent</h3>
<div data-uix-sound-grid>
{#each VARIANTS as name}
<button type="button" data-uix-sound-chip onclick={() => play(name)}>
<span data-uix-sound-chip-name>{name}</span>
<span data-uix-sound-chip-play>▶</span>
</button>
{/each}
</div>
<p data-uix-section-desc>
Un pack que sólo trae bases funciona entero: donde no haya variante, el intent se ignora. Un
pack cuidado añade variantes sólo donde le importa.
<strong>Primero, el nombre.</strong> Cada familia tiene una tabla que dice a qué suena, y
puede afinar por verbo. Un <code>commit</code> suena a
<code>tick</code>; un <code>emerge</code> suena a <code>open</code>, salvo que el verbo sea
<code>close</code>, en cuyo caso suena a <code>close</code>. Un componente normal no escribe
nada de esto: ya está en el sistema.
</p>
<p data-uix-section-desc>
<strong>Después, el intent.</strong> Con el nombre ya elegido, se busca si existe una versión
de ese sonido para este intent. <code>tick</code> + <code>threat</code>
encuentra <code>tick.threat</code>, que es un sonido distinto: más grave, más rasposo y
descendente. Si esa versión <em>no</em> existe —por ejemplo
<code>tick.affirm</code>— el intent simplemente se ignora y suena el
<code>tick</code> normal. Y si no hubiera ni siquiera <code>tick</code>, no sonaría nada; el
silencio es una respuesta válida.
</p>
<pre data-uix-code><code
>{`nombre = la tabla de la familia, afinada por verbo
sonido = catálogo[nombre.intent] ?? catálogo[nombre] ?? nada`}</code
></pre>
<p data-uix-section-desc>
Lo importante de este diseño: <strong>nada se deforma</strong>. Un intent no «hace más grave»
un sonido — <em>elige otro</em>, diseñado entero por separado. Es como funciona el
reconocimiento: distinguimos sonidos, no desplazamientos de parámetro.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>3 · Pruébalo</h2>
<h2 data-uix-section-title>3 · Míralo funcionar</h2>
<p data-uix-section-desc>
Elige familia, verbo e intent, y mira cómo se resuelve el nombre y qué entrada acaba sonando.
Elige una familia, un verbo y un intent, y observa las dos búsquedas. Para oírlo, enciende <em
>Sound</em
>
en el menú <em>Semantics</em> de la barra superior.
</p>
<div data-uix-controls>
@ -158,7 +173,7 @@ sonido = pack[nombre.intent] ?? pack[nombre] ?? nada`}</code
<label data-uix-control>
<span data-uix-control-label>verbo</span>
<select bind:value={verb}>
<option value="">(sin celda propia)</option>
<option value="">(cualquiera)</option>
{#each verbCells as v}<option value={v}>{v}</option>{/each}
</select>
</label>
@ -176,79 +191,90 @@ sonido = pack[nombre.intent] ?? pack[nombre] ?? nada`}</code
<div data-uix-sound-table>
<table>
<thead>
<tr><th>paso</th><th colspan={AXES.length}>resultado</th></tr>
</thead>
<tbody>
<tr>
<th scope="row">
<span data-uix-sound-layer>1 · el nombre</span>
<span data-uix-sound-layer-note>familia[verbo] ?? familia.default</span>
<span data-uix-sound-layer>Paso 1 — el nombre</span>
<span data-uix-sound-layer-note>lo dice la tabla de la familia</span>
</th>
<td colspan={AXES.length}>{chosenName ?? '— esta familia no nombra sonido'}</td>
<td>{chosenName ?? 'esta familia no suena'}</td>
</tr>
<tr>
<th scope="row">
<span data-uix-sound-layer>2 · la entrada</span>
<span data-uix-sound-layer-note>nombre.intent ?? nombre</span>
<span data-uix-sound-layer>Paso 2 — el sonido</span>
<span data-uix-sound-layer-note>¿hay versión para este intent?</span>
</th>
<td colspan={AXES.length}>{chosenEntry ?? '— no suena nada'}</td>
<td>
{#if !chosenEntry}
no suena nada
{:else if chosenEntry.includes('.')}
{chosenEntry} — sí, hay versión propia
{:else}
{chosenEntry} — no hay versión, se ignora el intent
{/if}
</td>
</tr>
<tr>
<th scope="row"><span data-uix-sound-layer>lo que suena</span></th>
{#each AXES as axis}
<td>{resolved ? (resolved[axis] ?? '—') : '—'}</td>
{/each}
</tr>
<tr>
<th scope="row"><span data-uix-sound-layer-note>eje</span></th>
{#each AXES as axis}
<td><span data-uix-sound-layer-note>{axis}</span></td>
{/each}
<th scope="row"><span data-uix-sound-layer>Lo que se oye</span></th>
<td>
{#if resolved}
{resolved.pitch} Hz · {resolved.duration} ms · {resolved.contour}
{#if resolved.roughness >= 0.2}· áspero{/if}
{:else}
silencio
{/if}
</td>
</tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>
Prueba <code>commit</code> + <code>threat</code>: no es un <code>tick</code> con más aspereza,
es <strong>otro sonido</strong> — más grave, más rasposo y descendente. Y prueba
<code>commit</code>
+ <code>affirm</code>: no hay variante, así que el intent se desprecia y suena el
<code>tick</code> de siempre.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>4 · Los gestos suenan por repetición</h2>
<p data-uix-section-desc>
Un arrastre no calcula nada: emite a su propio ritmo, y cada emisión toca
<code>step</code> — 18 ms. La
<strong>velocidad del gesto es la velocidad del trinquete</strong>, como una rueda física. Eso
sustituyó a tres resolvers que sintetizaban el tono desde posición y velocidad, lo último que
quedaba de aritmética. Y la familia <code>handle</code> está exenta de la memoria de frecuencia:
si no, el anti-fatiga estrangularía el trinquete al primer arrastre.
Tres combinaciones que enseñan la regla entera: <code>commit</code> +
<code>threat</code> (hay versión, suena otra cosa) · <code>commit</code> +
<code>affirm</code> (no hay versión, se ignora el intent) · <code>sustain</code>
(esa familia no suena, y está bien).
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>5 · Nadie escribe parámetros</h2>
<h2 data-uix-section-title>4 · Cuando un componente quiere otra cosa</h2>
<p data-uix-section-desc>
Ni un componente, ni un pack, ni un tema, ni la app. El tipo acepta un
<strong>nombre</strong> o <code>SILENT</code>. Si un producto necesita un sonido que el pack
no trae, lo <strong>registra</strong> — declara el nombre y su definición, una vez — y luego lo
nombra.
A veces un componente necesita apartarse del sonido por defecto de su familia. Lo hace <strong
>nombrando otro</strong
>, en una línea:
</p>
<pre data-uix-code><code
>{`// 1 · declarar el nombre · 2 · definirlo (síntesis, wav o mp3) · 3 · nombrarlo
declare module '$uix/sema' { interface SemaSoundNames { laser: true } }
createActiveUix({ events: { sounds: { laser: sample('/sounds/laser.mp3', reserva) } } })
{ selector: onProvider({ eventName: 'contact-activate' }), sound: 'laser' }`}</code
>{`// El botón de este componente no hace 'touch', hace 'snap'
{ selector: onTrigger({ eventName: 'contact-activate' }), sound: 'snap' }`}</code
></pre>
<p data-uix-section-desc>
Un <strong>sound pack</strong> es una implementación completa del vocabulario: mezcla libre de
síntesis, <code>.wav</code> y <code>.mp3</code>. El pack por defecto del framework es 100 %
síntesis a propósito — funciona sin red y no puede dar 404.
Lo que <strong>nunca</strong> hace —ni él, ni un tema, ni la aplicación— es escribir un
parámetro. No existe «súbele el tono 200 Hz». El tipo acepta un nombre del catálogo o
<code>SILENT</code>, y nada más. Si hace falta un sonido que no está, se
<a href="/uix/docs/sound/packs">añade al catálogo</a> con su nombre y luego se nombra.
</p>
<p data-uix-section-desc>
Por eso la mayoría de componentes del framework no tienen ninguna regla de sonido: el sistema
ya dice lo correcto, y sólo se escribe una línea cuando hay una decisión de verdad que tomar.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Y a partir de aquí</h2>
<ul data-uix-prose-list>
<li>
<a href="/uix/docs/sound/catalogo"><strong>El catálogo</strong></a> — cada sonido que existe,
por qué suena así, y audible.
</li>
<li>
<a href="/uix/docs/sound/packs"><strong>Sound packs</strong></a> — traer tu propia voz:
síntesis, <code>.wav</code>, <code>.mp3</code>, y cómo registrar nombres nuevos.
</li>
<li>
<a href="/uix/docs/sound/gestos"><strong>Gestos</strong></a> — por qué un arrastre suena por repetición
y no calculando.
</li>
</ul>
</section>
</div>

@ -0,0 +1,213 @@
<script lang="ts">
/**
* `/uix/docs/sound/catalogo` — every entry of the default pack, audible,
* with the reason it sounds the way it does.
*
* The values are DERIVED from `SEMA_MAP.sounds`; only the prose is authored
* here, so adding an entry makes it appear with its measurements.
*/
import { getActiveUix } from '$active-uix';
import { SEMA_MAP } from '$uix/sema';
const uix = getActiveUix();
const NAMES = Object.keys(SEMA_MAP.sounds);
let stage = $state<HTMLElement | null>(null);
const WHY: Record<string, { role: string; why: string }> = {
touch: {
role: 'algo se ha tocado',
why: 'Lo más corto que dice el sistema. Brillante e inmediato: confirma que el dedo llegó y se aparta enseguida, porque es lo que más veces vas a oír.'
},
step: {
role: 'un paso de un gesto',
why: 'Se repite mientras arrastras, así que tiene que ser diminuto: cualquier cosa más larga se emborronaría al repetirse cada 70 ms.'
},
tick: {
role: 'algo ha quedado hecho',
why: 'Un golpe en registro medio, no un clic. Es el sonido de guardar, confirmar, seleccionar — muy frecuente, así que no puede pesar.'
},
open: {
role: 'algo aparece',
why: 'Sube. Un contorno ascendente se lee como llegada sin necesidad de subir el volumen.'
},
close: {
role: 'algo se va',
why: 'Baja, y es más grave que su apertura. Abrir y cerrar tienen que oírse como una pareja.'
},
slide: {
role: 'el marco cambia',
why: 'Un arco grave y lateral: pestañas, pasos de un asistente, navegación. No es algo que aparece, es algo que se desplaza.'
},
alert: {
role: 'el sistema habla solo',
why: 'El único que suena sin que hayas hecho nada. Por eso es el más brillante y el más largo de las bases: tiene que ganarte la atención.'
},
air: {
role: 'coges algo',
why: 'El primero de los tres sonidos de arrastre. Aire que se levanta cuando agarras.'
},
settle: {
role: 'lo sueltas',
why: 'Cae y se posa. Desciende justo donde `air` subía, para que el par agarrar/soltar se reconozca.'
},
snap: {
role: 'encaja en su sitio',
why: 'Corto, agudo y en arco. El clic de algo que entra donde debía.'
},
'tick.fulfill': {
role: 'salió bien',
why: 'Brillante, ascendente y se le permite durar más. Es el único momento en que el sistema puede celebrar algo.'
},
'tick.risk': {
role: 'ojo con esto',
why: 'Registro medio y áspero. La aspereza no es adorno: es lo que hace que el oído lo marque como advertencia y no como confirmación.'
},
'tick.threat': {
role: 'esto va en serio',
why: 'Grave, muy áspero y descendente. No es el sonido normal «con más aspereza»: es otro sonido, para que no quepa duda.'
},
'tick.loss': {
role: 'ya no está',
why: 'Una caída oscura pero SIN aspereza — porque perder algo no es lo mismo que estar en peligro, y no deben sonar parecido.'
},
'alert.risk': {
role: 'aviso del sistema',
why: 'Como el aviso normal pero áspero y más grave, para que no se confunda con una notificación cualquiera.'
},
'alert.threat': {
role: 'error del sistema',
why: 'El más grave, el más áspero y el más largo del catálogo. Es el techo: si algo suena así, es lo peor que el sistema tiene que decir.'
}
};
function play(name: string) {
if (!stage) return;
void uix.events?.emit({
name: 'probe',
family: 'commit' as never,
target: stage,
overrides: { sound: name }
});
}
const AXES = ['pitch', 'centroid', 'duration', 'contour', 'roughness', 'gain'] as const;
</script>
<div data-uix-canvas-inner>
<div data-uix-eyebrow>Sema · el sonido</div>
<h1 data-uix-page-title>Todos los sonidos que existen.</h1>
<p data-uix-page-lede>
Estos son los sonidos que un componente puede pedir por su nombre. No hay más, y esa es la
gracia: un conjunto pequeño y cerrado significa que puedes aprendértelo, y que dos partes
distintas de la aplicación no acaban sonando parecido por accidente. Púlsalos para oírlos
(necesitas <em>Sound</em> encendido en la barra superior).
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>sonidos</span>{NAMES.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>ficheros de audio</span>ninguno</span>
</div>
<div bind:this={stage} data-uix-sound-stage aria-hidden="true"></div>
<section data-uix-section>
<h2 data-uix-section-title>Dos clases de entrada</h2>
<p data-uix-section-desc>
Las <strong>bases</strong> son el sonido de un tipo de suceso: tocar, confirmar, abrir. Las
<strong>versiones</strong>
llevan un sufijo con un intent (<code>tick.threat</code>) y son lo que suena cuando ese suceso
ocurre con esa carga. Si un suceso tiene un intent para el que no hay versión, suena la base —
nunca falla, sólo dice menos.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Bases</h2>
{#each NAMES.filter((n) => !n.includes('.')) as name}
{@const s = SEMA_MAP.sounds[name] as Record<string, unknown>}
{@const why = WHY[name]}
<div data-uix-sound-entry>
<div data-uix-sound-entry-head>
<button type="button" data-uix-sound-chip onclick={() => play(name)}>
<span data-uix-sound-chip-name>{name}</span>
<span data-uix-sound-chip-play>▶</span>
</button>
<span data-uix-sound-entry-role>{why?.role ?? ''}</span>
</div>
<p data-uix-sound-entry-why>{why?.why ?? ''}</p>
<div data-uix-sound-axes>
{#each AXES as axis}
<span data-uix-sound-axis
><span data-uix-sound-axis-key>{axis}</span>{String(s[axis])}</span
>
{/each}
</div>
</div>
{/each}
</section>
<section data-uix-section>
<h2 data-uix-section-title>Versiones por intent</h2>
{#each NAMES.filter((n) => n.includes('.')) as name}
{@const s = SEMA_MAP.sounds[name] as Record<string, unknown>}
{@const why = WHY[name]}
<div data-uix-sound-entry>
<div data-uix-sound-entry-head>
<button type="button" data-uix-sound-chip onclick={() => play(name)}>
<span data-uix-sound-chip-name>{name}</span>
<span data-uix-sound-chip-play>▶</span>
</button>
<span data-uix-sound-entry-role>{why?.role ?? ''}</span>
</div>
<p data-uix-sound-entry-why>{why?.why ?? ''}</p>
<div data-uix-sound-axes>
{#each AXES as axis}
<span data-uix-sound-axis
><span data-uix-sound-axis-key>{axis}</span>{String(s[axis])}</span
>
{/each}
</div>
</div>
{/each}
</section>
<section data-uix-section>
<h2 data-uix-section-title>Qué significan esos números</h2>
<p data-uix-section-desc>
Cada sonido se sintetiza a partir de seis valores. Los ves aquí para entender el diseño, pero <strong
>no vas a escribirlos nunca</strong
>: viven en el catálogo y en ningún otro sitio.
</p>
<ul data-uix-prose-list>
<li><code>pitch</code> — la altura, en hercios. Grave abajo, agudo arriba.</li>
<li>
<code>centroid</code> — el <em>brillo</em>. Técnicamente es dónde corta un filtro: cuanto
más alto, más armónicos pasan y más «cristalino» suena.
</li>
<li><code>duration</code> — cuánto dura, en milisegundos.</li>
<li>
<code>contour</code> — hacia dónde va la altura mientras suena: plano, subiendo, bajando o en
arco.
</li>
<li>
<code>roughness</code> — la aspereza. A partir de 0,2 el sonido empieza a «raspar», que es lo
que distingue un aviso de una confirmación.
</li>
<li><code>gain</code> — el volumen.</li>
</ul>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Por qué son tan distintos entre sí</h2>
<p data-uix-section-desc>
No es casualidad: hay una prueba automática que <strong>rechaza</strong> el catálogo si dos
sonidos se parecen demasiado. Para convivir, dos entradas tienen que separarse en al menos
<strong>dos</strong> de esos ejes con margen suficiente para oírlo — una quinta de altura, un tercio
de brillo, la mitad otra vez de duración, otro contorno, el doble de volumen o 0,2 de aspereza.
</p>
<p data-uix-section-desc>
Suena excesivo hasta que ocurre lo contrario. Un conjunto de sonidos que sólo se diferencian
en el volumen no es un vocabulario: es el mismo sonido repetido, y quien usa la aplicación no
distingue nada.
</p>
</section>
</div>

@ -0,0 +1,114 @@
<script lang="ts">
/**
* `/uix/docs/sound/gestos` — why a continuous gesture sounds by repetition.
*/
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
let stage = $state<HTMLElement | null>(null);
let rate = $state(80);
/** Fire `step` N times at the chosen interval — the ratchet, on demand. */
async function demo() {
if (!stage) return;
for (let i = 0; i < 12; i++) {
void uix.events?.emit({ name: 'handle-drag', family: 'handle' as never, target: stage });
await new Promise((r) => setTimeout(r, rate));
}
}
</script>
<div data-uix-canvas-inner>
<div data-uix-eyebrow>Sema · el sonido</div>
<h1 data-uix-page-title>Los gestos suenan como una rueda dentada.</h1>
<p data-uix-page-lede>
Arrastrar un deslizador o un panel no es un suceso: es <strong>muchos</strong>, uno detrás de
otro mientras mueves el dedo. Eso cambia por completo cómo debe sonar, y la respuesta del
sistema es la misma que la de una rueda física: un pequeño clic por paso.
</p>
<div bind:this={stage} data-uix-sound-stage aria-hidden="true"></div>
<section data-uix-section>
<h2 data-uix-section-title>El problema de un gesto</h2>
<p data-uix-section-desc>
Un botón suena una vez y ya está. Un arrastre, en cambio, dura: mientras mueves, el componente
va contando lo que pasa, varias veces por segundo. Si cada uno de esos avisos sonara como un
botón, tendrías una ametralladora en el oído.
</p>
<p data-uix-section-desc>
La tentación es calcular: hacer que el tono suba con la posición y el volumen con la
velocidad. Suena razonable escrito, pero produce un zumbido que se desliza y del que es
difícil sacar información — y obliga a meter matemáticas dentro de un componente, que es justo
lo que este sistema evita en todas partes.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>La respuesta: un clic diminuto, repetido</h2>
<p data-uix-section-desc>
El componente ya emite a su propio ritmo mientras arrastras. Basta con que cada emisión toque
un sonido muy corto —<code>step</code>, 18 ms— y la información sale sola:
<strong>cuanto más rápido mueves, más rápido suena</strong>. La dinámica del gesto es su
ritmo, no su volumen.
</p>
<p data-uix-section-desc>
Es exactamente lo que hace la rueda de un ratón o el selector de un móvil, y funciona por el
mismo motivo: el oído es muy bueno midiendo cadencia.
</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>velocidad del gesto</span>
<select bind:value={rate}>
<option value={140}>lento — 140 ms entre pasos</option>
<option value={80}>normal — 80 ms</option>
<option value={40}>rápido — 40 ms</option>
</select>
</label>
<button type="button" data-uix-sound-chip onclick={demo}>
<span data-uix-sound-chip-name>simular arrastre</span>
<span data-uix-sound-chip-play>▶</span>
</button>
</div>
<p data-uix-section-desc>
Prueba las tres velocidades seguidas: es el mismo sonido las tres veces, y aun así reconoces
cuál es rápido.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Una excepción que hay que conocer</h2>
<p data-uix-section-desc>
El sistema baja el volumen de un sonido que se repite mucho seguido: es una defensa contra la
fatiga, para que veinte notificaciones iguales no te martilleen.
</p>
<p data-uix-section-desc>
Con los gestos eso sería un desastre — la repetición <em>es</em> el mensaje, y esa defensa
apagaría la rueda en el primer arrastre. Por eso la familia
<code>handle</code> está <strong>exenta</strong>: sus emisiones se supone que son muchas y
regulares, y no significan insistencia.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Qué se gana y qué se pierde</h2>
<ul data-uix-prose-list>
<li>
<strong>Se gana</strong> que un componente no calcule nada: nombra
<code>step</code> y ya está, igual que cualquier otro sonido del sistema.
</li>
<li>
<strong>Se gana</strong> que funcione con ficheros: si tu pack pone un
<code>.mp3</code> en <code>step</code>, la rueda sigue funcionando igual, cosa que con tono
calculado era imposible.
</li>
<li>
<strong>Se pierde</strong> la relación entre posición y altura: antes, arrastrar hacia el
final subía el tono. Ahora la velocidad se oye en el ritmo, pero
<em>dónde estás</em> no se oye. Es una decisión consciente; si algún día hace falta, se recupera
con pasos cuantizados y no con matemáticas dentro del componente.
</li>
</ul>
</section>
</div>

@ -0,0 +1,164 @@
<script lang="ts">
/**
* `/uix/docs/sound/packs` — bringing your own sounds.
*
* Didactic order: what a pack IS, then swapping one, then adding a name
* that does not exist, then the physics of samples. No history.
*/
import { SEMA_MAP } from '$uix/sema';
const COUNT = Object.keys(SEMA_MAP.sounds).length;
</script>
<div data-uix-canvas-inner>
<div data-uix-eyebrow>Sema · el sonido</div>
<h1 data-uix-page-title>Traer tus propios sonidos.</h1>
<p data-uix-page-lede>
Los componentes piden sonidos por su nombre: <code>tick</code>, <code>open</code>,
<code>alert</code>. Lo que suena cada nombre lo decide una pieza aparte, un
<strong>sound pack</strong>. Cambiando el pack cambia la voz de toda la aplicación — sin tocar
un solo componente.
</p>
<section data-uix-section>
<h2 data-uix-section-title>La idea: nombres por un lado, sonidos por otro</h2>
<p data-uix-section-desc>
Piénsalo como una lista de palabras y un diccionario. La lista de palabras es fija: son los {COUNT}
nombres que un componente puede pronunciar, y no se inventan sobre la marcha. El diccionario —el
pack— dice a qué suena cada palabra, y ése sí lo puedes cambiar entero.
</p>
<pre data-uix-code><code
>{`// Lo que dice un componente. Nunca cambia.
sound: 'tick'
// Lo que dice el pack. Puedes reemplazarlo.
tick: { pitch: 850, centroid: 3200, duration: 70, contour: 'flat', gain: 0.08 }
tick: sample('/sonidos/mi-tick.mp3', reservaSintetica) // ← o esto`}</code
></pre>
<p data-uix-section-desc>
Al componente le da <strong>igual</strong> cuál de las dos sea. Esa indiferencia es justo lo que
hace posible cambiar la voz sin tocar nada más.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>El pack que viene de serie no trae ficheros</h2>
<p data-uix-section-desc>
Los {COUNT} sonidos por defecto están <strong>sintetizados</strong>: se generan en el
navegador con osciladores. Eso significa que suenan sin descargar nada, sin esperas la primera
vez, y sin que un despliegue mal copiado deje la aplicación muda.
</p>
<p data-uix-section-desc>
Si prefieres ficheros, los pones tú. Los <code>.wav</code> que hay en el repositorio son sólo un
ejemplo de cómo se hace.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Usar ficheros de audio</h2>
<pre data-uix-code><code
>{`sample('/sonidos/tick.mp3', {
// La reserva: lo que suena si el fichero no se puede cargar o decodificar.
// Ponla siempre — sin ella, un 404 deja ese sonido mudo para siempre.
pitch: 850, centroid: 3200, roughness: 0, attack: 2,
decay: 60, duration: 70, contour: 'flat', gain: 0.08
})`}</code
></pre>
<p data-uix-section-desc>
Valen <strong>wav, mp3, ogg y aac</strong> — el navegador los decodifica todos. Puedes mezclar libremente:
unos nombres sintetizados y otros con fichero, en el mismo pack.
</p>
<p data-uix-section-desc>
<strong>Un límite que conviene conocer antes de diseñar.</strong> De un fichero sólo se puede
controlar el volumen: no se le puede cambiar el tono ni la velocidad. Así que si quieres que
un mismo suceso suene distinto según su carga —una confirmación normal frente a una peligrosa—
necesitas <strong>un fichero por cada caso</strong>
(<code>tick.mp3</code> y <code>tick.threat.mp3</code>), no uno solo modificado. Con sonidos
sintetizados el problema no existe, porque cada versión se diseña entera.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Añadir un sonido que no está en la lista</h2>
<p data-uix-section-desc>
La lista de nombres está cerrada para que nadie improvise, pero <strong
>no está cerrada a que crezca</strong
>. Si tu producto necesita un sonido propio, lo registras una vez y a partir de ahí se nombra
como cualquier otro.
</p>
<pre data-uix-code><code
>{`// 1 · Declara que el nombre existe (una vez, en tu producto)
declare module '$uix/sema' {
interface SemaSoundNames { laser: true }
}
// 2 · Di a qué suena
createActiveUix({
events: { sounds: { laser: sample('/sonidos/laser.mp3', reserva) } }
})
// 3 · Úsalo donde quieras
{ selector: onProvider({ eventName: 'contact-activate' }), sound: 'laser' }`}</code
></pre>
<p data-uix-section-desc>
Si además registras <code>'laser.threat'</code>, la regla normal lo encontrará sola cuando el
suceso lleve ese intent. Y como el nombre está declarado en los tipos, una errata (<code
>'lasser'</code
>) no compila.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Cambiar la voz en caliente</h2>
<p data-uix-section-desc>
Un pack no es sólo algo que se elige al arrancar: se puede cambiar mientras la aplicación
corre, y deshacer.
</p>
<pre data-uix-code><code
>{`// El sonido es un eje del tema, junto al color y los demás
eidos.applyTheme({ color: '#3b5bdb', sound: { tick: { gain: 0.05 } } });
eidos.clearTheme(); // vuelve a lo de origen
// O sólo el sonido
uix.events.applySounds({ tick: { gain: 0.05 } });
uix.events.clearMap();`}</code
></pre>
<p data-uix-section-desc>
Fíjate en qué se puede cambiar y qué no: puedes cambiar <em>a qué suena</em> un nombre, pero
no <em>qué nombres existen</em>. Un tema ajusta la voz; no puede inventar una palabra que
ningún componente sabría pronunciar.
</p>
</section>
<section data-uix-section>
<h2 data-uix-section-title>Quién puede hacer qué</h2>
<div data-uix-sound-table>
<table>
<thead>
<tr><th>si eres…</th><th>puedes</th><th>cómo</th></tr>
</thead>
<tbody>
<tr>
<th scope="row"><span data-uix-sound-layer>el producto</span></th>
<td>añadir nombres nuevos</td>
<td>declararlo + registrar su sonido</td>
</tr>
<tr>
<th scope="row"><span data-uix-sound-layer>un tema</span></th>
<td>cambiar a qué suenan</td>
<td><code>applySounds()</code></td>
</tr>
<tr>
<th scope="row"><span data-uix-sound-layer>un componente</span></th>
<td>elegir cuál usa</td>
<td><code>sound: 'tick'</code> o <code>SILENT</code></td>
</tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>
Y algo que <strong>nadie</strong> puede, en ninguna de las tres posiciones: escribir un parámetro
suelto. No hay forma de decir «este botón, 200 Hz más agudo». Los números viven en el pack, y sólo
ahí.
</p>
</section>
</div>

@ -1889,3 +1889,49 @@ samp {
[data-uix-prose-list] li {
margin-block-end: var(--uix-space-2);
}
/* ── /uix/docs/sound/catalogo — one entry: chip, role, reason, measurements ── */
[data-uix-sound-entry] {
padding-block: var(--uix-space-5);
border-block-end: 1px solid var(--uix-line-soft);
}
[data-uix-sound-entry-head] {
display: flex;
align-items: center;
gap: var(--uix-space-4);
flex-wrap: wrap;
}
[data-uix-sound-entry-head] [data-uix-sound-chip] {
min-width: 10rem;
flex: 0 0 auto;
}
[data-uix-sound-entry-role] {
font-size: var(--uix-text-md);
color: var(--uix-text);
font-weight: 500;
}
[data-uix-sound-entry-why] {
margin-block: var(--uix-space-3) var(--uix-space-4);
max-width: 62ch;
line-height: var(--uix-leading-prose);
color: var(--uix-text-muted);
}
[data-uix-sound-axes] {
display: flex;
flex-wrap: wrap;
gap: var(--uix-space-2) var(--uix-space-4);
font-family: var(--uix-font-mono);
font-size: var(--uix-text-2xs);
color: var(--uix-text-faint);
}
[data-uix-sound-axis-key] {
margin-inline-end: var(--uix-space-1);
opacity: 0.7;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.