@joewinke/jatui 0.54.1 → 0.56.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.
@@ -6,16 +6,30 @@
6
6
  *
7
7
  * Interaction contract:
8
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
9
+ * engine call, made synchronously inside the click handler. With no
10
+ * playable scape registered (links only) the click opens the menu.
11
+ * - The menu (menu button pattern) opens with ArrowDown/ArrowUp, a
12
+ * press-and-hold (~450 ms) or a right-click (Android/Chrome fire
12
13
  * 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.
14
+ * the keyboard path always works). Top level: a volume slider wired to
15
+ * `engine.setVolume`, then one row per section — Radio / Focus /
16
+ * Noise / Meditation … in the host's order (`AmbienceOptions.groups`,
17
+ * jatui-mdq2p) — each opening a submenu of its items. Right / Enter /
18
+ * Space open a submenu and move focus into it; Left / Escape close it
19
+ * and return focus to its row (the ContextMenu submenu pattern); a
20
+ * mouse hover opens a row's submenu without moving focus.
21
+ * - Scapes are `menuitemradio` (current one checked; its section row
22
+ * carries a dot and the scape's name). Link items (radio stations) are
23
+ * real `<a target="_blank">` menu items with an external-link glyph:
24
+ * the station's own site or player opens in a new tab. Nothing is
25
+ * relayed — we never proxy a third-party stream.
26
+ * - A playing playlist shows its current track's attribution (title,
27
+ * composer, license) under the playlist item and its section row.
28
+ * - With a menu or submenu open, the mouse wheel over that surface
29
+ * adjusts volume (wheel up = louder, ~5% per notch; the list still
30
+ * scrolls while it has room) — the wheel escapes to the page the moment
31
+ * the pointer leaves the surface. Each surface (panel and any open
32
+ * submenu) carries its own non-passive listener; never window.
19
33
  *
20
34
  * A11y: the trigger is `aria-pressed` while playing and carries the full
21
35
  * menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
@@ -28,58 +42,41 @@
28
42
  * Token-only theming (DaisyUI + `--jatui-*` + `--motion-*`). SSR-safe: the
29
43
  * engine is lazy and nothing here touches `window` at import.
30
44
  */
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
- }
45
+ import type { AmbienceGroupDef, AmbienceItem, ScapeGroup } from '../ambience/types';
46
+ import {
47
+ AMBIENCE_DEFAULT_GROUP_LABELS,
48
+ AMBIENCE_DEFAULT_GROUP_ORDER,
49
+ groupAmbienceItems,
50
+ type AmbienceSection
51
+ } from '../ambience/groups';
52
+ import { trackAttribution, type PlaylistTrack } from '../ambience/scapes/playlist';
53
+
54
+ /** Default menu section order (when the host configures no `groups`). */
55
+ export const AMBIENCE_GROUP_ORDER: readonly ScapeGroup[] = AMBIENCE_DEFAULT_GROUP_ORDER;
56
+
57
+ /** Human labels for the library's own groups. */
58
+ export const GROUP_LABELS: Readonly<Record<string, string>> = AMBIENCE_DEFAULT_GROUP_LABELS;
59
+
60
+ /** One section of the menu: a top-level row and its submenu. */
61
+ export type AmbienceScapeGroup = AmbienceSection;
57
62
 
58
63
  /**
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.
64
+ * Group items for the menu: the host's section order (or the default),
65
+ * registration order within a section, empty sections omitted. A group
66
+ * missing from the order still renders, after the ordered ones, in
67
+ * first-seen order — a menu never silently drops a registered item.
63
68
  */
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;
69
+ export function groupScapes(
70
+ items: AmbienceItem[],
71
+ groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>
72
+ ): AmbienceScapeGroup[] {
73
+ return groupAmbienceItems(items, groups);
74
+ }
75
+
76
+ /** "Gymnopédie No. 1 — Erik Satie · Public domain": a playing track's line. */
77
+ export function nowPlayingLine(track: PlaylistTrack | null | undefined): string {
78
+ if (!track) return '';
79
+ return `${track.title} — ${trackAttribution(track)}`;
83
80
  }
84
81
 
