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
parent
bf12b8a571
commit
68a48a4401
@ -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,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,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,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,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>
|
||||
@ -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"><{part.defaultElement}></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><button></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 <g></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><g></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…
Reference in new issue