refactor(skin-media-player): un cableado y cuatro pinturas, con el movimiento del sistema

Reescritura completa de la versión A (`bf12b8a57`), que el autor valoró como
duplicación. La medición le daba la razón: `fit()` idéntico en dos ficheros,
`hold()` y `stop()` en otros dos, y `MediaPlayerProvider.require()` en los
cuatro. Eran cuatro componentes compartiendo carpeta, no un skin.

## La forma

Un componente cablea, los aparatos sólo pintan.

- `skin-media-player.svelte` — EL punto de cableado: un `require()`, una
  derivación de todo, y las teclas generadas recorriendo un mapa.
- `controls.ts` — datos por aparato: lienzo, teclas `[{id,x,y,w,h,action}]` y la
  ranura del volumen.
- `actions.ts` — el vocabulario único (`play·pause·toggle·stop·seek-*·rpm·none`),
  con `stop()` y el veto por `preventDefault()` en un solo sitio.
- `type-fit.svelte.ts` — el ajuste de `<text>`, una vez.
- Los cuatro cuerpos: props entran, SVG sale. Cero provider, cero `$effect`,
  cero contexto. Añadir un quinto aparato es un SVG y una fila de datos.

`<SkinMediaPlayer>` deja de montar player: es una PIEZA dentro de uno. No monta
`Media`, no monta barra y no decide la forma — `inline/bar/row/card` es el eje
`variant` de `AudioPlayer`, y la piel viaja dentro como hija.

## Movimiento: el del sistema, nada a mano

La versión A traía tres `@keyframes` locales y siete transiciones con números
crudos, ignorando `docs/theming/motion.md` (leída ahora, entera).

- Todo giro continuo es el preset **`spin`**; el respirar de la cinta, **`pulse`**.
  El periodo va en la ranura del propio loop (`--motion-loop-spin`: 1.333s a 45
  rpm, 1.8s a 33).
- Las transiciones consumen `--duration-*` / `--ease-*` por una ranura `--_smp-*`
  por eje.
- DEFECTO FUNCIONAL corregido: el generador apaga los loops por
  `[data-motion='reduce']` **y** por la media query; el bloque a mano sólo tenía
  la segunda, así que la preferencia UIX en `reduce` sin ajuste del SO no paraba
  el plato. Medido en navegador: ahora `animation-name: none`.
- Queda un `@keyframes` local, la aguja del vúmetro, anotado `functional:` y con
  el periodo en token: ningún preset representa un instrumento analógico.

## Tres defectos que sólo se ven en movimiento

Los tres pasaban toda comprobación de atributos y sólo aparecieron mirando la
imagen moverse:

1. **La sombra rotaba con el disco.** El `feDropShadow` colgaba del grupo que
   gira, así que su desplazamiento orbitaba (se leía como la sombra subiendo y
   bajando) y la penumbra de la región del filtro barría las marcas del plato,
   que parecían parpadear. El filtro pasa a un padre quieto — un disco es un
   círculo, su silueta no cambia. Igual en el brazo.
2. **Las marcas del plato salían disparadas en arco.** Al rehacer se perdió la
   regla del estrobo; sin `transform-origin` el preset gira alrededor del centro
   del viewBox (500, 440) en vez del centro del plato (406, 468.5).
3. **El tamaño no llegaba.** `max-inline-size` sólo encoge, así que `lg` y `xl`
   eran inertes en cuanto la columna era más estrecha (medido: md/lg/xl los tres
   a 720px). Ahora `inline-size: min(100%, …)`, base por objeto y factor por
   tamaño, una sola fuente en el recipe — la base estaba declarada dos veces,
   inline y en CSS, y ganaba la inline.

## Volumen

Primero se compuso el `Knob` del catálogo y era inmanejable: mapea el valor al
ángulo ABSOLUTO del puntero alrededor del centro (`Gesture.rotate`), o sea
orbitar el ratón en torno a un dial de 40px. Sustituido por el `VolumeSlider`
del propio player corriendo en una guía pintada por la máquina. El re-tinte va
sobre el MISMO descendiente que retinta el player (`media-player.css:420`) o su
regla gana por estar más cerca del thumb.

La banda inferior de la pletina se reordenó para alojarlo: vúmetro a media
anchura con el dial recentrado (aguja intacta), deslizador a 42 unidades del
bisel (20px a `md`), contador y piloto +20 a la derecha, la etiqueta REPROD del
piloto fuera, y «MODELO CR-77» al mismo ancho que «Levante» por `textLength` +
`lengthAdjust="spacing"`.

## Prop `labels` y la decisión de idioma

`labels` (default `true`) apaga la serigrafía entera de la máquina. Los rótulos
NO se traducen, y es decisión firmada: son parte del OBJETO, como la marca del
badge. Los nombres accesibles sí siguen el idioma, porque salen del morfo de las
partes compuestas.

## Demo canónica

`web/routes/uix/components/skin-media-player/+page.svelte` con la plantilla v2
de 9 pestañas + su entrada en el sidebar. El banco provisional de
`/otros/players` se borra: duplicaba la demo con DOS players independientes, que
es lo que produjo dos lecturas falsas («no está sincronizado», «fondo negro»).
Los cuatro bocetos del autor se quedan en la carpeta, ya sin ruta.

`preload="none"` en la demo a propósito: un preload de metadata abre una
petición de rango que el navegador aborta, y `smoke` cuenta ese aborto como
fallo same-origin.

## Auditoría — el hueco que queda

`component-guide.md` §5 dice que incluso un átomo pasivo es morfo-first, con un
morfo mínimo `scope: ['eidos']` y `## Passive justification`. Este componente no
lo tiene porque el alcance acordado excluía `src/uix/morfo/`, y la consecuencia
no es cosmética: **tres máquinas no lo ven** — `component:audit` enumera por
ficheros de morfo y no le da fila, `eidos-lint` lo salta («no morfo file») y
`morfo:check` no visita su demo. Los guards transversales sí pasan; los
específicos de componente no se han ejecutado sobre él ni una vez.

Pendiente además: `data-perm-step` en la demo (A37), sin el cual `perm:check` la
salta en silencio.

Verificado: `npm run check` sin errores nuevos (base 72/59) · 77 tests de eidos
(recipe-css-contract · component-api-contract · component-visual-attrs · motion)
· `rtl:check` 0/179 · `docs:check` 0/0 · `layer:check` 0 · `smoke` PASS ·
`morfo:check` 159/159. Y en Chrome, con el ratón: transporte en los dos
sentidos, brazo siguiendo el surco, bobinas por conservación de área, contador
de cinta, y el volumen moviendo `audio.volume` de verdad.

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

