@joewinke/jatui 0.27.1 → 0.29.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 +37 -0
- package/dist/assets/sounds/README.md +27 -14
- package/dist/assets/sounds/attention.d.ts +1 -0
- package/dist/assets/sounds/attention.js +4 -0
- package/dist/assets/sounds/chime.d.ts +1 -0
- package/dist/assets/sounds/chime.js +4 -0
- package/dist/assets/sounds/error-soft.d.ts +1 -0
- package/dist/assets/sounds/error-soft.js +4 -0
- package/dist/assets/sounds/select.d.ts +1 -0
- package/dist/assets/sounds/select.js +4 -0
- package/dist/assets/sounds/tick.d.ts +1 -0
- package/dist/assets/sounds/tick.js +4 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +10 -2
- package/dist/motion/easing.d.ts +14 -0
- package/dist/motion/easing.js +67 -0
- package/dist/motion/index.d.ts +17 -0
- package/dist/motion/index.js +11 -0
- package/dist/motion/level.d.ts +88 -0
- package/dist/motion/level.js +132 -0
- package/dist/motion/store.d.ts +40 -0
- package/dist/motion/store.js +132 -0
- package/dist/motion/transitions.d.ts +72 -0
- package/dist/motion/transitions.js +112 -0
- package/dist/sound/SoundSettings.svelte +209 -0
- package/dist/sound/SoundSettings.svelte.d.ts +18 -0
- package/dist/sound/backend.d.ts +24 -0
- package/dist/sound/backend.js +96 -0
- package/dist/sound/index.d.ts +20 -0
- package/dist/sound/index.js +14 -0
- package/dist/sound/level.d.ts +42 -0
- package/dist/sound/level.js +63 -0
- package/dist/sound/names.d.ts +62 -0
- package/dist/sound/names.js +196 -0
- package/dist/sound/player.d.ts +73 -0
- package/dist/sound/player.js +158 -0
- package/dist/sound/policy.d.ts +36 -0
- package/dist/sound/policy.js +56 -0
- package/dist/sound/preferences.d.ts +76 -0
- package/dist/sound/preferences.js +152 -0
- package/dist/styles/motion.css +88 -9
- package/dist/transitions/glide.d.ts +18 -6
- package/dist/transitions/glide.js +26 -31
- package/dist/utils/sound.d.ts +6 -0
- package/dist/utils/sound.js +12 -54
- package/package.json +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sound player — semantic names in, policy-gated audio out.
|
|
3
|
+
*
|
|
4
|
+
* const sound = getSoundPlayer()
|
|
5
|
+
* sound.play('confirm')
|
|
6
|
+
* await sound.register('incoming-call', '/sounds/ringer.mp3', { loopGapMs: 0 })
|
|
7
|
+
* const ring = sound.play('incoming-call') // loops
|
|
8
|
+
* ring.stop() // answered / declined
|
|
9
|
+
*
|
|
10
|
+
* `play` is synchronous and never throws: a sound that is blocked, missing or
|
|
11
|
+
* still decoding returns a handle anyway (`handle.blocked` says why it is
|
|
12
|
+
* silent). Looping sounds are re-checked whenever preferences change, so
|
|
13
|
+
* turning sound off or muting "calls" silences a ringing call immediately.
|
|
14
|
+
*/
|
|
15
|
+
import { createWebAudioBackend } from './backend';
|
|
16
|
+
import { decideSound, readSoundEnvironment } from './policy';
|
|
17
|
+
import { getSoundPreferences } from './preferences';
|
|
18
|
+
import { BUILTIN_CLIPS, isSoundName, SOUND_NAMES, soundSpecFor } from './names';
|
|
19
|
+
export function createSoundPlayer(options = {}) {
|
|
20
|
+
const prefs = options.preferences ?? getSoundPreferences();
|
|
21
|
+
const environment = options.environment ?? readSoundEnvironment;
|
|
22
|
+
let backend = options.backend ?? null;
|
|
23
|
+
const entries = new Map();
|
|
24
|
+
const active = new Set();
|
|
25
|
+
const now = options.now ?? (() => globalThis.performance?.now() ?? Date.now());
|
|
26
|
+
// Last time each name actually sounded — the throttle drops, never queues.
|
|
27
|
+
const lastPlayed = new Map();
|
|
28
|
+
const audio = () => (backend ??= createWebAudioBackend());
|
|
29
|
+
function clipFor(name) {
|
|
30
|
+
let entry = entries.get(name);
|
|
31
|
+
if (!entry)
|
|
32
|
+
entries.set(name, (entry = { spec: {} }));
|
|
33
|
+
if (!entry.clip) {
|
|
34
|
+
const src = entry.src
|
|
35
|
+
? Promise.resolve(entry.src)
|
|
36
|
+
: isSoundName(name)
|
|
37
|
+
? BUILTIN_CLIPS[name]().catch(() => null)
|
|
38
|
+
: Promise.resolve(null);
|
|
39
|
+
entry.clip = src.then((s) => (s ? audio().load(s) : null));
|
|
40
|
+
}
|
|
41
|
+
return entry.clip;
|
|
42
|
+
}
|
|
43
|
+
// Looping voices (ringers) obey preference changes while they play.
|
|
44
|
+
const unsubscribe = prefs.subscribe((p) => {
|
|
45
|
+
for (const a of [...active]) {
|
|
46
|
+
if (!a.spec.loop)
|
|
47
|
+
continue;
|
|
48
|
+
if (!decideSound(p, a.spec, environment()).play)
|
|
49
|
+
a.handle.stop();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
function silent(reason) {
|
|
53
|
+
return { stop() { }, playing: false, blocked: reason };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
async register(name, src, spec = {}) {
|
|
57
|
+
entries.set(name, { spec, src });
|
|
58
|
+
await clipFor(name);
|
|
59
|
+
},
|
|
60
|
+
play(name, opts = {}) {
|
|
61
|
+
const base = { ...soundSpecFor(name), ...(entries.get(name)?.spec ?? {}) };
|
|
62
|
+
const spec = {
|
|
63
|
+
...base,
|
|
64
|
+
volume: opts.volume ?? base.volume,
|
|
65
|
+
category: opts.category ?? base.category,
|
|
66
|
+
essential: opts.essential ?? base.essential,
|
|
67
|
+
loop: opts.loop ?? base.loop,
|
|
68
|
+
minIntervalMs: opts.minIntervalMs ?? base.minIntervalMs ?? 0
|
|
69
|
+
};
|
|
70
|
+
const p = prefs.get();
|
|
71
|
+
let gain;
|
|
72
|
+
if (opts.preview) {
|
|
73
|
+
gain = Math.min(1, Math.max(0, spec.volume)) * p.volume;
|
|
74
|
+
if (gain <= 0)
|
|
75
|
+
return silent('silent');
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const d = decideSound(p, spec, environment());
|
|
79
|
+
if (!d.play)
|
|
80
|
+
return silent(d.reason);
|
|
81
|
+
gain = d.gain;
|
|
82
|
+
const t = now();
|
|
83
|
+
const last = lastPlayed.get(name);
|
|
84
|
+
if (spec.minIntervalMs > 0 && last !== undefined && t - last < spec.minIntervalMs) {
|
|
85
|
+
return silent('throttled');
|
|
86
|
+
}
|
|
87
|
+
lastPlayed.set(name, t);
|
|
88
|
+
}
|
|
89
|
+
const playbackRate = rateFor(opts);
|
|
90
|
+
let voice = null;
|
|
91
|
+
let playing = true;
|
|
92
|
+
const record = {
|
|
93
|
+
spec,
|
|
94
|
+
handle: {
|
|
95
|
+
stop() {
|
|
96
|
+
if (!playing)
|
|
97
|
+
return;
|
|
98
|
+
playing = false;
|
|
99
|
+
active.delete(record);
|
|
100
|
+
voice?.stop();
|
|
101
|
+
},
|
|
102
|
+
get playing() {
|
|
103
|
+
return playing;
|
|
104
|
+
},
|
|
105
|
+
blocked: null
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
active.add(record);
|
|
109
|
+
clipFor(name).then((clip) => {
|
|
110
|
+
if (!playing)
|
|
111
|
+
return;
|
|
112
|
+
voice = clip
|
|
113
|
+
? audio().start(clip, {
|
|
114
|
+
gain,
|
|
115
|
+
loop: spec.loop,
|
|
116
|
+
loopGapMs: spec.loopGapMs,
|
|
117
|
+
playbackRate,
|
|
118
|
+
onEnded: () => record.handle.stop()
|
|
119
|
+
})
|
|
120
|
+
: null;
|
|
121
|
+
if (!voice)
|
|
122
|
+
record.handle.stop();
|
|
123
|
+
});
|
|
124
|
+
return record.handle;
|
|
125
|
+
},
|
|
126
|
+
async preload(names = SOUND_NAMES) {
|
|
127
|
+
await Promise.all(names.map((n) => clipFor(n)));
|
|
128
|
+
},
|
|
129
|
+
stopAll() {
|
|
130
|
+
for (const a of [...active])
|
|
131
|
+
a.handle.stop();
|
|
132
|
+
},
|
|
133
|
+
destroy() {
|
|
134
|
+
unsubscribe();
|
|
135
|
+
for (const a of [...active])
|
|
136
|
+
a.handle.stop();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Effective playback rate for a play: `playbackRate × 2^(pitch/12)`, clamped to a sane range. */
|
|
141
|
+
export function rateFor(opts) {
|
|
142
|
+
const rate = (opts.playbackRate ?? 1) * Math.pow(2, (opts.pitch ?? 0) / 12);
|
|
143
|
+
return Number.isFinite(rate) && rate > 0 ? Math.min(8, Math.max(0.125, rate)) : 1;
|
|
144
|
+
}
|
|
145
|
+
let shared = null;
|
|
146
|
+
/** The app-wide player, bound to the shared preferences store. */
|
|
147
|
+
export function getSoundPlayer() {
|
|
148
|
+
return (shared ??= createSoundPlayer());
|
|
149
|
+
}
|
|
150
|
+
/** Test seam: replace (or with no argument, drop) the shared player. */
|
|
151
|
+
export function setSoundPlayer(player) {
|
|
152
|
+
shared?.destroy();
|
|
153
|
+
shared = player;
|
|
154
|
+
}
|
|
155
|
+
/** Shorthand for `getSoundPlayer().play(name, options)`. */
|
|
156
|
+
export function playSound(name, options) {
|
|
157
|
+
return getSoundPlayer().play(name, options);
|
|
158
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sound policy — a pure function from (preferences, sound, environment) to
|
|
3
|
+
* "play at this gain" or "don't, because …". Every play goes through it, and
|
|
4
|
+
* because it is pure the whole discipline is unit-testable without audio.
|
|
5
|
+
*/
|
|
6
|
+
import type { SoundCategory } from './names';
|
|
7
|
+
import type { QuietHours, SoundPreferences } from './preferences';
|
|
8
|
+
export interface SoundEnvironment {
|
|
9
|
+
/** The document is hidden (background tab, minimized window). */
|
|
10
|
+
hidden: boolean;
|
|
11
|
+
/** `prefers-reduced-motion: reduce` is set at the OS level. */
|
|
12
|
+
reducedMotion: boolean;
|
|
13
|
+
now: Date;
|
|
14
|
+
}
|
|
15
|
+
export interface SoundRequest {
|
|
16
|
+
category: SoundCategory;
|
|
17
|
+
essential: boolean;
|
|
18
|
+
/** Base gain 0–1 (the spec volume or the caller's override). */
|
|
19
|
+
volume: number;
|
|
20
|
+
}
|
|
21
|
+
export type SoundBlockReason = 'disabled' | 'category-muted' | 'hidden' | 'quiet-hours' | 'quiet' | 'silent'
|
|
22
|
+
/** Dropped by the per-name throttle (set by the player, never by decideSound). */
|
|
23
|
+
| 'throttled';
|
|
24
|
+
export type SoundDecision = {
|
|
25
|
+
play: true;
|
|
26
|
+
gain: number;
|
|
27
|
+
} | {
|
|
28
|
+
play: false;
|
|
29
|
+
reason: SoundBlockReason;
|
|
30
|
+
gain: 0;
|
|
31
|
+
};
|
|
32
|
+
/** Is `now` inside the window? `start > end` wraps midnight; `start == end` is empty. */
|
|
33
|
+
export declare function inQuietHours(quietHours: QuietHours | null, now: Date): boolean;
|
|
34
|
+
export declare function decideSound(prefs: SoundPreferences, req: SoundRequest, env: SoundEnvironment): SoundDecision;
|
|
35
|
+
/** Read the live browser environment; on the server everything is "quiet". */
|
|
36
|
+
export declare function readSoundEnvironment(): SoundEnvironment;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sound policy — a pure function from (preferences, sound, environment) to
|
|
3
|
+
* "play at this gain" or "don't, because …". Every play goes through it, and
|
|
4
|
+
* because it is pure the whole discipline is unit-testable without audio.
|
|
5
|
+
*/
|
|
6
|
+
function minutes(hhmm) {
|
|
7
|
+
const [h, m] = hhmm.split(':').map(Number);
|
|
8
|
+
return h * 60 + m;
|
|
9
|
+
}
|
|
10
|
+
/** Is `now` inside the window? `start > end` wraps midnight; `start == end` is empty. */
|
|
11
|
+
export function inQuietHours(quietHours, now) {
|
|
12
|
+
if (!quietHours)
|
|
13
|
+
return false;
|
|
14
|
+
const s = minutes(quietHours.start);
|
|
15
|
+
const e = minutes(quietHours.end);
|
|
16
|
+
const t = now.getHours() * 60 + now.getMinutes();
|
|
17
|
+
if (s === e)
|
|
18
|
+
return false;
|
|
19
|
+
return s < e ? t >= s && t < e : t >= s || t < e;
|
|
20
|
+
}
|
|
21
|
+
export function decideSound(prefs, req, env) {
|
|
22
|
+
const no = (reason) => ({ play: false, reason, gain: 0 });
|
|
23
|
+
// Calls are opt-out; everything else is opt-in behind the master switch.
|
|
24
|
+
// A ringer that is silent out of the box is a missed call.
|
|
25
|
+
if (req.category !== 'calls' && !prefs.enabled)
|
|
26
|
+
return no('disabled');
|
|
27
|
+
if (!prefs.categories[req.category])
|
|
28
|
+
return no('category-muted');
|
|
29
|
+
// Calls are the one category that must reach a user who is looking elsewhere.
|
|
30
|
+
if (req.category !== 'calls') {
|
|
31
|
+
if (env.hidden)
|
|
32
|
+
return no('hidden');
|
|
33
|
+
if (inQuietHours(prefs.quietHours, env.now))
|
|
34
|
+
return no('quiet-hours');
|
|
35
|
+
}
|
|
36
|
+
if (req.category === 'ui' && !req.essential) {
|
|
37
|
+
if (prefs.quiet === 'on' || (prefs.quiet === 'auto' && env.reducedMotion))
|
|
38
|
+
return no('quiet');
|
|
39
|
+
}
|
|
40
|
+
const gain = Math.min(1, Math.max(0, req.volume)) * prefs.volume;
|
|
41
|
+
return gain > 0 ? { play: true, gain } : no('silent');
|
|
42
|
+
}
|
|
43
|
+
/** Read the live browser environment; on the server everything is "quiet". */
|
|
44
|
+
export function readSoundEnvironment() {
|
|
45
|
+
const now = new Date();
|
|
46
|
+
if (typeof document === 'undefined')
|
|
47
|
+
return { hidden: true, reducedMotion: false, now };
|
|
48
|
+
let reducedMotion = false;
|
|
49
|
+
try {
|
|
50
|
+
reducedMotion = !!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// matchMedia unavailable — treat as no preference
|
|
54
|
+
}
|
|
55
|
+
return { hidden: document.visibilityState === 'hidden', reducedMotion, now };
|
|
56
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound preferences — the ONE store every app sound goes through.
|
|
3
|
+
*
|
|
4
|
+
* Framework-free (subscribe/get/set) so it works in plain TS, node tests and
|
|
5
|
+
* Svelte alike; `SoundSettings.svelte` is a thin view over it. Persisted per
|
|
6
|
+
* device in localStorage, SSR-safe (no storage → in-memory defaults), synced
|
|
7
|
+
* across tabs via the `storage` event, and tolerant of corrupt/partial JSON.
|
|
8
|
+
*
|
|
9
|
+
* UI and notification sounds are OFF by default (opt-in); calls are ON by
|
|
10
|
+
* default (opt-out) so an incoming call rings out of the box. A host can flip
|
|
11
|
+
* the UI default per site with `<html data-sound="subtle">` (level.ts); a
|
|
12
|
+
* saved visitor preference always wins over it. A device that set the pre-0.28 boolean
|
|
13
|
+
* (`jatui-sound-enabled`) is migrated on first read.
|
|
14
|
+
*/
|
|
15
|
+
import { type SoundCategory } from './names';
|
|
16
|
+
/** Local wall-clock window, 'HH:MM' 24h. `start > end` wraps midnight. */
|
|
17
|
+
export interface QuietHours {
|
|
18
|
+
start: string;
|
|
19
|
+
end: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Non-essential UI sounds (taps, toggles, confirms):
|
|
23
|
+
* - `auto` — silenced when the OS asks for reduced motion
|
|
24
|
+
* - `on` — always silenced
|
|
25
|
+
* - `off` — never silenced by this preference
|
|
26
|
+
*/
|
|
27
|
+
export type SoundQuietMode = 'auto' | 'on' | 'off';
|
|
28
|
+
export interface SoundPreferences {
|
|
29
|
+
/** Master switch for `ui` + `notifications` (opt-in). Calls ignore it — mute them via `categories.calls`. */
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** Master volume 0–1, multiplied into every sound's base gain. */
|
|
32
|
+
volume: number;
|
|
33
|
+
/** Per-category switch — `false` mutes that category. */
|
|
34
|
+
categories: Record<SoundCategory, boolean>;
|
|
35
|
+
/** Everything except calls is silent inside this window. `null` = off. */
|
|
36
|
+
quietHours: QuietHours | null;
|
|
37
|
+
quiet: SoundQuietMode;
|
|
38
|
+
}
|
|
39
|
+
export declare const SOUND_PREFS_STORAGE_KEY = "jatui-sound-prefs";
|
|
40
|
+
/** The pre-semantic boolean key from utils/sound.ts — read once to migrate. */
|
|
41
|
+
export declare const LEGACY_SOUND_ENABLED_KEY = "jatui-sound-enabled";
|
|
42
|
+
export declare const DEFAULT_SOUND_PREFERENCES: SoundPreferences;
|
|
43
|
+
/** Coerce anything (parsed JSON, a partial patch) into a complete, valid object. */
|
|
44
|
+
export declare function normalizeSoundPreferences(raw: unknown, base?: SoundPreferences): SoundPreferences;
|
|
45
|
+
export type SoundPreferencesPatch = Partial<Omit<SoundPreferences, 'categories'>> & {
|
|
46
|
+
categories?: Partial<Record<SoundCategory, boolean>>;
|
|
47
|
+
};
|
|
48
|
+
export interface SoundPreferencesStore {
|
|
49
|
+
get(): SoundPreferences;
|
|
50
|
+
/** Merge a patch (categories merge per key) and persist. */
|
|
51
|
+
set(patch: SoundPreferencesPatch | ((current: SoundPreferences) => SoundPreferencesPatch)): void;
|
|
52
|
+
setCategory(category: SoundCategory, on: boolean): void;
|
|
53
|
+
reset(): void;
|
|
54
|
+
/** Calls `fn` immediately and on every change; returns the unsubscribe. */
|
|
55
|
+
subscribe(fn: (prefs: SoundPreferences) => void): () => void;
|
|
56
|
+
/** Drop the cross-tab listener. */
|
|
57
|
+
destroy(): void;
|
|
58
|
+
}
|
|
59
|
+
/** Minimal Storage surface — `localStorage`, `sessionStorage` or a test fake. */
|
|
60
|
+
export type SoundPreferencesStorage = Pick<Storage, 'getItem' | 'setItem'>;
|
|
61
|
+
export interface CreateSoundPreferencesOptions {
|
|
62
|
+
/** Defaults to `globalThis.localStorage` when present; `null` = memory only. */
|
|
63
|
+
storage?: SoundPreferencesStorage | null;
|
|
64
|
+
key?: string;
|
|
65
|
+
/** Overrides applied over the library defaults for a device with nothing saved. */
|
|
66
|
+
defaults?: SoundPreferencesPatch;
|
|
67
|
+
}
|
|
68
|
+
export declare function createSoundPreferences(options?: CreateSoundPreferencesOptions): SoundPreferencesStore;
|
|
69
|
+
/**
|
|
70
|
+
* The app-wide store (lazy, so SSR imports never touch storage). Its defaults
|
|
71
|
+
* come from the site sound level on `<html data-sound>` at creation time, so
|
|
72
|
+
* `subtle` starts UI sound on for a visitor with nothing saved (see level.ts).
|
|
73
|
+
*/
|
|
74
|
+
export declare function getSoundPreferences(): SoundPreferencesStore;
|
|
75
|
+
/** Test seam: replace (or with no argument, drop) the shared store. */
|
|
76
|
+
export declare function setSoundPreferences(store: SoundPreferencesStore | null): void;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound preferences — the ONE store every app sound goes through.
|
|
3
|
+
*
|
|
4
|
+
* Framework-free (subscribe/get/set) so it works in plain TS, node tests and
|
|
5
|
+
* Svelte alike; `SoundSettings.svelte` is a thin view over it. Persisted per
|
|
6
|
+
* device in localStorage, SSR-safe (no storage → in-memory defaults), synced
|
|
7
|
+
* across tabs via the `storage` event, and tolerant of corrupt/partial JSON.
|
|
8
|
+
*
|
|
9
|
+
* UI and notification sounds are OFF by default (opt-in); calls are ON by
|
|
10
|
+
* default (opt-out) so an incoming call rings out of the box. A host can flip
|
|
11
|
+
* the UI default per site with `<html data-sound="subtle">` (level.ts); a
|
|
12
|
+
* saved visitor preference always wins over it. A device that set the pre-0.28 boolean
|
|
13
|
+
* (`jatui-sound-enabled`) is migrated on first read.
|
|
14
|
+
*/
|
|
15
|
+
import { SOUND_CATEGORIES } from './names';
|
|
16
|
+
import { resolveSoundLevel, soundLevelDefaults } from './level';
|
|
17
|
+
export const SOUND_PREFS_STORAGE_KEY = 'jatui-sound-prefs';
|
|
18
|
+
/** The pre-semantic boolean key from utils/sound.ts — read once to migrate. */
|
|
19
|
+
export const LEGACY_SOUND_ENABLED_KEY = 'jatui-sound-enabled';
|
|
20
|
+
export const DEFAULT_SOUND_PREFERENCES = Object.freeze({
|
|
21
|
+
enabled: false,
|
|
22
|
+
volume: 1,
|
|
23
|
+
categories: Object.freeze({ ui: true, notifications: true, calls: true }),
|
|
24
|
+
quietHours: null,
|
|
25
|
+
quiet: 'auto'
|
|
26
|
+
});
|
|
27
|
+
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
28
|
+
function clampVolume(v, fallback) {
|
|
29
|
+
const n = typeof v === 'number' ? v : Number.NaN;
|
|
30
|
+
if (!Number.isFinite(n))
|
|
31
|
+
return fallback;
|
|
32
|
+
return Math.min(1, Math.max(0, n));
|
|
33
|
+
}
|
|
34
|
+
/** Coerce anything (parsed JSON, a partial patch) into a complete, valid object. */
|
|
35
|
+
export function normalizeSoundPreferences(raw, base = DEFAULT_SOUND_PREFERENCES) {
|
|
36
|
+
const r = (raw && typeof raw === 'object' ? raw : {});
|
|
37
|
+
const cats = (r.categories && typeof r.categories === 'object' ? r.categories : {});
|
|
38
|
+
const categories = {};
|
|
39
|
+
for (const c of SOUND_CATEGORIES) {
|
|
40
|
+
categories[c] = typeof cats[c] === 'boolean' ? cats[c] : base.categories[c];
|
|
41
|
+
}
|
|
42
|
+
let quietHours = base.quietHours;
|
|
43
|
+
if (r.quietHours === null)
|
|
44
|
+
quietHours = null;
|
|
45
|
+
else if (r.quietHours && typeof r.quietHours === 'object') {
|
|
46
|
+
const q = r.quietHours;
|
|
47
|
+
if (typeof q.start === 'string' && typeof q.end === 'string' && TIME_RE.test(q.start) && TIME_RE.test(q.end)) {
|
|
48
|
+
quietHours = { start: q.start, end: q.end };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
enabled: typeof r.enabled === 'boolean' ? r.enabled : base.enabled,
|
|
53
|
+
volume: clampVolume(r.volume, base.volume),
|
|
54
|
+
categories,
|
|
55
|
+
quietHours,
|
|
56
|
+
quiet: r.quiet === 'auto' || r.quiet === 'on' || r.quiet === 'off' ? r.quiet : base.quiet
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function defaultStorage() {
|
|
60
|
+
try {
|
|
61
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null; // access can throw (sandboxed iframe, blocked cookies)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function merge(current, patch) {
|
|
68
|
+
return normalizeSoundPreferences({ ...current, ...patch, categories: { ...current.categories, ...(patch.categories ?? {}) } }, current);
|
|
69
|
+
}
|
|
70
|
+
export function createSoundPreferences(options = {}) {
|
|
71
|
+
const storage = options.storage === undefined ? defaultStorage() : options.storage;
|
|
72
|
+
const key = options.key ?? SOUND_PREFS_STORAGE_KEY;
|
|
73
|
+
const defaults = merge(DEFAULT_SOUND_PREFERENCES, options.defaults ?? {});
|
|
74
|
+
const listeners = new Set();
|
|
75
|
+
function read() {
|
|
76
|
+
if (!storage)
|
|
77
|
+
return defaults;
|
|
78
|
+
try {
|
|
79
|
+
const raw = storage.getItem(key);
|
|
80
|
+
if (raw != null)
|
|
81
|
+
return normalizeSoundPreferences(JSON.parse(raw), defaults);
|
|
82
|
+
if (storage.getItem(LEGACY_SOUND_ENABLED_KEY) === 'true')
|
|
83
|
+
return { ...defaults, enabled: true };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// corrupt JSON or a throwing storage — fall back to defaults
|
|
87
|
+
}
|
|
88
|
+
return defaults;
|
|
89
|
+
}
|
|
90
|
+
let current = read();
|
|
91
|
+
function emit() {
|
|
92
|
+
for (const fn of listeners)
|
|
93
|
+
fn(current);
|
|
94
|
+
}
|
|
95
|
+
function commit(next) {
|
|
96
|
+
current = next;
|
|
97
|
+
if (storage) {
|
|
98
|
+
try {
|
|
99
|
+
storage.setItem(key, JSON.stringify(next));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// quota / private mode — keep the in-memory value
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
emit();
|
|
106
|
+
}
|
|
107
|
+
const onStorage = (e) => {
|
|
108
|
+
if (e.key !== key)
|
|
109
|
+
return;
|
|
110
|
+
current = read();
|
|
111
|
+
emit();
|
|
112
|
+
};
|
|
113
|
+
const crossTab = storage != null && typeof window !== 'undefined' && storage === defaultStorage();
|
|
114
|
+
if (crossTab)
|
|
115
|
+
window.addEventListener('storage', onStorage);
|
|
116
|
+
return {
|
|
117
|
+
get: () => current,
|
|
118
|
+
set(patch) {
|
|
119
|
+
commit(merge(current, typeof patch === 'function' ? patch(current) : patch));
|
|
120
|
+
},
|
|
121
|
+
setCategory(category, on) {
|
|
122
|
+
commit(merge(current, { categories: { [category]: on } }));
|
|
123
|
+
},
|
|
124
|
+
reset() {
|
|
125
|
+
commit(defaults);
|
|
126
|
+
},
|
|
127
|
+
subscribe(fn) {
|
|
128
|
+
listeners.add(fn);
|
|
129
|
+
fn(current);
|
|
130
|
+
return () => listeners.delete(fn);
|
|
131
|
+
},
|
|
132
|
+
destroy() {
|
|
133
|
+
listeners.clear();
|
|
134
|
+
if (crossTab)
|
|
135
|
+
window.removeEventListener('storage', onStorage);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
let shared = null;
|
|
140
|
+
/**
|
|
141
|
+
* The app-wide store (lazy, so SSR imports never touch storage). Its defaults
|
|
142
|
+
* come from the site sound level on `<html data-sound>` at creation time, so
|
|
143
|
+
* `subtle` starts UI sound on for a visitor with nothing saved (see level.ts).
|
|
144
|
+
*/
|
|
145
|
+
export function getSoundPreferences() {
|
|
146
|
+
return (shared ??= createSoundPreferences({ defaults: soundLevelDefaults(resolveSoundLevel()) }));
|
|
147
|
+
}
|
|
148
|
+
/** Test seam: replace (or with no argument, drop) the shared store. */
|
|
149
|
+
export function setSoundPreferences(store) {
|
|
150
|
+
shared?.destroy();
|
|
151
|
+
shared = store;
|
|
152
|
+
}
|
package/dist/styles/motion.css
CHANGED
|
@@ -14,27 +14,106 @@
|
|
|
14
14
|
* Policy: shared/craft/animation-discipline.md in the jat repo
|
|
15
15
|
* ("Curve vs spring" + "Surfaces" sections).
|
|
16
16
|
*/
|
|
17
|
+
/*
|
|
18
|
+
* Base tokens: the STANDARD-level values. Hosts and components should read
|
|
19
|
+
* the derived tokens further down (--motion-instant, --motion-travel, ...),
|
|
20
|
+
* never these — the derived ones carry the motion level.
|
|
21
|
+
*/
|
|
17
22
|
:root {
|
|
18
23
|
/* Easing */
|
|
19
24
|
--motion-glide: cubic-bezier(0.16, 1, 0.3, 1); /* ease-out-expo family; default */
|
|
20
|
-
--motion-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* single overshoot; confirmation only */
|
|
25
|
+
--motion-spring-base: cubic-bezier(0.34, 1.56, 0.64, 1); /* single overshoot; confirmation only */
|
|
21
26
|
--motion-standard: cubic-bezier(0.2, 0, 0, 1); /* M3 standard; symmetric moves */
|
|
22
27
|
|
|
23
28
|
/* Duration */
|
|
24
|
-
--motion-instant: 80ms; /* press/hover micro-feedback (50–100ms band) */
|
|
25
|
-
--motion-fast: 150ms; /* default state-confirmation */
|
|
26
|
-
--motion-enter: 200ms; /* entering UI: modals, sheets, dropdowns, toasts */
|
|
27
|
-
--motion-move: 300ms; /* container morphs, cross-panel moves */
|
|
29
|
+
--motion-instant-base: 80ms; /* press/hover micro-feedback (50–100ms band) */
|
|
30
|
+
--motion-fast-base: 150ms; /* default state-confirmation */
|
|
31
|
+
--motion-enter-base: 200ms; /* entering UI: modals, sheets, dropdowns, toasts */
|
|
32
|
+
--motion-move-base: 300ms; /* container morphs, cross-panel moves */
|
|
33
|
+
|
|
34
|
+
/* Level inputs (standard) */
|
|
35
|
+
--motion-scale: 1; /* duration multiplier */
|
|
36
|
+
--motion-travel: 16px; /* entrance travel distance */
|
|
37
|
+
--motion-ambient: 1; /* 1 = decorative/ambient motion may run, 0 = off */
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Motion level (jatui-j6833) — data-motion="calm|standard|lively" on <html>
|
|
42
|
+
* or any subtree. Scheme + semantics: docs/motion.md.
|
|
43
|
+
*
|
|
44
|
+
* The derived tokens are RE-DECLARED on every [data-motion] element (not only
|
|
45
|
+
* on :root) because a custom property's var()/calc() resolves where it is
|
|
46
|
+
* declared — a subtree that only changed --motion-scale would otherwise
|
|
47
|
+
* inherit :root's already-computed durations.
|
|
48
|
+
*
|
|
49
|
+
* Values MUST match src/lib/motion/level.ts (tests/unit/motionLevel.test.ts).
|
|
50
|
+
*/
|
|
51
|
+
:root,
|
|
52
|
+
[data-motion] {
|
|
53
|
+
--motion-instant: calc(var(--motion-instant-base) * var(--motion-scale));
|
|
54
|
+
--motion-fast: calc(var(--motion-fast-base) * var(--motion-scale));
|
|
55
|
+
--motion-enter: calc(var(--motion-enter-base) * var(--motion-scale));
|
|
56
|
+
--motion-move: calc(var(--motion-move-base) * var(--motion-scale));
|
|
57
|
+
--motion-spring: var(--motion-spring-base);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
[data-motion='standard'] {
|
|
61
|
+
--motion-scale: 1;
|
|
62
|
+
--motion-travel: 16px;
|
|
63
|
+
--motion-ambient: 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* calm: quicker, shorter travel, no springs, no ambient/decorative motion */
|
|
67
|
+
[data-motion='calm'] {
|
|
68
|
+
--motion-scale: 0.75;
|
|
69
|
+
--motion-travel: 8px;
|
|
70
|
+
--motion-ambient: 0;
|
|
71
|
+
--motion-spring: var(--motion-glide);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* lively: a touch slower and further — surfaces whose job is to feel alive */
|
|
75
|
+
[data-motion='lively'] {
|
|
76
|
+
--motion-scale: 1.25;
|
|
77
|
+
--motion-travel: 24px;
|
|
78
|
+
--motion-ambient: 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
* Ambient/decorative motion hook: mark looping or purely decorative
|
|
83
|
+
* animation (shimmer, pulse, floating blobs, marquee) with
|
|
84
|
+
* .jatui-motion-ambient and it stops on calm surfaces and under reduced
|
|
85
|
+
* motion. Never put this class on motion that carries meaning (a spinner).
|
|
86
|
+
*/
|
|
87
|
+
[data-motion='calm'] .jatui-motion-ambient,
|
|
88
|
+
.jatui-motion-ambient[data-motion='calm'] {
|
|
89
|
+
animation: none !important;
|
|
28
90
|
}
|
|
29
91
|
|
|
30
92
|
/*
|
|
31
93
|
* Global reduced-motion floor: strip motion, keep end states visible.
|
|
94
|
+
* Every motion level yields to it — durations and travel collapse to 0,
|
|
95
|
+
* springs flatten, ambient motion stops.
|
|
32
96
|
* Components should still pair every animation with a static affordance
|
|
33
97
|
* (color, position, label) so the state change survives without motion.
|
|
34
98
|
* Component-local guards remain good practice — not every consumer loads
|
|
35
99
|
* this file — but this is the backstop.
|
|
36
100
|
*/
|
|
37
101
|
@media (prefers-reduced-motion: reduce) {
|
|
102
|
+
:root,
|
|
103
|
+
[data-motion],
|
|
104
|
+
[data-motion='calm'],
|
|
105
|
+
[data-motion='standard'],
|
|
106
|
+
[data-motion='lively'] {
|
|
107
|
+
--motion-scale: 0;
|
|
108
|
+
--motion-travel: 0px;
|
|
109
|
+
--motion-ambient: 0;
|
|
110
|
+
--motion-spring: var(--motion-glide);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.jatui-motion-ambient {
|
|
114
|
+
animation: none !important;
|
|
115
|
+
}
|
|
116
|
+
|
|
38
117
|
*,
|
|
39
118
|
*::before,
|
|
40
119
|
*::after {
|
|
@@ -92,7 +171,7 @@
|
|
|
92
171
|
|
|
93
172
|
@keyframes jatui-reveal-fade-in-bottom {
|
|
94
173
|
from {
|
|
95
|
-
transform: translateY(
|
|
174
|
+
transform: translateY(calc(var(--motion-travel) * 1.5));
|
|
96
175
|
opacity: 0;
|
|
97
176
|
}
|
|
98
177
|
to {
|
|
@@ -103,7 +182,7 @@
|
|
|
103
182
|
|
|
104
183
|
@keyframes jatui-reveal-fade-in-top {
|
|
105
184
|
from {
|
|
106
|
-
transform: translateY(-
|
|
185
|
+
transform: translateY(calc(var(--motion-travel) * -1.5));
|
|
107
186
|
opacity: 0;
|
|
108
187
|
}
|
|
109
188
|
to {
|
|
@@ -114,7 +193,7 @@
|
|
|
114
193
|
|
|
115
194
|
@keyframes jatui-reveal-fade-in-left {
|
|
116
195
|
from {
|
|
117
|
-
transform: translateX(-
|
|
196
|
+
transform: translateX(calc(var(--motion-travel) * -1.5));
|
|
118
197
|
opacity: 0;
|
|
119
198
|
}
|
|
120
199
|
to {
|
|
@@ -125,7 +204,7 @@
|
|
|
125
204
|
|
|
126
205
|
@keyframes jatui-reveal-fade-in-right {
|
|
127
206
|
from {
|
|
128
|
-
transform: translateX(
|
|
207
|
+
transform: translateX(calc(var(--motion-travel) * 1.5));
|
|
129
208
|
opacity: 0;
|
|
130
209
|
}
|
|
131
210
|
to {
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
+
import type { EasingFn } from '../motion/easing';
|
|
2
|
+
import { type MotionDurationToken, type MotionEasingToken, type MotionLevel } from '../motion/level';
|
|
1
3
|
export type GlideOrigin = 'center' | 'top' | 'tr' | 'right' | 'br' | 'bottom' | 'bl' | 'left' | 'tl';
|
|
2
4
|
export interface GlideParams {
|
|
3
5
|
/** Direction the element slides in from. Default 'bottom'. */
|
|
4
6
|
from?: GlideOrigin;
|
|
5
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Travel distance in px at the STANDARD level. Default: the level's
|
|
9
|
+
* `--motion-travel` (8 calm / 16 standard / 24 lively). An explicit value
|
|
10
|
+
* is scaled by the same ratio, so calm surfaces always travel half as far.
|
|
11
|
+
*/
|
|
6
12
|
distance?: number;
|
|
7
13
|
/** Scale factor at the start of the enter (e.g. 0.95). Omit = no scale. */
|
|
8
14
|
scale?: number;
|
|
9
15
|
/** Add motion-blur (blur filter) during travel. Default false. */
|
|
10
16
|
blur?: boolean;
|
|
11
|
-
/** Duration
|
|
12
|
-
duration?: number;
|
|
13
|
-
/** Delay in ms. Default 0. */
|
|
17
|
+
/** Duration: a token or standard-level ms, scaled by the level. Default 'enter' (200ms). */
|
|
18
|
+
duration?: MotionDurationToken | number;
|
|
19
|
+
/** Delay in standard-level ms, scaled by the level. Default 0. */
|
|
14
20
|
delay?: number;
|
|
21
|
+
/** Easing token or function. Default 'glide'. */
|
|
22
|
+
easing?: MotionEasingToken | EasingFn;
|
|
23
|
+
/** Pin the motion level instead of reading the nearest [data-motion]. */
|
|
24
|
+
level?: MotionLevel;
|
|
15
25
|
}
|
|
16
26
|
/**
|
|
17
27
|
* Parameterized directional enter/exit transition.
|
|
@@ -21,11 +31,13 @@ export interface GlideParams {
|
|
|
21
31
|
*
|
|
22
32
|
* <div in:glide={{ from: 'bottom' }} out:glide={{ from: 'bottom' }}>…</div>
|
|
23
33
|
*
|
|
24
|
-
*
|
|
34
|
+
* Level-aware (jatui-j6833): duration, delay and travel scale with the
|
|
35
|
+
* surface's `data-motion` level and ease on the `--motion-glide` curve.
|
|
36
|
+
* Reduced-motion: duration 0 — the element lands in its end state.
|
|
25
37
|
*/
|
|
26
38
|
export declare function glide(node: HTMLElement, params?: GlideParams): {
|
|
27
39
|
delay: number;
|
|
28
40
|
duration: number;
|
|
29
|
-
easing:
|
|
41
|
+
easing: EasingFn;
|
|
30
42
|
css(t: number): string;
|
|
31
43
|
};
|