@joewinke/jatui 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +46 -0
  2. package/dist/ambience/README.md +131 -0
  3. package/dist/ambience/breath.d.ts +22 -0
  4. package/dist/ambience/breath.js +43 -0
  5. package/dist/ambience/default.d.ts +24 -0
  6. package/dist/ambience/default.js +34 -0
  7. package/dist/ambience/engine.d.ts +21 -0
  8. package/dist/ambience/engine.js +313 -0
  9. package/dist/ambience/index.d.ts +23 -0
  10. package/dist/ambience/index.js +27 -0
  11. package/dist/ambience/master.d.ts +49 -0
  12. package/dist/ambience/master.js +83 -0
  13. package/dist/ambience/persist.d.ts +11 -0
  14. package/dist/ambience/persist.js +24 -0
  15. package/dist/ambience/registry.d.ts +11 -0
  16. package/dist/ambience/registry.js +16 -0
  17. package/dist/ambience/scapes/focus.d.ts +55 -0
  18. package/dist/ambience/scapes/focus.js +245 -0
  19. package/dist/ambience/scapes/hibiscus.d.ts +103 -0
  20. package/dist/ambience/scapes/hibiscus.js +345 -0
  21. package/dist/ambience/scapes/nature.d.ts +36 -0
  22. package/dist/ambience/scapes/nature.js +325 -0
  23. package/dist/ambience/scapes/noise.d.ts +77 -0
  24. package/dist/ambience/scapes/noise.js +226 -0
  25. package/dist/ambience/scapes/playlist.d.ts +129 -0
  26. package/dist/ambience/scapes/playlist.js +569 -0
  27. package/dist/ambience/types.d.ts +80 -0
  28. package/dist/ambience/types.js +9 -0
  29. package/dist/components/AmbienceToggle.svelte +599 -0
  30. package/dist/components/AmbienceToggle.svelte.d.ts +74 -0
  31. package/dist/components/VideoPlayer.svelte +73 -6
  32. package/dist/components/VideoPlayer.svelte.d.ts +9 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +9 -0
  35. package/dist/video/keys.d.ts +4 -3
  36. package/dist/video/keys.js +5 -4
  37. package/dist/video/settings/remembered.d.ts +9 -0
  38. package/dist/video/settings/remembered.js +9 -0
  39. package/package.json +1 -1
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Ambience engine — public types. The scape contract lives here so the
3
+ * parallel scape tasks build against it without importing the engine.
4
+ *
5
+ * Design rules (jatui-km0qy): headless — no UI, no window at import, one
6
+ * AudioContext per engine created lazily on the first gesture-driven play,
7
+ * generative Web Audio only (no files, no libraries).
8
+ */
9
+ /** A node scapes connect INTO; the shared master chain owns what happens after. */
10
+ export interface ScapeContext {
11
+ /** The engine's single AudioContext (created lazily, already running). */
12
+ audio: AudioContext;
13
+ /** Connect your scape's output(s) here — never to `destination` directly. */
14
+ input: AudioNode;
15
+ /** The one shared breath LFO (see breath.ts). */
16
+ breath: BreathClock;
17
+ /** Lookahead scheduler: call `cb` on every engine tick (~250 ms). Registering
18
+ * returns an unsubscribe so a scape can stop being ticked on stop(). */
19
+ lookahead: (cb: (now: number) => void) => () => void;
20
+ /** Deterministic-ish random source scoped to the scape instance. */
21
+ rand: () => number;
22
+ }
23
+ export interface ScapeInstance {
24
+ /** Begin sounding at audio time `at` (usually ctx.currentTime). */
25
+ start(at: number): void;
26
+ /** Fade yourself out over `fade` seconds and tear your own nodes down. */
27
+ stop(at: number, fade: number): void;
28
+ /** Optional live parameter (scape-defined keys). */
29
+ setParam?(key: string, value: number): void;
30
+ }
31
+ export type ScapeGroup = 'meditation' | 'focus' | 'noise' | 'nature' | 'music';
32
+ export interface Scape {
33
+ id: string;
34
+ label: string;
35
+ group: ScapeGroup;
36
+ description?: string;
37
+ /** Build the graph. Called once per play — a scape must be re-buildable. */
38
+ build(ctx: ScapeContext): ScapeInstance;
39
+ }
40
+ /** Shape of the AudioContext we need — the real one satisfies it; tests fake it. */
41
+ import type { BreathClock } from './breath';
42
+ export type AudioContextLike = AudioContext;
43
+ export interface AmbienceState {
44
+ /** Currently selected scape id (persisted preselect counts), or null. */
45
+ currentId: string | null;
46
+ playing: boolean;
47
+ volume: number;
48
+ }
49
+ export interface BreathState {
50
+ /** 0..1 within the current half-breath (inhale or exhale). */
51
+ value: number;
52
+ /** True while inhaling. */
53
+ rising: boolean;
54
+ }
55
+ export interface Ambience {
56
+ register(scape: Scape): void;
57
+ list(): Scape[];
58
+ /** MUST be called from a user gesture (a click/keydown handler). Rejects
59
+ * otherwise. Resolves once the scape is scheduled and audibly started. */
60
+ play(id: string): Promise<void>;
61
+ /** Fade out ~0.55 s; the AudioContext closes ~3 s later. A play() inside
62
+ * that window cancels the close and fades back in over ~4 s. */
63
+ stop(): void;
64
+ /** Immediate: no fades, context closed, timers cleared. */
65
+ kill(): void;
66
+ setVolume(v: number): void;
67
+ get state(): AmbienceState;
68
+ subscribe(fn: (state: AmbienceState) => void): () => void;
69
+ onBreath(fn: (b: BreathState) => void): () => void;
70
+ }
71
+ export interface AmbienceOptions {
72
+ storageKey?: string;
73
+ defaultVolume?: number;
74
+ /** Gesture probe, injectable for tests. Default: navigator.userActivation
75
+ * when it exists (trusted-UI truth), otherwise permissive (SSR/node).
76
+ */
77
+ isGesture?: () => boolean;
78
+ /** Context factory, injectable for tests (default: `new AudioContext()`). */
79
+ createContext?: () => AudioContextLike;
80
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Ambience engine — public types. The scape contract lives here so the
3
+ * parallel scape tasks build against it without importing the engine.
4
+ *
5
+ * Design rules (jatui-km0qy): headless — no UI, no window at import, one
6
+ * AudioContext per engine created lazily on the first gesture-driven play,
7
+ * generative Web Audio only (no files, no libraries).
8
+ */
9
+ export {};
@@ -0,0 +1,599 @@
1
+ <script module lang="ts">
2
+ /**
3
+ * AmbienceToggle — the audio twin of ThemeToggle (jatui-b7qvr): one small
4
+ * speaker button for the app shell over the headless ambience engine
5
+ * (`createAmbience()` from `src/lib/ambience/`).
6
+ *
7
+ * Interaction contract:
8
+ * - Click (or Space/Enter) = play / stop the last scape. A gesture-gated
9
+ * engine call, made synchronously inside the click handler.
10
+ * - The scape menu (menu button pattern) opens with ArrowDown/ArrowUp,
11
+ * a press-and-hold (~450 ms) or a right-click (Android/Chrome fire
12
+ * contextmenu on touch holds; iOS may cancel the pointer instead —
13
+ * the keyboard path always works). It lists every
14
+ * registered scape grouped meditation / focus / noise / nature / music
15
+ * (`menuitemradio` items, current scape checked), carries a volume
16
+ * slider wired to `engine.setVolume`, and shows each scape's
17
+ * `description` as its secondary line — playlist scapes put their
18
+ * track attribution there.
19
+ *
20
+ * A11y: the trigger is `aria-pressed` while playing and carries the full
21
+ * menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
22
+ * An sr-only description teaches the menu gestures, and an `aria-live`
23
+ * status line announces state changes ("Ambience: Hibiscus, playing").
24
+ * The speaker glyph morphs off↔on on the motion tokens and, while playing,
25
+ * breathes with `engine.onBreath` — ambient motion, so it is static under
26
+ * `prefers-reduced-motion` and `data-motion="calm"`.
27
+ *
28
+ * Token-only theming (DaisyUI + `--jatui-*` + `--motion-*`). SSR-safe: the
29
+ * engine is lazy and nothing here touches `window` at import.
30
+ */
31
+ import type { Ambience, Scape, ScapeGroup } from '../ambience/types';
32
+
33
+ /** Canonical menu group order (the Scape contract's groups). */
34
+ export const AMBIENCE_GROUP_ORDER: readonly ScapeGroup[] = [
35
+ 'meditation',
36
+ 'focus',
37
+ 'noise',
38
+ 'nature',
39
+ 'music'
40
+ ];
41
+
42
+ /** Human labels for the canonical groups. */
43
+ export const GROUP_LABELS: Readonly<Record<ScapeGroup, string>> = {
44
+ meditation: 'Meditation',
45
+ focus: 'Focus',
46
+ noise: 'Noise',
47
+ nature: 'Nature',
48
+ music: 'Music'
49
+ };
50
+
51
+ /** One grouped section of the scape menu. */
52
+ export interface AmbienceScapeGroup {
53
+ group: ScapeGroup;
54
+ label: string;
55
+ scapes: Scape[];
56
+ }
57
+
58
+ /**
59
+ * Group scapes for the menu: canonical group order, registration order
60
+ * within a group, empty groups omitted. A group outside the union (a
61
+ * registry from a newer engine) still renders, after the known ones, in
62
+ * first-seen order — a menu never silently drops a registered scape.
63
+ */
64
+ export function groupScapes(scapes: Scape[]): AmbienceScapeGroup[] {
65
+ const byGroup = new Map<string, Scape[]>();
66
+ for (const s of scapes) {
67
+ const list = byGroup.get(s.group) ?? [];
68
+ list.push(s);
69
+ byGroup.set(s.group, list);
70
+ }
71
+ const out: AmbienceScapeGroup[] = [];
72
+ for (const g of AMBIENCE_GROUP_ORDER) {
73
+ const list = byGroup.get(g);
74
+ if (!list?.length) continue;
75
+ out.push({ group: g, label: GROUP_LABELS[g], scapes: list });
76
+ byGroup.delete(g);
77
+ }
78
+ for (const [g, list] of byGroup) {
79
+ const label = g ? g.charAt(0).toUpperCase() + g.slice(1) : 'Other';
80
+ out.push({ group: g as ScapeGroup, label, scapes: list });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * The live label — "Ambience: Hibiscus, playing" while sounding,
87
+ * "Ambience: Hibiscus, stopped" once a scape has been selected but is not
88
+ * playing, "Ambience: off" before anything was selected.
89
+ */
90
+ export function ambienceLiveLabel(currentLabel: string | null, playing: boolean): string {
91
+ if (!currentLabel) return 'Ambience: off';
92
+ return `Ambience: ${currentLabel}, ${playing ? 'playing' : 'stopped'}`;
93
+ }
94
+
95
+ /** Transient failure label — a rejected engine.play() (gesture probe,
96
+ * context suspend, unknown scape) must never surface as an unhandled
97
+ * rejection OR as silence: the trigger visibly fails to respond, so the
98
+ * sr-only status explains it. Cleared by the next successful play. */
99
+ export const PLAY_ERROR_LABEL = 'Ambience: couldn\'t start — tap again';
100
+
101
+ /**
102
+ * Glyph scale for a breath value (0..1): the speaker grows on the inhale
103
+ * and settles on the exhale — 6% total, clamped against out-of-range input.
104
+ */
105
+ export function breathScale(value: number): number {
106
+ const v = Math.min(1, Math.max(0, value));
107
+ return 1 + 0.06 * v;
108
+ }
109
+ </script>
110
+
111
+ <script lang="ts">
112
+ import { getMotionContext } from '../motion/level';
113
+
114
+ let {
115
+ engine,
116
+ compact = false,
117
+ placement = 'bottom-end'
118
+ }: {
119
+ /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. */
120
+ engine: Ambience;
121
+ /** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
122
+ compact?: boolean;
123
+ /** Which way the menu opens from the trigger. Auto-flips vertically when the viewport has no room on that side. */
124
+ placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
125
+ } = $props();
126
+
127
+ const menuId = $props.id();
128
+ const hintId = `${menuId}-hint`;
129
+
130
+ /** Long-press duration for the pointer path to the menu. */
131
+ const LONG_PRESS_MS = 450;
132
+ /** Worst-case menu height used for the open-direction flip decision. */
133
+ const MENU_EST_HEIGHT = 340;
134
+
135
+ let playing = $state(false);
136
+ let currentId = $state<string | null>(null);
137
+ let volume = $state(0.6);
138
+ let scapes = $state<Scape[]>([]);
139
+ let open = $state(false);
140
+ // svelte-ignore state_referenced_locally -- only the seed; resolvePlacement() reads the live prop
141
+ let effectivePlacement = $state<'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'>(
142
+ placement
143
+ );
144
+
145
+ /** Set by a long-press so the click that follows the release is swallowed —
146
+ * but only the mouse click (detail > 0), never a keyboard activation. */
147
+ let longPressed = false;
148
+ let pressTimer: ReturnType<typeof setTimeout> | null = null;
149
+ let pressStart: { x: number; y: number } | null = null;
150
+
151
+ let btnRef = $state<HTMLButtonElement | null>(null);
152
+ let panelRef = $state<HTMLDivElement | null>(null);
153
+ let glyphRef = $state<HTMLSpanElement | null>(null);
154
+
155
+ let playError = $state(false);
156
+
157
+ const currentScape = $derived(scapes.find((s) => s.id === currentId) ?? null);
158
+ const currentLabel = $derived(currentScape?.label ?? currentId);
159
+ const groups = $derived(groupScapes(scapes));
160
+ const liveLabel = $derived(
161
+ playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
162
+ );
163
+ const disabled = $derived(scapes.length === 0);
164
+ const sliderValue = $derived(Math.round(volume * 100));
165
+
166
+ const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
167
+ const ICON_SIZE = $derived(compact ? 18 : 20);
168
+
169
+ // Engine state → component state. subscribe() fires once immediately, so
170
+ // this also fills the initial (persisted-preselect) state at mount.
171
+ $effect(() => {
172
+ return engine.subscribe((s) => {
173
+ playing = s.playing;
174
+ currentId = s.currentId;
175
+ volume = s.volume;
176
+ scapes = engine.list();
177
+ // A successful play (state flips to playing) clears the failure.
178
+ if (s.playing) playError = false;
179
+ });
180
+ });
181
+
182
+ // Breathing glyph: follow the engine's shared breath clock while playing,
183
+ // and only where ambient motion is allowed (getMotionContext().ambient is
184
+ // false under prefers-reduced-motion AND under data-motion="calm"). The
185
+ // value lands in a CSS variable the stylesheet turns into a smoothed
186
+ // scale — the callbacks run at the engine's ~4 Hz tick, the transition
187
+ // interpolates between them.
188
+ $effect(() => {
189
+ if (!playing) return;
190
+ if (!getMotionContext(btnRef).ambient) return;
191
+ const glyph = glyphRef;
192
+ if (!glyph) return;
193
+ const off = engine.onBreath((b) => {
194
+ glyph.style.setProperty('--jatui-ambience-breath', String(breathScale(b.value)));
195
+ });
196
+ return () => {
197
+ off();
198
+ // Drop the frozen scale so the stopped glyph sits at rest.
199
+ glyph.style.removeProperty('--jatui-ambience-breath');
200
+ };
201
+ });
202
+
203
+ // While open: click-outside dismissal (capture phase, so a press that
204
+ // begins outside never reaches the page first) and APG focus movement into
205
+ // the menu — focus the first nav item once the panel exists.
206
+ $effect(() => {
207
+ if (!open) return;
208
+ const panel = panelRef;
209
+ if (!panel) return;
210
+ navItems()[0]?.focus();
211
+ const onDocPointerDown = (e: PointerEvent) => {
212
+ const t = e.target as Node | null;
213
+ if (btnRef?.contains(t) || panelRef?.contains(t)) return;
214
+ closeMenu(false);
215
+ };
216
+ document.addEventListener('pointerdown', onDocPointerDown, true);
217
+ return () => document.removeEventListener('pointerdown', onDocPointerDown, true);
218
+ });
219
+
220
+ function navItems(): HTMLElement[] {
221
+ return panelRef ? Array.from(panelRef.querySelectorAll<HTMLElement>('[data-ambience-nav]')) : [];
222
+ }
223
+
224
+ function resolvePlacement(): 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' {
225
+ const el = btnRef;
226
+ if (!el || typeof window === 'undefined') return placement;
227
+ const r = el.getBoundingClientRect();
228
+ const spaceBelow = window.innerHeight - r.bottom;
229
+ const spaceAbove = r.top;
230
+ if (placement.startsWith('bottom') && spaceBelow < MENU_EST_HEIGHT && spaceAbove > spaceBelow)
231
+ return placement === 'bottom-end' ? 'top-end' : 'top-start';
232
+ if (placement.startsWith('top') && spaceAbove < MENU_EST_HEIGHT && spaceBelow > spaceAbove)
233
+ return placement === 'top-end' ? 'bottom-end' : 'bottom-start';
234
+ return placement;
235
+ }
236
+
237
+ function openMenu() {
238
+ if (disabled) return;
239
+ scapes = engine.list(); // freshest registry snapshot for the menu
240
+ effectivePlacement = resolvePlacement();
241
+ open = true;
242
+ }
243
+
244
+ function closeMenu(refocus = true) {
245
+ if (!open) return;
246
+ open = false;
247
+ if (refocus) btnRef?.focus();
248
+ }
249
+
250
+ function togglePlay(e: MouseEvent) {
251
+ // A long-press already opened the menu — don't also fire the toggle
252
+ // (keyboard activations, detail 0, are never suppressed).
253
+ if (longPressed && e.detail > 0) {
254
+ longPressed = false;
255
+ return;
256
+ }
257
+ longPressed = false;
258
+ if (open) closeMenu(false);
259
+ if (playing) {
260
+ engine.stop();
261
+ return;
262
+ }
263
+ const id = engine.state.currentId ?? engine.list()[0]?.id;
264
+ if (!id) return;
265
+ attemptPlay(id);
266
+ }
267
+
268
+ /** A rejected engine.play() sets the transient error state (the sr-only
269
+ * status announces it); success clears it. Never an unhandled rejection. */
270
+ function attemptPlay(id: string) {
271
+ void engine
272
+ .play(id)
273
+ .then(() => {
274
+ playError = false;
275
+ })
276
+ .catch(() => {
277
+ playError = true;
278
+ });
279
+ }
280
+
281
+ function selectScape(scape: Scape) {
282
+ closeMenu(true);
283
+ // Radio on the already-playing scape is a no-op (no restart crossfade);
284
+ // the checked-but-stopped scape resumes.
285
+ if (playing && scape.id === currentId) return;
286
+ attemptPlay(scape.id);
287
+ }
288
+
289
+ function onVolumeInput(e: Event) {
290
+ const v = Number((e.currentTarget as HTMLInputElement).value);
291
+ engine.setVolume(v / 100);
292
+ }
293
+
294
+ // --- pointer path to the menu: press-and-hold ----------------------------
295
+ function onPointerDown(e: PointerEvent) {
296
+ longPressed = false;
297
+ if (e.button !== 0 || disabled) return;
298
+ pressStart = { x: e.clientX, y: e.clientY };
299
+ pressTimer = setTimeout(() => {
300
+ pressTimer = null;
301
+ longPressed = true;
302
+ openMenu();
303
+ }, LONG_PRESS_MS);
304
+ }
305
+
306
+ function clearPress() {
307
+ if (pressTimer) {
308
+ clearTimeout(pressTimer);
309
+ pressTimer = null;
310
+ }
311
+ pressStart = null;
312
+ }
313
+
314
+ function onPointerMove(e: PointerEvent) {
315
+ if (!pressTimer || !pressStart) return;
316
+ const dx = e.clientX - pressStart.x;
317
+ const dy = e.clientY - pressStart.y;
318
+ if (dx * dx + dy * dy > 64) clearPress(); // moved ~8px — a drag, not a hold
319
+ }
320
+
321
+ function onContextMenu(e: MouseEvent) {
322
+ e.preventDefault();
323
+ clearPress();
324
+ openMenu();
325
+ }
326
+
327
+ function onButtonKeydown(e: KeyboardEvent) {
328
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
329
+ e.preventDefault();
330
+ openMenu();
331
+ }
332
+ }
333
+
334
+ // --- menu keyboard: APG roving among the nav items -----------------------
335
+ function onMenuKeydown(e: KeyboardEvent) {
336
+ if (e.key === 'Escape') {
337
+ e.preventDefault();
338
+ closeMenu(true);
339
+ return;
340
+ }
341
+ if (e.key === 'Tab') {
342
+ // Menus are not tab stops: close and let focus move on.
343
+ closeMenu(false);
344
+ return;
345
+ }
346
+ // The volume slider owns its own arrows/Home/End.
347
+ if (e.target instanceof HTMLInputElement) return;
348
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
349
+ e.preventDefault();
350
+ const items = navItems();
351
+ if (!items.length) return;
352
+ const current = items.indexOf(document.activeElement as HTMLElement);
353
+ let next: number;
354
+ if (e.key === 'Home') next = 0;
355
+ else if (e.key === 'End') next = items.length - 1;
356
+ else if (current === -1) next = e.key === 'ArrowUp' ? items.length - 1 : 0;
357
+ else if (e.key === 'ArrowDown') next = (current + 1) % items.length;
358
+ else next = (current - 1 + items.length) % items.length;
359
+ items[next]?.focus();
360
+ }
361
+
362
+ const PLACEMENT_CLASS = {
363
+ 'bottom-start': 'top-full left-0 mt-2',
364
+ 'bottom-end': 'top-full right-0 mt-2',
365
+ 'top-start': 'bottom-full left-0 mb-2',
366
+ 'top-end': 'bottom-full right-0 mb-2'
367
+ } as const;
368
+ </script>
369
+
370
+ <div class="relative inline-flex shrink-0">
371
+ <button
372
+ bind:this={btnRef}
373
+ type="button"
374
+ class="jatui-ambience-toggle inline-flex shrink-0 select-none items-center justify-center rounded-full
375
+ touch-manipulation
376
+ text-base-content transition-colors duration-[var(--motion-instant,80ms)]
377
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary
378
+ hover:bg-base-content/10 focus-visible:bg-base-content/10
379
+ active:bg-base-content/15 disabled:pointer-events-none disabled:opacity-50
380
+ {BUTTON_SIZE}"
381
+ class:is-playing={playing}
382
+ aria-label={liveLabel}
383
+ aria-pressed={playing}
384
+ aria-haspopup="menu"
385
+ aria-expanded={open}
386
+ aria-controls={open ? menuId : undefined}
387
+ aria-describedby={hintId}
388
+ title={liveLabel}
389
+ disabled={disabled}
390
+ onclick={togglePlay}
391
+ onkeydown={onButtonKeydown}
392
+ onpointerdown={onPointerDown}
393
+ onpointerup={clearPress}
394
+ onpointercancel={clearPress}
395
+ onpointermove={onPointerMove}
396
+ onpointerleave={clearPress}
397
+ oncontextmenu={onContextMenu}
398
+ >
399
+ <span
400
+ bind:this={glyphRef}
401
+ class="glyphs relative block"
402
+ style="width: {ICON_SIZE}px; height: {ICON_SIZE}px"
403
+ aria-hidden="true"
404
+ >
405
+ <!-- Stopped: speaker with a strike, lucide volume-x geometry. -->
406
+ <svg
407
+ xmlns="http://www.w3.org/2000/svg"
408
+ viewBox="0 0 24 24"
409
+ fill="none"
410
+ stroke="currentColor"
411
+ stroke-width="2"
412
+ stroke-linecap="round"
413
+ stroke-linejoin="round"
414
+ class="glyph glyph-off absolute inset-0"
415
+ width={ICON_SIZE}
416
+ height={ICON_SIZE}
417
+ >
418
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
419
+ <line x1="22" y1="9" x2="16" y2="15" />
420
+ <line x1="16" y1="9" x2="22" y2="15" />
421
+ </svg>
422
+ <!-- Playing: speaker with waves, lucide volume-2 geometry. -->
423
+ <svg
424
+ xmlns="http://www.w3.org/2000/svg"
425
+ viewBox="0 0 24 24"
426
+ fill="none"
427
+ stroke="currentColor"
428
+ stroke-width="2"
429
+ stroke-linecap="round"
430
+ stroke-linejoin="round"
431
+ class="glyph glyph-on absolute inset-0"
432
+ width={ICON_SIZE}
433
+ height={ICON_SIZE}
434
+ >
435
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
436
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
437
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
438
+ </svg>
439
+ </span>
440
+ </button>
441
+
442
+ <!-- Teaches the menu gestures to assistive tech (the gestures are not
443
+ obvious from a button that both toggles and opens a menu). -->
444
+ <span id={hintId} class="sr-only">Press the down arrow, or press and hold or right-click, to choose a scape.</span>
445
+
446
+ {#if open}
447
+ <div
448
+ bind:this={panelRef}
449
+ id={menuId}
450
+ role="menu"
451
+ tabindex="-1"
452
+ aria-label="Ambience"
453
+ class="jatui-ambience-menu absolute z-50 w-72 max-w-[calc(100vw-1rem)] overflow-y-auto
454
+ rounded-box border border-base-300 bg-base-100 p-1.5 text-base-content shadow-xl
455
+ max-h-[min(24rem,calc(100vh-1rem))] menu-pop
456
+ {PLACEMENT_CLASS[effectivePlacement]}"
457
+ onkeydown={onMenuKeydown}
458
+ >
459
+ <!-- Volume: a real slider in the roving set; it owns its own arrows. -->
460
+ <div role="none" class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
461
+ <div class="mb-1.5 flex items-center justify-between">
462
+ <span class="text-jatui-label text-base-content/60">Volume</span>
463
+ <span class="text-2xs text-base-content/50 tabular-nums">{sliderValue}%</span>
464
+ </div>
465
+ <input
466
+ data-ambience-nav
467
+ tabindex="-1"
468
+ type="range"
469
+ class="range range-xs w-full"
470
+ min="0"
471
+ max="100"
472
+ step="1"
473
+ value={sliderValue}
474
+ aria-label="Ambience volume"
475
+ aria-valuetext="{sliderValue}%"
476
+ oninput={onVolumeInput}
477
+ />
478
+ </div>
479
+
480
+ {#each groups as g (g.group)}
481
+ <div role="group" aria-label={g.label} class="py-1">
482
+ <div class="text-jatui-label text-base-content/45 px-2 py-1" aria-hidden="true">
483
+ {g.label}
484
+ </div>
485
+ {#each g.scapes as s (s.id)}
486
+ <button
487
+ type="button"
488
+ role="menuitemradio"
489
+ aria-checked={s.id === currentId}
490
+ tabindex="-1"
491
+ data-ambience-nav
492
+ class="flex w-full items-start gap-2 rounded px-2 py-1.5 text-left
493
+ transition-colors duration-[var(--motion-instant,80ms)]
494
+ hover:bg-base-content/10 focus-visible:bg-base-content/10
495
+ focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary
496
+ {s.id === currentId ? 'bg-base-content/5' : ''}"
497
+ onclick={() => selectScape(s)}
498
+ >
499
+ <span class="min-w-0 flex-1">
500
+ <span class="block truncate text-sm font-medium">{s.label}</span>
501
+ {#if s.description}
502
+ <!-- Secondary line: for playlist scapes this is the track attribution. -->
503
+ <span class="text-2xs text-base-content/55 block truncate">{s.description}</span>
504
+ {/if}
505
+ </span>
506
+ {#if s.id === currentId}
507
+ <svg
508
+ xmlns="http://www.w3.org/2000/svg"
509
+ viewBox="0 0 24 24"
510
+ fill="currentColor"
511
+ class="mt-1 size-3.5 shrink-0 text-primary"
512
+ aria-hidden="true"
513
+ >
514
+ <path d="M20.285 2l-11.285 11.567-5.286-5.011-3.714 3.716 9 8.728 15-15.285z"></path>
515
+ </svg>
516
+ {:else}
517
+ <span class="mt-1 size-3.5 shrink-0" aria-hidden="true"></span>
518
+ {/if}
519
+ </button>
520
+ {/each}
521
+ </div>
522
+ {/each}
523
+ </div>
524
+ {/if}
525
+
526
+ <!-- Live status: announces play/stop/switch without stealing focus. -->
527
+ <span role="status" aria-live="polite" class="sr-only">{liveLabel}</span>
528
+ </div>
529
+
530
+ <style>
531
+ /*
532
+ * Off↔on morph: transform + opacity only (compositor-friendly), spring for
533
+ * the small-element settle, glide for the fade — the ThemeToggle recipe.
534
+ */
535
+ .glyph {
536
+ transition:
537
+ transform var(--motion-fast, 150ms) var(--motion-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
538
+ opacity var(--motion-fast, 150ms) var(--motion-glide, cubic-bezier(0.16, 1, 0.3, 1));
539
+ }
540
+
541
+ .glyph-off {
542
+ opacity: 1;
543
+ transform: rotate(0deg) scale(1);
544
+ }
545
+
546
+ .glyph-on {
547
+ opacity: 0;
548
+ transform: rotate(-90deg) scale(0.5);
549
+ }
550
+
551
+ .is-playing .glyph-off {
552
+ opacity: 0;
553
+ transform: rotate(90deg) scale(0.5);
554
+ }
555
+
556
+ .is-playing .glyph-on {
557
+ opacity: 1;
558
+ transform: rotate(0deg) scale(1);
559
+ }
560
+
561
+ /*
562
+ * The breath: the engine's shared breath clock drives
563
+ * --jatui-ambience-breath at its ~4 Hz tick; the transition interpolates
564
+ * between the steps so the glyph visibly inhales and exhales.
565
+ */
566
+ .glyphs {
567
+ transform: scale(var(--jatui-ambience-breath, 1));
568
+ transition: transform 420ms var(--motion-glide, cubic-bezier(0.16, 1, 0.3, 1));
569
+ }
570
+
571
+ /* Ambient decorative loop — static under reduced motion … */
572
+ @media (prefers-reduced-motion: reduce) {
573
+ .glyph {
574
+ transition: none;
575
+ }
576
+ .glyphs {
577
+ transform: none;
578
+ transition: none;
579
+ }
580
+ .menu-pop {
581
+ animation: none;
582
+ }
583
+ }
584
+
585
+ /* … and under data-motion="calm" (the house rule for ambient loops). */
586
+ :global([data-motion='calm']) .glyphs {
587
+ transform: none;
588
+ }
589
+
590
+ .menu-pop {
591
+ animation: jatui-menu-in var(--motion-enter, 200ms) var(--motion-glide, cubic-bezier(0.16, 1, 0.3, 1));
592
+ }
593
+
594
+ @keyframes jatui-menu-in {
595
+ from {
596
+ opacity: 0;
597
+ }
598
+ }
599
+ </style>