@joewinke/jatui 0.29.0 → 0.31.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.
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'
@@ -310,6 +312,7 @@ const callbacks: AnnotationCallbacks = {
310
312
  | Component | Purpose | Key Props |
311
313
  |-----------|---------|-----------|
312
314
  | `ThemeSelector` | DaisyUI theme switcher with preview dots | `themes`, `defaultTheme`, `dropUp`, `storageKey`, `onThemeChange` |
315
+ | `ThemeToggle` | Icon-only sun<->moon light/dark toggle with an animated morph (transform+opacity on motion tokens; instant under reduced-motion; spring flattens on `data-motion="calm"`). A pure control — flips the bindable `theme` and fires `onchange`; storage stays with the consumer. | `theme` (bindable), `lightTheme`, `darkTheme`, `onchange`, `size` |
313
316
 
314
317
  ### Keyboard Navigation
315
318
 
@@ -0,0 +1,158 @@
1
+ <script lang="ts">
2
+ /**
3
+ * ThemeToggle — icon-only light/dark switch with an animated sun<->moon morph
4
+ * (mos-p6o2d). The public-site counterpart to ThemeSelector: where a site
5
+ * offers exactly two themes, a dropdown with visible "Dark mode" text is
6
+ * chrome noise — this is a single icon button instead.
7
+ *
8
+ * The component is a CONTROL, not a theme manager: it flips the bindable
9
+ * `theme` between `lightTheme` and `darkTheme` and fires `onchange`. It does
10
+ * not touch localStorage, `document.documentElement` or any storage key —
11
+ * persistence, pre-paint system-preference behaviour and DB theme overrides
12
+ * stay with the consumer (see JST's PublicThemeToggle wrapper, which owns
13
+ * the exact storage contract ThemeSelector established).
14
+ *
15
+ * Motion: the morph uses transform + opacity only, driven by the jatui
16
+ * motion tokens (`--motion-fast` duration, spring for the settle, glide for
17
+ * the cross-fade). Honours `data-motion="calm"` (tokens re-declared there —
18
+ * springs flatten) and `prefers-reduced-motion` (instant swap, both via the
19
+ * motion.css global floor and a component-local guard, since not every
20
+ * consumer loads motion.css). Colours are token-only — no literals.
21
+ */
22
+ let {
23
+ theme = $bindable('light'),
24
+ lightTheme = 'light',
25
+ darkTheme = 'dark',
26
+ onchange,
27
+ size = 'md'
28
+ }: {
29
+ /** Current theme name (bindable). Anything that is not `darkTheme` reads as light. */
30
+ theme?: string;
31
+ /** Theme name considered "light" (sun shown; the target when dark). */
32
+ lightTheme?: string;
33
+ /** Theme name considered "dark" (moon shown; the target when light). */
34
+ darkTheme?: string;
35
+ /** Fired after the toggle flips the bound theme. */
36
+ onchange?: (theme: string) => void;
37
+ /** Hit-target size — sm 40px, md 44px, lg 48px. Never below the 40px touch floor. */
38
+ size?: 'sm' | 'md' | 'lg';
39
+ } = $props();
40
+
41
+ let isDark = $derived(theme === darkTheme);
42
+
43
+ const BUTTON_SIZES = {
44
+ sm: 'size-10',
45
+ md: 'size-11',
46
+ lg: 'size-12'
47
+ } as const;
48
+
49
+ const ICON_SIZES = { sm: 18, md: 20, lg: 24 } as const;
50
+
51
+ const label = $derived(isDark ? 'Switch to light mode' : 'Switch to dark mode');
52
+
53
+ function toggle() {
54
+ const next = isDark ? lightTheme : darkTheme;
55
+ theme = next;
56
+ onchange?.(next);
57
+ }
58
+ </script>
59
+
60
+ <button
61
+ type="button"
62
+ class="jatui-theme-toggle inline-flex shrink-0 items-center justify-center rounded-full
63
+ text-base-content transition-colors duration-[var(--motion-instant,80ms)]
64
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary
65
+ hover:bg-base-content/10 focus-visible:bg-base-content/10
66
+ active:bg-base-content/15 disabled:pointer-events-none disabled:opacity-50
67
+ {BUTTON_SIZES[size]}"
68
+ class:is-dark={isDark}
69
+ aria-label={label}
70
+ aria-pressed={isDark}
71
+ title={label}
72
+ onclick={toggle}
73
+ >
74
+ <span
75
+ class="glyphs relative block"
76
+ style="width: {ICON_SIZES[size]}px; height: {ICON_SIZES[size]}px"
77
+ aria-hidden="true"
78
+ >
79
+ <!-- Sun: ring + eight rays, lucide geometry. -->
80
+ <svg
81
+ xmlns="http://www.w3.org/2000/svg"
82
+ viewBox="0 0 24 24"
83
+ fill="none"
84
+ stroke="currentColor"
85
+ stroke-width="2"
86
+ stroke-linecap="round"
87
+ stroke-linejoin="round"
88
+ class="glyph glyph-sun absolute inset-0"
89
+ width={ICON_SIZES[size]}
90
+ height={ICON_SIZES[size]}
91
+ >
92
+ <circle cx="12" cy="12" r="4" />
93
+ <path d="M12 2v2" />
94
+ <path d="M12 20v2" />
95
+ <path d="m4.93 4.93 1.41 1.41" />
96
+ <path d="m17.66 17.66 1.41 1.41" />
97
+ <path d="M2 12h2" />
98
+ <path d="M20 12h2" />
99
+ <path d="m6.34 17.66-1.41 1.41" />
100
+ <path d="m19.07 4.93-1.41 1.41" />
101
+ </svg>
102
+ <!-- Moon: crescent, lucide geometry. -->
103
+ <svg
104
+ xmlns="http://www.w3.org/2000/svg"
105
+ viewBox="0 0 24 24"
106
+ fill="none"
107
+ stroke="currentColor"
108
+ stroke-width="2"
109
+ stroke-linecap="round"
110
+ stroke-linejoin="round"
111
+ class="glyph glyph-moon absolute inset-0"
112
+ width={ICON_SIZES[size]}
113
+ height={ICON_SIZES[size]}
114
+ >
115
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
116
+ </svg>
117
+ </span>
118
+ </button>
119
+
120
+ <style>
121
+ /*
122
+ * Morph: transform + opacity ONLY (compositor-friendly, no layout thrash).
123
+ * The outgoing glyph rotates away and shrinks while fading; the incoming
124
+ * one rotates in. Spring carries the small-element settle; glide the fade.
125
+ */
126
+ .glyph {
127
+ transition:
128
+ transform var(--motion-fast, 150ms) var(--motion-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
129
+ opacity var(--motion-fast, 150ms) var(--motion-glide, cubic-bezier(0.16, 1, 0.3, 1));
130
+ }
131
+
132
+ .glyph-sun {
133
+ opacity: 1;
134
+ transform: rotate(0deg) scale(1);
135
+ }
136
+
137
+ .glyph-moon {
138
+ opacity: 0;
139
+ transform: rotate(-90deg) scale(0.5);
140
+ }
141
+
142
+ .is-dark .glyph-sun {
143
+ opacity: 0;
144
+ transform: rotate(90deg) scale(0.5);
145
+ }
146
+
147
+ .is-dark .glyph-moon {
148
+ opacity: 1;
149
+ transform: rotate(0deg) scale(1);
150
+ }
151
+
152
+ /* Component-local reduced-motion guard — not every consumer loads motion.css. */
153
+ @media (prefers-reduced-motion: reduce) {
154
+ .glyph {
155
+ transition: none;
156
+ }
157
+ }
158
+ </style>
@@ -0,0 +1,15 @@
1
+ type $$ComponentProps = {
2
+ /** Current theme name (bindable). Anything that is not `darkTheme` reads as light. */
3
+ theme?: string;
4
+ /** Theme name considered "light" (sun shown; the target when dark). */
5
+ lightTheme?: string;
6
+ /** Theme name considered "dark" (moon shown; the target when light). */
7
+ darkTheme?: string;
8
+ /** Fired after the toggle flips the bound theme. */
9
+ onchange?: (theme: string) => void;
10
+ /** Hit-target size — sm 40px, md 44px, lg 48px. Never below the 40px touch floor. */
11
+ size?: 'sm' | 'md' | 'lg';
12
+ };
13
+ declare const ThemeToggle: import("svelte").Component<$$ComponentProps, {}, "theme">;
14
+ type ThemeToggle = ReturnType<typeof ThemeToggle>;
15
+ export default ThemeToggle;
package/dist/index.d.ts CHANGED
@@ -93,6 +93,7 @@ export type { AudioRecordingResult } from './utils/audioRecorderCore';
93
93
  export { createAudioRecorder } from './utils/audioRecorder';
94
94
  export type { AudioRecorder, AudioRecorderCore, AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecorderSnapshot } from './utils/audioRecorderCore';
95
95
  export { default as ThemeSelector } from './components/ThemeSelector.svelte';
96
+ export { default as ThemeToggle } from './components/ThemeToggle.svelte';
96
97
  export { default as Sparkline } from './components/Sparkline.svelte';
97
98
  export { default as BurndownChart } from './components/BurndownChart.svelte';
98
99
  export { default as AvatarUpload } from './components/AvatarUpload.svelte';
package/dist/index.js CHANGED
@@ -127,6 +127,7 @@ export { default as AudioRecorder } from './components/AudioRecorder.svelte';
127
127
  export { createAudioRecorder } from './utils/audioRecorder';
128
128
  // Components — from Headcount
129
129
  export { default as ThemeSelector } from './components/ThemeSelector.svelte';
130
+ export { default as ThemeToggle } from './components/ThemeToggle.svelte';
130
131
  export { default as Sparkline } from './components/Sparkline.svelte';
131
132
  export { default as BurndownChart } from './components/BurndownChart.svelte';
132
133
  // Components — from JST
@@ -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.29.0",
3
+ "version": "0.31.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",