@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.
Files changed (46) hide show
  1. package/CLAUDE.md +37 -0
  2. package/dist/assets/sounds/README.md +27 -14
  3. package/dist/assets/sounds/attention.d.ts +1 -0
  4. package/dist/assets/sounds/attention.js +4 -0
  5. package/dist/assets/sounds/chime.d.ts +1 -0
  6. package/dist/assets/sounds/chime.js +4 -0
  7. package/dist/assets/sounds/error-soft.d.ts +1 -0
  8. package/dist/assets/sounds/error-soft.js +4 -0
  9. package/dist/assets/sounds/select.d.ts +1 -0
  10. package/dist/assets/sounds/select.js +4 -0
  11. package/dist/assets/sounds/tick.d.ts +1 -0
  12. package/dist/assets/sounds/tick.js +4 -0
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.js +10 -2
  15. package/dist/motion/easing.d.ts +14 -0
  16. package/dist/motion/easing.js +67 -0
  17. package/dist/motion/index.d.ts +17 -0
  18. package/dist/motion/index.js +11 -0
  19. package/dist/motion/level.d.ts +88 -0
  20. package/dist/motion/level.js +132 -0
  21. package/dist/motion/store.d.ts +40 -0
  22. package/dist/motion/store.js +132 -0
  23. package/dist/motion/transitions.d.ts +72 -0
  24. package/dist/motion/transitions.js +112 -0
  25. package/dist/sound/SoundSettings.svelte +209 -0
  26. package/dist/sound/SoundSettings.svelte.d.ts +18 -0
  27. package/dist/sound/backend.d.ts +24 -0
  28. package/dist/sound/backend.js +96 -0
  29. package/dist/sound/index.d.ts +20 -0
  30. package/dist/sound/index.js +14 -0
  31. package/dist/sound/level.d.ts +42 -0
  32. package/dist/sound/level.js +63 -0
  33. package/dist/sound/names.d.ts +62 -0
  34. package/dist/sound/names.js +196 -0
  35. package/dist/sound/player.d.ts +73 -0
  36. package/dist/sound/player.js +158 -0
  37. package/dist/sound/policy.d.ts +36 -0
  38. package/dist/sound/policy.js +56 -0
  39. package/dist/sound/preferences.d.ts +76 -0
  40. package/dist/sound/preferences.js +152 -0
  41. package/dist/styles/motion.css +88 -9
  42. package/dist/transitions/glide.d.ts +18 -6
  43. package/dist/transitions/glide.js +26 -31
  44. package/dist/utils/sound.d.ts +6 -0
  45. package/dist/utils/sound.js +12 -54
  46. 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
+ }
@@ -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(24px);
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(-24px);
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(-24px);
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(24px);
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
- /** Travel distance in px. Default 16. */
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 in ms. Default 200. */
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
- * Reduced-motion: opacity fade only (no translate, scale, or blur).
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: (t: number) => number;
41
+ easing: EasingFn;
30
42
  css(t: number): string;
31
43
  };