@ -2,154 +2,182 @@
Fecha: 2026-08-15.
Una **piel** para el `MediaPlayer`: la cara del reproductor es un objeto físico
dibujado — un disco, un tocadiscos, un casete o una pletina. El comportamiento
es del player entero; aquí sólo vive la representación y las afordancias que ese
objeto tiene de verdad.
```svelte
<script>
import { SkinMediaPlayer } from '$uix/eidos/components/skin-media-player';
</script>
<SkinMediaPlayer skin="turntable" src="/track.mp3" title="Quédate" artist="Juan Luis Alarcón" />
```
Para componer a mano, la pieza suelta dentro de cualquier player:
La cara del `MediaPlayer` como **objeto físico dibujado**: un disco, un
tocadiscos, un casete o una pletina. Es una **pieza dentro** del player, nunca
un player: se monta junto a las partes del propio `MediaPlayer`, que conservan
el volumen, la búsqueda y todo lo demás.
```svelte
<script>
import { MediaPlayer } from '$uix/eidos/components/media-player';
import { Skin } from '$uix/eidos/components/skin-media-player';
import { SkinMediaPlayer } from '$uix/eidos/components/skin-media-player';
</script>
<MediaPlayer media="audio">
<MediaPlayer.Media src="/track.mp3" />
<Skin skin="deck" title="Quédate" />
<SkinMediaPlayer skin="deck" title="Quédate" artist="Juan Luis Alarcón" image="/cover.jpg" />
<MediaPlayer.DefaultControls />
</MediaPlayer>
```
## Baseline
**La forma del reproductor no es suya.** `inline · bar · row · card` es el eje
`variant` de `<AudioPlayer>`, y la piel viaja dentro como hija:
**No hay baseline.** `air` no tenía nada así, y las referencias headless del
proyecto (Radix / Ark / Bits / React Aria) no definen ni un media player, menos
aún una piel figurativa. El punto de partida son cuatro bocetos SVG del autor
(`web/routes/otros/players/`, banco de pruebas con estado simulado y sin audio)
y las dos pieles que ya existen en la casa: `ProofOfHuman.Clock` sobre el motor
`RotateAlign`, y las cuatro variantes de `AudioPlayer` sobre este mismo morfo.
```svelte
<AudioPlayer variant="bar" src="/track.mp3" title="Quédate">
<SkinMediaPlayer skin="turntable" title="Quédate" />
</AudioPlayer>
```
## Cómo está montado
Un componente cablea; los aparatos sólo pintan.
| Fichero | Papel |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `skin-media-player.svelte` | **El** cableado: un `MediaPlayerProvider.require()`, una derivación de todo, y las teclas generadas desde el mapa |
| `controls.ts` | Datos: por aparato, su lienzo, su tamaño natural y `[{ id, x, y, w, h, action }]` |
| `actions.ts` | El vocabulario único: `play · pause · toggle · stop · seek-back · seek-fwd · rpm · none`, resuelto una vez |
| `geometry.ts` | Matemática pura: enganche, brazo, bobinas, contador |
| `type-fit.svelte.ts` | El ajuste de `<text>`, una vez para las dos superficies de papel |
| `actions.ts` → `SkinFader` | La ranura del volumen por máquina: un hueco, no un dial |
| `skin-media-player-{objeto}.svelte` | **Sólo pintura**: props entran, SVG sale. Sin provider, sin efectos, sin contexto |
| `skin-media-player-key.svelte` | El `<button>` transparente colocado en % del viewBox |
## Las cuatro pieles
Añadir un quinto aparato es un SVG y una fila en `controls.ts`. No hay lógica
que tocar.
| `skin` | Objeto | Mandos | viewBox |
| ----------- | ---------- | --------------------------------------------- | ---------- |
| `record` | Disco | ninguno — pintura pura | 525 × 570 |
| `turntable` | Giradiscos | 33/45 · MARCHA · PARO · ELEVADOR | 1000 × 880 |
| `cassette` | Casete | ninguno — pintura pura | 1000 × 629 |
| `deck` | Pletina | GRAB · REBOB · REPROD · AVANCE · STOP · PAUSA | 1000 × 780 |
| `skin` | Objeto | Mandos | viewBox |
| ----------- | ---------- | ------------------------------------------------------- | ---------- |
| `record` | Disco | ninguno — pintura pura | 525 × 570 |
| `turntable` | Giradiscos | 33/45 · MARCHA · PARO · ELEVADOR · volumen | 1000 × 880 |
| `cassette` | Casete | ninguno — pintura pura | 1000 × 629 |
| `deck` | Pletina | GRAB · REBOB · REPROD · AVANCE · STOP · PAUSA · volumen | 1000 × 780 |
`turntable` anida `record` en el hueco del plato y `deck` anida `cassette` tras
el cristal, los dos como `<svg>` interior: la piel del soporte se reutiliza
entera, no se redibuja.
el cristal, los dos como `<svg>` interior: la pintura del soporte se reutiliza
entera.
## Baseline
**No hay baseline.** `air` no tenía nada así, y las referencias headless del
proyecto (Radix / Ark / Bits / React Aria) no definen ni un media player, menos
una piel figurativa. El punto de partida son cuatro bocetos SVG del autor
(`web/routes/otros/players/`, estado simulado y sin audio) y las dos pieles que
ya existen en la casa: `ProofOfHuman.Clock` sobre el motor `RotateAlign`, y las
cuatro variantes de `AudioPlayer` sobre este mismo morfo.
## Comparativa
| Referencia | Qué hace | Aquí |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Winamp / foobar2000** (skins clásicos) | El skin ES el reproductor: bitmap + mapa de zonas activas, cada uno reimplementa su transporte | El skin **no es** el reproductor: reflejo + afordancias sobre las partes reales. Cero comportamiento propio |
| **Vidstack / Media Chrome** | Layouts intercambiables sobre un controlador; chrome funcional, nunca figurativo | Mismo reparto (estado en el elemento, chrome fuera), llevado a una representación de objeto |
| **Plyr / Video.js** | «Skins» = temas CSS sobre la misma barra | Aquí la piel cambia el OBJETO, no el tema de una barra. La barra canónica sigue debajo |
| **`AudioPlayer` (esta casa)** | Raíz competidora + 4 variantes en `[data-variant]` | Mismo patrón, eje distinto: `[data-skin]`. `data-variant` ya está ocupado por las formas de audio |
| **`ProofOfHuman.Clock` (esta casa)** | Piel hermana sobre un motor soma, con recipe propia | Misma idea; aquí las cuatro conviven en una raíz porque el eje es «qué objeto se ve» |
| Referencia | Qué hace | Aquí |
| ---------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Winamp / foobar2000** (skins clásicos) | El skin ES el reproductor: bitmap + mapa de zonas, cada uno reimplementa su transporte | El skin no es el reproductor: refleja y compone. Cero comportamiento propio |
| **Vidstack / Media Chrome** | Layouts intercambiables sobre un controlador; chrome funcional, nunca figurativo | Mismo reparto (estado en el elemento, chrome fuera), llevado a una representación de objeto |
| **Plyr / Video.js** | «Skins» = temas CSS sobre la misma barra | Aquí la piel cambia el OBJETO. La barra del player sigue siendo la del player |
| **`AudioPlayer` (esta casa)** | Raíz competidora + 4 variantes en `[data-variant]` | La piel NO compite: es una pieza. `data-variant` sigue siendo del player; ésta estampa `data-skin` en lo suyo |
| **`ProofOfHuman.Clock` (esta casa)** | Piel hermana sobre un motor soma, con recipe propia | Misma idea; aquí las cuatro conviven en un componente porque sólo cambia la pintura |
## Decisiones
- **La piel es un COMPLEMENTO, no un reproductor.** Ninguno de los cuatro
objetos tiene mando de volumen ni permite buscar libremente: la barra canónica
va debajo y posee esos dos ejes. `controls={false}` es para quien los aporte
por otro lado. Sin esto, `<SkinMediaPlayer>` sería un player incompleto
disfrazado de completo.
- **La piel es un COMPLEMENTO.** Ninguno de los cuatro objetos tiene mando de
volumen ni permite buscar libremente, así que sola sería un player incompleto.
No monta `Media`, no monta barra y no decide la forma.
- **La afordancia es un `<button>` HTML superpuesto, nunca el `<g>` del SVG.**
Los bocetos ponían `role="button"` + `tabindex` sobre grupos SVG con Espacio y
Enter a mano. En eidos no hay un solo caso: `onion-menu` lo dice en su propio
marcado — _«a real HTML button overlaid at the SVG centre… which an SVG element
can't do»_ — y `natural-time-picker` hace lo mismo con el knob de su banda. Un
`<g>` no tiene teclado nativo, ni `disabled`, ni contorno que el sistema de
foco pueda dibujar. La pintura se queda en el SVG; sólo el área activa sale.
- **Componente de tono fijo.** Caoba, aluminio cepillado, cromo, marfil de las
teclas, granate de la etiqueta y oro del eje son la representación, no roles de
tema: un tocadiscos no se vuelve azul porque el tema lo haga. Los 83 valores del
autor se conservan intactos como literales en la pintura, bajo la excepción que
`recipe-css-contract.test.ts` ya reserva para `natural-time-picker`,
`color-picker` y `proof-of-human` (§Audit exceptions).
- **Tres estados, no dos** (`geometry.ts` → `engaged`). `provider.started` se
enciende con la primera reproducción y **no se apaga nunca**, así que por sí
solo dejaba el brazo apoyado sobre un disco parado. El enganche es
`started && !ended && (currentTime > 0 || !paused)`, y de ahí salen los tres
estados de una máquina: parada (brazo retirado), sonando, y en pausa (brazo en
su surco, elevador arriba, plato congelado).
can't do»_ — y `natural-time-picker` hace lo mismo con el knob de su banda.
- **Un cableado, no cuatro.** La primera versión (commit `bf12b8a57`, conservada
a propósito) tenía `fit()` duplicado en dos ficheros, `hold()` y `stop()` en
otros dos, y `MediaPlayerProvider.require()` en los cuatro: eran cuatro
componentes compartiendo carpeta. Ahora el aparato aporta pintura y datos.
- **Movimiento del sistema, nada a mano.** Cada giro continuo es el preset
**`spin`** y el respirar de la cinta es **`pulse`**, estampados como
`data-animation-style` con su periodo escrito en la ranura del propio loop
(`--motion-loop-spin`). Las transiciones consumen `--duration-*` / `--ease-*`
por una ranura `--_smp-*` por eje. En la primera versión eran tres
`@keyframes` y siete números crudos, **con un defecto real**: el generador
apaga los loops por `[data-motion='reduce']` **y** por la media query, y el
bloque escrito a mano sólo tenía la segunda — la preferencia UIX en `reduce`
sin ajuste del SO no paraba el plato. Medido en navegador: ahora sí.
- **Tres estados, no dos** (`geometry.engaged`). `provider.started` se enciende
con la primera reproducción y no se apaga nunca, así que por sí solo dejaba el
brazo apoyado sobre un disco parado. Con
`started && !ended && (currentTime > 0 || !paused)` salen los tres estados de
una máquina: parada (brazo retirado), sonando, y en pausa (brazo en su surco,
elevador arriba, plato congelado por `animation-play-state`).
- **El volumen es un deslizador, no una ruleta.** El primer intento compuso el
`Knob` del catálogo y era inmanejable: ese primitivo mapea el valor al ángulo
**absoluto** del puntero alrededor del centro (`Gesture.rotate`), así que fijar
un nivel obligaba a orbitar el ratón en torno a un dial de 40px. El player ya
tiene un control lineal de volumen, y las pletinas portátiles llevaban
deslizadores de verdad: la máquina pinta la **guía** y el `VolumeSlider`
compuesto corre por ella. ⚠️ El re-tinte tiene que ir sobre el MISMO
descendiente que retinta el player (`media-player.css:420`) o su regla gana por
estar más cerca del thumb — el cap salía oscuro por eso.
- **El tamaño es un ancho, no un tope.** `max-inline-size` no puede funcionar:
sólo encoge, así que `lg` y `xl` eran inertes en cuanto la columna era más
estrecha que la base (medido: md, lg y xl salían los tres a 720px). Es
`inline-size: min(100%, …)`, con una base por objeto y un factor por tamaño,
los dos en el recipe y en un solo sitio.
- **Los rótulos NO se traducen, y es una decisión.** La serigrafía de la máquina
(VELOCIDAD · MARCHA · REBOB · VU · VOLUMEN…) es parte del OBJETO, como la marca
del badge: una pletina española de 1973 lleva REPROD serigrafiado y no cambia
de idioma. Los **nombres accesibles** sí siguen el idioma, porque salen del
morfo de las partes compuestas — el lector dice «Reproducir» o «Play» mientras
la serigrafía no se mueve. La prop `labels` apaga la serigrafía entera sin
tocar ni los mandos ni sus nombres. Firmado por el usuario, 2026-08-15.
- **STOP se compone, porque el contrato no lo tiene.** El provider expone
`togglePlay`, `seek` y `seekBy`; no hay `stop` ni `pause`. PARO y STOP hacen
`togglePlay()` (si suena) + `seek(0)`. Es coordinación en eidos, y por eso está
escrita aquí: una pletina cuyo STOP no para miente más de lo que la
coordinación cuesta.
- **MARCHA y REPROD sólo arrancan; PAUSA sólo pausa.** Un `PlayButton` pelado es
un toggle, así que una tecla ya enclavada habría **pausado** al pulsarla. La
costura es la de `RateFloat`: se compone la parte real y su handler se anula
con `preventDefault()` cuando no toca.
- **Las r.p.m. son decorativas.** 33/45 cambian la velocidad del plato y de las
marcas estroboscópicas; **no tocan `playbackRate`**. Hacerlo exigiría saber a
qué velocidad se grabó el disco (dato que no existe en el modelo) y sonaría
mal: el navegador conserva el tono por defecto, así que un single de 45 puesto
a 33 saldría lento pero igual de agudo, que es justo lo que un tocadiscos no
hace. Decisión del usuario, 2026-08-15.
- **El contador de cinta lee el medio.** En el boceto lo movía un `setInterval`
propio; ahora sale de `progress`, así que el mando de puesta a cero sobra —
poner el contador a cero ES volver al principio, que es lo que hace STOP. La
ruedecilla se queda pintada, inerte.
- **El ajuste de tipografía lee ANTES de escribir.** `<text>` no salta de línea
ni se encoge, así que hay que medir; el boceto escribía `fontSize` y leía
`getComputedTextLength()` a continuación, que es el reflujo forzado que la casa
prohíbe. La lectura va diferida por `eidos.dom.measure(...)` y la longitud
natural se recupera de la escala aplicada, así que nunca hay lectura
sincrónica tras escritura.
- **Los estados discretos salen del CSS, no de JS.** `data-paused`, `data-ended`
y compañía ya están en `[data-media-player]`. Sólo el ángulo del brazo, los
radios de las bobinas y el contador necesitan valores continuos.
`togglePlay`, `seek` y `seekBy`; no hay `stop` ni `pause`. PARO/STOP hacen
`togglePlay()` + `seek(0)`, en `actions.ts` y en un solo sitio.
- **MARCHA/REPROD sólo arrancan; PAUSA sólo pausa.** Un `PlayButton` es un
toggle, así que una tecla ya enclavada habría pausado al pulsarla. La costura
es la de `RateFloat`: se compone la parte real y su handler se anula con
`preventDefault()` cuando no toca (`actions.vetoes`).
- **Las r.p.m. son decorativas.** 33/45 cambian el periodo del plato y del
estrobo; **no tocan `playbackRate`**. Exigiría saber a qué velocidad se grabó
el disco (dato que no existe) y sonaría mal: el navegador conserva el tono por
defecto. Decisión del usuario, 2026-08-15.
- **Componente de tono fijo.** Caoba, aluminio, cromo, marfil, granate y oro son
la representación, no roles de tema. Los 83 valores del autor se conservan
intactos bajo la excepción que `recipe-css-contract.test.ts` ya reserva para
los componentes que DIBUJAN un objeto físico.
- **El contador de cinta lee el medio**, no un `setInterval` propio; por eso la
ruedecilla de puesta a cero se queda pintada e inerte (poner a cero ES volver
al principio, que es STOP).
- **El ajuste de tipografía lee ANTES de escribir**, y diferido por
`eidos.dom.measure` anclado a un nodo, que es lo que la casa exige para
cualquier lectura de layout.
## Gaps
| Gap | Disposición | Detalle |
| ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| GRAB (grabar) | **descartar → pintado** | El puerto `MediaProvider` es play/pause/seek/volume/muted/rate. Grabar es captura, no reproducción, y no entra aquí. La tecla se pinta **deshabilitada** — la misma lectura que da un `SkipButton` sin manejador. Firmado por el usuario 2026-08-15. |
| 33/45 → `playbackRate` | **descartar** | Ver §Decisiones. La puerta queda abierta: un prop `rpm` numérico del disco + `preservesPitch = false` sobre el elemento nativo. |
| Enclavamiento de REBOB / AVANCE | **diferir** | En la pletina real las dos teclas se **enclavan** y bobinan de forma continua. Aquí son momentáneas y hacen un `seek` de `seekStep`: un bobinado continuo exigiría repetición mantenida, que es comportamiento nuevo y nadie lo pidió. |
| Textos de STOP y GRAB sin traducir | **deuda declarada** | El catálogo `components.media-player.*` no tiene `stop` ni `record`, y no se puede ampliar bajo el alcance acordado. Se usa un idlangref con `\|fallback`, que en lookup de primer nivel **no avisa** ([engine-langs.ts:140]) — pero sale siempre en inglés. |
| Las dos fuentes del dibujo | **deuda declarada** | `Bodoni Moda` y `Cormorant Garamond` las cargaba el banco de pruebas desde Google Fonts. El marco no las sirve: sin ellas la pintura cae a `Georgia, serif`, declarado como fallback en cada `font-family`. |
| Demo canónica (`web/routes/uix/…`) | **fuera de alcance** | El alcance acordado limitaba los cambios a la carpeta del componente. Sin demo no hay reglas `D-*`, ni `perm:check`, ni fila en el audit. |
| `Poster`, `Captions`, PiP, fullscreen | **descartar** | Son superficie de vídeo; la piel es de audio (`media="audio"`). |
| Aguja del VU ligada a la señal real | **diferir** | Oscila con una animación, como en el boceto. Leer amplitud real exige un `AnalyserNode`, que es del eje de sonido y no se toca fuera de su sesión. |
| Gap | Disposición | Detalle |
| ---------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GRAB (grabar) | **descartar → pintado** | El puerto `MediaProvider` es play/pause/seek/volume/muted/rate. Grabar es captura. La tecla se pinta **deshabilitada**, la misma lectura que un `SkipButton` sin manejador. Firmado 2026-08-15. |
| 33/45 → `playbackRate` | **descartar** | Ver §Decisiones. Puerta abierta: prop `rpm` numérico del disco + `preservesPitch = false`. |
| Enclavamiento de REBOB / AVANCE | **diferir** | En la pletina real se enclavan y bobinan de forma continua. Aquí son momentáneas y hacen `seek` de `seekStep`; el bobinado continuo es comportamiento nuevo. |
| Textos de STOP y GRAB sin traducir | **deuda declarada** | El catálogo `components.media-player.*` no tiene `stop` ni `record` y no se puede ampliar bajo el alcance acordado. Se usa un idlangref con `\|fallback`, silencioso en lookup de primer nivel. |
| Las dos fuentes del dibujo | **deuda declarada** | `Bodoni Moda` y `Cormorant Garamond` no las sirve el marco: sin ellas la pintura cae a `Georgia, serif`, declarado como fallback en cada `font-family`. |
| Demo canónica (`web/routes/uix/…`) | **fuera de alcance** | El alcance limitaba los cambios a la carpeta del componente. Sin demo no hay reglas `D-*`, ni `perm:check`, ni fila en el audit. |
| Aguja del VU ligada a la señal | **diferir** | Oscila con su propio keyframe. Leer amplitud real exige un `AnalyserNode`, que es del eje de sonido. |
## Audit exceptions
- **Tono fijo (`recipe-css-contract`)** — `skin-media-player` está en
`FIXED_TONE_COMPONENTS`. Es la cuarta entrada, y por el mismo motivo que las
tres anteriores: el componente **dibuja un objeto físico** cuyo aspecto debe
leerse igual en cualquier tema. Alcance: los literales de color de la pintura.
La excepción está acotada a esta carpeta a propósito — meterla en
`media-player/` habría eximido al recipe del player real.
- **R-1.1 exception:** el recipe no declara `[data-skin-media-player]` como raíz
con layout propio; la raíz visual sigue siendo `[data-media-player]` (esto es
una piel sobre él, no un componente con superficie propia). El recipe engancha
en `[data-skin-media-player-skin]` y en cada cuerpo.
- **R-1.2 / R-1.5 exception:** el estado disabled y el anillo de foco los aportan
los primitivos compuestos y la plataforma — las teclas son `<button>` nativos
con `:focus-visible` sobre `--focus-ring-*`, y `disabled` es el atributo real.
- **Sin tokens públicos `--skin-media-player-*`.** Requerirían una entrada en
`EidosConfig.recipes` (`lib/recipes/base.ts`), fuera del alcance acordado. Todo
lo retocable vive como privado `--_smp-*` en el recipe.
- **F-1.2** — la comparativa cita cinco referencias, dos de ellas internas
(`AudioPlayer`, `ProofOfHuman.Clock`): no existe un referente externo headless
de piel figurativa para un media player.
[engine-langs.ts:140]: ../../../arts/langs/engine-langs.ts
`FIXED_TONE_COMPONENTS`, por el mismo motivo que `natural-time-picker`,
`color-picker` y `proof-of-human`: **dibuja un objeto físico**. Acotada a esta
carpeta a propósito; dentro de `media-player/` habría eximido al recipe del
player real.
- **R-4.5** — un único `@keyframes` local, `smp-vu`, anotado `functional:`:
ningún preset representa un instrumento analógico. Su periodo sale de un token.
- **R-1.1 exception:** el recipe no declara una raíz con layout propio; la raíz
visual es `[data-media-player]`. Éste engancha en `[data-skin-media-player]` y
en cada cuerpo.
- **R-1.2 / R-1.5 exception:** disabled y anillo de foco los aportan la
plataforma y el sistema de foco — las teclas son `<button>` nativos con
`:focus-visible` sobre `--focus-ring-*`, y `disabled` es el atributo real.
- **Sin tokens públicos `--skin-media-player-*`** — requerirían una entrada en
`EidosConfig.recipes`, fuera del alcance acordado. Lo retocable vive como
privado `--_smp-*`.
- **F-1.2** — la comparativa cita cinco referencias, dos internas: no existe un
referente externo headless de piel figurativa para un media player.

@ -0,0 +1,81 @@
// The action vocabulary of a depicted machine, resolved ONCE against the
// player. A device contributes its paint and a table of coordinates; what a
// key MEANS lives here, in one place, for all four.
import type { MediaPlayerProvider } from '$soma/components/media-player';
/**
* What a depicted control can ask of the player.
*
* - `play` / `pause` — one-directional. A machine's REPROD key is already
* latched down while it plays: pressing it again must not pause, which a
* bare toggle would do.
* - `toggle` — the cueing lever, whose whole job is to alternate.
* - `stop` — pause + back to the start. The contract has NO `stop`: the
* provider exposes `togglePlay`, `seek` and `seekBy` only. Composing the two
* calls is a deliberate decision, recorded in the README.
* - `seek-back` / `seek-fwd` — ∓ the player's own `seekStep`.
* - `rpm-33` / `rpm-45` — platter speed. DECORATIVE: it drives the spin and
* the strobe, never `playbackRate`.
* - `none` — painted, unavailable. The record key of a player-only deck.
*/
export type SkinAction =
| 'play'
| 'pause'
| 'toggle'
| 'stop'
| 'seek-back'
| 'seek-fwd'
| 'rpm-33'
| 'rpm-45'
| 'none';
/**
* Which morfo part backs an action, when one does. A key with a part composes
* the REAL soma part (its `aria-label`, its `data-*`, its sema event); the
* rest are plain buttons that call the provider.
*/
export type SkinActionPart = 'play-button' | 'seek-button' | null;
export function partFor(action: SkinAction): SkinActionPart {
if (action === 'play' || action === 'pause' || action === 'toggle') return 'play-button';
if (action === 'seek-back' || action === 'seek-fwd') return 'seek-button';
return null;
}
/**
* Should the composed part's own handler be CANCELLED for this press?
*
* `PlayButton` is a toggle, so a one-directional key has to veto it in the
* seam — the pattern `RateFloat` already uses: the consumer's `onclick` runs
* first and `composeHandlers` skips soma's when it calls `preventDefault()`.
*/
export function vetoes(action: SkinAction, paused: boolean): boolean {
if (action === 'play') return !paused;
if (action === 'pause') return paused;
return false;
}
/** The actions that have no morfo part and act on the provider directly. */
export function runAction(
action: SkinAction,
provider: MediaPlayerProvider,
setRpm: (rpm: 33 | 45) => void
): void {
switch (action) {
case 'stop':
// No `stop` in the contract: pause, then rewind.
if (!provider.paused) provider.togglePlay();
provider.seek(0);
return;
case 'rpm-33':
setRpm(33);
return;
case 'rpm-45':
setRpm(45);
return;
default:
// `play` / `pause` / `toggle` / `seek-*` ride their composed part;
// `none` is inert.
return;
}
}

@ -0,0 +1,90 @@
// One table per depicted object. A device is DATA: its canvas, its natural
// size, and where its controls sit. Adding a fifth machine is an SVG plus an
// entry here — no wiring, because the wiring lives once in the component.
import type { SkinAction } from './actions';
/** A control's hit area, in the device's own viewBox units. */
export type SkinKey = {
id: string;
x: number;
y: number;
w: number;
h: number;
action: SkinAction;
};
/**
* The machine's volume control: a SLOT, not a dial.
*
* The first pass used the catalogue's `Knob`, and it was unusable — that
* primitive maps the value to the pointer's ABSOLUTE angle around the centre
* (`Gesture.rotate`), so setting a level means orbiting the mouse around a
* 40px circle. The player already owns a linear volume control, and portable
* decks really did carry sliders, so the machine gets a slot and the composed
* `VolumeSlider` runs in it.
*
* Absent on the two bare media — a record has no volume, and inventing one
* would be inventing the object.
*/
export type SkinFader = { x: number; y: number; w: number; h: number };
export type SkinDevice = {
/** The canvas the paint is authored in. */
view: { w: number; h: number };
/** Where the controls are. Empty for the two bare media. */
keys: readonly SkinKey[];
/** Where the volume slot is, when the machine has one. */
fader?: SkinFader;
};
export const DEVICES = {
record: {
view: { w: 525, h: 570 },
keys: [],
// A record has no volume control. Inventing one would be inventing the
// object, so the property is present and empty rather than absent.
fader: undefined
},
cassette: {
view: { w: 1000, h: 629 },
keys: [],
fader: undefined
},
turntable: {
view: { w: 1000, h: 880 },
keys: [
{ id: 'rpm-33', x: 736, y: 190, w: 90, h: 72, action: 'rpm-33' },
{ id: 'rpm-45', x: 836, y: 190, w: 90, h: 72, action: 'rpm-45' },
// MARCHA starts. PARO stops. The cueing lever lifts the stylus
// without stopping the platter, which is exactly pause.
{ id: 'run', x: 736, y: 300, w: 190, h: 78, action: 'play' },
{ id: 'stop', x: 736, y: 390, w: 190, h: 78, action: 'stop' },
// The lever travels 56 units when raised: the area covers both ends.
{ id: 'cue', x: 792, y: 526, w: 78, h: 108, action: 'toggle' }
],
// The free fascia between the platter and the mains lamp.
fader: { x: 452, y: 744, w: 196, h: 34 }
},
deck: {
view: { w: 1000, h: 780 },
keys: [
// GRAB has no counterpart and never will: the `MediaProvider` port is
// play/pause/seek/volume/muted/rate, and recording is capture, not
// playback. Painted, and painted UNAVAILABLE.
{ id: 'rec', x: 80, y: 604, w: 130, h: 128, action: 'none' },
{ id: 'rew', x: 222, y: 604, w: 130, h: 128, action: 'seek-back' },
{ id: 'play', x: 364, y: 604, w: 130, h: 128, action: 'play' },
{ id: 'ff', x: 506, y: 604, w: 130, h: 128, action: 'seek-fwd' },
{ id: 'stop', x: 648, y: 604, w: 130, h: 128, action: 'stop' },
{ id: 'pause', x: 790, y: 604, w: 130, h: 128, action: 'pause' }
],
// Between the VU meter and the counter. The meter's box was halved to
// make room — its needle geometry is untouched, only the bezel shrank —
// and the counter and the playback lamp moved right.
// 42 viewBox units clear of the meter's bezel — 20px at the deck's `md`
// width — and short enough to leave the same air before the counter.
fader: { x: 258, y: 498, w: 170, h: 34 }
}
} as const satisfies Record<string, SkinDevice>;
export type SkinName = keyof typeof DEVICES;