85
82
  /**
@@ -106,17 +103,111 @@
106
103
  const v = Math.min(1, Math.max(0, value));
107
104
  return 1 + 0.06 * v;
108
105
  }
106
+
107
+ /** Submenu width (px) — matches the `w-64` class on the submenu panel. */
108
+ export const SUBMENU_WIDTH = 256;
109
+
110
+ /**
111
+ * Where a submenu goes, in viewport px. Beside the menu panel on the
112
+ * preferred side (the side the menu already extends to), else the other
113
+ * side, else — a phone, no room either side — overlapping the panel just
114
+ * below its row, clamped inside the viewport.
115
+ */
116
+ export function placeSubmenu(
117
+ row: { top: number; bottom: number },
118
+ panel: { left: number; right: number },
119
+ viewport: { width: number },
120
+ prefer: 'left' | 'right',
121
+ width = SUBMENU_WIDTH,
122
+ gap = 4,
123
+ margin = 8
124
+ ): { x: number; y: number; side: 'left' | 'right' | 'over' } {
125
+ const right = panel.right + gap;
126
+ const left = panel.left - gap - width;
127
+ const fitsRight = right + width <= viewport.width - margin;
128
+ const fitsLeft = left >= margin;
129
+ const y = row.top - 6;
130
+ if (prefer === 'left' ? fitsLeft : fitsRight)
131
+ return { x: prefer === 'left' ? left : right, y, side: prefer };
132
+ if (prefer === 'left' ? fitsRight : fitsLeft)
133
+ return { x: prefer === 'left' ? right : left, y, side: prefer === 'left' ? 'right' : 'left' };
134
+ const x = Math.max(margin, Math.min(panel.left + 16, viewport.width - margin - width));
135
+ return { x, y: row.bottom + 2, side: 'over' };
136
+ }
137
+
138
+ // --- wheel volume over the open surfaces (jatui-xomny) -------------------
139
+
140
+ /** One mouse-wheel notch in pixel mode — Chrome/WebKit ship ±100 per notch. */
141
+ export const WHEEL_NOTCH_PX = 100;
142
+ /** One mouse-wheel notch in line mode — Firefox ships ±3 lines per notch. */
143
+ export const WHEEL_NOTCH_LINES = 3;
144
+ /** Volume change per wheel notch, on the 0-100 slider scale. */
145
+ export const WHEEL_STEP_PCT = 5;
146
+
147
+ /**
148
+ * A wheel delta normalized into notch units (~1 = one physical notch):
149
+ * pixel deltas divide by ~100 px, Firefox's line mode by 3 lines, and a
150
+ * page-mode delta counts as one notch. Trackpads emit small pixel deltas
151
+ * continuously — those are meant to accumulate (see wheelVolumeSteps),
152
+ * not to step on every event.
153
+ */
154
+ export function wheelNotches(deltaY: number, deltaMode: number): number {
155
+ if (deltaY === 0) return 0;
156
+ if (deltaMode === 1) return deltaY / WHEEL_NOTCH_LINES; // lines
157
+ if (deltaMode === 2) return deltaY; // one page ≈ one notch
158
+ return deltaY / WHEEL_NOTCH_PX; // pixels (deltaMode 0)
159
+ }
160
+
161
+ /**
162
+ * Accumulate a wheel delta into `acc` and return the WHOLE volume steps it
163
+ * adds up to, in volume terms — positive = louder (wheel up), negative =
164
+ * quieter (wheel down) — 0 while a partial notch is still pending. The
165
+ * remainder stays in `acc.value` across events, so trackpad pixel deltas
166
+ * accumulate and one wheel notch lands as about one step.
167
+ */
168
+ export function wheelVolumeSteps(
169
+ acc: { value: number },
170
+ deltaY: number,
171
+ deltaMode: number
172
+ ): number {
173
+ acc.value += wheelNotches(deltaY, deltaMode);
174
+ // Wheel down (positive delta) quiets — flip the sign into volume steps.
175
+ // +0 normalizes Math.trunc's negative zero, which toBe/toEqual treat as
176
+ // a different value from 0.
177
+ const steps = Math.trunc(-acc.value) + 0;
178
+ if (steps !== 0) acc.value += steps; // consume the whole steps
179
+ return steps;
180
+ }
181
+
182
+ /**
183
+ * The volume (0..1) after `steps` wheel steps: ±`stepPct` per step on the
184
+ * 0-100 slider scale, clamped to 0-100. The math runs on the rounded
185
+ * slider percent, so the result never carries float dust past what the
186
+ * slider itself would produce for the same value.
187
+ */
188
+ export function volumeAfterSteps(
189
+ volume: number,
190
+ steps: number,
191
+ stepPct = WHEEL_STEP_PCT
192
+ ): number {
193
+ const next = Math.round(volume * 100) + steps * stepPct;
194
+ return Math.min(100, Math.max(0, next)) / 100;
195
+ }
109
196
  </script>
110
197
 
111
198
  <script lang="ts">
199
+ import { untrack } from 'svelte';
112
200
  import { getMotionContext } from '../motion/level';
201
+ import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
202
+ import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
203
+ import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
113
204
 
