@joewinke/jatui 0.54.1 → 0.56.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 +7 -3
- package/README.md +3 -1
- package/dist/ambience/default.d.ts +3 -17
- package/dist/ambience/default.js +3 -2
- package/dist/ambience/engine.js +14 -3
- package/dist/ambience/groups.d.ts +45 -0
- package/dist/ambience/groups.js +98 -0
- package/dist/ambience/index.d.ts +4 -2
- package/dist/ambience/index.js +3 -1
- package/dist/ambience/registry.d.ts +6 -6
- package/dist/ambience/registry.js +31 -8
- package/dist/ambience/scapes/playlist.d.ts +16 -3
- package/dist/ambience/scapes/playlist.js +22 -1
- package/dist/ambience/types.d.ts +47 -3
- package/dist/components/AmbienceToggle.svelte +555 -107
- package/dist/components/AmbienceToggle.svelte.d.ts +92 -25
- package/dist/components/BottomSheet.svelte +21 -0
- package/dist/components/BottomSheet.svelte.d.ts +9 -0
- package/dist/components/ConfirmModal.svelte +11 -5
- package/dist/components/ConfirmModal.svelte.d.ts +9 -0
- package/dist/components/Drawer.svelte +23 -0
- package/dist/components/Drawer.svelte.d.ts +10 -0
- package/dist/components/InputDialog.svelte +6 -1
- package/dist/components/Modal.svelte +23 -8
- package/dist/components/Modal.svelte.d.ts +10 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/utils/dialogFocus.d.ts +54 -0
- package/dist/utils/dialogFocus.js +153 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -86,9 +86,9 @@ Then import:
|
|
|
86
86
|
| `ActivityHeatmap` | GitHub-style activity-per-day calendar (calls/day, tasks closed/day): 7 weekday rows × week columns, default range = last 12 months, `weekStart`, 4-step `color-mix` ramp from one theme `tone` (no hex), one floating tooltip, legend + summary ("1,204 calls in the last year"). `role="grid"` with ONE tab stop: arrows move by day (↑↓) / week (←→), PgUp/PgDn 4 weeks, Home/End row ends, mod+Home/End range ends, Enter/Space select. Pure layout helpers exported (`buildHeatmap`, `moveHeatFocus`). | `data` (`{date, count}[]` or `getDate`/`getCount`), `start?`/`end?`, `weekStart?`, `unit?` ('call' or ['call','calls']), `formatLabel?`, `tone?`, `levels?`, `levelOf?`, `compact?`, `showLegend?`/`showWeekdays?`/`showMonths?`, `summary?`, `selected` (bindable), `onSelectDay?(date, count)`, `locale?` |
|
|
87
87
|
| `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
|
|
88
88
|
| `Card` | Content card container | `title`, `children` (snippet) |
|
|
89
|
-
| `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **
|
|
89
|
+
| `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **On open, focus goes to the first fillable field** (WAI-ARIA APG): a `[data-autofocus]`/`autofocus` element, else the first enabled **rendered** text-entry control (readOnly/disabled textareas excluded, unrendered candidates skipped), else the dialog itself — `initialFocus` overrides the order (`'dialog'` restores the old container focus); the focus landing is verified, else the container is focused. When the dialog is the fallback focus, **Escape is owned by the dialog element itself** (not the window handler), so `trapKeys`' stopPropagation can't swallow Esc; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?`, `initialFocus?` |
|
|
90
90
|
| `ConfirmModal` | Yes/no confirmation dialog | `open` (bindable), `title`, `message`, `onConfirm`, `onCancel` |
|
|
91
|
-
| `Drawer` | Slide-out side panel. Header ✕ is a `CloseButton`; the body carries `use:scrollEdge` (opt out with `scrollEdge={false}`). | `open` (bindable), `side`, `scrollEdge?`, `children` (snippet) |
|
|
91
|
+
| `Drawer` | Slide-out side panel. Header ✕ is a `CloseButton`; the body carries `use:scrollEdge` (opt out with `scrollEdge={false}`). On open it focuses the first fillable field ([data-autofocus]/autofocus, else first enabled **rendered** text-entry control, else the panel) — `initialFocus` (`null` opts out). | `open` (bindable), `side`, `initialFocus?`, `scrollEdge?`, `children` (snippet) |
|
|
92
92
|
| `LoadingSpinner` | Spinner animation | `size`, `color` |
|
|
93
93
|
| `Sparkline` | Inline mini chart | `data: number[]`, `width`, `height`, `color` |
|
|
94
94
|
| `LazyImage` | Image with lazy loading + placeholder | `src`, `alt`, `placeholder` |
|
|
@@ -106,7 +106,7 @@ Then import:
|
|
|
106
106
|
| `BatchJobTable` | Generic queue runner (harvested from vid-gen's BatchGenerator) — `<BatchJobTable generics T>` over bindable `JobRow<T>[]`; host supplies async `run(data, {signal, attempt, reportProgress})`. Concurrency cap, optional per-run `processLimit` (quota budget), rolling-average ETA, per-row status badge / progress / elapsed / retry / skip / cancel / reorder — each cell a snippet override (`statusCell`, `progressCell`, `elapsedCell`, `actionsCell`), host data columns via `headers` + `cells`, extra toolbar content via `toolbar`. Uses `showToast` / `showConfirm` (mount `ToastHost` + `ConfirmDialog`). Exposes `start()`/`pause()`/`cancel()`/`retryFailed()`/`clearFinished()`. Pure scheduling helpers exported too (`nextJobsToStart`, `moveJob`, `summarizeJobs`, `toJobRows`). | `jobs` (bindable), `run`, `concurrencyLimit?` (3), `processLimit?`, `autoStart?`, `notify?`, `confirmDestructive?`, `onJobDone?`, `onJobFailed?`, `onBatchDone?` |
|
|
107
107
|
| `QuotaMeter` | "312 / 500 credits · resets in 4h 10m" bar — warning tint at `warnAt`, error at 100%. Presentational; the host owns the ledger. | `used`, `limit`, `resetsAt?`, `label?`, `unit?`, `warnAt?` (0.8), `size?` |
|
|
108
108
|
| `VariantMatrixPicker` | Category/option chip grid for building a combinatorial variant matrix (harvested from vid-gen's FrameVariantGenerator, presets stripped — caller-supplied `categories`). Each category tab shows a live selection count and an additive/multiplicative badge: multiplicative selections cartesian-multiply the total, additive ones stack flat onto every combination. Live combination count in the footer; algebra exported separately as pure functions (`generateCombinations`, `countCombinations`, `variantCategoryMode`, `findVariantOption`, `generateVariantFilename`). | `categories: VariantCategory[]`, `selected` (bindable `VariantSelection`), `forceMode?`, `showCount?` (true), `countLabel?`, `onChange?`, `class?` |
|
|
109
|
-
| `BottomSheet` | Mobile bottom sheet (upstreamed from the JAT IDE — jatui-rl7w6.2): drag handle with pointer capture, backdrop close, optional header. `snap='dual'` enables the inspector pattern — peek → expanded snap heights with flick-momentum release (fast flick ≥ 0.5 px/ms wins over position; flick down from peek dismisses) and `visualViewport` keyboard avoidance so content clears the soft keyboard. | `open` (bindable), `title?`, `zIndex?` (70), `fullHeight?`, `snap?` ('single'\|'dual'), `peekFraction?` (0.4), `expandedFraction?` (0.9), `onclose?`, `children` (snippet) |
|
|
109
|
+
| `BottomSheet` | Mobile bottom sheet (upstreamed from the JAT IDE — jatui-rl7w6.2): drag handle with pointer capture, backdrop close, optional header. `snap='dual'` enables the inspector pattern — peek → expanded snap heights with flick-momentum release (fast flick ≥ 0.5 px/ms wins over position; flick down from peek dismisses) and `visualViewport` keyboard avoidance so content clears the soft keyboard. | `open` (bindable), `title?`, `zIndex?` (70), `fullHeight?`, `snap?` ('single'\|'dual'), `peekFraction?` (0.4), `expandedFraction?` (0.9), `onclose?`, `initialFocus?` ('first-input': [data-autofocus]/autofocus, else first enabled **rendered** text-entry control, else the panel; `null` opts out), `children` (snippet) |
|
|
110
110
|
|
|
111
111
|
### Agent surfaces (BoardUI wave 2)
|
|
112
112
|
|
|
@@ -330,6 +330,10 @@ const rec = createAudioRecorder({
|
|
|
330
330
|
// Methods: start() stop() pause() resume() cancel() reset() destroy()
|
|
331
331
|
```
|
|
332
332
|
|
|
333
|
+
### Ambience (subpath: `jatui/ambience`, components on the root)
|
|
334
|
+
|
|
335
|
+
`createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = volume + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
|
|
336
|
+
|
|
333
337
|
### Sound (first-class sibling of motion)
|
|
334
338
|
|
|
335
339
|
One preferences store every app sound goes through. UI + notification sounds are **off by default** (opt-in); the `incoming-call` ringer is **opt-out** (plays out of the box, muted via `categories.calls`). Semantic names (`confirm`, `error`, `notify`, `message`, `send`, `toggle`, `tap`, `incoming-call`, plus the booking-delight set `tick` 0.03 / `select` 0.08 / `chime` 0.12 / `attention` 0.10) map to bundled CC0 clips or a host's own file (`SOUND_CLIP_VARIANTS['error-soft']` is a gentler `error`). Policy per category: background tab, quiet hours and the quiet/reduced-motion mode silence `ui`/`notifications`; calls always ring. **Throttle:** per-name `minIntervalMs` (tick 80 ms, select 120 ms) drops — never queues — a play inside the window (`blocked: 'throttled'`); per-call `minIntervalMs` overrides. **Pitch:** `playSound('tick', { pitch: pentatonicPitch(i) })` (semitones → `playbackRate`; `playbackRate` also accepted). `tick` is the one hover sound — call it only on pointer-fine devices. **Site level:** host stamps `<html data-sound="off|subtle">` (like `data-motion`; `setSoundLevel` / `resolveSoundLevel` / `soundLevelDefaults`); `subtle` only starts UI sound on for a visitor with no saved preference — a saved preference (the panel mute) always wins, and `decideSound` is unchanged. **iOS unlock:** the player creates its AudioContext lazily (inside clip-decode promise chains), which iOS Safari leaves suspended forever — call `unlockSound()` from inside a user gesture (or `armSoundUnlock()` once for a one-shot first-gesture listener); custom backends implement optional `unlock()` / `unlocked` and the player delegates. Discipline doc: `docs/sound.md`.
|
package/README.md
CHANGED
|
@@ -51,8 +51,10 @@ Key contract points:
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
Track URLs must be served with CORS headers (they are fetched as `HTMLAudioElement` sources fed into the Web Audio graph), and the host owns the manifest and its licensing claims — an example of public-domain Musopen recordings can be listed with attribution in the scape's `description`.
|
|
54
|
+
- **Menu sections** — `createAmbience({ groups: ['radio', 'focus', 'noise', { id: 'meditation', label: 'Calm' }] })` (also on `createDefaultAmbience`) orders and labels the toggle's sections; groups holding items but missing from the list still render after them. Without it the order is radio / focus / noise / meditation / nature / music. `ScapeGroup` accepts any string, so a host can add its own sections. A playlist goes in any section with `createPlaylistScape({ …, group: 'focus' })` (default `'music'`).
|
|
55
|
+
- **Radio = outbound links** — `engine.register(radioStation({ id, label, href, description? }))` adds a link item (group `'radio'`). The toggle renders it as an `<a target="_blank" rel="noopener noreferrer">` menu item with an external-link glyph; it opens the station's own site or player. We never relay a third-party stream (licensing), and `engine.play()` refuses a link. Only `http(s)` hrefs render (`safeAmbienceHref`). `engine.list()` stays playable-only; `engine.items()` lists scapes and links.
|
|
54
56
|
- **Persistence** — volume + last scape persist through the engine (custom `storageKey`); restoring preselects but never autoplays.
|
|
55
|
-
- **AmbienceToggle** — click plays/stops the last scape; ArrowDown, press-and-hold or right-click opens the
|
|
57
|
+
- **AmbienceToggle** — click plays/stops the last scape; ArrowDown, press-and-hold or right-click opens the menu: volume, then one row per section, each opening a submenu (Right / Enter / Space open and focus its first item, Left / Escape close back to the row, hover opens without moving focus). The current scape is checked and its section row names it; a playing playlist shows the track's title, composer and license; `aria-pressed` / `menuitemradio` / `aria-live` status wired; a rejected `play()` announces a transient “couldn't start — tap again” in the sr-only status, cleared by the next successful play; the glyph breathes with the shared breath clock and is static under reduced motion / `data-motion="calm"`.
|
|
56
58
|
|
|
57
59
|
Demo: `/components/ambience-toggle` on the docs site.
|
|
58
60
|
|
|
@@ -1,24 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
* createDefaultAmbience() — the "just works" engine.
|
|
3
|
-
*
|
|
4
|
-
* Every scape the library ships is pre-registered, in the menu order the
|
|
5
|
-
* AmbienceToggle groups them (meditation / focus / noise / nature). A host
|
|
6
|
-
* that supplies extra scapes (e.g. a playlist scape from a manifest of
|
|
7
|
-
* public-domain recordings) registers them on top — registry duplicate-id
|
|
8
|
-
* protection still applies.
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* import { createDefaultAmbience } from '../../../dist/ambience';
|
|
12
|
-
* const ambience = createDefaultAmbience();
|
|
13
|
-
* // inside a click handler:
|
|
14
|
-
* await ambience.play('rain');
|
|
15
|
-
*/
|
|
16
|
-
import { type Ambience } from './engine';
|
|
17
|
-
import type { Scape } from './types';
|
|
1
|
+
import type { Ambience, AmbienceOptions, Scape } from './types';
|
|
18
2
|
/** The scapes the library ships, in canonical menu order. */
|
|
19
3
|
export declare const DEFAULT_SCAPES: readonly Scape[];
|
|
20
4
|
export interface DefaultAmbienceOptions {
|
|
21
5
|
/** Passed through to `createAmbience` (e.g. a custom `storageKey`). */
|
|
22
6
|
storageKey?: string;
|
|
7
|
+
/** Menu section order/labels, passed through to `createAmbience`. */
|
|
8
|
+
groups?: AmbienceOptions['groups'];
|
|
23
9
|
}
|
|
24
10
|
export declare function createDefaultAmbience(options?: DefaultAmbienceOptions): Ambience;
|
package/dist/ambience/default.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* createDefaultAmbience() — the "just works" engine.
|
|
3
3
|
*
|
|
4
|
-
* Every scape the library ships is pre-registered
|
|
5
|
-
*
|
|
4
|
+
* Every scape the library ships is pre-registered (meditation / focus /
|
|
5
|
+
* noise / nature); the AmbienceToggle orders the sections by the `groups`
|
|
6
|
+
* option, or radio / focus / noise / meditation / nature / music. A host
|
|
6
7
|
* that supplies extra scapes (e.g. a playlist scape from a manifest of
|
|
7
8
|
* public-domain recordings) registers them on top — registry duplicate-id
|
|
8
9
|
* protection still applies.
|
package/dist/ambience/engine.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* NO UI here. The AmbienceToggle task (jatui-b7qvr) hosts this module.
|
|
11
11
|
*/
|
|
12
12
|
import { BreathClock } from './breath';
|
|
13
|
+
import { normalizeAmbienceGroups } from './groups';
|
|
13
14
|
import { createMasterChain } from './master';
|
|
14
15
|
import { loadPersisted, savePersisted } from './persist';
|
|
15
16
|
import { ScapeRegistry } from './registry';
|
|
@@ -42,6 +43,7 @@ export function createAmbience(options = {}) {
|
|
|
42
43
|
return nav?.userActivation ? nav.userActivation.isActive : true;
|
|
43
44
|
});
|
|
44
45
|
const registry = new ScapeRegistry();
|
|
46
|
+
const groupDefs = normalizeAmbienceGroups(options.groups);
|
|
45
47
|
const breath = new BreathClock();
|
|
46
48
|
// Resting state restored from storage (never autoplays — preselect only).
|
|
47
49
|
const persisted = loadPersisted(storageKey);
|
|
@@ -132,8 +134,11 @@ export function createAmbience(options = {}) {
|
|
|
132
134
|
}
|
|
133
135
|
async function play(id) {
|
|
134
136
|
const scape = registry.get(id);
|
|
135
|
-
if (!scape)
|
|
137
|
+
if (!scape) {
|
|
138
|
+
if (registry.item(id))
|
|
139
|
+
throw new Error(`Not playable (a link item): ${id}`);
|
|
136
140
|
throw new Error(`Unknown scape: ${id}`);
|
|
141
|
+
}
|
|
137
142
|
if (!isGesture())
|
|
138
143
|
throw new Error('play() must be called from a user gesture');
|
|
139
144
|
// Monotonic play token: a second play() (or a stop()/kill()) while this
|
|
@@ -279,12 +284,18 @@ export function createAmbience(options = {}) {
|
|
|
279
284
|
};
|
|
280
285
|
}
|
|
281
286
|
return {
|
|
282
|
-
register(
|
|
283
|
-
registry.register(
|
|
287
|
+
register(item) {
|
|
288
|
+
registry.register(item);
|
|
284
289
|
},
|
|
285
290
|
list() {
|
|
286
291
|
return registry.list();
|
|
287
292
|
},
|
|
293
|
+
items() {
|
|
294
|
+
return registry.items();
|
|
295
|
+
},
|
|
296
|
+
groups() {
|
|
297
|
+
return groupDefs.map((d) => ({ ...d }));
|
|
298
|
+
},
|
|
288
299
|
play,
|
|
289
300
|
stop,
|
|
290
301
|
kill,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu sections — the pure grouping the AmbienceToggle renders (jatui-mdq2p).
|
|
3
|
+
* The host orders and labels sections (`AmbienceOptions.groups`); without
|
|
4
|
+
* that, the default order is radio / focus / noise / meditation / nature /
|
|
5
|
+
* music. A group that holds items but is missing from the order still
|
|
6
|
+
* renders, after the ordered ones, in first-seen order: a menu never silently
|
|
7
|
+
* drops a registered item.
|
|
8
|
+
*/
|
|
9
|
+
import type { AmbienceGroupDef, AmbienceItem, AmbienceLink, ScapeGroup } from './types';
|
|
10
|
+
/** Default section order when the host configures none. */
|
|
11
|
+
export declare const AMBIENCE_DEFAULT_GROUP_ORDER: readonly ScapeGroup[];
|
|
12
|
+
/** Human labels for the library's own groups. */
|
|
13
|
+
export declare const AMBIENCE_DEFAULT_GROUP_LABELS: Readonly<Record<string, string>>;
|
|
14
|
+
/** One section of the menu: its id, label and items in registration order. */
|
|
15
|
+
export interface AmbienceSection<T extends AmbienceItem = AmbienceItem> {
|
|
16
|
+
group: ScapeGroup;
|
|
17
|
+
label: string;
|
|
18
|
+
scapes: T[];
|
|
19
|
+
}
|
|
20
|
+
/** Narrow an item to an outbound link. */
|
|
21
|
+
export declare function isAmbienceLink(item: AmbienceItem | null | undefined): item is AmbienceLink;
|
|
22
|
+
/** Normalize `groups` input (`'radio'` or `{ id, label }`), dropping blanks and duplicates. */
|
|
23
|
+
export declare function normalizeAmbienceGroups(groups: ReadonlyArray<ScapeGroup | AmbienceGroupDef> | undefined): AmbienceGroupDef[];
|
|
24
|
+
/** The label for a group: host label, then the library's, then the id capitalized. */
|
|
25
|
+
export declare function ambienceGroupLabel(group: ScapeGroup, defs?: readonly AmbienceGroupDef[]): string;
|
|
26
|
+
/**
|
|
27
|
+
* Group items into menu sections: host order (or the default order),
|
|
28
|
+
* registration order within a section, empty sections omitted, unlisted
|
|
29
|
+
* groups appended in first-seen order.
|
|
30
|
+
*/
|
|
31
|
+
export declare function groupAmbienceItems<T extends AmbienceItem>(items: readonly T[], groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>): AmbienceSection<T>[];
|
|
32
|
+
/** Build a radio-station link item (group 'radio' unless the host says otherwise). */
|
|
33
|
+
export declare function radioStation(options: {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string;
|
|
36
|
+
href: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
group?: ScapeGroup;
|
|
39
|
+
}): AmbienceLink;
|
|
40
|
+
/**
|
|
41
|
+
* The href the toggle may open: absolute http(s) only. A `javascript:` or
|
|
42
|
+
* `data:` href from a host's station list would run in the page, so it is
|
|
43
|
+
* refused (null) and the item renders disabled.
|
|
44
|
+
*/
|
|
45
|
+
export declare function safeAmbienceHref(href: string | null | undefined): string | null;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Default section order when the host configures none. */
|
|
2
|
+
export const AMBIENCE_DEFAULT_GROUP_ORDER = Object.freeze([
|
|
3
|
+
'radio',
|
|
4
|
+
'focus',
|
|
5
|
+
'noise',
|
|
6
|
+
'meditation',
|
|
7
|
+
'nature',
|
|
8
|
+
'music'
|
|
9
|
+
]);
|
|
10
|
+
/** Human labels for the library's own groups. */
|
|
11
|
+
export const AMBIENCE_DEFAULT_GROUP_LABELS = Object.freeze({
|
|
12
|
+
radio: 'Radio',
|
|
13
|
+
focus: 'Focus',
|
|
14
|
+
noise: 'Noise',
|
|
15
|
+
meditation: 'Meditation',
|
|
16
|
+
nature: 'Nature',
|
|
17
|
+
music: 'Music'
|
|
18
|
+
});
|
|
19
|
+
/** Narrow an item to an outbound link. */
|
|
20
|
+
export function isAmbienceLink(item) {
|
|
21
|
+
return !!item && item.kind === 'link';
|
|
22
|
+
}
|
|
23
|
+
/** Normalize `groups` input (`'radio'` or `{ id, label }`), dropping blanks and duplicates. */
|
|
24
|
+
export function normalizeAmbienceGroups(groups) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const g of groups ?? []) {
|
|
28
|
+
const def = typeof g === 'string' ? { id: g } : g;
|
|
29
|
+
if (!def?.id || seen.has(def.id))
|
|
30
|
+
continue;
|
|
31
|
+
seen.add(def.id);
|
|
32
|
+
out.push(def.label ? { id: def.id, label: def.label } : { id: def.id });
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** The label for a group: host label, then the library's, then the id capitalized. */
|
|
37
|
+
export function ambienceGroupLabel(group, defs = []) {
|
|
38
|
+
const host = defs.find((d) => d.id === group)?.label;
|
|
39
|
+
if (host)
|
|
40
|
+
return host;
|
|
41
|
+
if (AMBIENCE_DEFAULT_GROUP_LABELS[group])
|
|
42
|
+
return AMBIENCE_DEFAULT_GROUP_LABELS[group];
|
|
43
|
+
return group ? group.charAt(0).toUpperCase() + group.slice(1) : 'Other';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Group items into menu sections: host order (or the default order),
|
|
47
|
+
* registration order within a section, empty sections omitted, unlisted
|
|
48
|
+
* groups appended in first-seen order.
|
|
49
|
+
*/
|
|
50
|
+
export function groupAmbienceItems(items, groups) {
|
|
51
|
+
const defs = normalizeAmbienceGroups(groups);
|
|
52
|
+
const order = defs.length ? defs.map((d) => d.id) : AMBIENCE_DEFAULT_GROUP_ORDER;
|
|
53
|
+
const byGroup = new Map();
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const list = byGroup.get(item.group) ?? [];
|
|
56
|
+
list.push(item);
|
|
57
|
+
byGroup.set(item.group, list);
|
|
58
|
+
}
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const g of order) {
|
|
61
|
+
const list = byGroup.get(g);
|
|
62
|
+
if (!list?.length)
|
|
63
|
+
continue;
|
|
64
|
+
out.push({ group: g, label: ambienceGroupLabel(g, defs), scapes: list });
|
|
65
|
+
byGroup.delete(g);
|
|
66
|
+
}
|
|
67
|
+
for (const [g, list] of byGroup) {
|
|
68
|
+
out.push({ group: g, label: ambienceGroupLabel(g, defs), scapes: list });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** Build a radio-station link item (group 'radio' unless the host says otherwise). */
|
|
73
|
+
export function radioStation(options) {
|
|
74
|
+
return {
|
|
75
|
+
kind: 'link',
|
|
76
|
+
id: options.id,
|
|
77
|
+
label: options.label,
|
|
78
|
+
group: options.group ?? 'radio',
|
|
79
|
+
href: options.href,
|
|
80
|
+
description: options.description
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The href the toggle may open: absolute http(s) only. A `javascript:` or
|
|
85
|
+
* `data:` href from a host's station list would run in the page, so it is
|
|
86
|
+
* refused (null) and the item renders disabled.
|
|
87
|
+
*/
|
|
88
|
+
export function safeAmbienceHref(href) {
|
|
89
|
+
if (typeof href !== 'string')
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const u = new URL(href.trim());
|
|
93
|
+
return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/ambience/index.d.ts
CHANGED
|
@@ -15,9 +15,11 @@ export { hibiscusScape } from './scapes/hibiscus';
|
|
|
15
15
|
export { focusScape } from './scapes/focus';
|
|
16
16
|
export { noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape } from './scapes/noise';
|
|
17
17
|
export { rain, waves } from './scapes/nature';
|
|
18
|
-
export { createPlaylistScape } from './scapes/playlist';
|
|
18
|
+
export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
|
|
19
19
|
export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './scapes/playlist';
|
|
20
20
|
export { BreathClock, BREATHS_PER_MINUTE } from './breath';
|
|
21
21
|
export { MASTER } from './master';
|
|
22
22
|
export { ScapeRegistry } from './registry';
|
|
23
|
-
export
|
|
23
|
+
export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './groups';
|
|
24
|
+
export type { AmbienceSection } from './groups';
|
|
25
|
+
export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './types';
|
package/dist/ambience/index.js
CHANGED
|
@@ -21,7 +21,9 @@ export { rain, waves } from './scapes/nature';
|
|
|
21
21
|
// recordings. NOTE: never auto-registered in DEFAULT_SCAPES — a playlist is
|
|
22
22
|
// per-host (its manifest URL and licensing claims are the host's); build one
|
|
23
23
|
// with createPlaylistScape() and engine.register() it yourself.
|
|
24
|
-
export { createPlaylistScape } from './scapes/playlist';
|
|
24
|
+
export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
|
|
25
25
|
export { BreathClock, BREATHS_PER_MINUTE } from './breath';
|
|
26
26
|
export { MASTER } from './master';
|
|
27
27
|
export { ScapeRegistry } from './registry';
|
|
28
|
+
// Menu sections + outbound radio links (jatui-mdq2p).
|
|
29
|
+
export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './groups';
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
* The scape registry — a named map with duplicate-id protection. list()
|
|
3
|
-
* returns registration order (stable for menus).
|
|
4
|
-
*/
|
|
5
|
-
import type { Scape } from './types';
|
|
1
|
+
import type { AmbienceItem, AmbienceLink, Scape } from './types';
|
|
6
2
|
export declare class ScapeRegistry {
|
|
7
3
|
#private;
|
|
8
|
-
register(
|
|
4
|
+
register(item: Scape | AmbienceLink): void;
|
|
5
|
+
/** The playable scape with this id (undefined for a link or an unknown id). */
|
|
9
6
|
get(id: string): Scape | undefined;
|
|
7
|
+
/** Any registered item with this id. */
|
|
8
|
+
item(id: string): AmbienceItem | undefined;
|
|
10
9
|
list(): Scape[];
|
|
10
|
+
items(): AmbienceItem[];
|
|
11
11
|
}
|
|
@@ -1,16 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scape registry — a named map with duplicate-id protection. Holds
|
|
3
|
+
* playable scapes and outbound link items (radio stations) in ONE id space.
|
|
4
|
+
* list() returns the playable scapes and items() everything, both in
|
|
5
|
+
* registration order (stable for menus).
|
|
6
|
+
*/
|
|
7
|
+
import { isAmbienceLink } from './groups';
|
|
1
8
|
export class ScapeRegistry {
|
|
2
|
-
#
|
|
3
|
-
register(
|
|
4
|
-
if (!
|
|
9
|
+
#items = new Map();
|
|
10
|
+
register(item) {
|
|
11
|
+
if (!item?.id)
|
|
5
12
|
throw new Error('Scape must have an id');
|
|
6
|
-
if (this.#
|
|
7
|
-
throw new Error(`Scape id already registered: ${
|
|
8
|
-
|
|
13
|
+
if (this.#items.has(item.id))
|
|
14
|
+
throw new Error(`Scape id already registered: ${item.id}`);
|
|
15
|
+
if (isAmbienceLink(item)) {
|
|
16
|
+
if (typeof item.href !== 'string' || !item.href.trim())
|
|
17
|
+
throw new Error(`Link item needs an href: ${item.id}`);
|
|
18
|
+
}
|
|
19
|
+
else if (typeof item.build !== 'function') {
|
|
20
|
+
throw new Error(`Scape needs a build(): ${item.id}`);
|
|
21
|
+
}
|
|
22
|
+
this.#items.set(item.id, item);
|
|
9
23
|
}
|
|
24
|
+
/** The playable scape with this id (undefined for a link or an unknown id). */
|
|
10
25
|
get(id) {
|
|
11
|
-
|
|
26
|
+
const item = this.#items.get(id);
|
|
27
|
+
return item && !isAmbienceLink(item) ? item : undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Any registered item with this id. */
|
|
30
|
+
item(id) {
|
|
31
|
+
return this.#items.get(id);
|
|
12
32
|
}
|
|
13
33
|
list() {
|
|
14
|
-
return [...this.#
|
|
34
|
+
return [...this.#items.values()].filter((i) => !isAmbienceLink(i));
|
|
35
|
+
}
|
|
36
|
+
items() {
|
|
37
|
+
return [...this.#items.values()];
|
|
15
38
|
}
|
|
16
39
|
}
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* Nothing is registered globally; the integration task wires the default
|
|
37
37
|
* registry (jatui-poq30).
|
|
38
38
|
*/
|
|
39
|
-
import type { Scape } from '../types';
|
|
39
|
+
import type { Scape, ScapeGroup } from '../types';
|
|
40
40
|
export declare const PLAYLIST: Readonly<{
|
|
41
41
|
/** Crossfade between consecutive tracks (s). */
|
|
42
42
|
CROSSFADE: 3;
|
|
@@ -91,6 +91,9 @@ export interface PlaylistScapeOptions {
|
|
|
91
91
|
id: string;
|
|
92
92
|
label: string;
|
|
93
93
|
description?: string;
|
|
94
|
+
/** The menu section this playlist sits in (e.g. 'focus', 'meditation',
|
|
95
|
+
* or a host group). Default 'music'. */
|
|
96
|
+
group?: ScapeGroup;
|
|
94
97
|
/** Manifest URL — fetched lazily, ONCE, on the first play. A failed fetch
|
|
95
98
|
* is retried on the next play. */
|
|
96
99
|
manifestUrl?: string;
|
|
@@ -108,10 +111,13 @@ export interface PlaylistScapeOptions {
|
|
|
108
111
|
/** Injectable for tests (default `new Audio(url)`). */
|
|
109
112
|
createMediaElement?: (url: string) => HTMLAudioElement;
|
|
110
113
|
}
|
|
111
|
-
/** A Scape plus the playlist UI surface.
|
|
114
|
+
/** A Scape plus the playlist UI surface. Narrow a registered Scape with
|
|
115
|
+
* isPlaylistScape() in
|
|
112
116
|
* the toggle to reach state()/subscribe()/skip()/setShuffle(). */
|
|
113
117
|
export interface PlaylistScape extends Scape {
|
|
114
|
-
|
|
118
|
+
/** Discriminates a playlist from other scapes (the toggle shows the
|
|
119
|
+
* playing track's attribution for these). */
|
|
120
|
+
readonly playlist: true;
|
|
115
121
|
/** Current snapshot (also the initial value subscribe() fires with). */
|
|
116
122
|
state(): PlaylistState;
|
|
117
123
|
/** Subscribe to playlist state changes; fires immediately. */
|
|
@@ -127,3 +133,10 @@ export declare function createPlaylistScape(options: PlaylistScapeOptions): Play
|
|
|
127
133
|
/** Fisher–Yates over an injected rand (the engine's per-scape PRNG), so the
|
|
128
134
|
* play order is deterministic for tests. */
|
|
129
135
|
export declare function shuffledOrder(n: number, rand: () => number): number[];
|
|
136
|
+
/** Narrow a registered scape to a playlist (to reach its state/subscribe). */
|
|
137
|
+
export declare function isPlaylistScape(scape: unknown): scape is PlaylistScape;
|
|
138
|
+
/**
|
|
139
|
+
* The attribution line for a track: "Composer · Performer · License"
|
|
140
|
+
* (performer only when present and different from the composer).
|
|
141
|
+
*/
|
|
142
|
+
export declare function trackAttribution(track: PlaylistTrack | null | undefined): string;
|
|
@@ -489,8 +489,9 @@ export function createPlaylistScape(options) {
|
|
|
489
489
|
const scape = {
|
|
490
490
|
id: options.id,
|
|
491
491
|
label: options.label,
|
|
492
|
-
group: 'music',
|
|
492
|
+
group: options.group ?? 'music',
|
|
493
493
|
description: options.description,
|
|
494
|
+
playlist: true,
|
|
494
495
|
build(scapeCtx) {
|
|
495
496
|
const inst = buildInstance(scapeCtx);
|
|
496
497
|
latest = inst;
|
|
@@ -567,3 +568,23 @@ function isValidTrack(t) {
|
|
|
567
568
|
(o.sourceUrl === undefined ||
|
|
568
569
|
(typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')));
|
|
569
570
|
}
|
|
571
|
+
/** Narrow a registered scape to a playlist (to reach its state/subscribe). */
|
|
572
|
+
export function isPlaylistScape(scape) {
|
|
573
|
+
return (typeof scape === 'object' &&
|
|
574
|
+
scape !== null &&
|
|
575
|
+
scape.playlist === true &&
|
|
576
|
+
typeof scape.subscribe === 'function');
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The attribution line for a track: "Composer · Performer · License"
|
|
580
|
+
* (performer only when present and different from the composer).
|
|
581
|
+
*/
|
|
582
|
+
export function trackAttribution(track) {
|
|
583
|
+
if (!track)
|
|
584
|
+
return '';
|
|
585
|
+
const parts = [track.composer];
|
|
586
|
+
if (track.performer && track.performer !== track.composer)
|
|
587
|
+
parts.push(track.performer);
|
|
588
|
+
parts.push(track.license);
|
|
589
|
+
return parts.filter(Boolean).join(' · ');
|
|
590
|
+
}
|
package/dist/ambience/types.d.ts
CHANGED
|
@@ -28,8 +28,38 @@ export interface ScapeInstance {
|
|
|
28
28
|
/** Optional live parameter (scape-defined keys). */
|
|
29
29
|
setParam?(key: string, value: number): void;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A menu section. The named members are the library's own groups (radio holds
|
|
33
|
+
* outbound station links); any other string is a host-defined group — it
|
|
34
|
+
* renders after the configured ones, labelled from its id unless the host
|
|
35
|
+
* labels it via `AmbienceOptions.groups`.
|
|
36
|
+
*/
|
|
37
|
+
export type ScapeGroup = 'radio' | 'focus' | 'noise' | 'meditation' | 'nature' | 'music' | (string & {});
|
|
38
|
+
/** A host's menu section: its id plus the label the toggle shows. */
|
|
39
|
+
export interface AmbienceGroupDef {
|
|
40
|
+
id: ScapeGroup;
|
|
41
|
+
label?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* An outbound link item (radio stations). The engine never plays it: the
|
|
45
|
+
* toggle opens `href` — the station's own site or player — in a new tab. We
|
|
46
|
+
* do NOT relay third-party streams (licensing), so a station is a link, not a
|
|
47
|
+
* scape.
|
|
48
|
+
*/
|
|
49
|
+
export interface AmbienceLink {
|
|
50
|
+
kind: 'link';
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
group: ScapeGroup;
|
|
54
|
+
/** The station's own page or web player. Opened in a new tab. */
|
|
55
|
+
href: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
}
|
|
58
|
+
/** Anything the menu lists: a playable scape or an outbound link. */
|
|
59
|
+
export type AmbienceItem = Scape | AmbienceLink;
|
|
32
60
|
export interface Scape {
|
|
61
|
+
/** Absent (or 'scape') on every playable scape; links carry 'link'. */
|
|
62
|
+
kind?: 'scape';
|
|
33
63
|
id: string;
|
|
34
64
|
label: string;
|
|
35
65
|
group: ScapeGroup;
|
|
@@ -53,10 +83,16 @@ export interface BreathState {
|
|
|
53
83
|
rising: boolean;
|
|
54
84
|
}
|
|
55
85
|
export interface Ambience {
|
|
56
|
-
|
|
86
|
+
/** Register a playable scape or an outbound link (ids are unique across both). */
|
|
87
|
+
register(item: Scape | AmbienceLink): void;
|
|
88
|
+
/** The playable scapes, registration order (links excluded). */
|
|
57
89
|
list(): Scape[];
|
|
90
|
+
/** Every registered item — scapes AND links — in registration order. */
|
|
91
|
+
items(): AmbienceItem[];
|
|
92
|
+
/** The host's menu sections in order (`AmbienceOptions.groups`), or [] for the default order. */
|
|
93
|
+
groups(): AmbienceGroupDef[];
|
|
58
94
|
/** MUST be called from a user gesture (a click/keydown handler). Rejects
|
|
59
|
-
* otherwise. Resolves once the scape is scheduled and audibly started. */
|
|
95
|
+
* otherwise, and for an unknown id or a link item. Resolves once the scape is scheduled and audibly started. */
|
|
60
96
|
play(id: string): Promise<void>;
|
|
61
97
|
/** Fade out ~0.55 s; the AudioContext closes ~3 s later. A play() inside
|
|
62
98
|
* that window cancels the close and fades back in over ~4 s. */
|
|
@@ -70,6 +106,14 @@ export interface Ambience {
|
|
|
70
106
|
}
|
|
71
107
|
export interface AmbienceOptions {
|
|
72
108
|
storageKey?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Menu sections in the order the toggle shows them, each optionally
|
|
111
|
+
* labelled (`'radio'` or `{ id: 'radio', label: 'Stations' }`). Groups
|
|
112
|
+
* holding items but missing here still render, after these — a menu never
|
|
113
|
+
* silently drops a registered item. Omitted: the default order
|
|
114
|
+
* radio / focus / noise / meditation / nature / music.
|
|
115
|
+
*/
|
|
116
|
+
groups?: Array<ScapeGroup | AmbienceGroupDef>;
|
|
73
117
|
defaultVolume?: number;
|
|
74
118
|
/** Gesture probe, injectable for tests. Default: navigator.userActivation
|
|
75
119
|
* when it exists (trusted-UI truth), otherwise permissive (SSR/node).
|