@ -1,7 +1,6 @@
// Pure geometry for the depicted objects. No runes, no DOM: every function maps
// playback progress (0..1) to a coordinate the SVG can paint. Kept out of the
// components because both vinyl skins share the tonearm solution and both tape
// skins share the reel solution.
// Pure geometry for the depicted objects: playback progress in, a coordinate
// the paint can use out. No runes, no DOM, no provider — the one component
// that owns the wiring calls these once and hands the results to the paint.
/* ── Engagement ──────────────────────────────────────────────────────────── */
@ -70,9 +69,12 @@ export function armAngle(progress: number, active: boolean): number {
return angleForRadius(GROOVE_IN + (GROOVE_OUT - GROOVE_IN) * clamped);
}
/** Seconds per platter revolution. 33⅓ rpm → 1.8 s, 45 rpm → 1.333 s. */
export function turnSeconds(rpm: 33 | 45): number {
return rpm === 33 ? 1.8 : 1.333;
/**
* Seconds per platter revolution — the value written into the `spin` loop's
* own knob (`--motion-loop-spin`). 33⅓ rpm → 1.8 s, 45 rpm → 1.333 s.
*/
export function turnSeconds(rpm: 33 | 45): string {
return rpm === 33 ? '1.8s' : '1.333s';
}
/* ── Tape reels ──────────────────────────────────────────────────────────── */
@ -104,8 +106,8 @@ export type Reels = {
* Each turn adds one tape thickness to the pack, so turns taken are
* proportional to how much the radius grew — the correct speed falls out on its
* own, and the small reel spins faster than the big one. Expressed as an ANGLE
* rather than an `animation-duration`, because retuning a running animation's
* duration makes it jump.
* rather than a loop duration, because retuning a running animation's period
* makes it jump.
*/
export function reels(progress: number): Reels {
const p = Math.min(1, Math.max(0, progress));

@ -1,29 +1,23 @@
// SkinMediaPlayer — a depicted surface for the MediaPlayer.
//
// import { SkinMediaPlayer } from '$uix/eidos/components/skin-media-player';
// <SkinMediaPlayer skin="turntable" src="/track.mp3" title="…" artist="…" />
// It is a PIECE, not a player: it goes inside a `<MediaPlayer>` alongside the
// player's own parts. The shape of the player (inline / bar / row / card) is
// the `AudioPlayer` variant axis and is chosen there, never here.
//
// The 10% that arranges the parts by hand mounts the depiction alone inside any
// player:
// import { MediaPlayer } from '$uix/eidos/components/media-player';
// import { SkinMediaPlayer } from '$uix/eidos/components/skin-media-player';
//
// import { Skin } from '$uix/eidos/components/skin-media-player';
// <MediaPlayer media="audio">
// <MediaPlayer.Media src="…" />
// <Skin skin="deck" title="…" />
// <MediaPlayer.Media src="/track.mp3" />
// <SkinMediaPlayer skin="deck" title="…" artist="…" image="…" />
// <MediaPlayer.DefaultControls />
// </MediaPlayer>
import SkinMediaPlayerComponent from './skin-media-player.svelte';
import Skin from './skin-media-player-skin.svelte';
const SkinMediaPlayer = SkinMediaPlayerComponent;
export { SkinMediaPlayer, Skin };
export default SkinMediaPlayer;
export { default as SkinMediaPlayer, default } from './skin-media-player.svelte';
export type {
SkinMediaPlayerProps,
SkinMediaPlayerSkin,
SkinMediaPlayerRpm,
SkinProps,
SkinIdentity
SkinIdentity,
SkinPaint
} from './types';

@ -1,18 +1,18 @@
<script lang="ts">
/**
* The cassette — shell, inlay, window and the two tape packs. Pure paint.
* The cassette — shell, inlay, window and the two tape packs.
* PAINT ONLY: props in, SVG out.
*
* viewBox 0 0 1000 629; the paper inlay occupies x 112–888, y 74–430.
* Mounted on its own it is the `cassette` skin; the `deck` nests it as an
* inner `<svg>` behind the smoked window, so everything but the identity
* rides `...rest` onto the root element.
* viewBox 0 0 1000 629; the paper inlay occupies x 112–888, y 74–430. The
* `deck` nests this as an inner `<svg>` behind the smoked window, so
* everything but the identity rides `...rest` onto the root element.
*/
import { fitText } from './type-fit.svelte';
import { ActiveEidos } from '$uix/eidos';
import { MediaPlayerProvider } from '$soma/components/media-player';
import { engaged, reels } from './geometry';
import type { SkinIdentity } from './types';
import type { SkinIdentity, SkinPaint } from './types';
let {
paint,
title = '',
artist = '',
year = '',
@ -21,56 +21,35 @@
side = 'A',
tapeType = 'C-60',
...rest
}: SkinIdentity & { [key: string]: unknown } = $props();
}: SkinIdentity & { paint: SkinPaint; [key: string]: unknown } = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
const spinning = $derived(!provider.paused);
const active = $derived(engaged(provider));
// Before metadata lands there is no duration, so the packs cannot be placed:
// the reels fall back to a constant spin instead of lying about the tape.
const timed = $derived(provider.duration > 0);
const pack = $derived(reels(provider.progress));
const pack = $derived(paint.reels);
const name = $derived([title, artist].filter(Boolean).join(' — '));
/** With a photo the type has less room before it collides with it. */
const textWidth = $derived(image ? 436 : 626);
// Deferred read, taken BEFORE any write — see the record skin for why.
let texts: (SVGTextElement | undefined)[] = $state([]);
const INLAY_FIT = [{ base: 56 }, { base: 24 }, { base: 20 }] as const;
const slots = $derived([
{ max: textWidth, base: 56 },
{ max: textWidth, base: 24 },
{ max: textWidth, base: 20 }
]);
function fit(el: SVGTextElement | undefined, max: number, base: number) {
if (!el?.getComputedTextLength) return;
const applied = parseFloat(el.style.fontSize) || base;
const measured = el.getComputedTextLength();
if (!measured) return;
const natural = (measured * base) / applied;
const next = natural > max ? Math.max(base * 0.45, (base * max) / natural) : base;
if (next !== applied) el.style.fontSize = `${next}px`;
}
$effect(() => {
void [title, artist, year, publisher, tapeType, textWidth];
// Spread, not a bare reference: reading each index is what makes the
// effect depend on the `bind:this` writes, which land after it first runs.
const els = [...texts];
const max = textWidth;
const anchor = els.find(Boolean);
if (!anchor) return;
return eidos.dom.measure(() => {
INLAY_FIT.forEach((slot, i) => fit(els[i], max, slot.base));
}, anchor);
});
fitText(
eidos,
() => texts,
() => slots,
() => [title, artist, year, publisher, tapeType, textWidth]
);
</script>
<svg
data-skin-media-player-cassette
data-active={active ? '' : undefined}
data-spinning={spinning ? '' : undefined}
data-timed={timed ? '' : undefined}
data-timed={paint.timed ? '' : undefined}
viewBox="0 0 1000 629"
style="--_smp-reel-left:{pack.spinLeft}deg; --_smp-reel-right:{pack.spinRight}deg"
role={name ? 'img' : undefined}
@ -208,9 +187,10 @@
<circle cx="366" cy="311" r="56" fill="#0b0805" />
<circle cx="634" cy="311" r="56" fill="#0b0805" />
<!-- The stretch of tape between the two packs -->
<!-- The stretch of tape between the two packs. Its breathing is the
system's `pulse` loop, stamped only while the tape runs. -->
<rect
class="smp-tape"
data-animation-style={paint.playing ? 'pulse' : undefined}
x={366 + pack.left}
y="304"
width={268 - pack.left - pack.right}
@ -241,7 +221,14 @@
/>
{/each}
<g class="smp-reel">
<!-- Free-running (no duration known) the reels take the `spin` loop with
their own period; timed, the angle comes from the elapsed time and
each reel's speed varies on its own with its pack radius. -->
<g
class="smp-reel"
data-animation-style={paint.active && !paint.timed ? 'spin' : undefined}
style="--motion-loop-spin:3.4s"
>
<g transform="translate(366 311) scale(0.565) translate(-366 -311)">
<circle cx="366" cy="311" r="46" fill="#e9e4d8" stroke="#8d8577" stroke-width="6" />
<g stroke="#8d8577" stroke-width="10" stroke-linecap="round">
@ -252,7 +239,11 @@
<circle cx="366" cy="311" r="16" fill="#8d8577" />
</g>
</g>
<g class="smp-reel smp-reel-right">
<g
class="smp-reel smp-reel-right"
data-animation-style={paint.active && !paint.timed ? 'spin' : undefined}
style="--motion-loop-spin:2.4s"
>
<g transform="translate(634 311) scale(0.565) translate(-634 -311)">
<circle cx="634" cy="311" r="46" fill="#e9e4d8" stroke="#8d8577" stroke-width="6" />
<g stroke="#8d8577" stroke-width="10" stroke-linecap="round">

@ -1,50 +1,23 @@
<script lang="ts">
/**
* The cassette deck — speaker, window, VU meter, tape counter and the piano
* keys. The cassette nests behind the smoked glass as an inner `<svg>`.
* keys. PAINT ONLY: props in, SVG out. The hit areas are laid over this from
* the table in `controls.ts`.
*
* viewBox 0 0 1000 780. The keyboard maps onto the player:
* REPROD → play · PAUSA → pause · STOP → pause + back to the start ·
* REBOB / AVANCE → seek ∓ `seekStep` · GRAB → disabled, always.
*
* GRAB has no counterpart and never will: the `MediaProvider` port is
* play/pause/seek/volume/muted/rate, and recording is capture, not playback.
* It is painted, and painted UNAVAILABLE — the same read the `SkipButton`
* gives a direction with no handler.
* viewBox 0 0 1000 780. The cassette nests behind the smoked glass as an
* inner `<svg>`.
*/
import { ActiveEidos } from '$uix/eidos';
import * as MediaPlayer from '$soma/components/media-player';
import { MediaPlayerProvider } from '$soma/components/media-player';
import Key from './skin-media-player-key.svelte';
import Cassette from './skin-media-player-cassette.svelte';
import { engaged, tapeCounter } from './geometry';
import type { SkinIdentity } from './types';
import type { SkinIdentity, SkinPaint } from './types';
let { brand = 'Levante', model = 'CR-77', ...identity }: SkinIdentity = $props();
let {
paint,
brand = 'Levante',
model = 'CR-77',
...identity
}: SkinIdentity & { paint: SkinPaint } = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
const active = $derived(engaged(provider));
const playing = $derived(!provider.paused);
const digits = $derived(tapeCounter(provider.progress));
const stopLabel = $derived(eidos.langs.ts('#?components.media-player.stop|Stop'));
const recordLabel = $derived(eidos.langs.ts('#?components.media-player.record|Record'));
/** STOP: the contract has no `stop`, so the two calls are composed here. */
function stop() {
if (!provider.paused) provider.togglePlay();
provider.seek(0);
}
let held = $state<string | null>(null);
const hold = (id: string) => ({
onpointerdown: () => (held = id),
onpointerup: () => (held = null),
onpointerleave: () => (held = null),
onblur: () => (held = null)
});
const GLYPHS = {
rec: 'M0,-13 A13,13 0 1,0 0,13 A13,13 0 1,0 0,-13 Z',
@ -55,26 +28,26 @@
pause: 'M-11,-12 H-3 V12 H-11 Z M3,-12 H11 V12 H3 Z'
} as const;
/** The face of each key. The hit areas + meanings live in `controls.ts`. */
const KEYS = [
{ id: 'rec', x: 80, text: 'GRAB' },
{ id: 'rew', x: 222, text: 'REBOB' },
{ id: 'play', x: 364, text: 'REPROD' },
{ id: 'ff', x: 506, text: 'AVANCE' },
{ id: 'stop', x: 648, text: 'STOP' },
{ id: 'pause', x: 790, text: 'PAUSA' }
{ id: 'rec', x: 80, text: 'GRAB', unavailable: true },
{ id: 'rew', x: 222, text: 'REBOB', unavailable: false },
{ id: 'play', x: 364, text: 'REPROD', unavailable: false },
{ id: 'ff', x: 506, text: 'AVANCE', unavailable: false },
{ id: 'stop', x: 648, text: 'STOP', unavailable: false },
{ id: 'pause', x: 790, text: 'PAUSA', unavailable: false }
] as const;
const latched = $derived<Record<string, boolean>>({
rec: false,
rew: false,
play: playing,
ff: false,
stop: false,
pause: active && provider.paused
});
/** The badge plate's shared width, in viewBox units. */
const BADGE_WIDTH = 190;
/** VU dial geometry. */
const VU = { cx: 216, cy: 574, r: 80 };
/**
* VU dial geometry. The bezel is HALF its original width — the meter never
* used the right half — and the dial recentres inside it, so the sweep and
* the needle are untouched, only the box around them shrank. The room this
* frees is what the volume knob sits in.
*/
const VU = { cx: 146, cy: 574, r: 80 };
const TICKS = [-26, -13, 0, 13, 26].map((deg) => {
const a = (deg * Math.PI) / 180;
const at = (rr: number) => [
@ -89,8 +62,7 @@
<svg
data-skin-media-player-deck
data-active={active ? '' : undefined}
data-spinning={playing ? '' : undefined}
data-spinning={paint.playing ? '' : undefined}
viewBox="0 0 1000 780"
aria-hidden="true"
>
@ -130,7 +102,7 @@
<circle cx="10.5" cy="10.5" r="5.2" fill="#1b140d" fill-opacity="0.9" />
</pattern>
<clipPath id="smp-vu-{uid}"><rect x="76" y="470" width="280" height="88" rx="6" /></clipPath>
<clipPath id="smp-vu-{uid}"><rect x="76" y="470" width="140" height="88" rx="6" /></clipPath>
<clipPath id="smp-glass-{uid}"
><rect x="456" y="128" width="452" height="304" rx="6" /></clipPath
>
@ -191,6 +163,7 @@
<Cassette
{...identity}
{paint}
x="464"
y="143"
width="436"
@ -218,37 +191,39 @@
stroke-width="3"
/>
<!-- VU meter -->
<rect x="76" y="470" width="280" height="88" rx="6" fill="#f2e8d0" />
<!-- VU meter — half-width bezel, same needle -->
<rect x="76" y="470" width="140" height="88" rx="6" fill="#f2e8d0" />
<g clip-path="url(#smp-vu-{uid})">
<path
d="M180.9 502.1 A80 80 0 0 1 251.1 502.1"
d="M110.9 502.1 A80 80 0 0 1 181.1 502.1"
fill="none"
stroke="#3a2e24"
stroke-width="1.5"
/>
<path d="M227.1 494.8 A80 80 0 0 1 251.1 502.1" fill="none" stroke="#6e1f2a" stroke-width="4" />
<path d="M157.1 494.8 A80 80 0 0 1 181.1 502.1" fill="none" stroke="#6e1f2a" stroke-width="4" />
{#each TICKS as t, i (i)}
<path d={t.d} stroke={t.zero ? '#6e1f2a' : '#3a2e24'} stroke-width={t.zero ? 2.5 : 1.5} />
{/each}
<g class="smp-needle">
<path d="M216 574 L216 492" stroke="#231b13" stroke-width="2.5" stroke-linecap="round" />
<path d="M146 574 L146 492" stroke="#231b13" stroke-width="2.5" stroke-linecap="round" />
</g>
<circle cx="216" cy="558" r="9" fill="#8f8672" />
<circle cx="146" cy="558" r="9" fill="#8f8672" />
</g>
<text
x="216"
y="548"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="5"
fill="#7a6a52">VU</text
>
{#if paint.labels}
<text
x="146"
y="548"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="5"
fill="#7a6a52">VU</text
>
{/if}
<rect
x="76"
y="470"
width="280"
width="140"
height="88"
rx="6"
fill="none"
@ -256,12 +231,13 @@
stroke-width="2"
/>
<!-- Tape counter — derived from the tape actually consumed -->
<rect x="386" y="482" width="180" height="66" rx="6" fill="#241c15" />
{#each digits as d, i (i)}
<rect x={399 + i * 40} y="494" width="34" height="42" rx="3" fill="#f2e8d0" />
<!-- Tape counter — derived from the tape actually consumed. Moved right to
clear the volume knob. -->
<rect x="490" y="482" width="180" height="66" rx="6" fill="#241c15" />
{#each paint.counter as d, i (i)}
<rect x={503 + i * 40} y="494" width="34" height="42" rx="3" fill="#f2e8d0" />
<text
x={416 + i * 40}
x={520 + i * 40}
y="528"
text-anchor="middle"
font-family="'Bodoni Moda',Georgia,serif"
@ -270,28 +246,49 @@
>
{/each}
<!-- Counter reset knob: paint only. The counter now reads the medium, so
zeroing it IS returning to the start, which is what STOP does. -->
<circle cx="590" cy="515" r="14" fill="url(#smp-bolt-{uid})" />
<circle cx="590" cy="515" r="7" fill="#1b140d" />
<!-- Volume. The machine paints the GROOVE; the cap that runs in it is the
player's own `VolumeSlider`, laid over the slot and re-tinted to the
deck's materials by the recipe. It sits in the band the halved VU bezel
freed. -->
{#if paint.labels}
<text
x="343"
y="488"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">VOLUMEN</text
>
{/if}
<rect x="258" y="498" width="170" height="34" rx="17" fill="#1b140d" />
<rect
x="264"
y="504"
width="158"
height="22"
rx="11"
fill="none"
stroke="#4f4838"
stroke-opacity="0.55"
stroke-width="1.5"
/>
<circle cx="640" cy="515" r="11" fill="#3a3227" />
<circle class="smp-lamp" cx="640" cy="515" r="8" fill="url(#smp-red-{uid})" />
<text
x="660"
y="521"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445"
>
REPROD
</text>
<!-- Playback lamp. No caption: a red light next to the transport says what
it is, and the word was eating the band. Trimmed bezel, moved right with
the counter. -->
<circle cx="710" cy="515" r="9" fill="#3a3227" />
<circle class="smp-lamp" cx="710" cy="515" r="6.5" fill="url(#smp-red-{uid})" />
<!-- Brand and model share ONE width: `textLength` with `lengthAdjust`
spacing, so the two lines block up as a plate. Only the tracking moves,
never the glyphs. -->
<text
x="930"
y="512"
text-anchor="end"
textLength={BADGE_WIDTH}
lengthAdjust="spacing"
font-family="'Bodoni Moda',Georgia,serif"
font-weight="700"
font-size="34"
@ -301,32 +298,35 @@
x="930"
y="540"
text-anchor="end"
textLength={BADGE_WIDTH}
lengthAdjust="spacing"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="17"
letter-spacing="6"
fill="#6f6757">MODELO {model}</text
>
<!-- ── Keyboard paint ─────────────────────────────────────────────────── -->
{#each KEYS as k (k.id)}
<text
x={k.x + 65}
y="590"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="3"
fill="#5c5445">{k.text}</text
>
{/each}
<!-- ── Keyboard faces ─────────────────────────────────────────────────── -->
{#if paint.labels}
{#each KEYS as k (k.id)}
<text
x={k.x + 65}
y="590"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="3"
fill="#5c5445">{k.text}</text
>
{/each}
{/if}
<rect x="60" y="596" width="880" height="150" rx="10" fill="#1b140d" />
{#each KEYS as k (k.id)}
<g
class="smp-piano-key"
data-pressed={latched[k.id] || held === k.id ? '' : undefined}
data-unavailable={k.id === 'rec' ? '' : undefined}
data-pressed={paint.pressed(k.id) ? '' : undefined}
data-unavailable={k.unavailable ? '' : undefined}
>
<rect x={k.x} y="604" width="130" height="128" rx="7" fill="#0d0906" />
<g class="smp-piano-key-body">
@ -347,52 +347,3 @@
</g>
{/each}
</svg>
<!-- ── The affordances ────────────────────────────────────────────────────── -->
<MediaPlayer.SeekButton direction="backward" onclick={() => (held = null)}>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={222} y={604} w={130} h={128} vw={1000} vh={780} {...hold('rew')} />
{/snippet}
</MediaPlayer.SeekButton>
<MediaPlayer.PlayButton
onclick={(e: MouseEvent) => {
// REPROD only starts — a latched key must not pause the machine.
if (!provider.paused) e.preventDefault();
}}
>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={364} y={604} w={130} h={128} vw={1000} vh={780} {...hold('play')} />
{/snippet}
</MediaPlayer.PlayButton>
<MediaPlayer.SeekButton direction="forward" onclick={() => (held = null)}>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={506} y={604} w={130} h={128} vw={1000} vh={780} {...hold('ff')} />
{/snippet}
</MediaPlayer.SeekButton>
<Key
x={648}
y={604}
w={130}
h={128}
vw={1000}
vh={780}
aria-label={stopLabel}
onclick={stop}
{...hold('stop')}
/>
<MediaPlayer.PlayButton
onclick={(e: MouseEvent) => {
// PAUSA only pauses.
if (provider.paused) e.preventDefault();
}}
>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={790} y={604} w={130} h={128} vw={1000} vh={780} {...hold('pause')} />
{/snippet}
</MediaPlayer.PlayButton>
<Key x={80} y={604} w={130} h={128} vw={1000} vh={780} aria-label={recordLabel} disabled />

@ -11,8 +11,6 @@
*
* The paint stays in the SVG; only the hit area lives here.
*/
import type { Snippet } from 'svelte';
let {
x,
y,
@ -20,7 +18,6 @@
h,
vw,
vh,
children,
...rest
}: {
/** Hit-area box, in the host SVG's viewBox units. */
@ -31,7 +28,6 @@
/** Host viewBox size, so the box resolves to percentages. */
vw: number;
vh: number;
children?: Snippet;
[key: string]: unknown;
} = $props();
@ -44,6 +40,4 @@
);
</script>
<button {...rest} type="button" data-skin-media-player-key {style}>
{@render children?.()}
</button>
<button {...rest} type="button" data-skin-media-player-key {style}></button>

@ -1,67 +1,37 @@
<script lang="ts">
/**
* The record — disc, grooves, label and the tonearm that reads it. Pure
* paint: it reflects the player and drives nothing.
* The record — disc, grooves, label and the tonearm that reads it.
* PAINT ONLY: props in, SVG out. No provider, no effect, no context.
*
* viewBox 0 0 525 570 — disc centred at (240, 308) with radius 200, spindle
* at (450, 100), which is also the arm's pivot. `width` is the TOTAL width,
* arm included: the disc takes 400 of those 525 units.
*
* Mounted on its own it is the `record` skin; the `turntable` nests it as an
* inner `<svg>` in the platter well, which is why every prop but the
* identity rides `...rest` onto the root element.
* The `turntable` nests this as an inner `<svg>` in the platter well, so
* every prop but the identity rides `...rest` onto the root element.
*/
import { fitText } from './type-fit.svelte';
import { ActiveEidos } from '$uix/eidos';
import { MediaPlayerProvider } from '$soma/components/media-player';
import { armAngle, engaged, turnSeconds } from './geometry';
import type { SkinIdentity, SkinMediaPlayerRpm } from './types';
import type { SkinIdentity, SkinPaint } from './types';
let {
paint,
title = '',
artist = '',
year = '',
publisher = '',
image = '',
imageVeil = 0.55,
rpm = 45,
...rest
}: SkinIdentity & { rpm?: SkinMediaPlayerRpm; [key: string]: unknown } = $props();
}: SkinIdentity & { paint: SkinPaint; [key: string]: unknown } = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
// Needle down — see `engaged`: a STOP parks the arm again.
const active = $derived(engaged(provider));
const spinning = $derived(!provider.paused);
const angle = $derived(armAngle(provider.progress, active));
const rpmLabel = $derived(rpm === 33 ? '33⅓ R.P.M.' : '45 R.P.M.');
const rpmLabel = $derived(paint.rpm === 33 ? '33⅓ R.P.M.' : '45 R.P.M.');
const name = $derived([title, artist].filter(Boolean).join(' — '));
/**
* Lowering and lifting the arm is the cueing lever's slow gesture; following
* the groove is a stream of small corrections. Two different transitions,
* and this flag says which one is due.
*/
let cueing = $state(false);
$effect(() => {
void active;
cueing = true;
const handle = eidos.timers.schedule(null, 1700, () => {
cueing = false;
});
return () => {
handle?.cancel();
};
});
/* ── Type fitting ───────────────────────────────────────────────────────
`<text>` neither wraps nor shrinks: a long title runs off the label. The
read is deferred and happens BEFORE the write — a layout read taken right
after a style write forces a reflow mid-turn, which this house forbids.
`getComputedTextLength` scales linearly with the font size, so the natural
length is recoverable from whatever size is currently applied. */
const LABEL_FIT = [
const SLOTS = [
{ max: 128, base: 8 },
{ max: 132, base: 22 },
{ max: 130, base: 8 },
@ -69,30 +39,12 @@
] as const;
let texts: (SVGTextElement | undefined)[] = $state([]);
function fit(el: SVGTextElement | undefined, max: number, base: number) {
if (!el?.getComputedTextLength) return;
const applied = parseFloat(el.style.fontSize) || base;
const measured = el.getComputedTextLength();
if (!measured) return;
const natural = (measured * base) / applied;
const next = natural > max ? Math.max(base * 0.45, (base * max) / natural) : base;
if (next !== applied) el.style.fontSize = `${next}px`;
}
$effect(() => {
// Explicit dependencies: any of these means the type must be re-fitted.
void [publisher, title, artist, year, rpmLabel];
// Spread, not a bare reference: reading each index is what makes the
// effect depend on the `bind:this` writes, which land after it first runs.
const els = [...texts];
// The node anchors the read to the right window (iframe / popup safe) —
// the bare form resolves nothing to schedule against.
const anchor = els.find(Boolean);
if (!anchor) return;
return eidos.dom.measure(() => {
LABEL_FIT.forEach((slot, i) => fit(els[i], slot.max, slot.base));
}, anchor);
});
fitText(
eidos,
() => texts,
() => SLOTS,
() => [publisher, title, artist, year, rpmLabel]
);
const GROOVES = Array.from({ length: Math.floor((200 - 76 - 6) / 4) + 1 }, (_, i) => 78 + i * 4);
@ -147,11 +99,9 @@
<svg
data-skin-media-player-record
data-active={active ? '' : undefined}
data-spinning={spinning ? '' : undefined}
data-cueing={cueing ? '' : undefined}
data-cueing={paint.cueing ? '' : undefined}
viewBox="0 0 525 570"
style="--_smp-turn:{turnSeconds(rpm)}s; --_smp-arm:{angle}deg"
style="--_smp-arm:{paint.arm}deg"
role={name ? 'img' : undefined}
aria-label={name || undefined}
aria-hidden={name ? undefined : 'true'}
@ -225,52 +175,68 @@
</defs>
<!-- ── Disc ───────────────────────────────────────────────────────────── -->
<g class="smp-disc" filter="url(#smp-disc-shadow-{uid})">
<circle cx="240" cy="308" r="200" fill="#17110c" />
<g fill="none" stroke="#241c15" stroke-width="1">
{#each GROOVES as r (r)}
<circle cx="240" cy="308" {r} />
{/each}
</g>
<circle cx="240" cy="308" r="199.5" fill="none" stroke="#e9dcbf" stroke-opacity="0.15" />
<!-- The shadow hangs on a STILL parent, never on the turning group. A filter
applied to the rotating element rotates with it: the drop shadow's own
offset swings around the disc (it reads as the shadow sliding up and
down), and the filter region's penumbra sweeps the platter's strobe
marks, which then look like they blink. Filtering a static wrapper still
shadows the turning content — with a fixed light direction, which is the
only correct one. A disc is a circle, so its silhouette never changes. -->
<g filter="url(#smp-disc-shadow-{uid})">
<!-- The turn is the system's `spin` loop, its period written into the loop's
own knob. Stamped only while engaged, so a stopped side returns the disc
to 0° and a paused one freezes it (the recipe pauses the play state). -->
<g
class="smp-disc"
data-animation-style={paint.active ? 'spin' : undefined}
style="--motion-loop-spin:{paint.turn}"
>
<circle cx="240" cy="308" r="200" fill="#17110c" />
<g fill="none" stroke="#241c15" stroke-width="1">
{#each GROOVES as r (r)}
<circle cx="240" cy="308" {r} />
{/each}
</g>
<circle cx="240" cy="308" r="199.5" fill="none" stroke="#e9dcbf" stroke-opacity="0.15" />
<!-- Label -->
<circle cx="240" cy="308" r="76" fill="#6e1f2a" />
{#if image}
<image
href={image}
x="164"
y="232"
width="152"
height="152"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#smp-label-{uid})"
/>
<circle cx="240" cy="308" r="76" fill="#4a0f18" opacity={imageVeil} />
{/if}
<circle cx="240" cy="308" r="75.5" fill="none" stroke="#f4ecdc" stroke-opacity="0.35" />
<!-- Label -->
<circle cx="240" cy="308" r="76" fill="#6e1f2a" />
{#if image}
<image
href={image}
x="164"
y="232"
width="152"
height="152"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#smp-label-{uid})"
/>
<circle cx="240" cy="308" r="76" fill="#4a0f18" opacity={imageVeil} />
{/if}
<circle cx="240" cy="308" r="75.5" fill="none" stroke="#f4ecdc" stroke-opacity="0.35" />
<g text-anchor="middle" fill="#e0c7ac" font-family="'Cormorant Garamond',Georgia,serif">
<text bind:this={texts[0]} x="240" y="282" font-size="8" letter-spacing="2"
>{publisher.toUpperCase()}</text
>
<text
bind:this={texts[1]}
x="240"
y="304"
font-family="'Bodoni Moda',Georgia,serif"
font-style="italic"
font-size="22"
fill="#f4ecdc">{title}</text
>
<text bind:this={texts[2]} x="240" y="318" font-size="8" letter-spacing="1.6"
>{artist.toUpperCase()}</text
>
<text bind:this={texts[3]} x="240" y="328" font-size="8" letter-spacing="1.6"
>{year} · {rpmLabel}</text
>
<g text-anchor="middle" fill="#e0c7ac" font-family="'Cormorant Garamond',Georgia,serif">
<text bind:this={texts[0]} x="240" y="282" font-size="8" letter-spacing="2"
>{publisher.toUpperCase()}</text
>
<text
bind:this={texts[1]}
x="240"
y="304"
font-family="'Bodoni Moda',Georgia,serif"
font-style="italic"
font-size="22"
fill="#f4ecdc">{title}</text
>
<text bind:this={texts[2]} x="240" y="318" font-size="8" letter-spacing="1.6"
>{artist.toUpperCase()}</text
>
<text bind:this={texts[3]} x="240" y="328" font-size="8" letter-spacing="1.6"
>{year} · {rpmLabel}</text
>
</g>
<circle cx="240" cy="338" r="4.5" fill="#f4ecdc" />
</g>
<circle cx="240" cy="338" r="4.5" fill="#f4ecdc" />
</g>
<!-- ── Spindle base (UNDER the arm, as on the machine) ────────────────── -->
@ -286,51 +252,55 @@
</g>
<!-- ── Tonearm ────────────────────────────────────────────────────────── -->
<g class="smp-arm" filter="url(#smp-arm-shadow-{uid})">
<rect
x="419"
y="32"
width="54"
height="34.3"
rx="23.8"
ry="15.1"
fill="url(#smp-counterweight-{uid})"
/>
<rect x="439.8" y="63.2" width="12.5" height="21.8" fill="url(#smp-collar-{uid})" />
<!-- border-radius 36% 36% 30% 30% does not fit a <rect>: it goes as a path -->
<path
d="M441.9 80.4 H450 A10.5 10.1 0 0 1 460.5 90.5 V100.1 A8.7 8.4 0 0 1 451.8 108.5
H440.1 A8.7 8.4 0 0 1 431.4 100.1 V90.5 A10.5 10.1 0 0 1 441.9 80.4 Z"
fill="url(#smp-pivot-{uid})"
/>
<rect x="440.8" y="106.9" width="10.4" height="174.7" fill="url(#smp-tube-{uid})" />
<rect x="437.2" y="275.4" width="17.7" height="15.6" rx="2" fill="url(#smp-clamp-{uid})" />
<!-- cartridge, tilted 16° on its mount -->
<g transform="rotate(-16 450.4 287.8)">
<!-- Same rule as the disc: the filter stays on the still parent, so the light
keeps coming from one direction while the arm swings under it. -->
<g filter="url(#smp-arm-shadow-{uid})">
<g class="smp-arm">
<rect
x="424.2"
y="287.8"
width="43.7"
height="37.4"
rx="2.5"
fill="url(#smp-cartridge-{uid})"
x="419"
y="32"
width="54"
height="34.3"
rx="23.8"
ry="15.1"
fill="url(#smp-counterweight-{uid})"
/>
<rect x="439.8" y="63.2" width="12.5" height="21.8" fill="url(#smp-collar-{uid})" />
<!-- border-radius 36% 36% 30% 30% does not fit a <rect>: it goes as a path -->
<path
d="M424.2 290.3 A2.5 2.5 0 0 1 426.7 287.8 H465.4 A2.5 2.5 0 0 1 467.9 290.3"
fill="none"
stroke="#e9dcbf"
stroke-opacity="0.18"
/>
<rect x="447.8" y="275.1" width="22.7" height="14.2" rx="2" fill="url(#smp-clip-{uid})" />
<rect
x="427.7"
y="321.5"
width="14.9"
height="23.2"
rx="1.5"
fill="url(#smp-headshell-{uid})"
d="M441.9 80.4 H450 A10.5 10.1 0 0 1 460.5 90.5 V100.1 A8.7 8.4 0 0 1 451.8 108.5
H440.1 A8.7 8.4 0 0 1 431.4 100.1 V90.5 A10.5 10.1 0 0 1 441.9 80.4 Z"
fill="url(#smp-pivot-{uid})"
/>
<rect x="432.5" y="343.2" width="3.5" height="9" fill="#e9dcbf" />
<rect x="440.8" y="106.9" width="10.4" height="174.7" fill="url(#smp-tube-{uid})" />
<rect x="437.2" y="275.4" width="17.7" height="15.6" rx="2" fill="url(#smp-clamp-{uid})" />
<!-- cartridge, tilted 16° on its mount -->
<g transform="rotate(-16 450.4 287.8)">
<rect
x="424.2"
y="287.8"
width="43.7"
height="37.4"
rx="2.5"
fill="url(#smp-cartridge-{uid})"
/>
<path
d="M424.2 290.3 A2.5 2.5 0 0 1 426.7 287.8 H465.4 A2.5 2.5 0 0 1 467.9 290.3"
fill="none"
stroke="#e9dcbf"
stroke-opacity="0.18"
/>
<rect x="447.8" y="275.1" width="22.7" height="14.2" rx="2" fill="url(#smp-clip-{uid})" />
<rect
x="427.7"
y="321.5"
width="14.9"
height="23.2"
rx="1.5"
fill="url(#smp-headshell-{uid})"
/>
<rect x="432.5" y="343.2" width="3.5" height="9" fill="#e9dcbf" />
</g>
</g>
</g>

@ -1,37 +0,0 @@
<script lang="ts">
/**
* `<Skin>` — the depicted object, mounted inside any `<MediaPlayer>`.
*
* It is a COMPLEMENT, not a player: none of the four objects has a volume
* knob and none can seek freely, so the canonical control bar stays. What
* the object DOES depict — the transport keys — is wired to the real parts.
*
* An eidos-only part by the four rules: it creates no behaviour, is no
* event target, owns no ARIA relationships, and emits only structure plus
* visual `data-*` the recipe reads.
*/
import './skin-media-player.css';
import { ActiveEidos } from '$uix/eidos';
import RecordSkin from './skin-media-player-record.svelte';
import Cassette from './skin-media-player-cassette.svelte';
import Turntable from './skin-media-player-turntable.svelte';
import Deck from './skin-media-player-deck.svelte';
import type { SkinProps } from './types';
let { skin = 'turntable', rpm = 45, ...identity }: SkinProps = $props();
const eidos = ActiveEidos.require();
const resolved = $derived(eidos.resolve(skin, 'turntable'));
</script>
<div data-skin-media-player-skin data-skin={resolved}>
{#if resolved === 'record'}
<RecordSkin {...identity} {rpm} />
{:else if resolved === 'cassette'}
<Cassette {...identity} />
{:else if resolved === 'deck'}
<Deck {...identity} />
{:else}
<Turntable {...identity} {rpm} />
{/if}
</div>

@ -1,69 +1,27 @@
<script lang="ts">
/**
* The turntable — cabinet, platter, strobe ring and the controls. The record
* nests in the platter well as an inner `<svg>`, so the `record` skin is
* reused whole rather than redrawn.
* The turntable — cabinet, platter, strobe ring and the control faces.
* PAINT ONLY: props in, SVG out. The hit areas are `<button>`s the wiring
* component lays over this, from the table in `controls.ts`.
*
* viewBox 0 0 1000 880. The controls map onto the machine's own meanings:
* MARCHA → play · PARO → stop (pause + return to the start) ·
* ELEVADOR → the cueing lever, which lifts the stylus without stopping the
* platter, and that is exactly pause.
*
* The keys are real HTML `<button>`s laid over the paint — see
* `skin-media-player-key.svelte` for why an SVG `<g>` cannot be one.
* viewBox 0 0 1000 880. The record nests in the platter well as an inner
* `<svg>`, so its paint is reused whole rather than redrawn.
*/
import { ActiveEidos } from '$uix/eidos';
import * as MediaPlayer from '$soma/components/media-player';
import { MediaPlayerProvider } from '$soma/components/media-player';
import Key from './skin-media-player-key.svelte';
import { engaged } from './geometry';
import RecordSkin from './skin-media-player-record.svelte';
import type { SkinIdentity, SkinMediaPlayerRpm } from './types';
import type { SkinIdentity, SkinPaint } from './types';
let {
paint,
brand = 'Levante',
model = 'TD-45',
rpm = 45,
...identity
}: SkinIdentity & { rpm?: SkinMediaPlayerRpm } = $props();
}: SkinIdentity & { paint: SkinPaint } = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
const active = $derived(engaged(provider));
const playing = $derived(!provider.paused);
/** Platter speed belongs to the MACHINE, not to the record. Decorative. */
// svelte-ignore state_referenced_locally
let speed = $state<SkinMediaPlayerRpm>(rpm);
/**
* STOP has no part in the contract — the player exposes `togglePlay`, `seek`
* and `seekBy`, with no `stop`. Composing the two calls here is a deliberate
* decision (recorded in the README): a deck whose STOP does not stop is a
* worse lie than the coordination.
*/
function stop() {
if (!provider.paused) provider.togglePlay();
provider.seek(0);
}
/** Momentary key held down — the paint reads it; `:active` cannot cross from
* the overlay button back to the `<g>` it covers. */
let held = $state<string | null>(null);
const hold = (id: string) => ({
onpointerdown: () => (held = id),
onpointerup: () => (held = null),
onpointerleave: () => (held = null),
onblur: () => (held = null)
});
const stopLabel = $derived(eidos.langs.ts('#?components.media-player.stop|Stop'));
/**
* Strobe marks printed on the platter rim: they spin with it, so they share
* the record's animation and duration.
* Strobe marks printed on the platter rim: they turn with it, so they take
* the same `spin` loop and the same period.
*
* They come in four groups rather than one continuous ring, because of
* temporal aliasing: at 60 fps and 45 rpm the platter advances 4.5° per
@ -78,15 +36,14 @@
/** Wood grain. */
const GRAIN = [190, 268, 352, 470, 588, 664, 742];
const FACES = [
{ id: 'run', y: 300, text: 'MARCHA', ink: '#3a2e24' },
{ id: 'stop', y: 390, text: 'PARO', ink: '#6e1f2a' }
] as const;
</script>
<svg
data-skin-media-player-turntable
data-active={active ? '' : undefined}
data-spinning={playing ? '' : undefined}
viewBox="0 0 1000 880"
aria-hidden="true"
>
<svg data-skin-media-player-turntable viewBox="0 0 1000 880" aria-hidden="true">
<defs>
<linearGradient id="smp-wood-{uid}" x1="0" y1="0" x2="0.25" y2="1">
<stop offset="0" stop-color="#7a5430" />
@ -172,7 +129,11 @@
stroke-opacity="0.7"
stroke-width="2"
/>
<g class="smp-strobe" style="--_smp-turn:{speed === 33 ? 1.8 : 1.333}s">
<g
class="smp-strobe"
data-animation-style={paint.active ? 'spin' : undefined}
style="--motion-loop-spin:{paint.turn}"
>
{#each STROBE as m (m.a)}
<rect
x={406 - (m.index ? 3.5 : 2)}
@ -200,7 +161,7 @@
<!-- The platter well: the record with its arm goes in here -->
<RecordSkin
{...identity}
rpm={speed}
{paint}
x="150"
y="140"
width="560"
@ -215,18 +176,20 @@
<circle cx="406" cy="468.5" r="9" fill="url(#smp-chrome-{uid})" />
<circle cx="406" cy="468.5" r="9" fill="none" stroke="#6f6757" stroke-width="1" />
<!-- ── Control paint (the hit areas are the buttons outside this svg) ──── -->
<text
x="831"
y="178"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">VELOCIDAD</text
>
{#each [{ v: 33 as const, x: 736 }, { v: 45 as const, x: 836 }] as b (b.v)}
<g class="smp-button" data-pressed={speed === b.v || held === `rpm-${b.v}` ? '' : undefined}>
<!-- ── Control faces (the hit areas are laid over this svg) ───────────── -->
{#if paint.labels}
<text
x="831"
y="178"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">VELOCIDAD</text
>
{/if}
{#each [{ v: 33, x: 736 }, { v: 45, x: 836 }] as b (b.v)}
<g class="smp-button" data-pressed={paint.pressed(`rpm-${b.v}`) ? '' : undefined}>
<rect x={b.x} y="190" width="90" height="72" rx="6" fill="#0d0906" />
<g class="smp-button-face">
<rect x={b.x} y="190" width="90" height="64" rx="6" fill="url(#smp-button-{uid})" />
@ -243,51 +206,82 @@
</g>
{/each}
{#each [{ id: 'run', y: 300, text: 'MARCHA', on: playing }, { id: 'stop', y: 390, text: 'PARO', on: false }] as b (b.id)}
<g class="smp-button" data-pressed={b.on || held === b.id ? '' : undefined}>
{#each FACES as b (b.id)}
<g class="smp-button" data-pressed={paint.pressed(b.id) ? '' : undefined}>
<rect x="736" y={b.y} width="190" height="78" rx="8" fill="#0d0906" />
<g class="smp-button-face">
<rect x="736" y={b.y} width="190" height="70" rx="8" fill="url(#smp-button-{uid})" />
<text
x="831"
y={b.y + 44}
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="22"
letter-spacing="5"
fill={b.id === 'stop' ? '#6e1f2a' : '#3a2e24'}>{b.text}</text
>
{#if paint.labels}
<text
x="831"
y={b.y + 44}
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="22"
letter-spacing="5"
fill={b.ink}>{b.text}</text
>
{/if}
</g>
</g>
{/each}
<!-- Cueing lever: lifts the stylus without stopping the platter = pause -->
<text
x="831"
y="512"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">ELEVADOR</text
>
{#if paint.labels}
<text
x="831"
y="512"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">ELEVADOR</text
>
{/if}
<rect x="800" y="524" width="62" height="128" rx="10" fill="#241c15" />
<g class="smp-lever" data-raised={active && provider.paused ? '' : undefined}>
<g class="smp-lever" data-raised={paint.active && !paint.playing ? '' : undefined}>
<rect x="792" y="582" width="78" height="52" rx="10" fill="url(#smp-chrome-{uid})" />
</g>
<!-- Volume. The machine paints the GROOVE; the cap that runs in it is the
player's own `VolumeSlider`, laid over the slot and re-tinted to the
cabinet's materials by the recipe. -->
{#if paint.labels}
<text
x="550"
y="736"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">VOLUMEN</text
>
{/if}
<rect x="452" y="744" width="196" height="34" rx="17" fill="#241c15" />
<rect
x="458"
y="750"
width="184"
height="22"
rx="11"
fill="none"
stroke="#6f6757"
stroke-opacity="0.5"
stroke-width="1.5"
/>
<circle cx="760" cy="700" r="11" fill="#3a3227" />
<circle class="smp-lamp" cx="760" cy="700" r="8" fill="url(#smp-lamp-{uid})" />
<text
x="782"
y="706"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445"
>
RED
</text>
{#if paint.labels}
<text
x="782"
y="706"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">RED</text
>
{/if}
<!-- Brand plate -->
<rect x="96" y="728" width="240" height="50" rx="4" fill="#241c15" />
@ -310,53 +304,3 @@
fill="#6f6757">MODELO {model}</text
>
</svg>
<!-- ── The affordances ──────────────────────────────────────────────────────
Speed is the machine's own visual state, so those two are plain buttons.
The rest go through the player's real parts, except STOP, which the
contract does not declare. -->
{#each [{ v: 33 as const, x: 736 }, { v: 45 as const, x: 836 }] as b (b.v)}
<Key
x={b.x}
y={190}
w={90}
h={72}
vw={1000}
vh={880}
aria-label="{b.v} rpm"
aria-pressed={speed === b.v}
onclick={() => (speed = b.v)}
{...hold(`rpm-${b.v}`)}
/>
{/each}
<MediaPlayer.PlayButton
onclick={(e: MouseEvent) => {
// MARCHA only starts. A bare toggle here would PAUSE a machine whose key
// is already latched down — the opposite of what the key means.
if (!provider.paused) e.preventDefault();
}}
>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={736} y={300} w={190} h={78} vw={1000} vh={880} {...hold('run')} />
{/snippet}
</MediaPlayer.PlayButton>
<Key
x={736}
y={390}
w={190}
h={78}
vw={1000}
vh={880}
aria-label={stopLabel}
onclick={stop}
{...hold('stop')}
/>
<MediaPlayer.PlayButton>
{#snippet child({ props }: { props: Record<string, unknown> })}
<!-- The lever travels 56 units when raised: the hit area covers both ends. -->
<Key {...props} x={792} y={526} w={78} h={108} vw={1000} vh={880} />
{/snippet}
</MediaPlayer.PlayButton>

@ -3,52 +3,98 @@
* FIXED-TONE COMPONENT. The four objects are physical things: mahogany,
* brushed aluminium, chrome, ivory keys, a wine label, a gold spindle. Those
* hues are the depiction, not theme roles — a turntable does not turn blue
* because the theme does. They are declared as annotated literals in the SVG
* paint, under the exception `recipe-css-contract.test.ts` already carves for
* `natural-time-picker`, `color-picker` and `proof-of-human`. This file holds
* the geometry, the states and the movement; the palette stays with the paint.
* because the theme does. They are declared as literals in the SVG paint,
* under the exception `recipe-css-contract.test.ts` already carves for
* `natural-time-picker`, `color-picker` and `proof-of-human`.
*
* The keys are real HTML buttons laid over the SVG. Everything about them here
* is positioning and feedback: their focus ring, hit area and disabled
* semantics come from the platform + the focus-ring system.
* MOTION. Nothing here is hand-rolled. Every continuous turn is the system's
* `spin` loop and the tape's breathing is `pulse`, both stamped as
* `data-animation-style` with their period written into the loop's own knob
* (`--motion-loop-spin`); reduced-motion comes free and by BOTH routes
* (`[data-motion='reduce']` and the media query), which a local `@keyframes`
* would have missed. Transitions consume `--duration-*` / `--ease-*` through
* a `--_smp-*` slot per axis, so a theme can retune them without touching a
* component. The ONE local keyframe left is the VU needle: no preset depicts
* an analogue meter.
*/
[data-skin-media-player-skin] {
[data-skin-media-player] {
--_smp-aspect: 1000 / 880;
/* A depicted object has a natural size. Left at `100%` the machines are
nearly square, so they eat the viewport and push the player's own control
bar below the fold — the sketches capped them for the same reason
(`clamp(…, 34vw, 400px)` scaled per object). */
--_smp-max: 760px;
/* A depicted object has a size of its own — it is a THING, not a fluid
block. Left to fill its container the machines are nearly square, eat the
page and push the player's own control bar below the fold.
The base is the `md` width, supplied per device by `controls.ts`. */
--_smp-base: 480px;
/* And it follows the player's `size`, like every other piece of chrome
inside it. Both slots are design values: retune here, in one place. */
--_smp-scale: 1;
--_smp-size: calc(var(--_smp-base) * var(--_smp-scale));
/* Timing slots — the recipe's public surface for movement. */
--_smp-groove-duration: var(--duration-slower);
--_smp-cue-duration: calc(var(--duration-sustained) * 1.7);
--_smp-cue-ease: var(--ease-default);
--_smp-reel-duration: var(--duration-slow);
--_smp-key-duration: var(--duration-fast);
--_smp-key-ease: var(--ease-out);
--_smp-lever-duration: var(--duration-slow);
--_smp-lever-ease: var(--ease-default);
--_smp-lamp-duration: var(--duration-normal);
--_smp-needle-duration: var(--duration-deliberate);
--_smp-vu-period: calc(var(--duration-sustained) * 2.3);
position: relative;
display: block;
inline-size: 100%;
max-inline-size: var(--_smp-max);
/* The SIZE, not a ceiling. `max-inline-size` was the first attempt and it
cannot work: a cap only ever shrinks, so `lg` and `xl` were inert the
moment the container was narrower than the base — measured, all three of
md/lg/xl rendered at the same 720px. `min()` keeps it from overflowing a
narrow column while letting the size axis actually set the width. */
inline-size: min(100%, var(--_smp-size));
aspect-ratio: var(--_smp-aspect);
margin-inline: auto;
}
[data-skin-media-player-skin][data-skin='record'] {
/* The player owns the size axis and stamps it on its own root; the skin is a
descendant, so it reads it from there — no prop of its own, no second
vocabulary. */
[data-media-player][data-size='sm'] [data-skin-media-player] {
--_smp-scale: 0.7;
}
[data-media-player][data-size='lg'] [data-skin-media-player] {
--_smp-scale: 1.35;
}
[data-media-player][data-size='xl'] [data-skin-media-player] {
--_smp-scale: 1.7;
}
[data-skin-media-player][data-skin='record'] {
--_smp-aspect: 525 / 570;
--_smp-max: 525px;
--_smp-base: 340px;
}
[data-skin-media-player-skin][data-skin='cassette'] {
[data-skin-media-player][data-skin='cassette'] {
--_smp-aspect: 1000 / 629;
--_smp-max: 424px;
--_smp-base: 300px;
}
[data-skin-media-player-skin][data-skin='deck'] {
[data-skin-media-player][data-skin='deck'] {
--_smp-aspect: 1000 / 780;
--_smp-max: 760px;
--_smp-base: 480px;
}
[data-skin-media-player-skin] > svg {
[data-skin-media-player] > svg {
display: block;
inline-size: 100%;
block-size: 100%;
overflow: visible;
}
/* A paused machine holds its turn where it is. The loop preset is continuous
by design and brings no pause gate; this is the gate, not a second
animation. */
[data-media-player][data-paused] [data-skin-media-player] [data-animation-style='spin'] {
animation-play-state: paused;
}
/* ── The record ───────────────────────────────────────────────────────────── */
[data-skin-media-player-record] .smp-disc {
@ -56,16 +102,6 @@
transform-origin: 240px 308px;
}
/* The animation is only declared while a side is playing, so a stopped medium
returns the disc to 0° and a paused one freezes it where it was. */
[data-skin-media-player-record][data-active] .smp-disc {
animation: smp-turn var(--_smp-turn, 1.333s) linear infinite;
animation-play-state: paused;
}
[data-skin-media-player-record][data-spinning] .smp-disc {
animation-play-state: running;
}
[data-skin-media-player-record] .smp-arm {
transform-box: view-box;
/* The pivot. An explicit `transform-box` matters: without it the origin
@ -73,20 +109,18 @@
from somewhere else. */
transform-origin: 450px 100px;
transform: rotate(var(--_smp-arm, -9deg));
/* Following the groove: small, frequent steps. */
transition: transform 0.45s linear;
/* Following the groove: small, frequent steps, so the curve is linear. */
transition: transform var(--_smp-groove-duration) linear;
}
/* Lowering or lifting the arm: the cueing lever's slow, heavy gesture. */
[data-skin-media-player-record][data-cueing] .smp-arm {
transition: transform 1.7s cubic-bezier(0.35, 0.05, 0.2, 1);
transition: transform var(--_smp-cue-duration) var(--_smp-cue-ease);
}
/* ── The cassette ─────────────────────────────────────────────────────────── */
[data-skin-media-player-cassette] {
filter: drop-shadow(
0 20px 34px rgba(58, 46, 36, 0.45)
); /* literal: depicted object shadow, theme-independent */
filter: drop-shadow(0 20px 34px rgba(58, 46, 36, 0.45));
}
/* The cassette's own shadow paints nothing inside the deck's window. */
[data-skin-media-player-deck] [data-skin-media-player-cassette] {
@ -98,56 +132,39 @@
transform-origin: center;
}
/* Free-running: with no duration the tape position is unknown, so the reels
turn at a constant rate instead of lying about where the tape is. */
[data-skin-media-player-cassette][data-active]:not([data-timed]) .smp-reel {
animation: smp-turn 3.4s linear infinite;
animation-play-state: paused;
}
[data-skin-media-player-cassette][data-active]:not([data-timed]) .smp-reel-right {
animation-duration: 2.4s;
}
[data-skin-media-player-cassette][data-spinning]:not([data-timed]) .smp-reel {
animation-play-state: running;
}
/* Timed: the angle comes from the elapsed time, and each reel's speed varies on
its own with the radius of its pack. The transition only smooths between the
updates the player pushes. */
/* Timed: the angle comes from the elapsed time, and each reel's speed varies
on its own with the radius of its pack. The transition only smooths between
the updates the player pushes. (Free-running takes the `spin` loop instead —
stamped in the paint.) */
[data-skin-media-player-cassette][data-timed] .smp-reel {
transform: rotate(var(--_smp-reel-left, 0deg));
transition: transform 0.35s linear;
transition: transform var(--_smp-reel-duration) linear;
}
[data-skin-media-player-cassette][data-timed] .smp-reel-right {
transform: rotate(var(--_smp-reel-right, 0deg));
}
/* Back to the start means back to zero: do not animate the rewind. */
[data-skin-media-player-cassette][data-timed]:not([data-active]) .smp-reel {
[data-media-player]:not([data-can-play]) [data-skin-media-player-cassette][data-timed] .smp-reel {
transition: none;
}
[data-skin-media-player-cassette][data-spinning] .smp-tape {
animation: smp-tape-pulse 1.6s ease-in-out infinite;
}
/* ── The turntable ────────────────────────────────────────────────────────── */
/* The strobe marks are printed on the platter RIM, so they turn about the
platter's centre — not the canvas's. Without this the `spin` loop falls back
to `50% 50%` of the view-box (500, 440) and the marks sail off the platter in
an arc. Explicit `transform-box` for the same reason the disc has one. */
[data-skin-media-player-turntable] .smp-strobe {
transform-box: view-box;
transform-origin: 406px 468.5px;
}
[data-skin-media-player-turntable][data-active] .smp-strobe {
animation: smp-turn var(--_smp-turn, 1.333s) linear infinite;
animation-play-state: paused;
}
[data-skin-media-player-turntable][data-spinning] .smp-strobe {
animation-play-state: running;
}
/* ── Control faces ────────────────────────────────────────────────────────── */
.smp-button-face {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.11s cubic-bezier(0.3, 0.7, 0.4, 1);
transition: transform var(--_smp-key-duration) var(--_smp-key-ease);
}
.smp-button[data-pressed] .smp-button-face {
transform: translateY(8px);
@ -156,32 +173,16 @@
.smp-lever > rect {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.35s cubic-bezier(0.35, 0.05, 0.2, 1);
transition: transform var(--_smp-lever-duration) var(--_smp-lever-ease);
}
.smp-lever[data-raised] > rect {
transform: translateY(-56px);
}
.smp-lamp {
opacity: 0.18; /* literal: an unlit bulb still shows its glass */
transition: opacity 0.2s;
}
[data-active] .smp-lamp {
opacity: 0.55; /* literal: powered, not playing */
}
[data-spinning] .smp-lamp {
opacity: 1;
}
[data-skin-media-player-turntable][data-active] .smp-lamp {
opacity: 1;
}
/* ── The deck ─────────────────────────────────────────────────────────────── */
.smp-piano-key-body {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.11s cubic-bezier(0.3, 0.7, 0.4, 1);
transition: transform var(--_smp-key-duration) var(--_smp-key-ease);
}
.smp-piano-key[data-pressed] .smp-piano-key-body {
transform: translateY(14px);
@ -190,68 +191,33 @@
opacity: var(--opacity-disabled);
}
.smp-needle {
transform-box: view-box;
transform-origin: 216px 574px;
transform: rotate(-26deg);
transition: transform 0.7s ease-out;
}
[data-skin-media-player-deck][data-spinning] .smp-needle {
animation: smp-vu 2.3s ease-in-out infinite;
}
/* ── The affordances ──────────────────────────────────────────────────────── */
[data-skin-media-player-key] {
/* Physical `left` / `top`: a depicted machine does not mirror — the tonearm
is on the right of every turntable ever built. The logical grid governs
flow, not objects. */
position: absolute;
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
border-radius: var(--radius-sm);
/* The paint lives in the SVG underneath; this is only the hit area. */
appearance: none;
.smp-lamp {
opacity: 0.18; /* literal: an unlit bulb still shows its glass */
transition: opacity var(--_smp-lamp-duration);
}
[data-skin-media-player-key]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
[data-media-player]:not([data-paused]) .smp-lamp {
opacity: 1;
}
[data-skin-media-player-key]:disabled {
cursor: not-allowed;
[data-skin-media-player-deck][data-spinning] .smp-lamp {
opacity: 1;
}
/* ── Movement ─────────────────────────────────────────────────────────────── */
/* ── The VU needle ────────────────────────────────────────────────────────── */
/* functional: the platter and the reels turn — this is the depicted object's
own mechanics, not a perceptual signature the motion channel could carry. */
@keyframes smp-turn {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
.smp-needle {
transform-box: view-box;
transform-origin: 146px 574px;
transform: rotate(-26deg);
transition: transform var(--_smp-needle-duration) var(--ease-out);
}
/* functional: the tape between the packs breathes while it runs. */
@keyframes smp-tape-pulse {
0%,
100% {
opacity: 0.82;
}
50% {
opacity: 1;
}
[data-skin-media-player-deck][data-spinning] .smp-needle {
animation: smp-vu var(--_smp-vu-period) ease-in-out infinite;
}
/* functional: a VU needle's swing is the instrument's behaviour, not a UI
transition — it is a depicted meter, and it never reads the real signal. */
/* functional: a VU needle's swing is the depicted instrument's behaviour, not
a perceptual signature — no preset represents an analogue meter, and it
never reads the real signal. Its period rides a token like every other
duration here. */
@keyframes smp-vu {
0% {
transform: rotate(-17deg);
@ -282,22 +248,89 @@
}
}
/* ── The affordances ──────────────────────────────────────────────────────── */
[data-skin-media-player-key] {
/* Physical `left` / `top`: a depicted machine does not mirror — the tonearm
is on the right of every turntable ever built. The logical grid governs
flow, not objects. */
position: absolute;
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
border-radius: var(--radius-sm);
/* The paint lives in the SVG underneath; this is only the hit area. */
appearance: none;
}
[data-skin-media-player-key]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
[data-skin-media-player-key]:disabled {
cursor: not-allowed;
}
/* The volume slot. The machine paints the groove; the control that runs in it
is the player's OWN `VolumeSlider`, so this only places it and re-tints the
composed Slider to the machine's materials — the same move the player makes
with its scrubber. The first pass used the catalogue's `Knob` and it was
unusable: that primitive reads the pointer's ABSOLUTE angle around the
centre, so setting a level meant orbiting the mouse around a 40px dial. */
[data-skin-media-player-fader] {
position: absolute;
display: flex;
align-items: center;
/* The player sizes its own volume rail for a control row (a fixed width and
a trailing margin). Here the machine's groove decides, and the inline
style carries it. */
margin-inline-end: 0;
}
/* The tint has to land on the SAME descendant the player re-tints, or its rule
wins by being closer to the thumb (`media-player.css:420`). */
[data-skin-media-player-fader] [data-slider] {
/* The groove is already drawn in the SVG underneath. */
--slider-track-bg: transparent;
--slider-range-bg: transparent;
/* The cap is a KEY: the same ivory ramp the piano keys and the console
buttons are cut from, so the machine reads as one object. `background`
takes a gradient (slider.css:149), so the ramp goes straight in. */
--slider-thumb-bg: linear-gradient(#fbf7ec, #eee5cf 62%, #cfc5ac);
--slider-thumb-border: #4f4838;
--slider-thumb-border-width: 1.5px;
--slider-thumb-radius: var(--radius-sm);
inline-size: 100%;
}
/* Only the transitions need a reduce branch: the loops are stopped by the
generator, by both routes, for free. */
@media (prefers-reduced-motion: reduce) {
[data-skin-media-player-record][data-active] .smp-disc,
[data-skin-media-player-turntable][data-active] .smp-strobe,
[data-skin-media-player-cassette][data-active] .smp-reel,
[data-skin-media-player-cassette][data-spinning] .smp-tape,
[data-skin-media-player-deck][data-spinning] .smp-needle {
animation: none;
}
[data-skin-media-player-record] .smp-arm,
[data-skin-media-player-cassette][data-timed] .smp-reel,
.smp-button-face,
.smp-piano-key-body,
.smp-lever > rect {
.smp-lever > rect,
.smp-needle {
transition: none;
}
[data-skin-media-player-deck][data-spinning] .smp-needle {
animation: none;
transform: rotate(4deg);
}
}
[data-motion='reduce'] [data-skin-media-player-record] .smp-arm,
[data-motion='reduce'] [data-skin-media-player-cassette][data-timed] .smp-reel,
[data-motion='reduce'] .smp-button-face,
[data-motion='reduce'] .smp-piano-key-body,
[data-motion='reduce'] .smp-lever > rect,
[data-motion='reduce'] .smp-needle {
transition: none;
}
[data-motion='reduce'] [data-skin-media-player-deck][data-spinning] .smp-needle {
animation: none;
transform: rotate(4deg);
}

@ -1,77 +1,177 @@
<script lang="ts">
/**
* `<SkinMediaPlayer>` — the one-tag door: an `<audio>` player whose face is a
* depicted object. A COMPETING ROOT over the same soma contract (the
* Toast/Toaster precedent the `AudioPlayer` already set), deliberately not
* `MediaPlayer.Skin`.
* `<SkinMediaPlayer>` — the depicted surface of a MediaPlayer, mounted INSIDE
* one. THE single wiring point: it takes the player, derives everything the
* paint needs once, and turns each device's control table into real buttons.
*
* The bar below is not decoration and not optional by accident: the four
* objects have no volume knob and no free seek, so the depiction alone would
* be an incomplete player. `controls={false}` is for a consumer who supplies
* those two axes elsewhere.
* A device contributes two things and nothing else: its SVG (a paint file,
* with no provider, no effects and no context) and its row in `controls.ts`.
* Adding a fifth machine adds no logic.
*
* It is a COMPLEMENT, never a player: none of the four objects has a volume
* knob and none can seek freely, so the player's own controls stay. And it
* decides nothing about the player's SHAPE — inline / bar / row / card is
* the `AudioPlayer` variant axis, chosen on the player.
*/
import './skin-media-player.css';
import { MediaPlayer } from '../media-player';
import Skin from './skin-media-player-skin.svelte';
import type { SkinMediaPlayerProps } from './types';
import { ActiveEidos } from '$uix/eidos';
import * as MediaPlayer from '$soma/components/media-player';
import { MediaPlayerProvider } from '$soma/components/media-player';
import RecordSkin from './skin-media-player-record.svelte';
import Cassette from './skin-media-player-cassette.svelte';
import Turntable from './skin-media-player-turntable.svelte';
import Deck from './skin-media-player-deck.svelte';
import Key from './skin-media-player-key.svelte';
import { DEVICES } from './controls';
import { partFor, runAction, vetoes, type SkinAction } from './actions';
import { armAngle, engaged, reels, tapeCounter, turnSeconds } from './geometry';
import type { SkinMediaPlayerProps, SkinMediaPlayerRpm, SkinPaint } from './types';
let { skin = 'turntable', rpm = 45, labels = true, ...identity }: SkinMediaPlayerProps = $props();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
const resolved = $derived(eidos.resolve(skin, 'turntable'));
const device = $derived(DEVICES[resolved]);
/** Platter speed belongs to the MACHINE, not to the record. Decorative. */
// svelte-ignore state_referenced_locally
let speed = $state<SkinMediaPlayerRpm>(rpm);
const active = $derived(engaged(provider));
const playing = $derived(!provider.paused);
/**
* Lowering and lifting the arm is the cueing lever's slow gesture; following
* the groove is a stream of small corrections. Two different transitions,
* and this flag says which one is due.
*/
let cueing = $state(false);
$effect(() => {
void active;
cueing = true;
const handle = eidos.timers.schedule(null, 1700, () => {
cueing = false;
});
return () => {
handle?.cancel();
};
});
let {
skin = 'turntable',
rpm = 45,
src,
loop,
preload = 'metadata',
controls = true,
title,
artist,
year,
publisher,
image,
imageVeil,
side,
tapeType,
brand,
model,
metadata,
children,
...rest
}: SkinMediaPlayerProps = $props();
/**
* Momentary press. The paint reads it because `:active` cannot cross from
* the overlay button back to the `<g>` it covers, and this also covers
* keyboard activation, which `:active` reports inconsistently.
*/
let held = $state<string | null>(null);
const hold = (id: string) => ({
onpointerdown: () => (held = id),
onpointerup: () => (held = null),
onpointerleave: () => (held = null),
onblur: () => (held = null)
});
/** A key is latched when its action describes the state the player is in. */
function latched(action: SkinAction): boolean {
if (action === 'play') return playing;
if (action === 'pause') return active && !playing;
if (action === 'rpm-33') return speed === 33;
if (action === 'rpm-45') return speed === 45;
return false;
}
const paint = $derived<SkinPaint>({
active,
playing,
timed: provider.duration > 0,
arm: armAngle(provider.progress, active),
cueing,
turn: turnSeconds(speed),
rpm: speed,
reels: reels(provider.progress),
counter: tapeCounter(provider.progress),
labels,
pressed: (id) => {
if (held === id) return true;
const key = device.keys.find((k) => k.id === id);
return key ? latched(key.action) : false;
}
});
// The two keys with no morfo part behind them. `|fallback` on a top-level
// lookup is silent by design, so a catalog entry can land later without
// changing this — see the README's Gaps for the untranslated debt.
const stopLabel = $derived(eidos.langs.ts('#?components.media-player.stop|Stop'));
const recordLabel = $derived(eidos.langs.ts('#?components.media-player.record|Record'));
// The identity you SEE is the identity the OS shows — the same derivation
// `AudioPlayer` makes (D-AP2.5), from the label the object prints.
const resolvedMetadata = $derived(
metadata ??
(title || artist || image
? { title, artist, artwork: image ? [{ src: image }] : undefined }
: undefined)
);
function plainLabel(action: SkinAction): string {
if (action === 'stop') return stopLabel;
if (action === 'none') return recordLabel;
// `rpm` is an international abbreviation and needs no catalog entry.
return `${action === 'rpm-33' ? 33 : 45} rpm`;
}
</script>
<MediaPlayer media="audio" metadata={resolvedMetadata} data-skin-media-player {...rest}>
<MediaPlayer.Media {src} {loop} {preload} />
<Skin
{skin}
{rpm}
{title}
{artist}
{year}
{publisher}
{image}
{imageVeil}
{side}
{tapeType}
{brand}
{model}
{#snippet fader(f: { x: number; y: number; w: number; h: number })}
{@const vw = device.view.w}
{@const vh = device.view.h}
<!-- The player's OWN volume control, run inside the slot the machine paints.
Physical placement, like the keys: a depicted machine does not mirror.
The recipe re-tints the composed Slider to the machine's materials —
the same move the player itself makes with the scrubber. -->
<MediaPlayer.VolumeSlider
data-skin-media-player-fader
style="left:{(f.x / vw) * 100}%;top:{(f.y / vh) * 100}%;inline-size:{(f.w / vw) *
100}%;block-size:{(f.h / vh) * 100}%"
/>
{#if controls}
<MediaPlayer.Controls>
<MediaPlayer.PlayButton />
<MediaPlayer.Time type="current" />
<MediaPlayer.TimeSlider />
<MediaPlayer.Time type="duration" />
<MediaPlayer.MuteButton />
<MediaPlayer.VolumeSlider />
</MediaPlayer.Controls>
{/snippet}
<div data-skin-media-player data-skin={resolved}>
{#if resolved === 'record'}
<RecordSkin {...identity} {paint} />
{:else if resolved === 'cassette'}
<Cassette {...identity} {paint} />
{:else if resolved === 'deck'}
<Deck {...identity} {paint} />
{:else}
<Turntable {...identity} {paint} />
{/if}
{#each device.keys as key (key.id)}
{@const part = partFor(key.action)}
{@const box = { x: key.x, y: key.y, w: key.w, h: key.h, vw: device.view.w, vh: device.view.h }}
{#if part === 'play-button'}
<MediaPlayer.PlayButton
onclick={(e: MouseEvent) => {
// A one-directional key vetoes soma's toggle in the seam — the
// `RateFloat` pattern. MARCHA must not pause a latched machine.
if (vetoes(key.action, provider.paused)) e.preventDefault();
}}
>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} {...box} {...hold(key.id)} />
{/snippet}
</MediaPlayer.PlayButton>
{:else if part === 'seek-button'}
<MediaPlayer.SeekButton direction={key.action === 'seek-back' ? 'backward' : 'forward'}>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} {...box} {...hold(key.id)} />
{/snippet}
</MediaPlayer.SeekButton>
{:else}
<Key
{...box}
aria-label={plainLabel(key.action)}
aria-pressed={key.action.startsWith('rpm') ? latched(key.action) : undefined}
disabled={key.action === 'none' || undefined}
onclick={() => runAction(key.action, provider, (v) => (speed = v))}
{...hold(key.id)}
/>
{/if}
{/each}
{#if device.fader}
{@render fader(device.fader)}
{/if}
{@render children?.()}
</MediaPlayer>
</div>

@ -0,0 +1,53 @@
// Fitting `<text>` to a printed label, once for both paper surfaces (the
// record label and the cassette inlay).
//
// `<text>` neither wraps nor shrinks, so a long title runs off the label and
// has to be measured. Two rules the house imposes, both honoured here:
//
// · the read is DEFERRED (`dom.measure` — a coalesced animation frame), never
// synchronous after a style write, which would force a reflow mid-turn;
// · the read happens BEFORE the write. `getComputedTextLength` scales
// linearly with the font size, so the natural length is recoverable from
// whatever size is currently applied — one conditional write per pass, and
// no read-after-write ordering anywhere.
import type { ActiveEidos } from '$uix/eidos';
/** One text slot: how much room it has, and the size it wants. */
export type FitSlot = { max: number; base: number };
/** Shrink one `<text>` so it fits `max`, never below 45% of `base`. */
function fitOne(el: SVGTextElement | undefined, max: number, base: number): void {
if (!el?.getComputedTextLength) return;
const applied = parseFloat(el.style.fontSize) || base;
const measured = el.getComputedTextLength();
if (!measured) return;
const natural = (measured * base) / applied;
const next = natural > max ? Math.max(base * 0.45, (base * max) / natural) : base;
if (next !== applied) el.style.fontSize = `${next}px`;
}
/**
* Register the fitter for a set of text slots.
*
* `elements()` is read INSIDE the effect and spread, so the effect depends on
* each `bind:this` write — a bare array reference only tracks the identity and
* would never re-run once the bindings land. The node passed to `measure`
* anchors the frame to the right window (iframe / popup safe).
*/
export function fitText(
eidos: ActiveEidos,
elements: () => readonly (SVGTextElement | undefined)[],
slots: () => readonly FitSlot[],
deps: () => unknown
): void {
$effect(() => {
void deps();
const els = [...elements()];
const spec = slots();
const anchor = els.find(Boolean);
if (!anchor) return;
return eidos.dom.measure(() => {
spec.forEach((slot, i) => fitOne(els[i], slot.max, slot.base));
}, anchor);
});
}

@ -1,23 +1,22 @@
// SkinMediaPlayer — a DEPICTED surface for the MediaPlayer. The four skins are
// physical objects, so this file names them and the identity they print on
// themselves. No behaviour lives here: the player owns it all.
import type { MediaPlayerProps } from '../media-player/types';
// SkinMediaPlayer — a DEPICTED surface for the MediaPlayer.
import type { ResponsiveProp } from '$uix/eidos/lib/types';
import type { Reels } from './geometry';
import type { SkinName } from './controls';
/**
* The four depicted objects. Two are the bare medium (`record`, `cassette`) and
* two are the machine that plays it (`turntable` nests the record, `deck` nests
* the cassette) — the same pairing the source sketches had as
* the cassette) — the pairing the source sketches had as
* `vinilo · giradiscos · casete · pletina`.
*/
export type SkinMediaPlayerSkin = 'record' | 'turntable' | 'cassette' | 'deck';
export type SkinMediaPlayerSkin = SkinName;
/** Platter speed. DECORATIVE: it drives the spin and the strobe, never the audio. */
export type SkinMediaPlayerRpm = 33 | 45;
/**
* What the object PRINTS on itself. These are content, not chrome: a record
* label and a cassette inlay carry the title the same way an `Artwork` does.
* What the object PRINTS on itself. Content, not chrome: a record label and a
* cassette inlay carry the title the way an `Artwork` does.
*/
export type SkinIdentity = {
/** Work title — the largest line on the label / inlay. */
@ -42,35 +41,61 @@ export type SkinIdentity = {
model?: string;
};
/** The depiction itself, mounted INSIDE any `<MediaPlayer>`. */
export type SkinProps = SkinIdentity & {
/**
* Everything the paint needs, derived ONCE by the component that owns the
* wiring. A paint file takes this and returns SVG — no provider, no effects,
* no context.
*/
export type SkinPaint = {
/** Needle down / tape threaded (see `geometry.engaged`). */
active: boolean;
/** Running right now. */
playing: boolean;
/** Duration is known, so the packs can be placed. */
timed: boolean;
/** Tonearm degrees. */
arm: number;
/** The arm is being lowered or lifted (the slow cueing gesture). */
cueing: boolean;
/** Platter period, as the `spin` loop's own knob wants it (`1.333s`). */
turn: string;
/** Platter speed, printed on the label and latched on the key. */
rpm: SkinMediaPlayerRpm;
/** Tape pack radii + accumulated reel rotation. */
reels: Reels;
/** Four-digit tape counter. */
counter: string[];
/** Paint the silkscreened legends. */
labels: boolean;
/** Is this control latched or held down right now? */
pressed: (id: string) => boolean;
};
/**
* `<SkinMediaPlayer>` — the depicted surface, mounted INSIDE a `<MediaPlayer>`.
*
* It is a COMPLEMENT, never a player: none of the four objects has a volume
* knob and none can seek freely, so the player's own controls stay. It also
* decides nothing about the player's SHAPE — inline / bar / row / card is the
* `AudioPlayer` variant axis, chosen on the player, never here.
*/
export type SkinMediaPlayerProps = SkinIdentity & {
/** Which object to depict. @default 'turntable' */
skin?: ResponsiveProp<SkinMediaPlayerSkin>;
/**
* Platter speed for the two vinyl skins. Decorative — it sets the spin
* duration and the strobe, and never touches `playbackRate`. @default 45
* period and the strobe, and never touches `playbackRate`. @default 45
*/
rpm?: SkinMediaPlayerRpm;
/**
* Paint the machine's silkscreened legends (VELOCIDAD · MARCHA · REBOB …).
*
* They are part of the DEPICTION, not chrome: fixed strings, like the brand
* on the badge — a Spanish deck from 1973 carries REPROD silkscreened and
* does not change language. The accessible names are a different thing and
* are always localized, because they come from the composed parts' morfo
* texts. Turn this off for a cleaner face; nothing about the controls or
* their names changes. @default true
*/
labels?: boolean;
};
/**
* `<SkinMediaPlayer>` — the one-tag door: mounts `<MediaPlayer media="audio">`
* + the depiction + a canonical control bar. A COMPETING ROOT over the same
* soma contract (the Toast/Toaster precedent the `AudioPlayer` already set),
* deliberately not `MediaPlayer.Skin`.
*
* The depiction is a COMPLEMENT, never the whole player: none of the four
* objects has a volume knob and none can seek freely, so the canonical bar
* rides below and owns those two axes.
*/
export type SkinMediaPlayerProps = Omit<MediaPlayerProps, 'media'> &
SkinProps & {
/** Audio source URL (forwarded to the native `<audio>`). */
src?: string;
/** Native loop. */
loop?: boolean;
/** Native preload hint. @default 'metadata' */
preload?: 'none' | 'metadata' | 'auto';
/** Render the canonical control bar below the depiction. @default true */
controls?: boolean;
};

@ -1,108 +0,0 @@
<script lang="ts">
// Verification bench for the media-player skins. The MediaPlayer is composed
// HERE, in the open: the skin is one child among the player's own parts, and
// the shape of the player is chosen on the player, never by the skin.
import { onDestroy } from 'svelte';
import { createActiveUix, setActiveUix } from '$active-uix';
import { Soma } from '$soma/core/soma.svelte';
import { ActiveEidos } from '$uix/eidos';
import { MediaPlayer } from '$uix/eidos/components/media-player';
import { AudioPlayer } from '$uix/eidos/components/audio-player';
import { Skin } from '$uix/eidos/components/skin-media-player';
import type { SkinMediaPlayerSkin } from '$uix/eidos/components/skin-media-player';
import type { AudioPlayerVariant } from '$uix/eidos/components/audio-player';
const uix = createActiveUix({ langs: { schema: {}, defaultLocale: 'es' as const } });
setActiveUix(uix);
Soma.create();
const eidos = ActiveEidos.create({ applyDom: true, theme: 'base' });
onDestroy(() => {
eidos.dispose();
uix.dispose();
});
const SKINS: SkinMediaPlayerSkin[] = ['record', 'turntable', 'cassette', 'deck'];
const VARIANTS: AudioPlayerVariant[] = ['card', 'row', 'bar', 'inline'];
let skin = $state<SkinMediaPlayerSkin>('turntable');
let variant = $state<AudioPlayerVariant>('card');
const TRACK = {
src: '/music/t2.wav',
title: 'Quédate',
artist: 'Juan Luis Alarcón',
year: '1973',
publisher: 'Discos Levante',
image: '/images/298.jpg'
};
</script>
<div style="padding:2rem;max-width:960px;margin-inline:auto;display:grid;gap:2.5rem">
<div style="display:flex;gap:1.5rem;flex-wrap:wrap">
<div style="display:flex;gap:.35rem;flex-wrap:wrap">
{#each SKINS as s (s)}
<button type="button" data-bench-skin={s} aria-pressed={skin === s} onclick={() => (skin = s)}
>{s}</button
>
{/each}
</div>
<div style="display:flex;gap:.35rem;flex-wrap:wrap">
{#each VARIANTS as v (v)}
<button
type="button"
data-bench-variant={v}
aria-pressed={variant === v}
onclick={() => (variant = v)}>{v}</button
>
{/each}
</div>
</div>
<!-- A · the player composed by hand: every part is visible in this file. -->
<section data-bench="compose">
<h2>MediaPlayer compuesto a mano</h2>
<MediaPlayer media="audio">
<MediaPlayer.Media src={TRACK.src} preload="metadata" />
<Skin
{skin}
title={TRACK.title}
artist={TRACK.artist}
year={TRACK.year}
publisher={TRACK.publisher}
image={TRACK.image}
/>
<MediaPlayer.Controls>
<MediaPlayer.PlayButton />
<MediaPlayer.SeekButton direction="backward" />
<MediaPlayer.SeekButton direction="forward" />
<MediaPlayer.Time type="current" />
<MediaPlayer.TimeSlider />
<MediaPlayer.Time type="duration" />
<MediaPlayer.MuteButton />
<MediaPlayer.VolumeSlider />
</MediaPlayer.Controls>
</MediaPlayer>
</section>
<!-- B · the same skin inside the shaped audio player. The shape (card / row /
bar / inline) is the PLAYER's axis; the skin rides along as a child. -->
<section data-bench="audio-player">
<h2>AudioPlayer variant="{variant}"</h2>
<AudioPlayer
{variant}
src={TRACK.src}
title={TRACK.title}
artist={TRACK.artist}
artworkSrc={TRACK.image}
>
<Skin
{skin}
title={TRACK.title}
artist={TRACK.artist}
year={TRACK.year}
publisher={TRACK.publisher}
image={TRACK.image}
/>
</AudioPlayer>
</section>
</div>

@ -342,7 +342,8 @@
{ slug: '/uix/components/carousel', label: 'Carousel' },
{ slug: '/uix/components/qr-code', label: 'QR code' },
{ slug: '/uix/components/barcode', label: 'Barcode' },
{ slug: '/uix/components/media-player', label: 'Media player' }
{ slug: '/uix/components/media-player', label: 'Media player' },
{ slug: '/uix/components/skin-media-player', label: 'Media player skin' }
]
},
{

@ -0,0 +1,787 @@
<script lang="ts">
/**
* SkinMediaPlayer — v2 demo layout. A DEPICTED surface for the MediaPlayer:
* the skin paints a physical object (record · turntable · cassette · deck)
* and wires the controls that object really has to the player's own parts.
* It declares no morfo of its own — the contract on the Morfo / Sema tabs is
* the media-player's, which is the point: a skin adds paint, never behaviour.
*/
import { MediaPlayer } from '$uix/eidos/components/media-player';
import { AudioPlayer, type AudioPlayerVariant } from '$uix/eidos/components/audio-player';
import {
SkinMediaPlayer,
type SkinMediaPlayerSkin,
type SkinMediaPlayerRpm
} from '$uix/eidos/components/skin-media-player';
import { compileMorfo } from '$uix/morfo';
import { mediaPlayerMorfo } from '@/uix/morfo/components/media-player';
import { getActiveUix } from '$active-uix';
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
// prettier-ignore
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ──────────────────────────────────────────────────────────
const SKINS: SkinMediaPlayerSkin[] = ['record', 'turntable', 'cassette', 'deck'];
const VARIANTS: AudioPlayerVariant[] = ['card', 'row', 'bar', 'inline'];
let skin = $state<SkinMediaPlayerSkin>('turntable');
let rpm = $state<SkinMediaPlayerRpm>(45);
/** Which player hosts the skin: composed by hand, or the shaped AudioPlayer. */
let host = $state<'compose' | 'audio-player'>('compose');
let variant = $state<AudioPlayerVariant>('card');
let useDefault = $state(true);
let size = $state<'sm' | 'md' | 'lg' | 'xl'>('md');
let showImage = $state(true);
let labels = $state(true);
// The identity the object PRINTS on itself — content, not chrome.
let title = $state('Quédate');
let artist = $state('Juan Luis Alarcón');
let year = $state('1973');
let publisher = $state('Discos Levante');
let side = $state('A');
let tapeType = $state('C-60');
let brand = $state('Levante');
// A LOCAL track: the skins reflect real playback (arm angle, reel radii, tape
// counter), so a same-origin clip with a known duration is what exercises
// them. A remote CORS-blocked source still plays, but the demo should not
// depend on the network to show the geometry moving.
//
// `preload="none"`, deliberately: a metadata preload opens a range request
// the browser aborts as soon as it has the header, and `smoke` counts that
// abort as a same-origin request failure. Nothing is lost — the duration
// arrives on the first play, and until then the reels run in the free-spin
// mode the cassette already declares.
const SRC = '/music/t2.wav';
const IMAGE = '/images/298.jpg';
const image = $derived(showImage ? IMAGE : undefined);
const identity = $derived({ title, artist, year, publisher, side, tapeType, brand, image });
// ── System (foundation) axes — applied to the stage ─────────────────────
let density = $state('comfortable');
let scaling = $state(1);
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
let dirMode = $state<'auto' | 'ltr' | 'rtl'>('auto');
const dir = $derived(dirMode === 'auto' ? undefined : dirMode);
let borderWidth = $state(1);
const stageStyle = $derived(`--scaling: ${scaling}; --border-width: ${borderWidth}px;`);
// ── Live trace (data-event) ─────────────────────────────────────────────
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
if (stageRef) return trace.observe(stageRef);
});
// ── Compiled morfo (the PLAYER's — the skin declares none) ──────────────
const compiled = compileMorfo(mediaPlayerMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const actions = $derived([...compiled.actions.byName.values()]);
/** Which controls the depicted object actually has. Mirrors `controls.ts`. */
const KEYS: Record<SkinMediaPlayerSkin, string> = {
record: '— pintura pura',
turntable: '33 · 45 · MARCHA · PARO · ELEVADOR',
cassette: '— pintura pura',
deck: 'GRAB · REBOB · REPROD · AVANCE · STOP · PAUSA'
};
// ── Snippet (parity with the live preview) ──────────────────────────────
const idLines = $derived([
` title="${title}"`,
` artist="${artist}"`,
...(showImage ? [' image="/cover.jpg"'] : []),
...(labels ? [] : [' labels={false}'])
]);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
host === 'audio-player'
? " import { AudioPlayer } from '$uix/eidos/components/audio-player';"
: " import { MediaPlayer } from '$uix/eidos/components/media-player';",
" import { SkinMediaPlayer } from '$uix/eidos/components/skin-media-player';",
'</' + 'script>',
'',
...(host === 'audio-player'
? [
'<AudioPlayer',
` variant="${variant}"`,
' src="/track.mp3"',
` title="${title}"`,
` artist="${artist}"`,
'>',
' <SkinMediaPlayer',
` skin="${skin}"`,
...(skin === 'record' || skin === 'turntable' ? [` rpm={${rpm}}`] : []),
...idLines.map((l) => ' ' + l),
' />',
'</AudioPlayer>'
]
: [
`<MediaPlayer media="audio"${size === 'md' ? '' : ` size="${size}"`}>`,
' <MediaPlayer.Media src="/track.mp3" preload="none" />',
' <SkinMediaPlayer',
` skin="${skin}"`,
...(skin === 'record' || skin === 'turntable' ? [` rpm={${rpm}}`] : []),
...idLines.map((l) => ' ' + l),
' />',
...(useDefault
? [' <MediaPlayer.DefaultControls />']
: [
' <MediaPlayer.Controls>',
' <MediaPlayer.PlayButton />',
' <MediaPlayer.Time type="current" />',
' <MediaPlayer.TimeSlider />',
' <MediaPlayer.Time type="duration" />',
' <MediaPlayer.MuteButton />',
' <MediaPlayer.VolumeSlider />',
' </MediaPlayer.Controls>'
]),
'</MediaPlayer>'
])
].join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Media · SkinMediaPlayer</div>
<h1 data-uix-page-title>Media player skin</h1>
<p data-uix-page-lede>
The face of a <code>MediaPlayer</code> as a drawn physical object — a record, a turntable, a
cassette, a deck. It is a <strong>piece inside</strong> the player, never a player: it mounts
beside the player's own parts, which keep volume, seeking and everything else. What the object
really has — a MARCHA key, a cueing lever, a piano keyboard — is wired to the real
<code>MediaPlayer</code> parts; what it does not have, it does not invent. One component wires;
each device contributes only its SVG and a row of coordinates.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>skins</span>{SKINS.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>own morfo</span>none (eidos-only)</span>
<span data-uix-meta-pill><span data-uix-meta-key>contract</span>{mediaPlayerMorfo.kebab}</span
>
</div>
</header>
<!-- Live preview always rendered, System axes applied to the stage -->
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={mode === 'inherit' ? undefined : mode}
data-mode={mode === 'inherit' ? undefined : mode}
{...dir ? { dir } : {}}
style={stageStyle}
>
<div style="margin-inline: auto;">
{#key host}
{#if host === 'audio-player'}
<!-- The shape is the PLAYER's axis. The skin rides inside as a child. -->
<AudioPlayer {variant} {dir} {size} src={SRC} {title} {artist} artworkSrc={image}>
<SkinMediaPlayer {skin} {rpm} {labels} {...identity} />
</AudioPlayer>
{:else}
<MediaPlayer media="audio" {dir} {size}>
<MediaPlayer.Media src={SRC} preload="none" />
<SkinMediaPlayer {skin} {rpm} {labels} {...identity} />
{#if useDefault}
<MediaPlayer.DefaultControls />
{:else}
<MediaPlayer.Controls>
<MediaPlayer.PlayButton />
<MediaPlayer.Time type="current" />
<MediaPlayer.TimeSlider />
<MediaPlayer.Time type="duration" />
<MediaPlayer.MuteButton />
<MediaPlayer.VolumeSlider />
</MediaPlayer.Controls>
{/if}
</MediaPlayer>
{/if}
{/key}
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.entries.length === 0}
<span>press a key on the machine — the events are the player's, not the skin's</span>
{:else}
{#each trace.entries.slice(0, 3) as entry}
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
{/each}
{/if}
</div>
</div>
<!-- Tabs -->
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
<span data-uix-layer-badge="eidos">eidos</span> System
</button>
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}
>Motion</button
>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span><span data-uix-tab-count>{actions.length}</span>
</button>
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}
>Services</button
>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span><span data-uix-tab-count
>{partsList.length}p · {actions.length}e</span
>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}
>Recipe</button
>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
<strong>skin</strong> chooses the depicted object; <strong>host</strong> chooses who mounts
it. Both machines drive the real transport: MARCHA and REPROD only start (a latched key must
not pause), PAUSA and the cueing lever only pause, PARO/STOP rewind to the beginning. GRAB
is painted <em>unavailable</em> — recording is capture, not playback, and the
<code>MediaProvider</code> port has none.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> skin
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>skin <span data-uix-control-hint>{KEYS[skin]}</span></span>
<span data-uix-chips role="radiogroup">
{#each SKINS as s}
<button data-uix-chip data-active={skin === s} onclick={() => (skin = s)}>{s}</button>
{/each}
</span>
</label>
{#if skin === 'record' || skin === 'turntable'}
<label data-uix-control>
<span data-uix-control-label
>rpm <span data-uix-control-hint>decorative — platter + strobe only</span></span
>
<span data-uix-chips role="radiogroup">
{#each [33, 45] as r}
<button data-uix-chip data-active={rpm === r} onclick={() => (rpm = r as 33 | 45)}
>{r}</button
>
{/each}
</span>
</label>
{/if}
<label data-uix-control>
<span data-uix-control-label>cover image</span>
<span data-uix-switch
><input type="checkbox" bind:checked={showImage} /><span data-uix-switch-label
>{showImage ? 'on' : 'off'}</span
></span
>
</label>
<label data-uix-control>
<span data-uix-control-label
>labels <span data-uix-control-hint>the machine's silkscreen</span></span
>
<span data-uix-switch
><input type="checkbox" bind:checked={labels} /><span data-uix-switch-label
>{labels ? 'on' : 'off'}</span
></span
>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> host player
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label
>host <span data-uix-control-hint>the skin never mounts a player</span></span
>
<span data-uix-chips role="radiogroup">
{#each [['compose', 'MediaPlayer (by hand)'], ['audio-player', 'AudioPlayer']] as [key, text]}
<button
data-uix-chip
data-active={host === key}
onclick={() => (host = key as typeof host)}>{text}</button
>
{/each}
</span>
</label>
{#if host === 'audio-player'}
<label data-uix-control>
<span data-uix-control-label
>variant <span data-uix-control-hint>the PLAYER's shape axis</span></span
>
<span data-uix-chips role="radiogroup">
{#each VARIANTS as v}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}
>{v}</button
>
{/each}
</span>
</label>
{:else}
<label data-uix-control>
<span data-uix-control-label>controls</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={useDefault} onclick={() => (useDefault = true)}
>DefaultControls</button
>
<button data-uix-chip data-active={!useDefault} onclick={() => (useDefault = false)}
>manual parts</button
>
</span>
</label>
{/if}
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg', 'xl'] as s}
<button
data-uix-chip
data-active={size === s}
onclick={() => (size = s as typeof size)}>{s}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> identity
<span data-uix-control-hint>what the object prints on itself</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>title</span>
<input data-uix-input type="text" bind:value={title} />
</label>
<label data-uix-control>
<span data-uix-control-label>artist</span>
<input data-uix-input type="text" bind:value={artist} />
</label>
<label data-uix-control>
<span data-uix-control-label>year</span>
<input data-uix-input type="text" bind:value={year} />
</label>
<label data-uix-control>
<span data-uix-control-label>publisher</span>
<input data-uix-input type="text" bind:value={publisher} />
</label>
{#if skin === 'cassette' || skin === 'deck'}
<label data-uix-control>
<span data-uix-control-label>side</span>
<input data-uix-input type="text" bind:value={side} />
</label>
<label data-uix-control>
<span data-uix-control-label>tape type</span>
<input data-uix-input type="text" bind:value={tapeType} />
</label>
{/if}
{#if skin === 'turntable' || skin === 'deck'}
<label data-uix-control>
<span data-uix-control-label>brand</span>
<input data-uix-input type="text" bind:value={brand} />
</label>
{/if}
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>a piece inside the player</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'system'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="eidos">eidos</span> System axes</h2>
<p data-uix-section-desc>
Foundation knobs applied live to the stage. <strong>mode</strong> is the interesting one
here: the skin is a <em>fixed-tone</em> component — mahogany, brushed aluminium, chrome and
ivory are the depiction, not theme roles, so the object reads identically in light and dark
while the player's control bar around it follows the theme. <strong>dir</strong> does not mirror
the machine either: a tonearm sits on the right of every turntable ever built.
</p>
<SystemAxes bind:density bind:scaling bind:mode bind:dir={dirMode} bind:borderWidth />
</section>
{/if}
{#if tab === 'motion'}
<section data-uix-section>
<h2 data-uix-section-title>Motion catalog</h2>
<p data-uix-section-desc>
Nothing in this skin is hand-rolled. Every continuous turn — platter, strobe, free-running
reels — is the system's <code>spin</code> loop and the tape's breathing is
<code>pulse</code>, stamped as <code>data-animation-style</code> with the period written
into the loop's own knob (<code>--motion-loop-spin</code>: <code>1.333s</code> at 45 rpm,
<code>1.8s</code> at 33). Transitions consume <code>--duration-*</code> /
<code>--ease-*</code> through a <code>--_smp-*</code> slot per axis. A paused machine holds
its turn in place (<code>animation-play-state</code>), and a stopped one drops the preset so
the disc returns to 0°. The one local keyframe left is the VU needle: no preset depicts an
analogue meter.
</p>
<MotionPanel
note="Reduced motion arrives for free and by BOTH routes — the generator stops loop presets on [data-motion='reduce'] as well as the media query, so the UIX preference alone parks the platter."
/>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="sema">sema</span> firma + sound</h2>
<p data-uix-section-desc>
<strong>The skin declares no event.</strong> These {actions.length} are the
<code>media-player</code>'s, and that is the whole point: a depicted key composes the real
part, so pressing MARCHA fires the player's <code>commit-toggle-play</code> exactly as the
canonical button does. Turn on <strong>Semantics → Sound</strong> in the topbar, then ▶ play to
hear each firma.
</p>
<SemaPanel
{actions}
{uix}
getTarget={() => stageRef?.querySelector('[data-media-player]') ?? stageRef}
/>
</section>
{/if}
{#if tab === 'services'}
<section data-uix-section>
<h2 data-uix-section-title>Services</h2>
<p data-uix-section-desc>
<strong>langs</strong> (i18n). Every key that composes a real part inherits the player's
localized name — switch language in the topbar and MARCHA announces itself as Play /
Reproducir. Two keys have no part behind them and no catalog entry either (<code>stop</code
>, <code>record</code>): they resolve through an idlangref
<code>|fallback</code>, which is silent on a top-level lookup but always renders English. A
declared debt, not an oversight — see the component README.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Text key</th><th>Default (en)</th><th>Source</th></tr></thead>
<tbody>
{#each Object.entries(mediaPlayerMorfo.texts ?? {}) as [key, ref]}
<tr
><td class="name">{key}</td><td class="type">{String(ref).split('|')[1] ?? '—'}</td
><td><span data-uix-tag data-kind="soma">morfo</span></td></tr
>
{/each}
<tr
><td class="name">stop</td><td class="type">Stop</td><td
><span data-uix-tag data-kind="eidos">fallback only</span></td
></tr
>
<tr
><td class="name">record</td><td class="type">Record</td><td
><span data-uix-tag data-kind="eidos">fallback only</span></td
></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
The whole surface is the object and what it prints. There is no
<code>src</code>, no <code>controls</code> and no shape prop: those belong to the player that
hosts it.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr
><td class="name">skin</td><td class="type"
>'record' | 'turntable' | 'cassette' | 'deck'</td
><td class="default">'turntable'</td><td
>Which object to depict. Accepts a responsive map. <code>turntable</code> nests
<code>record</code> in the platter well and <code>deck</code> nests
<code>cassette</code> behind the glass, so the medium's paint is reused whole.</td
></tr
>
<tr
><td class="name">rpm</td><td class="type">33 | 45</td><td class="default">45</td><td
>Platter speed. <strong>Decorative</strong>: it sets the spin period and the strobe
and never touches <code>playbackRate</code> — the browser preserves pitch by default,
so a 45 played at 33 would sound slow but not lower, which is exactly what a turntable
does not do.</td
></tr
>
<tr
><td class="name">title · artist · year · publisher</td><td class="type">string</td
><td class="default empty">—</td><td
>The record label / cassette inlay copy. Auto-fitted to the printed area.</td
></tr
>
<tr
><td class="name">image</td><td class="type">string</td><td class="default empty"
>—</td
><td>Cover art printed on the label or the inlay, under a wine veil.</td></tr
>
<tr
><td class="name">imageVeil</td><td class="type">number</td><td class="default"
>0.55</td
><td>Opacity of that veil, so the type stays legible over any cover.</td></tr
>
<tr
><td class="name">side · tapeType</td><td class="type">string</td><td class="default"
>'A' · 'C-60'</td
><td>Cassette markings.</td></tr
>
<tr
><td class="name">brand · model</td><td class="type">string</td><td class="default"
>'Levante' · per device</td
><td>The machine's badge.</td></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> contract</h2>
<p data-uix-section-desc>
<strong>The skin has no morfo.</strong> It is an eidos-only piece by the four rules: it
creates no behaviour, is no event target, owns no ARIA relationships, and emits only
structure plus visual <code>data-*</code>. The contract below is
<code>src/uix/morfo/components/media-player.ts</code>, which is what its keys compose.
</p>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead
><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th></tr
></thead
>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead
><tr
><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th
>target</th
></tr
></thead
>
<tbody>
{#each actions as action}
{@const sem = action.semantic}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default"
>{'intent' in sem && typeof sem.intent === 'string' ? sem.intent : '—'}</td
>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>What the skin adds</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Attribute</th><th>On</th><th>Meaning</th></tr></thead>
<tbody>
<tr
><td class="name">data-skin</td><td class="type">the skin root</td><td
>Which object is depicted. Visual axis, eidos-only.</td
></tr
>
<tr
><td class="name">data-animation-style</td><td class="type">turning parts</td><td
>The system's loop preset (<code>spin</code> / <code>pulse</code>).</td
></tr
>
<tr
><td class="name">data-pressed · data-raised · data-cueing · data-timed</td><td
class="type">paint groups</td
><td>Purely visual state the recipe reads. No behaviour, no ARIA.</td></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/skin-media-player/skin-media-player.css</code>.
A <strong>fixed-tone</strong> component: the 83 colours of the drawing live as literals in
the SVG paint, under the exception the guard already carves for components that depict a
physical thing (<code>natural-time-picker</code>, <code>color-picker</code>,
<code>proof-of-human</code>). The recipe itself holds geometry, state and movement only.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr
><td class="name">[data-media-player][data-paused] … [data-animation-style='spin']</td
><td><span data-uix-tag data-kind="soma">morfo</span></td></tr
>
<tr
><td class="name">[data-skin-media-player][data-skin='X']</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
></tr
>
<tr
><td class="name">[data-skin-media-player-key] · the overlay button</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
></tr
>
<tr
><td class="name">.smp-arm · .smp-reel · .smp-lever · .smp-piano-key</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
></tr
>
<tr
><td class="name">--_smp-arm / --_smp-reel-* / --_smp-max</td><td
><span data-uix-tag data-kind="eidos">eidos (CSS var)</span></td
></tr
>
<tr
><td class="name">--motion-loop-spin</td><td
><span data-uix-tag data-kind="eidos">motion (loop knob)</span></td
></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The drawing is a picture and the controls are real buttons — that split is the whole
accessibility model. The SVG is <code>aria-hidden</code> unless an identity is supplied, in
which case it is a labelled <code>role="img"</code>; every key is a native
<code>&lt;button&gt;</code> laid over it, so Enter / Space, <code>disabled</code> and the focus
ring are the platform's.
</p>
<div data-uix-subsection-head>Why not a role on the &lt;g&gt;</div>
<p data-uix-section-desc>
The source sketches put <code>role="button"</code> + <code>tabindex</code> on SVG groups and
re-implemented Space / Enter by hand. Nothing in eidos does that:
<code>onion-menu</code> overlays a real HTML button at its SVG centre for the same reason,
and
<code>natural-time-picker</code> uses a composed Slider thumb for its knob. A
<code>&lt;g&gt;</code> has no native keyboard, no <code>disabled</code>, and no outline the
focus-ring system can draw.
</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr
><td><span data-uix-kbd>Tab</span></td><td
>Reaches each depicted control in visual order</td
></tr
>
<tr
><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td
>Presses it — native, because it is a native button</td
></tr
>
<tr
><td colspan="2"
>The player's own hotkeys (<span data-uix-kbd>Space</span>,
<span data-uix-kbd>←</span>/<span data-uix-kbd>→</span>,
<span data-uix-kbd>m</span>, …) keep working: they belong to the region, not to the
skin.</td
></tr
>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr
><td class="name">role="img" + aria-label</td><td class="type"
>on the drawing, when a title / artist is given (the label is the work, not prose)</td
></tr
>
<tr
><td class="name">aria-hidden</td><td class="type"
>on the drawing with no identity, and on every nested medium</td
></tr
>
<tr
><td class="name">aria-label</td><td class="type"
>on each key — inherited from the composed part where one exists</td
></tr
>
<tr
><td class="name">aria-pressed</td><td class="type">on the 33 / 45 speed keys</td></tr
>
<tr
><td class="name">disabled</td><td class="type"
>on GRAB — real, not a look: the port has no recording</td
></tr
>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.