@joewinke/jatui 0.30.0 → 0.31.1

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.
package/CLAUDE.md CHANGED
@@ -229,7 +229,7 @@ const rec = createAudioRecorder({
229
229
 
230
230
  ### Sound (first-class sibling of motion)
231
231
 
232
- One preferences store every app sound goes through. UI + notification sounds are **off by default** (opt-in); the `incoming-call` ringer is **opt-out** (plays out of the box, muted via `categories.calls`). Semantic names (`confirm`, `error`, `notify`, `message`, `send`, `toggle`, `tap`, `incoming-call`, plus the booking-delight set `tick` 0.03 / `select` 0.08 / `chime` 0.12 / `attention` 0.10) map to bundled CC0 clips or a host's own file (`SOUND_CLIP_VARIANTS['error-soft']` is a gentler `error`). Policy per category: background tab, quiet hours and the quiet/reduced-motion mode silence `ui`/`notifications`; calls always ring. **Throttle:** per-name `minIntervalMs` (tick 80 ms, select 120 ms) drops — never queues — a play inside the window (`blocked: 'throttled'`); per-call `minIntervalMs` overrides. **Pitch:** `playSound('tick', { pitch: pentatonicPitch(i) })` (semitones → `playbackRate`; `playbackRate` also accepted). `tick` is the one hover sound — call it only on pointer-fine devices. **Site level:** host stamps `<html data-sound="off|subtle">` (like `data-motion`; `setSoundLevel` / `resolveSoundLevel` / `soundLevelDefaults`); `subtle` only starts UI sound on for a visitor with no saved preference — a saved preference (the panel mute) always wins, and `decideSound` is unchanged. Discipline doc: `docs/sound.md`.
232
+ One preferences store every app sound goes through. UI + notification sounds are **off by default** (opt-in); the `incoming-call` ringer is **opt-out** (plays out of the box, muted via `categories.calls`). Semantic names (`confirm`, `error`, `notify`, `message`, `send`, `toggle`, `tap`, `incoming-call`, plus the booking-delight set `tick` 0.03 / `select` 0.08 / `chime` 0.12 / `attention` 0.10) map to bundled CC0 clips or a host's own file (`SOUND_CLIP_VARIANTS['error-soft']` is a gentler `error`). Policy per category: background tab, quiet hours and the quiet/reduced-motion mode silence `ui`/`notifications`; calls always ring. **Throttle:** per-name `minIntervalMs` (tick 80 ms, select 120 ms) drops — never queues — a play inside the window (`blocked: 'throttled'`); per-call `minIntervalMs` overrides. **Pitch:** `playSound('tick', { pitch: pentatonicPitch(i) })` (semitones → `playbackRate`; `playbackRate` also accepted). `tick` is the one hover sound — call it only on pointer-fine devices. **Site level:** host stamps `<html data-sound="off|subtle">` (like `data-motion`; `setSoundLevel` / `resolveSoundLevel` / `soundLevelDefaults`); `subtle` only starts UI sound on for a visitor with no saved preference — a saved preference (the panel mute) always wins, and `decideSound` is unchanged. **iOS unlock:** the player creates its AudioContext lazily (inside clip-decode promise chains), which iOS Safari leaves suspended forever — call `unlockSound()` from inside a user gesture (or `armSoundUnlock()` once for a one-shot first-gesture listener); custom backends implement optional `unlock()` / `unlocked` and the player delegates. Discipline doc: `docs/sound.md`.
233
233
 
234
234
  | Component | Purpose | Key Props |
235
235
  |-----------|---------|-----------|
@@ -238,10 +238,12 @@ One preferences store every app sound goes through. UI + notification sounds are
238
238
  ```typescript
239
239
  import {
240
240
  playSound, // playSound('confirm') → SoundHandle { stop, playing, blocked } — never throws
241
- getSoundPlayer, // .register(name, src, spec?) · .play · .preload · .stopAll
241
+ getSoundPlayer, // .register(name, src, spec?) · .play · .preload · .stopAll · .unlock()/.unlocked
242
242
  getSoundPreferences, // .get · .set(patch) · .setCategory · .reset · .subscribe (localStorage 'jatui-sound-prefs', cross-tab)
243
243
  createSoundPreferences, createSoundPlayer, // isolated instances (tests, demos)
244
244
  decideSound, // the pure policy, for custom players
245
+ unlockSound, // iOS/Safari gesture unlock — call inside a click/touchend handler
246
+ armSoundUnlock, // one-shot first-gesture listener (self-removing, re-armable)
245
247
  SOUND_NAMES, SOUND_SPECS, SOUND_CATEGORIES
246
248
  } from "jatui"
247
249
  // handle.blocked: 'disabled' | 'category-muted' | 'hidden' | 'quiet-hours' | 'quiet' | 'silent'
@@ -1,3 +1,8 @@
1
+ <script lang="ts" module>
2
+ // Per-instance id source for the menu panel (see `menuId` below).
3
+ let menuSeq = 0;
4
+ </script>
5
+
1
6
  <script lang="ts">
2
7
  /**
3
8
  * ContextMenu Component
@@ -93,6 +98,21 @@
93
98
  let submenuX = $state(0);
94
99
  let submenuY = $state(0);
95
100
 
101
+ /**
102
+ * Unique id for THIS menu panel. The roving-focus queries below target
103
+ * `[data-context-menu="…"] > button` instead of `:scope > button` because
104
+ * jsdom's selector engine (nwsapi) expands `:scope` to `tag + .classList[0]`
105
+ * — and this panel's first class is the Tailwind arbitrary value
106
+ * `min-w-[200px]`, which nwsapi then cannot parse (`[200px]` looks like an
107
+ * attribute selector to it). Real browsers are fine either way; avoiding
108
+ * `:scope` keeps every jsdom test suite free of the resulting unhandled
109
+ * errors. The unique id preserves the direct-children semantics of
110
+ * `:scope >`: a nested submenu panel is rendered INSIDE this panel and
111
+ * carries its own id, so it can never leak its items into this level's
112
+ * query.
113
+ */
114
+ const menuId = `cm-${++menuSeq}`;
115
+
96
116
  function closeSubmenu() {
97
117
  if (submenuIndex === null) return;
98
118
  submenuIndex = null;
@@ -179,9 +199,13 @@
179
199
  function menuItemButtons(): HTMLButtonElement[] {
180
200
  if (!menuEl) return [];
181
201
  // Only DIRECT children — don't include nested submenu items when nav-ing
182
- // this level.
202
+ // this level. The menu panel carries a unique data-context-menu id, so
203
+ // this can't be matched by a nested panel (see `menuId` for why this
204
+ // isn't a `:scope >` selector — nwsapi/jsdom).
183
205
  return Array.from(
184
- menuEl.querySelectorAll(':scope > button[role="menuitem"]:not(:disabled)')
206
+ menuEl.querySelectorAll(
207
+ `[data-context-menu="${menuId}"] > button[role="menuitem"]:not(:disabled)`
208
+ )
185
209
  );
186
210
  }
187
211
 
@@ -190,11 +214,24 @@
190
214
  const activeBtn = document.activeElement as HTMLElement | null;
191
215
  if (!activeBtn) return -1;
192
216
  const buttons = Array.from(
193
- menuEl.querySelectorAll(':scope > button[role="menuitem"]')
217
+ menuEl.querySelectorAll(`[data-context-menu="${menuId}"] > button[role="menuitem"]`)
194
218
  ) as HTMLButtonElement[];
195
219
  return buttons.indexOf(activeBtn as HTMLButtonElement);
196
220
  }
197
221
 
222
+ // First focusable item of the OPEN nested submenu. The submenu's panel is
223
+ // a direct child of this menu's panel (nested in-DOM so click-outside
224
+ // treats it as inside — see the SelfContextMenu render below); finding it
225
+ // by traversal instead of a `:scope >` selector keeps nwsapi (jsdom) out
226
+ // of the picture entirely.
227
+ function firstSubmenuItem(): HTMLButtonElement | null {
228
+ if (!menuEl) return null;
229
+ const panel = Array.from(menuEl.children).find(
230
+ (c) => c.getAttribute('role') === 'menu'
231
+ );
232
+ return (panel?.querySelector('button[role="menuitem"]:not(:disabled)') ?? null) as HTMLButtonElement | null;
233
+ }
234
+
198
235
  function isEditableTarget(target: EventTarget | null): boolean {
199
236
  const el = target as HTMLElement | null;
200
237
  if (!el) return false;
@@ -230,12 +267,8 @@
230
267
  // ArrowRight is an explicit "enter the submenu" gesture — focus
231
268
  // its first item. Hover/onfocus open the submenu without moving
232
269
  // focus (see autoFocus prop on the recursive instance).
233
- const el = menuEl;
234
270
  requestAnimationFrame(() => {
235
- const first = el?.querySelector(
236
- ':scope > [role="menu"] > button[role="menuitem"]:not(:disabled)'
237
- ) as HTMLButtonElement | null;
238
- first?.focus();
271
+ firstSubmenuItem()?.focus();
239
272
  });
240
273
  }
241
274
  return;
@@ -274,12 +307,8 @@
274
307
  if (item.submenu?.length) {
275
308
  const buttons = menuItemButtons();
276
309
  openSubmenuAt(buttons[idx], idx);
277
- const el = menuEl;
278
310
  requestAnimationFrame(() => {
279
- const first = el?.querySelector(
280
- ':scope > [role="menu"] > button[role="menuitem"]:not(:disabled)'
281
- ) as HTMLButtonElement | null;
282
- first?.focus();
311
+ firstSubmenuItem()?.focus();
283
312
  });
284
313
  } else {
285
314
  handleItemClick(item, idx, e);
@@ -305,12 +334,8 @@
305
334
  // by the time this effect runs, so focus-return-on-close still works.
306
335
  $effect(() => {
307
336
  if (open && menuEl && autoFocus) {
308
- const el = menuEl;
309
337
  requestAnimationFrame(() => {
310
- const buttons = Array.from(
311
- el.querySelectorAll(':scope > button[role="menuitem"]:not(:disabled)')
312
- ) as HTMLButtonElement[];
313
- buttons[0]?.focus();
338
+ menuItemButtons()[0]?.focus();
314
339
  });
315
340
  }
316
341
  });
@@ -326,6 +351,7 @@
326
351
  bind:this={menuEl}
327
352
  role="menu"
328
353
  tabindex="-1"
354
+ data-context-menu={menuId}
329
355
  class="min-w-[200px] bg-base-200 border border-base-300 rounded-md py-1 {className}"
330
356
  style="z-index: {zIndex};"
331
357
  use:floating={{ anchor, placement: 'bottom-start', flip: true, shift: true, offset: 2, onDismiss: close }}
@@ -68,7 +68,11 @@
68
68
  }
69
69
  function preview(name: SoundId) {
70
70
  stopPreview();
71
- previewing = (player ?? getSoundPlayer()).play(name, { preview: true, loop: false });
71
+ const p = player ?? getSoundPlayer();
72
+ // A preview click is a real gesture: unlock here too, so a first-ever
73
+ // preview on iOS actually sounds instead of resuming a dead context.
74
+ void p.unlock();
75
+ previewing = p.play(name, { preview: true, loop: false });
72
76
  previewTimer = setTimeout(stopPreview, PREVIEW_MAX_MS);
73
77
  }
74
78
  onDestroy(stopPreview);
@@ -2,6 +2,11 @@
2
2
  * Audio output backend. The player talks to this interface so its policy and
3
3
  * bookkeeping run in node tests with a fake; `createWebAudioBackend` is the
4
4
  * real one (lazy AudioContext, decoded-buffer playback through a GainNode).
5
+ *
6
+ * Backends MAY implement `unlock()` / `unlocked` — the player's `unlock()`
7
+ * and `unlocked` delegate to them (see `sound/unlock.ts` for the host-facing
8
+ * helpers). A backend without them is assumed fine: it reports unlocked and
9
+ * the player's unlock is a no-op.
5
10
  */
6
11
  export interface SoundVoice {
7
12
  stop(): void;
@@ -20,5 +25,19 @@ export interface SoundBackend {
20
25
  /** Decode a data URI or URL. Resolves `null` when it can't be played here. */
21
26
  load(src: string): Promise<unknown | null>;
22
27
  start(clip: unknown, options: StartOptions): SoundVoice | null;
28
+ /**
29
+ * Create/resume the output context from inside a user-gesture handler
30
+ * (click, touchend, keydown). OPTIONAL: only iOS/Safari needs it, and
31
+ * only for Web Audio — there the context must be created or resumed
32
+ * synchronously inside the gesture, or it stays suspended and every
33
+ * sound is silent (mos-rd6kw). `sound/unlock.ts` calls this for a host.
34
+ */
35
+ unlock?(): Promise<void>;
36
+ /**
37
+ * True when the output context is running. OPTIONAL: a backend that
38
+ * has neither `unlock` nor `unlocked` reports unlocked (nothing to
39
+ * unlock); one that unlocks should report its state honestly.
40
+ */
41
+ readonly unlocked?: boolean;
23
42
  }
24
43
  export declare function createWebAudioBackend(): SoundBackend;
@@ -2,6 +2,11 @@
2
2
  * Audio output backend. The player talks to this interface so its policy and
3
3
  * bookkeeping run in node tests with a fake; `createWebAudioBackend` is the
4
4
  * real one (lazy AudioContext, decoded-buffer playback through a GainNode).
5
+ *
6
+ * Backends MAY implement `unlock()` / `unlocked` — the player's `unlock()`
7
+ * and `unlocked` delegate to them (see `sound/unlock.ts` for the host-facing
8
+ * helpers). A backend without them is assumed fine: it reports unlocked and
9
+ * the player's unlock is a no-op.
5
10
  */
6
11
  function dataUriBytes(uri) {
7
12
  const binary = atob(uri.slice(uri.indexOf(',') + 1));
@@ -12,13 +17,25 @@ function dataUriBytes(uri) {
12
17
  }
13
18
  export function createWebAudioBackend() {
14
19
  let ctx = null;
20
+ /**
21
+ * The context, memoized. It is created on the FIRST call — unlock()
22
+ * makes that first call happen synchronously inside a user gesture,
23
+ * which is the only way iOS Safari ever leaves it running (mos-rd6kw).
24
+ * Memoizing also means playback after an unlock reuses the unlocked
25
+ * context instead of lazily racing a second one.
26
+ */
15
27
  function context() {
16
28
  if (ctx)
17
29
  return ctx;
18
- if (typeof AudioContext === 'undefined')
30
+ // Old WebKit only ever exposed the prefixed constructor.
31
+ const Ctx = typeof AudioContext === 'undefined'
32
+ ? globalThis
33
+ .webkitAudioContext
34
+ : AudioContext;
35
+ if (!Ctx)
19
36
  return null;
20
37
  try {
21
- ctx = new AudioContext();
38
+ ctx = new Ctx();
22
39
  }
23
40
  catch {
24
41
  return null;
@@ -91,6 +108,26 @@ export function createWebAudioBackend() {
91
108
  out.disconnect();
92
109
  }
93
110
  };
111
+ },
112
+ /**
113
+ * Create and resume the context NOW — call from inside a user-gesture
114
+ * handler (see `sound/unlock.ts`). Everything up to and including the
115
+ * `resume()` call stays synchronous, which is what keeps the gesture;
116
+ * the await only wraps the promise. Desktop browsers permit this any
117
+ * time; iOS Safari is the reason it exists.
118
+ */
119
+ async unlock() {
120
+ try {
121
+ const audio = context();
122
+ if (audio?.state === 'suspended')
123
+ await audio.resume();
124
+ }
125
+ catch {
126
+ /* the unlock must never break the gesture it runs in */
127
+ }
128
+ },
129
+ get unlocked() {
130
+ return ctx?.state === 'running';
94
131
  }
95
132
  };
96
133
  }
@@ -17,4 +17,5 @@ export { createSoundPlayer, getSoundPlayer, setSoundPlayer, playSound, rateFor a
17
17
  export type { SoundId, SoundPlayer, SoundHandle, PlaySoundOptions, CreateSoundPlayerOptions } from './player';
18
18
  export { createWebAudioBackend } from './backend';
19
19
  export type { SoundBackend, SoundVoice, StartOptions as SoundStartOptions } from './backend';
20
+ export { unlockSound, armSoundUnlock } from './unlock';
20
21
  export { default as SoundSettings } from './SoundSettings.svelte';
@@ -11,4 +11,7 @@ export { SOUND_LEVELS, DEFAULT_SOUND_LEVEL, SOUND_LEVEL_ATTR, isSoundLevel, pars
11
11
  export { decideSound, inQuietHours, readSoundEnvironment } from './policy';
12
12
  export { createSoundPlayer, getSoundPlayer, setSoundPlayer, playSound, rateFor as soundPlaybackRate } from './player';
13
13
  export { createWebAudioBackend } from './backend';
14
+ // The iOS/Safari gesture unlock (mos-rd6kw): unlockSound() from inside a
15
+ // gesture, or armSoundUnlock() once to listen for the first one.
16
+ export { unlockSound, armSoundUnlock } from './unlock';
14
17
  export { default as SoundSettings } from './SoundSettings.svelte';
@@ -50,6 +50,18 @@ export interface SoundPlayer {
50
50
  /** Point a name at a clip (data URI or URL) and decode it. */
51
51
  register(name: SoundId, src: string, spec?: Partial<SoundSpec>): Promise<void>;
52
52
  play(name: SoundId, options?: PlaySoundOptions): SoundHandle;
53
+ /**
54
+ * Unlock audio output from inside a user-gesture handler (iOS/Safari
55
+ * keeps a context suspended unless the gesture created/resumed it —
56
+ * see `sound/unlock.ts`). Backends without an unlock concept resolve
57
+ * immediately. A cheap no-op once unlocked.
58
+ */
59
+ unlock(): Promise<void>;
60
+ /**
61
+ * True when the backend reports a running output context; a backend
62
+ * with no unlock concept always reports true. False before any unlock.
63
+ */
64
+ readonly unlocked: boolean;
53
65
  /** Decode clips ahead of time (defaults to every semantic name). */
54
66
  preload(names?: readonly SoundId[]): Promise<void>;
55
67
  stopAll(): void;
@@ -126,6 +126,20 @@ export function createSoundPlayer(options = {}) {
126
126
  async preload(names = SOUND_NAMES) {
127
127
  await Promise.all(names.map((n) => clipFor(n)));
128
128
  },
129
+ // The gesture unlock (mos-rd6kw): delegate to the backend — the
130
+ // default backend has the iOS-safe synchronous create+resume; a fake
131
+ // or a legacy backend without one resolves silently and reports
132
+ // unlocked, so a host that arms the unlock never has to branch.
133
+ async unlock() {
134
+ await audio().unlock?.();
135
+ },
136
+ get unlocked() {
137
+ // Reading this never creates the backend: nothing exists to be
138
+ // unlocked until something asked to play or unlock.
139
+ if (!backend)
140
+ return false;
141
+ return backend.unlocked ?? !backend.unlock;
142
+ },
129
143
  stopAll() {
130
144
  for (const a of [...active])
131
145
  a.handle.stop();
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The iOS/Safari audio unlock (mos-rd6kw, found in MOS's booking panel).
3
+ *
4
+ * Safari keeps an AudioContext suspended unless it was created or resumed
5
+ * inside a user gesture's synchronous call stack. The player creates its
6
+ * context lazily — inside clip-loading promise chains — which is never a
7
+ * gesture, so on an iPhone every sound stays silent no matter how many taps
8
+ * happened before it. The fix is one call from inside the gesture:
9
+ *
10
+ * button.addEventListener('click', () => void unlockSound());
11
+ *
12
+ * or, once at startup, the auto-installed one-shot listener:
13
+ *
14
+ * armSoundUnlock(); // first pointerup / touchend / click / keydown
15
+ *
16
+ * Unlock creates AND resumes the context synchronously (the constructor and
17
+ * the `resume()` call land inside the gesture; only the promise is awaited),
18
+ * so the later plays through the same player are allowed to sound. When a
19
+ * cue must be heard in the gesture itself, play it right after unlock in
20
+ * that same handler. iOS can re-suspend the context after the tab is
21
+ * backgrounded — re-arm (`armed` is cleared once the listener fires) or call
22
+ * `unlockSound()` again from the gesture that follows. And a "silent" phone
23
+ * can still be the hardware ring/silent switch, which mutes Web Audio on
24
+ * iOS no matter how the context was unlocked.
25
+ */
26
+ /**
27
+ * Create/resume the shared player's audio context. Call from inside a user
28
+ * gesture. Resolves even where audio is unsupported (no context here) and
29
+ * is a cheap no-op once the context is running.
30
+ */
31
+ export declare function unlockSound(): Promise<void>;
32
+ /**
33
+ * Install a ONE-SHOT unlock listener for the first pointer/key gesture on
34
+ * `target` (default: the document; pass an explicit target only where the
35
+ * gesture scope is narrower). Handy where no single call site owns "the
36
+ * first gesture" — a public page, a settings screen. Idempotent while
37
+ * armed: a second call before the first gesture is a no-op; the listeners
38
+ * remove themselves the first time any of the gestures fires, and calling
39
+ * this again re-arms (iOS can re-suspend the context after the tab is
40
+ * backgrounded — arm again on `visibilitychange`).
41
+ */
42
+ export declare function armSoundUnlock(target?: Document | HTMLElement): void;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The iOS/Safari audio unlock (mos-rd6kw, found in MOS's booking panel).
3
+ *
4
+ * Safari keeps an AudioContext suspended unless it was created or resumed
5
+ * inside a user gesture's synchronous call stack. The player creates its
6
+ * context lazily — inside clip-loading promise chains — which is never a
7
+ * gesture, so on an iPhone every sound stays silent no matter how many taps
8
+ * happened before it. The fix is one call from inside the gesture:
9
+ *
10
+ * button.addEventListener('click', () => void unlockSound());
11
+ *
12
+ * or, once at startup, the auto-installed one-shot listener:
13
+ *
14
+ * armSoundUnlock(); // first pointerup / touchend / click / keydown
15
+ *
16
+ * Unlock creates AND resumes the context synchronously (the constructor and
17
+ * the `resume()` call land inside the gesture; only the promise is awaited),
18
+ * so the later plays through the same player are allowed to sound. When a
19
+ * cue must be heard in the gesture itself, play it right after unlock in
20
+ * that same handler. iOS can re-suspend the context after the tab is
21
+ * backgrounded — re-arm (`armed` is cleared once the listener fires) or call
22
+ * `unlockSound()` again from the gesture that follows. And a "silent" phone
23
+ * can still be the hardware ring/silent switch, which mutes Web Audio on
24
+ * iOS no matter how the context was unlocked.
25
+ */
26
+ import { getSoundPlayer } from './player';
27
+ /**
28
+ * Create/resume the shared player's audio context. Call from inside a user
29
+ * gesture. Resolves even where audio is unsupported (no context here) and
30
+ * is a cheap no-op once the context is running.
31
+ */
32
+ export async function unlockSound() {
33
+ await getSoundPlayer().unlock();
34
+ }
35
+ const GESTURES = ['pointerup', 'touchend', 'click', 'keydown'];
36
+ /** The target `armSoundUnlock` installed its one-shot listener on, if any. */
37
+ let armed = null;
38
+ /**
39
+ * Install a ONE-SHOT unlock listener for the first pointer/key gesture on
40
+ * `target` (default: the document; pass an explicit target only where the
41
+ * gesture scope is narrower). Handy where no single call site owns "the
42
+ * first gesture" — a public page, a settings screen. Idempotent while
43
+ * armed: a second call before the first gesture is a no-op; the listeners
44
+ * remove themselves the first time any of the gestures fires, and calling
45
+ * this again re-arms (iOS can re-suspend the context after the tab is
46
+ * backgrounded — arm again on `visibilitychange`).
47
+ */
48
+ export function armSoundUnlock(target) {
49
+ if (armed)
50
+ return;
51
+ const t = target ?? (typeof document === 'undefined' ? undefined : document);
52
+ if (!t)
53
+ return;
54
+ armed = t;
55
+ const fire = () => {
56
+ armed = null;
57
+ for (const g of GESTURES)
58
+ t.removeEventListener(g, fire);
59
+ void unlockSound();
60
+ };
61
+ for (const g of GESTURES)
62
+ t.addEventListener(g, fire, { passive: true });
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",