114
205
  let {
115
206
  engine,
116
207
  compact = false,
117
208
  placement = 'bottom-end'
118
209
  }: {
119
- /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. */
210
+ /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
120
211
  engine: Ambience;
121
212
  /** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
122
213
  compact?: boolean;
@@ -126,6 +217,7 @@
126
217
 
127
218
  const menuId = $props.id();
128
219
  const hintId = `${menuId}-hint`;
220
+ const subId = `${menuId}-sub`;
129
221
 
130
222
  /** Long-press duration for the pointer path to the menu. */
131
223
  const LONG_PRESS_MS = 450;
@@ -135,37 +227,64 @@
135
227
  let playing = $state(false);
136
228
  let currentId = $state<string | null>(null);
137
229
  let volume = $state(0.6);
138
- let scapes = $state<Scape[]>([]);
230
+ let items = $state<AmbienceItem[]>([]);
139
231
  let open = $state(false);
140
232
  // svelte-ignore state_referenced_locally -- only the seed; resolvePlacement() reads the live prop
141
233
  let effectivePlacement = $state<'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'>(
142
234
  placement
143
235
  );
144
236
 
237
+ /** The section whose submenu is open (null = none), and where it sits
238
+ * relative to the wrapper (the submenu's offset parent). */
239
+ let subGroup = $state<string | null>(null);
240
+ let subPos = $state<{ left: number; top: number; side: 'left' | 'right' | 'over' }>({
241
+ left: 0,
242
+ top: 0,
243
+ side: 'right'
244
+ });
245
+
145
246
  /** Set by a long-press so the click that follows the release is swallowed —
146
247
  * but only the mouse click (detail > 0), never a keyboard activation. */
147
248
  let longPressed = false;
148
249
  let pressTimer: ReturnType<typeof setTimeout> | null = null;
149
250
  let pressStart: { x: number; y: number } | null = null;
150
251
 
252
+ let wrapRef = $state<HTMLDivElement | null>(null);
151
253
  let btnRef = $state<HTMLButtonElement | null>(null);
152
254
  let panelRef = $state<HTMLDivElement | null>(null);
255
+ let subRef = $state<HTMLDivElement | null>(null);
153
256
  let glyphRef = $state<HTMLSpanElement | null>(null);
257
+ /** The volume section (header + slider) at the top of the panel. */
258
+ let volumeRef = $state<HTMLDivElement | null>(null);
154
259
 
155
260
  let playError = $state(false);
261
+ let track = $state<PlaylistState['track']>(null);
156
262
 
157
- const currentScape = $derived(scapes.find((s) => s.id === currentId) ?? null);
158
- const currentLabel = $derived(currentScape?.label ?? currentId);
159
- const groups = $derived(groupScapes(scapes));
263
+ // Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
264
+ // the pending partial notch between wheel events so trackpad pixel deltas
265
+ // accumulate into whole steps. Reset every time the menu opens; shared by
266
+ // the panel and its submenus for continuity.
267
+ const wheelAcc = { value: 0 };
268
+
269
+ const scapes = $derived(items.filter((i): i is Scape => !isAmbienceLink(i)));
270
+ const currentItem = $derived(items.find((s) => s.id === currentId) ?? null);
271
+ const currentLabel = $derived(currentItem?.label ?? currentId);
272
+ const groups = $derived(groupScapes(items, engine.groups?.()));
273
+ const openSection = $derived(groups.find((g) => g.group === subGroup) ?? null);
274
+ const trackLine = $derived(playing ? nowPlayingLine(track) : '');
160
275
  const liveLabel = $derived(
161
276
  playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
162
277
  );
163
- const disabled = $derived(scapes.length === 0);
278
+ const disabled = $derived(items.length === 0);
164
279
  const sliderValue = $derived(Math.round(volume * 100));
165
280
 
166
281
  const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
167
282
  const ICON_SIZE = $derived(compact ? 18 : 20);
168
283
 
284
+ function snapshot(): AmbienceItem[] {
285
+ return engine.items?.() ?? engine.list();
286
+ }
287
+
169
288
  // Engine state → component state. subscribe() fires once immediately, so
170
289
  // this also fills the initial (persisted-preselect) state at mount.
171
290
  $effect(() => {
@@ -173,12 +292,24 @@
173
292
  playing = s.playing;
174
293
  currentId = s.currentId;
175
294
  volume = s.volume;
176
- scapes = engine.list();
295
+ items = snapshot();
177
296
  // A successful play (state flips to playing) clears the failure.
178
297
  if (s.playing) playError = false;
179
298
  });
180
299
  });
181
300
 
