@joewinke/jatui 0.27.1 → 0.28.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 +16 -14
- 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 +22 -0
- package/dist/sound/backend.js +95 -0
- package/dist/sound/index.d.ts +18 -0
- package/dist/sound/index.js +13 -0
- package/dist/sound/names.d.ts +39 -0
- package/dist/sound/names.js +121 -0
- package/dist/sound/player.d.ts +60 -0
- package/dist/sound/player.js +141 -0
- package/dist/sound/policy.d.ts +34 -0
- package/dist/sound/policy.js +56 -0
- package/dist/sound/preferences.d.ts +70 -0
- package/dist/sound/preferences.js +145 -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
package/CLAUDE.md
CHANGED
|
@@ -227,6 +227,43 @@ const rec = createAudioRecorder({
|
|
|
227
227
|
// Methods: start() stop() pause() resume() cancel() reset() destroy()
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
+
### Sound (first-class sibling of motion)
|
|
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`) map to bundled CC0 clips or a host's own file. Policy per category: background tab, quiet hours and the quiet/reduced-motion mode silence `ui`/`notifications`; calls always ring. Discipline doc: `docs/sound.md`.
|
|
233
|
+
|
|
234
|
+
| Component | Purpose | Key Props |
|
|
235
|
+
|-----------|---------|-----------|
|
|
236
|
+
| `SoundSettings` | Drop-in settings section over the store: master switch, volume, per-category rows (calls row + volume stay live when the master switch is off), quiet hours, preview buttons. | `preferences?`, `player?`, `categories?: SoundCategory[]`, `showQuietHours?`, `title?`, `class?` |
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
import {
|
|
240
|
+
playSound, // playSound('confirm') → SoundHandle { stop, playing, blocked } — never throws
|
|
241
|
+
getSoundPlayer, // .register(name, src, spec?) · .play · .preload · .stopAll
|
|
242
|
+
getSoundPreferences, // .get · .set(patch) · .setCategory · .reset · .subscribe (localStorage 'jatui-sound-prefs', cross-tab)
|
|
243
|
+
createSoundPreferences, createSoundPlayer, // isolated instances (tests, demos)
|
|
244
|
+
decideSound, // the pure policy, for custom players
|
|
245
|
+
SOUND_NAMES, SOUND_SPECS, SOUND_CATEGORIES
|
|
246
|
+
} from "jatui"
|
|
247
|
+
// handle.blocked: 'disabled' | 'category-muted' | 'hidden' | 'quiet-hours' | 'quiet' | 'silent'
|
|
248
|
+
// { preview: true } bypasses every check but master volume — only behind a "test sound" button.
|
|
249
|
+
// Legacy registerSound / isSoundEnabled / setSoundEnabled still exported and route through the store.
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Motion level
|
|
253
|
+
|
|
254
|
+
Motion is a token layer (`styles/motion.css`: `--motion-glide`/`spring`/`standard` curves; `--motion-instant`/`fast`/`enter`/`move` durations). `data-motion="calm|standard|lively"` on `<html>` or any subtree rescales durations (×0.75/×1/×1.25) and travel (8/16/24px); `calm` also flattens springs and stops `.jatui-motion-ambient` (mark decorative loops with it). `prefers-reduced-motion` wins at every level. An unknown value resolves to `standard`. Scheme + rules: `docs/motion.md`.
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
import {
|
|
258
|
+
motionFade, motionScale, motionSlide, glide, // token-built transitions (use instead of svelte/transition fade/scale/slide/fly)
|
|
259
|
+
setMotionLevel, // setMotionLevel('calm') · setMotionLevel('lively', heroEl) · setMotionLevel(null)
|
|
260
|
+
createMotionLevelStore, // persisted store: $motion.level / .reduced / .effective
|
|
261
|
+
getMotionContext, motionDuration, motionEasing // read the level from JS
|
|
262
|
+
} from "jatui"
|
|
263
|
+
// Transition params: duration = token ('instant'|'fast'|'enter'|'move') or standard-level ms (scaled by the level);
|
|
264
|
+
// easing = token ('glide'|'spring'|'standard') or fn; level pins it instead of reading the DOM.
|
|
265
|
+
```
|
|
266
|
+
|
|
230
267
|
### PDF
|
|
231
268
|
|
|
232
269
|
| Component | Purpose | Key Props |
|
|
@@ -28,20 +28,22 @@ Original pack: **Kenney Interface Sounds** — https://kenney.nl/assets/interfac
|
|
|
28
28
|
|
|
29
29
|
## Usage
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
Don't import these clips directly. Play them by **semantic name** through the
|
|
32
|
+
sound player, which loads them lazily and applies the user's preferences
|
|
33
|
+
(on/off, volume, categories, quiet hours). See `docs/sound.md`.
|
|
34
|
+
|
|
35
|
+
| Semantic name | Clip |
|
|
36
|
+
|---------------|------|
|
|
37
|
+
| `confirm` | success |
|
|
38
|
+
| `error` | error |
|
|
39
|
+
| `notify`, `message` | notify |
|
|
40
|
+
| `send` | send |
|
|
41
|
+
| `toggle` | toggle |
|
|
42
|
+
| `tap` | pop |
|
|
43
|
+
| `incoming-call` | notify, replayed every 1.2 s (register a real ringer file instead) |
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
play('error', { volume: 0.2 });
|
|
45
|
+
```typescript
|
|
46
|
+
import { playSound } from 'jatui';
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
setSoundEnabled(true);
|
|
46
|
-
isSoundEnabled(); // → true
|
|
48
|
+
playSound('confirm');
|
|
47
49
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -205,7 +205,10 @@ export { glide } from './transitions/glide';
|
|
|
205
205
|
export type { GlideOrigin, GlideParams } from './transitions/glide';
|
|
206
206
|
export { themeWipe } from './utils/themeWipe';
|
|
207
207
|
export { viewTransition } from './utils/viewTransition';
|
|
208
|
-
export {
|
|
208
|
+
export { MOTION_LEVELS, DEFAULT_MOTION_LEVEL, MOTION_LEVEL_ATTR, MOTION_DURATIONS, MOTION_LEVEL_SCALE, MOTION_LEVEL_TRAVEL, MOTION_EASING_POINTS, isMotionLevel, parseMotionLevel, prefersReducedMotion, resolveMotionLevel, motionContextFor, getMotionContext, motionDuration, effectiveEasing, setMotionLevel, cubicBezier, motionEasing, motionFade, motionScale, motionSlide, resolveMotionTiming, createMotionLevelStore } from './motion';
|
|
209
|
+
export type { MotionLevel, MotionDurationToken, MotionEasingToken, MotionContext, EasingFn, MotionTransitionParams, MotionTransitionConfig, MotionFadeParams, MotionScaleParams, MotionSlideParams, ResolvedMotionTiming, MotionState, MotionLevelStore, MotionLevelStoreOptions } from './motion';
|
|
210
|
+
export { registerSound, isSoundEnabled, setSoundEnabled } from './utils/sound';
|
|
211
|
+
export * from './sound';
|
|
209
212
|
export { copyText, readClipboardText } from './utils/clipboard';
|
|
210
213
|
export { splitText } from './utils/splitText';
|
|
211
214
|
export type { SplitBy, SplitTextOptions, SplitTextResult } from './utils/splitText';
|
package/dist/index.js
CHANGED
|
@@ -245,8 +245,16 @@ export { glide } from './transitions/glide';
|
|
|
245
245
|
// View Transitions kit
|
|
246
246
|
export { themeWipe } from './utils/themeWipe';
|
|
247
247
|
export { viewTransition } from './utils/viewTransition';
|
|
248
|
-
//
|
|
249
|
-
|
|
248
|
+
// Motion level kit (jatui-j6833): data-motion="calm|standard|lively" scales
|
|
249
|
+
// the --motion-* tokens per surface (styles/motion.css); these helpers,
|
|
250
|
+
// transitions and the store read the same level. Scheme: docs/motion.md.
|
|
251
|
+
export { MOTION_LEVELS, DEFAULT_MOTION_LEVEL, MOTION_LEVEL_ATTR, MOTION_DURATIONS, MOTION_LEVEL_SCALE, MOTION_LEVEL_TRAVEL, MOTION_EASING_POINTS, isMotionLevel, parseMotionLevel, prefersReducedMotion, resolveMotionLevel, motionContextFor, getMotionContext, motionDuration, effectiveEasing, setMotionLevel, cubicBezier, motionEasing, motionFade, motionScale, motionSlide, resolveMotionTiming, createMotionLevelStore } from './motion';
|
|
252
|
+
// Sound kit (jatui-kf62q): one preferences store (off by default, master
|
|
253
|
+
// volume, per-category mute, quiet hours, reduced-motion-aware), semantic
|
|
254
|
+
// sound names, a pure policy and SoundSettings. Discipline: docs/sound.md.
|
|
255
|
+
// playSound comes from ./sound; the legacy flag helpers route through it.
|
|
256
|
+
export { registerSound, isSoundEnabled, setSoundEnabled } from './utils/sound';
|
|
257
|
+
export * from './sound';
|
|
250
258
|
// Clipboard (secure-context aware: execCommand fallback for plain-HTTP origins)
|
|
251
259
|
export { copyText, readClipboardText } from './utils/clipboard';
|
|
252
260
|
// Text utilities
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JS easing functions for the `--motion-*` curves, so Svelte transitions
|
|
3
|
+
* (which interpolate in JS and emit keyframes) move on the same curves the
|
|
4
|
+
* CSS tokens describe. No dependency: a small cubic-bezier solver.
|
|
5
|
+
*/
|
|
6
|
+
import { type MotionEasingToken } from './level';
|
|
7
|
+
export type EasingFn = (t: number) => number;
|
|
8
|
+
/**
|
|
9
|
+
* CSS `cubic-bezier(x1, y1, x2, y2)` as a function of progress t ∈ [0, 1].
|
|
10
|
+
* Newton–Raphson with a bisection fallback; y may overshoot [0, 1] (springs).
|
|
11
|
+
*/
|
|
12
|
+
export declare function cubicBezier(x1: number, y1: number, x2: number, y2: number): EasingFn;
|
|
13
|
+
/** The JS easing for a `--motion-*` easing token. */
|
|
14
|
+
export declare function motionEasing(token: MotionEasingToken): EasingFn;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JS easing functions for the `--motion-*` curves, so Svelte transitions
|
|
3
|
+
* (which interpolate in JS and emit keyframes) move on the same curves the
|
|
4
|
+
* CSS tokens describe. No dependency: a small cubic-bezier solver.
|
|
5
|
+
*/
|
|
6
|
+
import { MOTION_EASING_POINTS } from './level';
|
|
7
|
+
/**
|
|
8
|
+
* CSS `cubic-bezier(x1, y1, x2, y2)` as a function of progress t ∈ [0, 1].
|
|
9
|
+
* Newton–Raphson with a bisection fallback; y may overshoot [0, 1] (springs).
|
|
10
|
+
*/
|
|
11
|
+
export function cubicBezier(x1, y1, x2, y2) {
|
|
12
|
+
const cx = 3 * x1;
|
|
13
|
+
const bx = 3 * (x2 - x1) - cx;
|
|
14
|
+
const ax = 1 - cx - bx;
|
|
15
|
+
const cy = 3 * y1;
|
|
16
|
+
const by = 3 * (y2 - y1) - cy;
|
|
17
|
+
const ay = 1 - cy - by;
|
|
18
|
+
const sampleX = (s) => ((ax * s + bx) * s + cx) * s;
|
|
19
|
+
const sampleY = (s) => ((ay * s + by) * s + cy) * s;
|
|
20
|
+
const slopeX = (s) => (3 * ax * s + 2 * bx) * s + cx;
|
|
21
|
+
function solveX(x) {
|
|
22
|
+
let s = x;
|
|
23
|
+
for (let i = 0; i < 8; i++) {
|
|
24
|
+
const err = sampleX(s) - x;
|
|
25
|
+
if (Math.abs(err) < 1e-6)
|
|
26
|
+
return s;
|
|
27
|
+
const d = slopeX(s);
|
|
28
|
+
if (Math.abs(d) < 1e-6)
|
|
29
|
+
break;
|
|
30
|
+
s -= err / d;
|
|
31
|
+
}
|
|
32
|
+
let lo = 0;
|
|
33
|
+
let hi = 1;
|
|
34
|
+
s = x;
|
|
35
|
+
while (lo < hi) {
|
|
36
|
+
const v = sampleX(s);
|
|
37
|
+
if (Math.abs(v - x) < 1e-6)
|
|
38
|
+
return s;
|
|
39
|
+
if (x > v)
|
|
40
|
+
lo = s;
|
|
41
|
+
else
|
|
42
|
+
hi = s;
|
|
43
|
+
s = (lo + hi) / 2;
|
|
44
|
+
if (hi - lo < 1e-7)
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
return (t) => {
|
|
50
|
+
if (t <= 0)
|
|
51
|
+
return 0;
|
|
52
|
+
if (t >= 1)
|
|
53
|
+
return 1;
|
|
54
|
+
return sampleY(solveX(t));
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const cache = new Map();
|
|
58
|
+
/** The JS easing for a `--motion-*` easing token. */
|
|
59
|
+
export function motionEasing(token) {
|
|
60
|
+
let fn = cache.get(token);
|
|
61
|
+
if (!fn) {
|
|
62
|
+
const [x1, y1, x2, y2] = MOTION_EASING_POINTS[token];
|
|
63
|
+
fn = cubicBezier(x1, y1, x2, y2);
|
|
64
|
+
cache.set(token, fn);
|
|
65
|
+
}
|
|
66
|
+
return fn;
|
|
67
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jatui motion kit — the motion level scheme, token easings, level-aware
|
|
3
|
+
* transitions and the scroll-reveal action, in one import.
|
|
4
|
+
* Scheme + level semantics: docs/motion.md.
|
|
5
|
+
*/
|
|
6
|
+
export { MOTION_LEVELS, DEFAULT_MOTION_LEVEL, MOTION_LEVEL_ATTR, MOTION_DURATIONS, MOTION_LEVEL_SCALE, MOTION_LEVEL_TRAVEL, MOTION_EASING_POINTS, isMotionLevel, parseMotionLevel, prefersReducedMotion, resolveMotionLevel, motionContextFor, getMotionContext, motionDuration, effectiveEasing, setMotionLevel } from './level';
|
|
7
|
+
export type { MotionLevel, MotionDurationToken, MotionEasingToken, MotionContext } from './level';
|
|
8
|
+
export { cubicBezier, motionEasing } from './easing';
|
|
9
|
+
export type { EasingFn } from './easing';
|
|
10
|
+
export { motionFade, motionScale, motionSlide, resolveMotionTiming } from './transitions';
|
|
11
|
+
export type { MotionTransitionParams, MotionTransitionConfig, MotionFadeParams, MotionScaleParams, MotionSlideParams, ResolvedMotionTiming } from './transitions';
|
|
12
|
+
export { createMotionLevelStore } from './store';
|
|
13
|
+
export type { MotionState, MotionLevelStore, MotionLevelStoreOptions } from './store';
|
|
14
|
+
export { glide } from '../transitions/glide';
|
|
15
|
+
export type { GlideOrigin, GlideParams } from '../transitions/glide';
|
|
16
|
+
export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS } from '../actions/reveal';
|
|
17
|
+
export type { RevealOptions, RevealVariant } from '../actions/reveal';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jatui motion kit — the motion level scheme, token easings, level-aware
|
|
3
|
+
* transitions and the scroll-reveal action, in one import.
|
|
4
|
+
* Scheme + level semantics: docs/motion.md.
|
|
5
|
+
*/
|
|
6
|
+
export { MOTION_LEVELS, DEFAULT_MOTION_LEVEL, MOTION_LEVEL_ATTR, MOTION_DURATIONS, MOTION_LEVEL_SCALE, MOTION_LEVEL_TRAVEL, MOTION_EASING_POINTS, isMotionLevel, parseMotionLevel, prefersReducedMotion, resolveMotionLevel, motionContextFor, getMotionContext, motionDuration, effectiveEasing, setMotionLevel } from './level';
|
|
7
|
+
export { cubicBezier, motionEasing } from './easing';
|
|
8
|
+
export { motionFade, motionScale, motionSlide, resolveMotionTiming } from './transitions';
|
|
9
|
+
export { createMotionLevelStore } from './store';
|
|
10
|
+
export { glide } from '../transitions/glide';
|
|
11
|
+
export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS } from '../actions/reveal';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion level — a per-surface dial for how much the UI moves.
|
|
3
|
+
*
|
|
4
|
+
* A host sets `data-motion="calm|standard|lively"` on `<html>` or on any
|
|
5
|
+
* subtree. `styles/motion.css` rescales the `--motion-*` duration and travel
|
|
6
|
+
* tokens under that attribute, and the JS side (transitions, `use:reveal`)
|
|
7
|
+
* reads the same level through `getMotionContext(node)` so CSS and JS motion
|
|
8
|
+
* agree. The nearest `[data-motion]` ancestor wins; no attribute = standard.
|
|
9
|
+
*
|
|
10
|
+
* calm durations × 0.75, travel 8px, springs flattened to glide,
|
|
11
|
+
* ambient/decorative motion OFF. Dense tools, dashboards.
|
|
12
|
+
* standard the tokens as written. The default.
|
|
13
|
+
* lively durations × 1.25, travel 24px. Marketing, onboarding, empty
|
|
14
|
+
* states — surfaces whose job is to feel alive.
|
|
15
|
+
*
|
|
16
|
+
* `prefers-reduced-motion: reduce` always wins over every level: durations
|
|
17
|
+
* and travel collapse to 0, springs and ambient motion are off. A level can
|
|
18
|
+
* never opt a reduced-motion user back into motion.
|
|
19
|
+
*
|
|
20
|
+
* The numbers here MUST match styles/motion.css — tests/unit/motionLevel.test.ts
|
|
21
|
+
* parses the stylesheet and fails on drift.
|
|
22
|
+
*/
|
|
23
|
+
export type MotionLevel = 'calm' | 'standard' | 'lively';
|
|
24
|
+
/** Duration tokens, named after their `--motion-<name>` custom property. */
|
|
25
|
+
export type MotionDurationToken = 'instant' | 'fast' | 'enter' | 'move';
|
|
26
|
+
/** Easing tokens, named after their `--motion-<name>` custom property. */
|
|
27
|
+
export type MotionEasingToken = 'glide' | 'spring' | 'standard';
|
|
28
|
+
export declare const MOTION_LEVELS: readonly MotionLevel[];
|
|
29
|
+
export declare const DEFAULT_MOTION_LEVEL: MotionLevel;
|
|
30
|
+
/** The attribute a host sets on `<html>` or any subtree. */
|
|
31
|
+
export declare const MOTION_LEVEL_ATTR = "data-motion";
|
|
32
|
+
/** Standard-level durations in ms — the `--motion-*-base` tokens. */
|
|
33
|
+
export declare const MOTION_DURATIONS: Readonly<Record<MotionDurationToken, number>>;
|
|
34
|
+
/** Duration multiplier per level — `--motion-scale`. */
|
|
35
|
+
export declare const MOTION_LEVEL_SCALE: Readonly<Record<MotionLevel, number>>;
|
|
36
|
+
/** Entrance travel distance in px per level — `--motion-travel`. */
|
|
37
|
+
export declare const MOTION_LEVEL_TRAVEL: Readonly<Record<MotionLevel, number>>;
|
|
38
|
+
/** Cubic-bezier control points for the easing tokens (same as motion.css). */
|
|
39
|
+
export declare const MOTION_EASING_POINTS: Readonly<Record<MotionEasingToken, readonly [number, number, number, number]>>;
|
|
40
|
+
/** Everything a piece of motion needs to know about where it is running. */
|
|
41
|
+
export interface MotionContext {
|
|
42
|
+
/** The level the surface asked for (nearest `[data-motion]`, else standard). */
|
|
43
|
+
level: MotionLevel;
|
|
44
|
+
/** `prefers-reduced-motion: reduce` — overrides the level when true. */
|
|
45
|
+
reduced: boolean;
|
|
46
|
+
/** Duration multiplier: the level's scale, or 0 when reduced. */
|
|
47
|
+
scale: number;
|
|
48
|
+
/** Travel distance in px: the level's travel, or 0 when reduced. */
|
|
49
|
+
travel: number;
|
|
50
|
+
/** Whether ambient/decorative motion may run (standard + lively, never reduced). */
|
|
51
|
+
ambient: boolean;
|
|
52
|
+
/** Whether spring (overshoot) easing may run (standard + lively, never reduced). */
|
|
53
|
+
spring: boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare function isMotionLevel(value: unknown): value is MotionLevel;
|
|
56
|
+
/** Coerce any attribute/storage value to a level; unknown values become the default. */
|
|
57
|
+
export declare function parseMotionLevel(value: unknown, fallback?: MotionLevel): MotionLevel;
|
|
58
|
+
/** True when the user asked the OS for reduced motion. SSR-safe (false). */
|
|
59
|
+
export declare function prefersReducedMotion(): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The level in force for `node`: its nearest `[data-motion]` ancestor
|
|
62
|
+
* (inclusive), else `<html>`'s, else standard. A `[data-motion]` with an
|
|
63
|
+
* unknown value counts as standard rather than being skipped, so a typo
|
|
64
|
+
* fails visibly-safe instead of silently inheriting `lively` from above.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveMotionLevel(node?: Element | null): MotionLevel;
|
|
67
|
+
/** Build a context from a level + reduced flag (pure — no DOM). */
|
|
68
|
+
export declare function motionContextFor(level: MotionLevel, reduced?: boolean): MotionContext;
|
|
69
|
+
/** The motion context in force for `node` (or the document when omitted). */
|
|
70
|
+
export declare function getMotionContext(node?: Element | null): MotionContext;
|
|
71
|
+
/**
|
|
72
|
+
* A token duration (or a standard-level ms value) scaled for a context.
|
|
73
|
+
* Returns 0 under reduced motion.
|
|
74
|
+
*
|
|
75
|
+
* motionDuration('enter', ctx) // 200 standard, 150 calm, 250 lively
|
|
76
|
+
* motionDuration(400, ctx) // 300 calm
|
|
77
|
+
*/
|
|
78
|
+
export declare function motionDuration(token: MotionDurationToken | number, ctx: MotionContext): number;
|
|
79
|
+
/**
|
|
80
|
+
* The easing token actually allowed in a context: spring degrades to glide
|
|
81
|
+
* wherever springs are off (calm, reduced). Other tokens pass through.
|
|
82
|
+
*/
|
|
83
|
+
export declare function effectiveEasing(token: MotionEasingToken, ctx: MotionContext): MotionEasingToken;
|
|
84
|
+
/**
|
|
85
|
+
* Set the level on a surface (default `<html>`). Passing `null` removes the
|
|
86
|
+
* attribute so the surface inherits again. No-op without a DOM.
|
|
87
|
+
*/
|
|
88
|
+
export declare function setMotionLevel(level: MotionLevel | null, target?: Element | null): void;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion level — a per-surface dial for how much the UI moves.
|
|
3
|
+
*
|
|
4
|
+
* A host sets `data-motion="calm|standard|lively"` on `<html>` or on any
|
|
5
|
+
* subtree. `styles/motion.css` rescales the `--motion-*` duration and travel
|
|
6
|
+
* tokens under that attribute, and the JS side (transitions, `use:reveal`)
|
|
7
|
+
* reads the same level through `getMotionContext(node)` so CSS and JS motion
|
|
8
|
+
* agree. The nearest `[data-motion]` ancestor wins; no attribute = standard.
|
|
9
|
+
*
|
|
10
|
+
* calm durations × 0.75, travel 8px, springs flattened to glide,
|
|
11
|
+
* ambient/decorative motion OFF. Dense tools, dashboards.
|
|
12
|
+
* standard the tokens as written. The default.
|
|
13
|
+
* lively durations × 1.25, travel 24px. Marketing, onboarding, empty
|
|
14
|
+
* states — surfaces whose job is to feel alive.
|
|
15
|
+
*
|
|
16
|
+
* `prefers-reduced-motion: reduce` always wins over every level: durations
|
|
17
|
+
* and travel collapse to 0, springs and ambient motion are off. A level can
|
|
18
|
+
* never opt a reduced-motion user back into motion.
|
|
19
|
+
*
|
|
20
|
+
* The numbers here MUST match styles/motion.css — tests/unit/motionLevel.test.ts
|
|
21
|
+
* parses the stylesheet and fails on drift.
|
|
22
|
+
*/
|
|
23
|
+
export const MOTION_LEVELS = ['calm', 'standard', 'lively'];
|
|
24
|
+
export const DEFAULT_MOTION_LEVEL = 'standard';
|
|
25
|
+
/** The attribute a host sets on `<html>` or any subtree. */
|
|
26
|
+
export const MOTION_LEVEL_ATTR = 'data-motion';
|
|
27
|
+
/** Standard-level durations in ms — the `--motion-*-base` tokens. */
|
|
28
|
+
export const MOTION_DURATIONS = {
|
|
29
|
+
instant: 80,
|
|
30
|
+
fast: 150,
|
|
31
|
+
enter: 200,
|
|
32
|
+
move: 300
|
|
33
|
+
};
|
|
34
|
+
/** Duration multiplier per level — `--motion-scale`. */
|
|
35
|
+
export const MOTION_LEVEL_SCALE = {
|
|
36
|
+
calm: 0.75,
|
|
37
|
+
standard: 1,
|
|
38
|
+
lively: 1.25
|
|
39
|
+
};
|
|
40
|
+
/** Entrance travel distance in px per level — `--motion-travel`. */
|
|
41
|
+
export const MOTION_LEVEL_TRAVEL = {
|
|
42
|
+
calm: 8,
|
|
43
|
+
standard: 16,
|
|
44
|
+
lively: 24
|
|
45
|
+
};
|
|
46
|
+
/** Cubic-bezier control points for the easing tokens (same as motion.css). */
|
|
47
|
+
export const MOTION_EASING_POINTS = {
|
|
48
|
+
glide: [0.16, 1, 0.3, 1],
|
|
49
|
+
spring: [0.34, 1.56, 0.64, 1],
|
|
50
|
+
standard: [0.2, 0, 0, 1]
|
|
51
|
+
};
|
|
52
|
+
export function isMotionLevel(value) {
|
|
53
|
+
return typeof value === 'string' && MOTION_LEVELS.includes(value);
|
|
54
|
+
}
|
|
55
|
+
/** Coerce any attribute/storage value to a level; unknown values become the default. */
|
|
56
|
+
export function parseMotionLevel(value, fallback = DEFAULT_MOTION_LEVEL) {
|
|
57
|
+
if (typeof value !== 'string')
|
|
58
|
+
return fallback;
|
|
59
|
+
const v = value.trim().toLowerCase();
|
|
60
|
+
return isMotionLevel(v) ? v : fallback;
|
|
61
|
+
}
|
|
62
|
+
/** True when the user asked the OS for reduced motion. SSR-safe (false). */
|
|
63
|
+
export function prefersReducedMotion() {
|
|
64
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
65
|
+
return false;
|
|
66
|
+
try {
|
|
67
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The level in force for `node`: its nearest `[data-motion]` ancestor
|
|
75
|
+
* (inclusive), else `<html>`'s, else standard. A `[data-motion]` with an
|
|
76
|
+
* unknown value counts as standard rather than being skipped, so a typo
|
|
77
|
+
* fails visibly-safe instead of silently inheriting `lively` from above.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveMotionLevel(node) {
|
|
80
|
+
const el = (node ?? (typeof document !== 'undefined' ? document.documentElement : null));
|
|
81
|
+
if (!el || typeof el.closest !== 'function')
|
|
82
|
+
return DEFAULT_MOTION_LEVEL;
|
|
83
|
+
const host = el.closest(`[${MOTION_LEVEL_ATTR}]`);
|
|
84
|
+
return host ? parseMotionLevel(host.getAttribute(MOTION_LEVEL_ATTR)) : DEFAULT_MOTION_LEVEL;
|
|
85
|
+
}
|
|
86
|
+
/** Build a context from a level + reduced flag (pure — no DOM). */
|
|
87
|
+
export function motionContextFor(level, reduced = false) {
|
|
88
|
+
const lively = level !== 'calm';
|
|
89
|
+
return {
|
|
90
|
+
level,
|
|
91
|
+
reduced,
|
|
92
|
+
scale: reduced ? 0 : MOTION_LEVEL_SCALE[level],
|
|
93
|
+
travel: reduced ? 0 : MOTION_LEVEL_TRAVEL[level],
|
|
94
|
+
ambient: !reduced && lively,
|
|
95
|
+
spring: !reduced && lively
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** The motion context in force for `node` (or the document when omitted). */
|
|
99
|
+
export function getMotionContext(node) {
|
|
100
|
+
return motionContextFor(resolveMotionLevel(node), prefersReducedMotion());
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A token duration (or a standard-level ms value) scaled for a context.
|
|
104
|
+
* Returns 0 under reduced motion.
|
|
105
|
+
*
|
|
106
|
+
* motionDuration('enter', ctx) // 200 standard, 150 calm, 250 lively
|
|
107
|
+
* motionDuration(400, ctx) // 300 calm
|
|
108
|
+
*/
|
|
109
|
+
export function motionDuration(token, ctx) {
|
|
110
|
+
const base = typeof token === 'number' ? token : MOTION_DURATIONS[token];
|
|
111
|
+
return Math.round(Math.max(0, base) * ctx.scale);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The easing token actually allowed in a context: spring degrades to glide
|
|
115
|
+
* wherever springs are off (calm, reduced). Other tokens pass through.
|
|
116
|
+
*/
|
|
117
|
+
export function effectiveEasing(token, ctx) {
|
|
118
|
+
return token === 'spring' && !ctx.spring ? 'glide' : token;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Set the level on a surface (default `<html>`). Passing `null` removes the
|
|
122
|
+
* attribute so the surface inherits again. No-op without a DOM.
|
|
123
|
+
*/
|
|
124
|
+
export function setMotionLevel(level, target) {
|
|
125
|
+
const el = target ?? (typeof document !== 'undefined' ? document.documentElement : null);
|
|
126
|
+
if (!el)
|
|
127
|
+
return;
|
|
128
|
+
if (level === null)
|
|
129
|
+
el.removeAttribute(MOTION_LEVEL_ATTR);
|
|
130
|
+
else
|
|
131
|
+
el.setAttribute(MOTION_LEVEL_ATTR, level);
|
|
132
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion-level store — a Svelte store (subscribe contract, usable as
|
|
3
|
+
* `$motion`) that owns a surface's `data-motion` attribute, optionally
|
|
4
|
+
* persists the user's choice, and tracks `prefers-reduced-motion` live.
|
|
5
|
+
*
|
|
6
|
+
* const motion = createMotionLevelStore({ storageKey: 'app.motion' })
|
|
7
|
+
* motion.set('calm') // writes data-motion="calm" on <html>
|
|
8
|
+
* $motion.level / $motion.reduced // reactive in components
|
|
9
|
+
* $motion.effective // 'reduced' when the OS asks, else level
|
|
10
|
+
*
|
|
11
|
+
* Plain store (no runes), so it works in .ts, in SSR (no DOM access until a
|
|
12
|
+
* method or subscriber needs it) and under node:test.
|
|
13
|
+
*/
|
|
14
|
+
import { type MotionLevel } from './level';
|
|
15
|
+
export interface MotionState {
|
|
16
|
+
/** The level chosen for the surface. */
|
|
17
|
+
level: MotionLevel;
|
|
18
|
+
/** `prefers-reduced-motion: reduce` is on — every level yields to it. */
|
|
19
|
+
reduced: boolean;
|
|
20
|
+
/** What actually runs: 'reduced' when reduced, else the level. */
|
|
21
|
+
effective: MotionLevel | 'reduced';
|
|
22
|
+
}
|
|
23
|
+
export interface MotionLevelStoreOptions {
|
|
24
|
+
/** Element that carries `data-motion`. Default `document.documentElement`, resolved lazily. */
|
|
25
|
+
target?: Element | (() => Element | null) | null;
|
|
26
|
+
/** Persist the chosen level in localStorage under this key. Omit = no persistence. */
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
/** Level when nothing is stored and the target has no attribute. Default 'standard'. */
|
|
29
|
+
initial?: MotionLevel;
|
|
30
|
+
}
|
|
31
|
+
export interface MotionLevelStore {
|
|
32
|
+
subscribe(run: (state: MotionState) => void): () => void;
|
|
33
|
+
/** Set the level (writes the attribute, persists when configured). */
|
|
34
|
+
set(level: MotionLevel): void;
|
|
35
|
+
/** Clear the stored choice and fall back to `initial`. */
|
|
36
|
+
reset(): void;
|
|
37
|
+
/** Current state, without subscribing. */
|
|
38
|
+
get(): MotionState;
|
|
39
|
+
}
|
|
40
|
+
export declare function createMotionLevelStore(options?: MotionLevelStoreOptions): MotionLevelStore;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion-level store — a Svelte store (subscribe contract, usable as
|
|
3
|
+
* `$motion`) that owns a surface's `data-motion` attribute, optionally
|
|
4
|
+
* persists the user's choice, and tracks `prefers-reduced-motion` live.
|
|
5
|
+
*
|
|
6
|
+
* const motion = createMotionLevelStore({ storageKey: 'app.motion' })
|
|
7
|
+
* motion.set('calm') // writes data-motion="calm" on <html>
|
|
8
|
+
* $motion.level / $motion.reduced // reactive in components
|
|
9
|
+
* $motion.effective // 'reduced' when the OS asks, else level
|
|
10
|
+
*
|
|
11
|
+
* Plain store (no runes), so it works in .ts, in SSR (no DOM access until a
|
|
12
|
+
* method or subscriber needs it) and under node:test.
|
|
13
|
+
*/
|
|
14
|
+
import { DEFAULT_MOTION_LEVEL, parseMotionLevel, prefersReducedMotion, resolveMotionLevel, setMotionLevel } from './level';
|
|
15
|
+
function stateOf(level, reduced) {
|
|
16
|
+
return { level, reduced, effective: reduced ? 'reduced' : level };
|
|
17
|
+
}
|
|
18
|
+
function readStorage(key) {
|
|
19
|
+
if (!key)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function writeStorage(key, value) {
|
|
29
|
+
if (!key)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
if (typeof localStorage === 'undefined')
|
|
33
|
+
return;
|
|
34
|
+
if (value === null)
|
|
35
|
+
localStorage.removeItem(key);
|
|
36
|
+
else
|
|
37
|
+
localStorage.setItem(key, value);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// storage blocked (private mode, quota) — the attribute still applies
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function createMotionLevelStore(options = {}) {
|
|
44
|
+
const { storageKey } = options;
|
|
45
|
+
const initial = options.initial ?? DEFAULT_MOTION_LEVEL;
|
|
46
|
+
const subscribers = new Set();
|
|
47
|
+
let state = null;
|
|
48
|
+
let stopMedia = null;
|
|
49
|
+
function target() {
|
|
50
|
+
const t = options.target;
|
|
51
|
+
if (typeof t === 'function')
|
|
52
|
+
return t();
|
|
53
|
+
if (t)
|
|
54
|
+
return t;
|
|
55
|
+
return typeof document !== 'undefined' ? document.documentElement : null;
|
|
56
|
+
}
|
|
57
|
+
function load() {
|
|
58
|
+
if (state)
|
|
59
|
+
return state;
|
|
60
|
+
const stored = readStorage(storageKey);
|
|
61
|
+
const el = target();
|
|
62
|
+
let level;
|
|
63
|
+
if (stored !== null) {
|
|
64
|
+
level = parseMotionLevel(stored, initial);
|
|
65
|
+
setMotionLevel(level, el);
|
|
66
|
+
}
|
|
67
|
+
else if (el?.hasAttribute?.('data-motion')) {
|
|
68
|
+
level = resolveMotionLevel(el);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
level = initial;
|
|
72
|
+
}
|
|
73
|
+
state = stateOf(level, prefersReducedMotion());
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
function emit(next) {
|
|
77
|
+
state = next;
|
|
78
|
+
for (const run of subscribers)
|
|
79
|
+
run(next);
|
|
80
|
+
}
|
|
81
|
+
function watchReducedMotion() {
|
|
82
|
+
if (stopMedia || typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
83
|
+
return;
|
|
84
|
+
let mql;
|
|
85
|
+
try {
|
|
86
|
+
mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const onChange = (e) => {
|
|
92
|
+
const cur = load();
|
|
93
|
+
if (cur.reduced !== e.matches)
|
|
94
|
+
emit(stateOf(cur.level, e.matches));
|
|
95
|
+
};
|
|
96
|
+
if (typeof mql.addEventListener === 'function') {
|
|
97
|
+
mql.addEventListener('change', onChange);
|
|
98
|
+
stopMedia = () => mql.removeEventListener('change', onChange);
|
|
99
|
+
}
|
|
100
|
+
else if (typeof mql.addListener === 'function') {
|
|
101
|
+
mql.addListener(onChange);
|
|
102
|
+
stopMedia = () => mql.removeListener(onChange);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
subscribe(run) {
|
|
107
|
+
subscribers.add(run);
|
|
108
|
+
if (subscribers.size === 1)
|
|
109
|
+
watchReducedMotion();
|
|
110
|
+
run(load());
|
|
111
|
+
return () => {
|
|
112
|
+
subscribers.delete(run);
|
|
113
|
+
if (subscribers.size === 0 && stopMedia) {
|
|
114
|
+
stopMedia();
|
|
115
|
+
stopMedia = null;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
set(level) {
|
|
120
|
+
const next = parseMotionLevel(level, initial);
|
|
121
|
+
setMotionLevel(next, target());
|
|
122
|
+
writeStorage(storageKey, next);
|
|
123
|
+
emit(stateOf(next, load().reduced));
|
|
124
|
+
},
|
|
125
|
+
reset() {
|
|
126
|
+
writeStorage(storageKey, null);
|
|
127
|
+
setMotionLevel(initial, target());
|
|
128
|
+
emit(stateOf(initial, load().reduced));
|
|
129
|
+
},
|
|
130
|
+
get: load
|
|
131
|
+
};
|
|
132
|
+
}
|