@joewinke/jatui 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -59,7 +59,7 @@ Then import:
59
59
  | `TimeSlotPicker` | Slots grouped morning/afternoon/evening as one `role="radiogroup"` (`role="radio"` + `aria-checked`, full spoken date+time): roving arrows skip unavailable slots, Up/Down jump a visual row, Home/End, Space/Enter choose. Times and grouping follow `timeZone`'s wall clock. | `slots: TimeSlot[]`, `onSelect`, `selectedSlot`, `loading`, `accentColor`, `hourCycle?` ('h12' \| 'h23'), `timeZone?` (IANA), `locale?`, `selectOnFocus?`, `timezone?` (snippet row above the slots) |
60
60
  | `BookingScheduler` | Calendly-shaped composition over `CalendarPicker` + `TimeSlotPicker`: details pane (host, duration, location, zone), time-zone select + 12h/24h toggle, slot list, Confirm. Container-query layout (3 panes → 2 → 1). Host supplies data; nothing is booked here. | `getSlots(date, timeZone)` (sync/async, stale responses dropped), `getAvailability?`, `selectedDate`/`selectedSlot`/`timeZone`/`hourCycle` (bindable), `title`, `durationMinutes`, `hostName`, `location`, `description`, `details?` (snippet), `onConfirm(slot, {timeZone})`, `confirming` |
61
61
  | `EventCalendar` | Month / week / day / agenda event calendar, engine upstreamed from MOS `CalendarGrid`: one lane allocator per month row (`packWeek`/`computeVisibility`, measured lane capacity), "+N more" expands the week IN PLACE, week/day time grid with side-by-side overlap layout (`layoutDay`) + all-day bar row + now-line, agenda list, month-switcher pill (the shared month panel), details popover snippet, HTML5 drag-to-move, drag- or Shift+arrow range select. Event colours from `--jatui-event-N{,-bg,-fg}`. Generic over your event type. | `events: CalendarEventItem[]` (`{id,title,start,end?,allDay?,color?: 1-6 \| semantic,…}`), `view`/`date` (bindable), `views`, `weekStart`, `locale`, `hourCycle`, `dayStartHour`/`dayEndHour`, `hourHeight`, `height`, `eventDetails?`/`eventContent?`/`toolbarEnd?` (snippets), `moreAction`, `onEventClick`, `onDayClick`, `onCreate(date, hour?)`, `onEventMove(ev, {date, hour?, allDay})`, `onRangeSelect`, `onRangeChange(range, view)` |
62
- | `CalendarMonthGrid` | The shared month panel every picker uses — exported for custom surfaces. `<table role="grid">`, roving tabindex, range band that bridges cell gaps and rounds at week edges, disabled days stay focusable (`aria-disabled`), `day` snippet + `describeDay` for extra per-day content/name. Pair with `createCalendarGrid()`. | `month`, `focusedDate`, `onFocusDate`, `onSelect`, `selected?`, `range?`, `isDisabled?`, `min`/`max`, `weekStart`, `locale`, `showOutsideDays`, `fixedWeeks`, `size`, `onHover?`, `day?`, `describeDay?` |
62
+ | `CalendarMonthGrid` | The shared month panel every picker uses — exported for custom surfaces. `<table role="grid">`, roving tabindex, range band that bridges cell gaps and rounds at week edges, disabled days stay focusable (`aria-disabled`), `day` snippet + `describeDay` for extra per-day content/name. Pair with `createCalendarGrid()`. | `month`, `focusedDate`, `onFocusDate`, `onSelect`, `selected?`, `range?`, `isDisabled?`, `min`/`max`, `weekStart`, `locale`, `showOutsideDays`, `fixedWeeks`, `size`, `fill?` (true; pass `false` in a `max-content` popover or other shrink-to-fit host — a filling table's max-content is unbounded in Chrome), `onHover?`, `day?`, `describeDay?` |
63
63
  | `AvailabilityModal` | Date override editor (modal) | `date`, `overrides`, `onSave`, `onClose` |
64
64
  | `BookingForm` | Guest info form for booking | `onSubmit`, `submitting`, `requirePhone`, `accentColor`, `showAttendeeOption` |
65
65
  | `CountdownTimer` | Countdown to target date | `targetDate: Date`, `label` |
@@ -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 grouped scape menu with volume; `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"`.
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;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * createDefaultAmbience() — the "just works" engine.
3
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
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.
@@ -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(scape) {
283
- registry.register(scape);
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
+ }
@@ -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 type { Ambience, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './types';
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';
@@ -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(scape: Scape): void;
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
- #scapes = new Map();
3
- register(scape) {
4
- if (!scape?.id)
9
+ #items = new Map();
10
+ register(item) {
11
+ if (!item?.id)
5
12
  throw new Error('Scape must have an id');
6
- if (this.#scapes.has(scape.id))
7
- throw new Error(`Scape id already registered: ${scape.id}`);
8
- this.#scapes.set(scape.id, scape);
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
- return this.#scapes.get(id);
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.#scapes.values()];
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. Cast a registered Scape to this in
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
- group: 'music';
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
+ }
@@ -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
- export type ScapeGroup = 'meditation' | 'focus' | 'noise' | 'nature' | 'music';
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
- register(scape: Scape): void;
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).