301
+ // The current scape is a playlist → follow its track for the attribution line.
302
+ $effect(() => {
303
+ const item = currentItem;
304
+ if (!isPlaylistScape(item)) {
305
+ track = null;
306
+ return;
307
+ }
308
+ return item.subscribe((s) => {
309
+ track = s.status === 'playing' ? s.track : null;
310
+ });
311
+ });
312
+
182
313
  // Breathing glyph: follow the engine's shared breath clock while playing,
183
314
  // and only where ambient motion is allowed (getMotionContext().ambient is
184
315
  // false under prefers-reduced-motion AND under data-motion="calm"). The
@@ -201,8 +332,10 @@
201
332
  });
202
333
 
203
334
  // 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.
335
+ // begins outside never reaches the page first), the non-passive wheel
336
+ // listener on the PANEL element (never window — see onSurfaceWheel), and
337
+ // APG focus movement into the menu — focus the first nav item once the
338
+ // panel exists. A resize closes an open submenu (its position is stale).
206
339
  $effect(() => {
207
340
  if (!open) return;
208
341
  const panel = panelRef;
@@ -210,17 +343,60 @@
210
343
  navItems()[0]?.focus();
211
344
  const onDocPointerDown = (e: PointerEvent) => {
212
345
  const t = e.target as Node | null;
213
- if (btnRef?.contains(t) || panelRef?.contains(t)) return;
346
+ if (btnRef?.contains(t) || panelRef?.contains(t) || subRef?.contains(t)) return;
214
347
  closeMenu(false);
215
348
  };
349
+ const onResize = () => closeSubmenu(false);
216
350
  document.addEventListener('pointerdown', onDocPointerDown, true);
217
- return () => document.removeEventListener('pointerdown', onDocPointerDown, true);
351
+ window.addEventListener('resize', onResize);
352
+ panel.addEventListener('wheel', onPanelWheel, { passive: false });
353
+ return () => {
354
+ document.removeEventListener('pointerdown', onDocPointerDown, true);
355
+ window.removeEventListener('resize', onResize);
356
+ panel.removeEventListener('wheel', onPanelWheel);
357
+ };
218
358
  });
219
359
 
360
+ // Non-passive wheel on any open submenu too (jatui-xomny): over the
361
+ // submenu the wheel is volume with the same rule — a list that can still
362
+ // scroll keeps scrolling. The submenu is a SIBLING of the panel (never
363
+ // clipped by its scroll box), so it carries its own listener.
364
+ $effect(() => {
365
+ const sub = subRef;
366
+ if (!open || !sub || subGroup === null) return;
367
+ const onSubWheel = (e: WheelEvent) => onSurfaceWheel(e, sub, false);
368
+ sub.addEventListener('wheel', onSubWheel, { passive: false });
369
+ return () => sub.removeEventListener('wheel', onSubWheel);
370
+ });
371
+
372
+ // Keep an open submenu inside the viewport vertically once its real
373
+ // height is known.
374
+ $effect(() => {
375
+ const sub = subRef;
376
+ const wrap = wrapRef;
377
+ if (!sub || !wrap || !subGroup) return;
378
+ const r = sub.getBoundingClientRect();
379
+ const overflow = r.bottom - (window.innerHeight - 8);
380
+ if (overflow > 0) {
381
+ const wrapTop = wrap.getBoundingClientRect().top;
382
+ const pos = untrack(() => subPos);
383
+ subPos = { ...pos, top: Math.max(8 - wrapTop, pos.top - overflow) };
384
+ }
385
+ });
386
+
387
+ /** Top-level roving set: the volume slider and the section rows. */
220
388
  function navItems(): HTMLElement[] {
221
389
  return panelRef ? Array.from(panelRef.querySelectorAll<HTMLElement>('[data-ambience-nav]')) : [];
222
390
  }
223
391
 
392
+ function subItems(): HTMLElement[] {
393
+ return subRef ? Array.from(subRef.querySelectorAll<HTMLElement>('[data-ambience-sub]')) : [];
394
+ }
395
+
396
+ function rowFor(group: string): HTMLElement | null {
397
+ return panelRef?.querySelector<HTMLElement>(`[data-ambience-group="${CSS.escape(group)}"]`) ?? null;
398
+ }
399
+
224
400
  function resolvePlacement(): 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' {
225
401
  const el = btnRef;
226
402
  if (!el || typeof window === 'undefined') return placement;
@@ -236,17 +412,57 @@
236
412
 
237
413
  function openMenu() {
238
414
  if (disabled) return;
239
- scapes = engine.list(); // freshest registry snapshot for the menu
415
+ items = snapshot(); // freshest registry snapshot for the menu
240
416
  effectivePlacement = resolvePlacement();
417
+ subGroup = null;
418
+ wheelAcc.value = 0; // a fresh menu starts from no pending wheel delta
241
419
  open = true;
242
420
  }
243
421
 
244
422
  function closeMenu(refocus = true) {
245
423
  if (!open) return;
246
424
  open = false;
425
+ subGroup = null;
247
426
  if (refocus) btnRef?.focus();
248
427
  }
249
428
 
429
+ function openSubmenu(group: string, focusFirst: boolean) {
430
+ const row = rowFor(group);
431
+ const wrap = wrapRef;
432
+ if (!row || !wrap || !panelRef) return;
433
+ const rr = row.getBoundingClientRect();
434
+ const pr = panelRef.getBoundingClientRect();
435
+ const wr = wrap.getBoundingClientRect();
436
+ const prefer = effectivePlacement.endsWith('end') ? 'left' : 'right';
437
+ const p = placeSubmenu(rr, pr, { width: window.innerWidth }, prefer);
438
+ subPos = { left: p.x - wr.left, top: p.y - wr.top, side: p.side };
439
+ subGroup = group;
440
+ if (focusFirst) queueMicrotask(() => subItems()[0]?.focus());
441
+ }
442
+
443
+ function closeSubmenu(refocusRow = true) {
444
+ const g = subGroup;
445
+ if (g === null) return;
446
+ subGroup = null;
447
+ if (refocusRow) rowFor(g)?.focus();
448
+ }
449
+
450
+ function onRowClick(e: MouseEvent, group: string) {
451
+ // Keyboard activation (Enter / Space → detail 0) always opens and enters;
452
+ // a mouse click on the open row's own submenu toggles it shut.
453
+ if (e.detail > 0 && subGroup === group) {
454
+ closeSubmenu(true);
455
+ return;
456
+ }
457
+ openSubmenu(group, true);
458
+ }
459
+
460
+ function onRowPointerEnter(e: PointerEvent, group: string) {
461
+ // Hover opens (mouse/pen only — a touch tap is a click). Focus stays put.
462
+ if (e.pointerType === 'touch' || subGroup === group) return;
463
+ openSubmenu(group, false);
464
+ }
465
+
250
466
  function togglePlay(e: MouseEvent) {
251
467
  // A long-press already opened the menu — don't also fire the toggle
252
468
  // (keyboard activations, detail 0, are never suppressed).
@@ -255,13 +471,22 @@
255
471
  return;
256
472
  }
257
473
  longPressed = false;
258
- if (open) closeMenu(false);
474
+ if (open) {
475
+ closeMenu(false);
476
+ if (!engine.list().length) return;
477
+ }
259
478
  if (playing) {
260
479
  engine.stop();
261
480
  return;
262
481
  }
263
- const id = engine.state.currentId ?? engine.list()[0]?.id;
264
- if (!id) return;
482
+ const playable = engine.list();
483
+ const last = engine.state.currentId;
484
+ const id = last && playable.some((s) => s.id === last) ? last : playable[0]?.id;
485
+ if (!id) {
486
+ // Links only (e.g. a radio-only shell): the trigger opens the menu.
487
+ openMenu();
488
+ return;
489
+ }
265
490
  attemptPlay(id);
266
491
  }
267
492
 
@@ -286,11 +511,84 @@
286
511
  attemptPlay(scape.id);
287
512
  }
288
513
 
514
+ function onLinkClick() {
515
+ // The anchor's own default action opens the new tab; just get out of the way.
516
+ closeMenu(false);
517
+ }
518
+
289
519
  function onVolumeInput(e: Event) {
290
520
  const v = Number((e.currentTarget as HTMLInputElement).value);
291
521
  engine.setVolume(v / 100);
292
522
  }
293
523
 
524
+ // --- wheel volume over the open surfaces (jatui-xomny) -------------------
525
+ // While the menu (or one of its submenus) is open the wheel adjusts volume
526
+ // instead of scrolling the page — EXCEPT over a scrollable list that can
527
+ // still move in the wheel's direction, where the wheel stays a scroll. The
528
+ // listeners are non-passive on the surface elements (the panel and any
529
+ // open submenu — the submenu is a SIBLING of the panel, so it needs its
530
+ // own) and are only attached while open, so a wheel anywhere else scrolls
531
+ // the page exactly as before.
532
+
533
+ /**
534
+ * The elements a native wheel scroll could move for a target inside
535
+ * `surface`: every HTMLElement from the target up to and including the
536
+ * surface itself (the panel and each submenu are their own
537
+ * `overflow-y-auto` scroll containers).
538
+ */
539
+ function scrollablesBetween(target: Node, surface: HTMLElement): HTMLElement[] {
540
+ const out: HTMLElement[] = [];
541
+ let el: Node | null = target;
542
+ while (el && el !== surface) {
543
+ if (el instanceof HTMLElement) out.push(el);
544
+ el = el.parentNode;
545
+ }
546
+ out.push(surface);
547
+ return out;
548
+ }
549
+
550
+ /** Can `el` still move in the wheel's direction? (1px epsilon: a list
551
+ * sitting at its edge with sub-pixel scrollTop is at the end.) */
552
+ function canScrollFurther(el: HTMLElement, down: boolean): boolean {
553
+ const style = getComputedStyle(el);
554
+ const scrolls =
555
+ (style.overflowY === 'auto' || style.overflowY === 'scroll') &&
556
+ el.scrollHeight > el.clientHeight;
557
+ if (!scrolls) return false;
558
+ return down ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollTop > 1;
559
+ }
560
+
561
+ /**
562
+ * The shared wheel handler for one open surface. `hasVolume` marks the
563
+ * panel — the surface that owns the volume section, where the wheel is
564
+ * ALWAYS volume (it is a control, not a list). Over the list rows the
565
+ * wheel stays a scroll while some scrollable under the pointer can still
566
+ * move that way; past the end (or when the list is shorter than the
567
+ * surface) it steps the volume instead of letting the scroll escape to
568
+ * the page.
569
+ */
570
+ function onSurfaceWheel(e: WheelEvent, surface: HTMLElement, hasVolume: boolean) {
571
+ const target = e.target as Node | null;
572
+ if (!target || !surface.contains(target)) return;
573
+ // Vertical wheel only — shift+wheel is horizontal scrolling.
574
+ if (e.deltaY === 0) return;
575
+ const down = e.deltaY > 0;
576
+ const overVolume = hasVolume && (volumeRef?.contains(target) ?? false);
577
+ const hijack =
578
+ overVolume || !scrollablesBetween(target, surface).some((el) => canScrollFurther(el, down));
579
+ if (!hijack) return; // native scroll — never preventDefault, never step
580
+
581
+ e.preventDefault();
582
+ const steps = wheelVolumeSteps(wheelAcc, e.deltaY, e.deltaMode);
583
+ if (steps !== 0) engine.setVolume(volumeAfterSteps(volume, steps));
584
+ }
585
+
586
+ function onPanelWheel(e: WheelEvent) {
587
+ const panel = panelRef;
588
+ if (!panel) return;
589
+ onSurfaceWheel(e, panel, true);
590
+ }
591
+
294
592
  // --- pointer path to the menu: press-and-hold ----------------------------
295
593
  function onPointerDown(e: PointerEvent) {
296
594
  longPressed = false;
@@ -331,11 +629,25 @@
331
629
  }
332
630
  }
333
631
 
334
- // --- menu keyboard: APG roving among the nav items -----------------------
632
+ /** APG roving: next index for an arrow / Home / End key, wrapping. */
633
+ function rove(list: HTMLElement[], key: string): void {
634
+ if (!list.length) return;
635
+ const current = list.indexOf(document.activeElement as HTMLElement);
636
+ let next: number;
637
+ if (key === 'Home') next = 0;
638
+ else if (key === 'End') next = list.length - 1;
639
+ else if (current === -1) next = key === 'ArrowUp' ? list.length - 1 : 0;
640
+ else if (key === 'ArrowDown') next = (current + 1) % list.length;
641
+ else next = (current - 1 + list.length) % list.length;
642
+ list[next]?.focus();
643
+ }
644
+
645
+ // --- top-level menu keyboard ---------------------------------------------
335
646
  function onMenuKeydown(e: KeyboardEvent) {
336
647
  if (e.key === 'Escape') {
337
648
  e.preventDefault();
338
- closeMenu(true);
649
+ if (subGroup !== null) closeSubmenu(true);
650
+ else closeMenu(true);
339
651
  return;
340
652
  }
341
653
  if (e.key === 'Tab') {
@@ -345,18 +657,56 @@
345
657
  }
346
658
  // The volume slider owns its own arrows/Home/End.
347
659
  if (e.target instanceof HTMLInputElement) return;
660
+ const group = (e.target as HTMLElement | null)?.dataset?.ambienceGroup;
661
+ // Enter / Space reach onRowClick as a native click (detail 0).
662
+ if (group && e.key === 'ArrowRight') {
663
+ e.preventDefault();
664
+ openSubmenu(group, true);
665
+ return;
666
+ }
667
+ if (e.key === 'ArrowLeft' && subGroup !== null) {
668
+ e.preventDefault();
669
+ closeSubmenu(true);
670
+ return;
671
+ }
348
672
  if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
349
673
  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();
674
+ // Moving between rows closes a hover-opened submenu.
675
+ if (subGroup !== null) subGroup = null;
676
+ rove(navItems(), e.key);
677
+ }
678
+
679
+ // --- submenu keyboard -----------------------------------------------------
680
+ function onSubKeydown(e: KeyboardEvent) {
681
+ if (e.key === 'Escape' || e.key === 'ArrowLeft') {
682
+ e.preventDefault();
683
+ closeSubmenu(true);
684
+ return;
685
+ }
686
+ if (e.key === 'Tab') {
687
+ closeMenu(false);
688
+ return;
689
+ }
690
+ if (e.key === ' ' && e.target instanceof HTMLAnchorElement) {
691
+ // Links activate on Enter natively; Space is the menuitem convention.
692
+ e.preventDefault();
693
+ e.target.click();
694
+ return;
695
+ }
696
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
697
+ e.preventDefault();
698
+ rove(subItems(), e.key);
699
+ }
700
+
701
+ /** The secondary line under a section row: what's current in it. */
702
+ function rowDetail(g: AmbienceScapeGroup): string {
703
+ if (!currentItem || currentItem.group !== g.group || isAmbienceLink(currentItem)) return '';
704
+ if (trackLine && isPlaylistScape(currentItem)) return `${currentItem.label} · ${trackLine}`;
705
+ return playing ? `${currentItem.label} · playing` : currentItem.label;
706
+ }
707
+
708
+ function linkHref(item: AmbienceLink): string | null {
709
+ return safeAmbienceHref(item.href);
360
710
  }
361
711
 
362
712
  const PLACEMENT_CLASS = {
@@ -365,9 +715,14 @@
365
715
  'top-start': 'bottom-full left-0 mb-2',
366
716
  'top-end': 'bottom-full right-0 mb-2'
367
717
  } as const;
718
+
719
+ const ITEM_CLASS = `flex w-full items-start gap-2 rounded px-2 py-1.5 text-left
720
+ transition-colors duration-[var(--motion-instant,80ms)]
721
+ hover:bg-base-content/10 focus-visible:bg-base-content/10
722
+ focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary`;
368
723
  </script>
369
724
 
370
- <div class="relative inline-flex shrink-0">
725
+ <div bind:this={wrapRef} class="relative inline-flex shrink-0">
371
726
  <button
372
727
  bind:this={btnRef}
373
728
  type="button"
@@ -455,9 +810,10 @@
455
810
  max-h-[min(24rem,calc(100vh-1rem))] menu-pop
456
811
  {PLACEMENT_CLASS[effectivePlacement]}"
457
812
  onkeydown={onMenuKeydown}
813
+ onscroll={() => closeSubmenu(false)}
458
814
  >
459
815
  <!-- 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">
816
+ <div role="none" bind:this={volumeRef} class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
461
817
  <div class="mb-1.5 flex items-center justify-between">
462
818
  <span class="text-jatui-label text-base-content/60">Volume</span>
463
819
  <span class="text-2xs text-base-content/50 tabular-nums">{sliderValue}%</span>
@@ -478,32 +834,124 @@
478
834
  </div>
479
835
 
480
836
  {#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)}
837
+ {@const detail = rowDetail(g)}
838
+ {@const hasCurrent = !!currentItem && currentItem.group === g.group}
839
+ <button
840
+ type="button"
841
+ role="menuitem"
842
+ tabindex="-1"
843
+ data-ambience-nav
844
+ data-ambience-group={g.group}
845
+ aria-haspopup="menu"
846
+ aria-expanded={subGroup === g.group}
847
+ aria-controls={subGroup === g.group ? subId : undefined}
848
+ class="{ITEM_CLASS} items-center {subGroup === g.group ? 'bg-base-content/10' : ''}"
849
+ onclick={(e) => onRowClick(e, g.group)}
850
+ onpointerenter={(e) => onRowPointerEnter(e, g.group)}
851
+ >
852
+ <span class="min-w-0 flex-1">
853
+ <span class="block truncate text-sm font-medium">{g.label}</span>
854
+ {#if detail}
855
+ <span class="text-2xs text-base-content/55 block truncate">{detail}</span>
856
+ {/if}
857
+ </span>
858
+ {#if hasCurrent}
859
+ <!-- Current-scape marker: the section that holds it. -->
860
+ <span class="size-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true"></span>
861
+ <span class="sr-only">(current)</span>
862
+ {/if}
863
+ <svg
864
+ xmlns="http://www.w3.org/2000/svg"
865
+ viewBox="0 0 24 24"
866
+ fill="none"
867
+ stroke="currentColor"
868
+ stroke-width="2"
869
+ stroke-linecap="round"
870
+ stroke-linejoin="round"
871
+ class="size-4 shrink-0 text-base-content/50 {subPos.side === 'left' && subGroup === g.group ? 'rotate-180' : ''}"
872
+ aria-hidden="true"
873
+ >
874
+ <path d="m9 18 6-6-6-6" />
875
+ </svg>
876
+ </button>
877
+ {/each}
878
+ </div>
879
+
880
+ {#if openSection}
881
+ <!-- Submenu: a sibling of the panel (never clipped by its scroll box),
882
+ positioned against the wrapper. -->
883
+ <div
884
+ bind:this={subRef}
885
+ id={subId}
886
+ role="menu"
887
+ tabindex="-1"
888
+ aria-label={openSection.label}
889
+ data-side={subPos.side}
890
+ class="jatui-ambience-submenu absolute z-[51] w-64 max-w-[calc(100vw-1rem)] overflow-y-auto
891
+ rounded-box border border-base-300 bg-base-100 p-1.5 text-base-content shadow-xl
892
+ max-h-[min(22rem,calc(100vh-1rem))] menu-pop"
893
+ style="left: {subPos.left}px; top: {subPos.top}px"
894
+ onkeydown={onSubKeydown}
895
+ >
896
+ {#each openSection.scapes as s (s.id)}
897
+ {#if isAmbienceLink(s)}
898
+ {@const href = linkHref(s)}
899
+ <a
900
+ role="menuitem"
901
+ tabindex="-1"
902
+ data-ambience-sub
903
+ href={href ?? undefined}
904
+ target="_blank"
905
+ rel="noopener noreferrer"
906
+ aria-disabled={href ? undefined : 'true'}
907
+ class="{ITEM_CLASS} {href ? '' : 'pointer-events-none opacity-50'}"
908
+ onclick={onLinkClick}
909
+ >
910
+ <span class="min-w-0 flex-1">
911
+ <span class="block truncate text-sm font-medium">{s.label}</span>
912
+ {#if s.description}
913
+ <span class="text-2xs text-base-content/55 block truncate">{s.description}</span>
914
+ {/if}
915
+ <span class="sr-only">(opens in a new tab)</span>
916
+ </span>
917
+ <!-- External link, lucide external-link geometry. -->
918
+ <svg
919
+ xmlns="http://www.w3.org/2000/svg"
920
+ viewBox="0 0 24 24"
921
+ fill="none"
922
+ stroke="currentColor"
923
+ stroke-width="2"
924
+ stroke-linecap="round"
925
+ stroke-linejoin="round"
926
+ class="mt-1 size-3.5 shrink-0 text-base-content/55"
927
+ aria-hidden="true"
928
+ >
929
+ <path d="M15 3h6v6" />
930
+ <path d="M10 14 21 3" />
931
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
932
+ </svg>
933
+ </a>
934
+ {:else}
935
+ {@const isCurrent = s.id === currentId}
936
+ {@const line = isCurrent && trackLine && isPlaylistScape(s) ? trackLine : s.description}
486
937
  <button
487
938
  type="button"
488
939
  role="menuitemradio"
489
- aria-checked={s.id === currentId}
940
+ aria-checked={isCurrent}
490
941
  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' : ''}"
942
+ data-ambience-sub
943
+ class="{ITEM_CLASS} {isCurrent ? 'bg-base-content/5' : ''}"
497
944
  onclick={() => selectScape(s)}
498
945
  >
499
946
  <span class="min-w-0 flex-1">
500
947
  <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>
948
+ {#if line}
949
+ <!-- Secondary line: a playing playlist's track attribution
950
+ (title — composer · license), else the description. -->
951
+ <span class="text-2xs text-base-content/55 block truncate" title={line}>{line}</span>
504
952
  {/if}
505
953
  </span>
506
- {#if s.id === currentId}
954
+ {#if isCurrent}
507
955
  <svg
508
956
  xmlns="http://www.w3.org/2000/svg"
509
957
  viewBox="0 0 24 24"
@@ -517,10 +965,10 @@
517
965
  <span class="mt-1 size-3.5 shrink-0" aria-hidden="true"></span>
518
966
  {/if}
519
967
  </button>
520
- {/each}
521
- </div>
522
- {/each}
523
- </div>
968
+ {/if}
969
+ {/each}
970
+ </div>
971
+ {/if}
524
972
  {/if}
525
973
 
526
974
  <!-- Live status: announces play/stop/switch without stealing focus. -->