@joewinke/jatui 0.57.0 → 0.59.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
@@ -83,7 +83,7 @@ Then import:
83
83
  | `StatGroup` | Lays out StatCards: `cards` (responsive auto-fit grid) or `joined` (one bordered panel with hairline dividers) — the replacement for raw DaisyUI `stats` markup. | `columns?`, `minWidth?` ('12rem'), `variant?`, `ariaLabel?`, `children` |
84
84
  | `ChartCard` | "Headline + period switch + chart" shell that draws NO chart (bring layerchart / d3 / SVG). The `chart` snippet gets `{ period, hover, hovered }`; call `hover({value, label, delta?})` as the pointer moves and the headline tweens to that point and the caption shows its label; `hover(null)` (or pointer leave) restores the total. Periods use SegmentedControl. | `label`, `value`, `format?`, `delta?`, `invertDelta?`, `caption?`, `periods?`, `period` (bindable), `onPeriodChange?`, `chartHeight?`, `loading?`, `chart` (snippet), `actions?`/`footer?` (snippets) |
85
85
  | `BarList` | Ranked horizontal bars ("top pages / top callers"): label + value as text, a decorative token-tinted bar sized against the max, optional `href` or `onSelect` per row, `hint`, per-row `tone`, `limit` + "Show N more". Pure HTML/CSS, no chart lib. | `items: BarListItem[]`, `sort?` (`desc`), `limit?`, `format?`, `formatValue?(item, total)`, `labelHeader?`/`valueHeader?`, `tone?`, `size?`, `onSelect?`, `label?` (snippet) |
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?` |
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. `fill` (default `true`) sizes the cells to the container — pure CSS (`container-type: inline-size` + cqi), clamped 8–20px (`--hm-cell-min`/`--hm-cell-max`), scrolling only below the floor and the legend parked under the grid's right edge; `fill={false}` is the legacy fixed 12px grid (and the one to pass in a shrink-to-fit host, where an inline-size container collapses). 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?`, `fill?` (true), `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
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?` |
@@ -6,5 +6,8 @@ export interface DefaultAmbienceOptions {
6
6
  storageKey?: string;
7
7
  /** Menu section order/labels, passed through to `createAmbience`. */
8
8
  groups?: AmbienceOptions['groups'];
9
+ /** Cross-tab shared player (jatui-lm1mf): one tab plays, every other tab
10
+ * is a remote showing the same live state. */
11
+ shared?: boolean;
9
12
  }
10
13
  export declare function createDefaultAmbience(options?: DefaultAmbienceOptions): Ambience;
@@ -28,7 +28,11 @@ export const DEFAULT_SCAPES = Object.freeze([
28
28
  waves
29
29
  ]);
30
30
  export function createDefaultAmbience(options = {}) {
31
- const ambience = createAmbience(options);
31
+ const ambience = createAmbience({
32
+ storageKey: options.storageKey,
33
+ groups: options.groups,
34
+ shared: options.shared
35
+ });
32
36
  for (const scape of DEFAULT_SCAPES)
33
37
  ambience.register(scape);
34
38
  return ambience;
@@ -9,7 +9,9 @@
9
9
  * // inside a click handler:
10
10
  * await ambience.play('rain');
11
11
  */
12
- export { createAmbience, TIMING } from './engine';
12
+ export { createAmbience, createAmbienceLocal } from './shared';
13
+ export { TIMING } from './engine';
14
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager } from './shared';
13
15
  export { createDefaultAmbience, DEFAULT_SCAPES, type DefaultAmbienceOptions } from './default';
14
16
  export { hibiscusScape } from './scapes/hibiscus';
15
17
  export { focusScape } from './scapes/focus';
@@ -9,7 +9,11 @@
9
9
  * // inside a click handler:
10
10
  * await ambience.play('rain');
11
11
  */
12
- export { createAmbience, TIMING } from './engine';
12
+ // createAmbience() is the shared-aware factory (jatui-lm1mf): identical to
13
+ // the plain engine until a host passes { shared: true }.
14
+ export { createAmbience, createAmbienceLocal } from './shared';
15
+ export { TIMING } from './engine';
16
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING } from './shared';
13
17
  export { createDefaultAmbience, DEFAULT_SCAPES } from './default';
14
18
  // The shipped scapes — hosts that want a hand-picked registry import them
15
19
  // individually instead of using createDefaultAmbience().
@@ -6,6 +6,8 @@
6
6
  export interface PersistedAmbience {
7
7
  volume?: number;
8
8
  lastScape?: string;
9
+ /** Cross-tab mute (jatui-lm1mf) — shared by every tab of the origin. */
10
+ muted?: boolean;
9
11
  }
10
12
  export declare function loadPersisted(key: string): PersistedAmbience;
11
13
  export declare function savePersisted(key: string, value: PersistedAmbience): void;
@@ -126,6 +126,10 @@ export interface PlaylistScape extends Scape {
126
126
  skip(): void;
127
127
  /** Toggle shuffle; the current track stays, the rest reorder at once. */
128
128
  setShuffle(on: boolean): void;
129
+ /** Crossfade to an absolute track index — used on leadership handover
130
+ * (jatui-lm1mf) so the new leader tab resumes the position the old one
131
+ * left. No-op while not playing. */
132
+ startAt(index: number): void;
129
133
  /** The resolved track list, or null until the manifest resolved. */
130
134
  tracks(): PlaylistTrack[] | null;
131
135
  }
@@ -461,6 +461,15 @@ export function createPlaylistScape(options) {
461
461
  if (ni !== -1)
462
462
  startTrack(ni, 'cross');
463
463
  }
464
+ /** Leadership handover (jatui-lm1mf): crossfade to an absolute track
465
+ * index so the new leader tab picks up the position the old one left. */
466
+ function startAtInternal(index) {
467
+ if (released || !started || !resolved || order.length === 0)
468
+ return;
469
+ const ni = plainOrder(resolved.length).indexOf(Math.trunc(index));
470
+ if (ni !== -1 && ni !== pos)
471
+ startTrack(ni, 'cross');
472
+ }
464
473
  /** setShuffle mid-play: with shuffle the rest reorder (current first);
465
474
  * without, the natural order resumes from the current track. */
466
475
  function reshuffle() {
@@ -482,6 +491,7 @@ export function createPlaylistScape(options) {
482
491
  return {
483
492
  api: { start, stop, setParam },
484
493
  skip: skipInternal,
494
+ startAt: startAtInternal,
485
495
  reshuffle,
486
496
  isReleased: () => released
487
497
  };
@@ -514,6 +524,10 @@ export function createPlaylistScape(options) {
514
524
  if (latest && !latest.isReleased())
515
525
  latest.reshuffle();
516
526
  },
527
+ startAt(index) {
528
+ if (latest && !latest.isReleased())
529
+ latest.startAt(index);
530
+ },
517
531
  tracks: () => (resolved ? [...resolved] : null)
518
532
  };
519
533
  return scape;
@@ -0,0 +1,97 @@
1
+ import type { Ambience, AmbienceOptions } from './types';
2
+ /** BroadcastChannel name — the ambience control channel for the origin. */
3
+ export declare const AMBIENCE_CHANNEL = "jatui:ambience";
4
+ /** Web Locks lock name — holding it IS being the leader. */
5
+ export declare const AMBIENCE_LOCK = "jatui:ambience:leader";
6
+ export declare const SHARED_TIMING: Readonly<{
7
+ /** Leader heartbeat (ms) — also the fallback-election liveness proof. */
8
+ HEARTBEAT_MS: 1000;
9
+ /** A leader silent this long (ms) counts as gone. */
10
+ STALE_MS: 3000;
11
+ /** After broadcasting `claim`, wait this long (ms) for a live leader's
12
+ * heartbeat reassert before taking over. */
13
+ CLAIM_GUARD_MS: 300;
14
+ /** A remote play command waits this long (ms) for the leader's echo. */
15
+ REMOTE_PLAY_TIMEOUT_MS: 2500;
16
+ }>;
17
+ /** The live cross-tab state every tab renders. */
18
+ export interface SharedAmbienceState {
19
+ /** Currently playing (or preselected) scape id, or null. */
20
+ currentId: string | null;
21
+ /** True while the leader is sounding. */
22
+ playing: boolean;
23
+ /** The user's volume (0..1) — independent of mute. */
24
+ volume: number;
25
+ /** True while muted (engine runs at 0 volume; the slider keeps its value). */
26
+ muted: boolean;
27
+ /** Who owns the AudioContext right now, if known. */
28
+ role: 'leader' | 'remote' | 'unknown';
29
+ /** The leader tab's id (channel-internal, for `gone` bookkeeping). */
30
+ leaderTabId: string | null;
31
+ /** True once the leader tab is gone (pagehide or heartbeat silence):
32
+ * playback has stopped; play in THIS tab claims leadership. */
33
+ leaderGone: boolean;
34
+ /** The playing track's attribution line ("Title — Composer · License"),
35
+ * published by the leader; null when unknown or nothing is playing. */
36
+ nowPlaying: string | null;
37
+ /** Position of the published now-playing track in its playlist (0-based),
38
+ * for leadership handover of playlist scapes. */
39
+ trackIndex: number | null;
40
+ }
41
+ /** An Ambience whose playback is shared across tabs (see module doc). */
42
+ export interface SharedAmbience extends Ambience {
43
+ /** The full cross-tab state (the plain `state` getter is its Ambience subset). */
44
+ get sharedState(): SharedAmbienceState;
45
+ /** Subscribe to the full cross-tab state; fires immediately. */
46
+ subscribeShared(fn: (s: SharedAmbienceState) => void): () => void;
47
+ /** Mute without losing the volume value (persisted with it). */
48
+ setMuted(muted: boolean): void;
49
+ toggleMute(): void;
50
+ /** Leader-side hook: the host publishes the playing track's attribution
51
+ * (and playlist position) so remote tabs show it too. */
52
+ setNowPlaying(line: string | null, trackIndex?: number | null): void;
53
+ /** Release the lock, stop the heartbeat and close the channel. The local
54
+ * engine is killed if this tab was the leader. */
55
+ destroy(): void;
56
+ }
57
+ export interface SharedAmbienceOptions extends AmbienceOptions {
58
+ /** Override the channel name (tests, multi-host pages). */
59
+ channelName?: string;
60
+ /** Override the lock name (tests). */
61
+ lockName?: string;
62
+ /** Injectable channel constructor for tests (default: BroadcastChannel). */
63
+ createChannel?: (name: string) => SharedChannel | null;
64
+ /** Injectable Web Locks surface for tests (default: navigator.locks). */
65
+ locks?: SharedLockManager | null;
66
+ /** Injectable tab id for tests (default: random). */
67
+ tabId?: string;
68
+ heartbeatMs?: number;
69
+ staleMs?: number;
70
+ }
71
+ /** The slice of BroadcastChannel the engine needs. */
72
+ export interface SharedChannel {
73
+ postMessage(message: unknown): void;
74
+ onmessage: ((event: {
75
+ data: unknown;
76
+ }) => void) | null;
77
+ close(): void;
78
+ }
79
+ /** The slice of the Web Locks API the engine needs. */
80
+ export interface SharedLockManager {
81
+ request(name: string, options: {
82
+ mode?: 'shared' | 'exclusive';
83
+ ifAvailable?: boolean;
84
+ steal?: boolean;
85
+ }, callback: (lock: unknown) => unknown | Promise<unknown>): Promise<unknown>;
86
+ }
87
+ /**
88
+ * Build a shared (cross-tab) ambience engine. Every tab runs one; only the
89
+ * leader's local engine ever sounds. See the module doc for the protocol.
90
+ */
91
+ export declare function createSharedAmbience(options?: SharedAmbienceOptions): SharedAmbience;
92
+ /** createAmbience() with the `shared` opt-in (jatui-lm1mf): identical to the
93
+ * plain engine until a host passes `{ shared: true }`, which makes playback
94
+ * cross-tab — one leader, all other tabs remotes. Hosts keep the exact
95
+ * Ambience interface (AmbienceToggle needs no changes beyond reading the
96
+ * shared now-playing line). */
97
+ export declare function createAmbience(options?: AmbienceOptions): Ambience;
@@ -0,0 +1,642 @@
1
+ /**
2
+ * The cross-tab shared player (jatui-lm1mf): exactly ONE tab owns the
3
+ * AudioContext and plays; every other tab is a REMOTE that renders the same
4
+ * live state and sends commands. Open MOS in ten tabs and the tab playing
5
+ * ambience is no longer unfindable — any tab's rail toggle shows and controls
6
+ * the one that's sounding.
7
+ *
8
+ * Leader election:
9
+ * - Web Locks API (`navigator.locks.request(lockName)`) when available —
10
+ * the lock IS the leadership, so the browser guarantees mutual exclusion.
11
+ * - Fallback (no Web Locks): a BroadcastChannel heartbeat. The leader
12
+ * heartbeats every HEARTBEAT_MS; a tab that hears no beat for STALE_MS
13
+ * may claim, guarded by a short window in which a live leader reasserts.
14
+ *
15
+ * Transport (always BroadcastChannel 'jatui:ambience'):
16
+ * - `state` / `heartbeat` — the leader publishes the live shared state
17
+ * (playing scape, volume, muted, track attribution, its tab id).
18
+ * - `cmd` — a remote tells the leader to stop / setVolume / toggleMute /
19
+ * play (play only when the fallback election defers to a live leader).
20
+ * - `want` / `claim` — the heartbeat election.
21
+ * - `gone` — the leader's pagehide: remotes immediately mark the leader
22
+ * gone (playback stops — never a surprise autoplay in a background tab).
23
+ *
24
+ * Starting a scape in tab B while tab A plays makes B the leader: with
25
+ * Web Locks, B's user-gesture play STEALS the lock, A's held request promise
26
+ * rejects (AbortError) and A fades out — never two tabs audible. Playlist
27
+ * scapes hand over their track position (published by the leader, applied
28
+ * by the new leader via PlaylistScape.startAt).
29
+ *
30
+ * Resuming after the leader tab closes/reloads: playback is stopped and no
31
+ * remote autoplays; pressing play (the toggle's existing button — a user
32
+ * gesture, satisfying the autoplay policy) claims leadership and plays.
33
+ *
34
+ * Volume and mute persist in localStorage (shared by all tabs of the
35
+ * origin). The `storage` event is deliberately NOT the transport — it skips
36
+ * the writer tab; BroadcastChannel delivers to every OTHER tab.
37
+ *
38
+ * NO UI here. Hosts get everything through createAmbience({ shared: true }) —
39
+ * the Ambience interface is unchanged, so AmbienceToggle needs no structural
40
+ * changes beyond reading the shared now-playing line.
41
+ */
42
+ import { createAmbience as createLocalEngine } from './engine';
43
+ import { isPlaylistScape } from './scapes/playlist';
44
+ import { loadPersisted, savePersisted } from './persist';
45
+ /** BroadcastChannel name — the ambience control channel for the origin. */
46
+ export const AMBIENCE_CHANNEL = 'jatui:ambience';
47
+ /** Web Locks lock name — holding it IS being the leader. */
48
+ export const AMBIENCE_LOCK = 'jatui:ambience:leader';
49
+ export const SHARED_TIMING = Object.freeze({
50
+ /** Leader heartbeat (ms) — also the fallback-election liveness proof. */
51
+ HEARTBEAT_MS: 1000,
52
+ /** A leader silent this long (ms) counts as gone. */
53
+ STALE_MS: 3000,
54
+ /** After broadcasting `claim`, wait this long (ms) for a live leader's
55
+ * heartbeat reassert before taking over. */
56
+ CLAIM_GUARD_MS: 300,
57
+ /** A remote play command waits this long (ms) for the leader's echo. */
58
+ REMOTE_PLAY_TIMEOUT_MS: 2500
59
+ });
60
+ function randomTabId() {
61
+ try {
62
+ return `tab-${crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
63
+ }
64
+ catch {
65
+ return `tab-${Math.random().toString(36).slice(2)}`;
66
+ }
67
+ }
68
+ /** The browser truth for a local user gesture (mirrors engine.ts default). */
69
+ function defaultGesture() {
70
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
71
+ return nav?.userActivation ? nav.userActivation.isActive : true;
72
+ }
73
+ /**
74
+ * Build a shared (cross-tab) ambience engine. Every tab runs one; only the
75
+ * leader's local engine ever sounds. See the module doc for the protocol.
76
+ */
77
+ export function createSharedAmbience(options = {}) {
78
+ const channelName = options.channelName ?? AMBIENCE_CHANNEL;
79
+ const lockName = options.lockName ?? AMBIENCE_LOCK;
80
+ const tabId = options.tabId ?? randomTabId();
81
+ const heartbeatMs = options.heartbeatMs ?? SHARED_TIMING.HEARTBEAT_MS;
82
+ const staleMs = options.staleMs ?? SHARED_TIMING.STALE_MS;
83
+ const storageKey = options.storageKey ?? 'jatui:ambience';
84
+ // A remote command carries the ORIGINATING tab's gesture, not this one's —
85
+ // the leader must be allowed to apply it (its context, if running, needs
86
+ // no new activation; if it can't start, the play just fails loudly).
87
+ let remoteCommand = false;
88
+ const gestureProbe = () => remoteCommand ? true : options.isGesture ? options.isGesture() : defaultGesture();
89
+ const local = createLocalEngine({ ...options, isGesture: gestureProbe });
90
+ const createChannel = options.createChannel ??
91
+ ((name) => typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(name) : null);
92
+ const locks = 'locks' in options ? (options.locks ?? null) : defaultLocks();
93
+ function defaultLocks() {
94
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
95
+ return nav && typeof nav.locks?.request === 'function' ? nav.locks : null;
96
+ }
97
+ const bc = createChannel(channelName);
98
+ if (bc)
99
+ bc.onmessage = (e) => {
100
+ const msg = e.data;
101
+ if (msg && (msg.t === 'heartbeat' || msg.t === 'state' || msg.t === 'claim'))
102
+ lastBeatTab = msg.from;
103
+ onMsg(msg);
104
+ };
105
+ // --- state ---------------------------------------------------------------
106
+ const persisted = loadPersisted(storageKey);
107
+ /** The user's volume — independent of mute (the engine runs 0 while muted). */
108
+ let userVolume = Math.min(1, Math.max(0, persisted.volume ?? 0.6));
109
+ let muted = persisted.muted ?? false;
110
+ let role = 'unknown';
111
+ let leaderGone = false;
112
+ /** Last state published by a leader (remotes only). */
113
+ let remoteS = null;
114
+ let lastBeatAt = 0;
115
+ /** The leader's now-playing line, published via setNowPlaying. */
116
+ let nowPlaying = null;
117
+ let nowPlayingIndex = null;
118
+ const ambienceSubs = new Set();
119
+ const sharedSubs = new Set();
120
+ function sharedState() {
121
+ const st = local.state;
122
+ return {
123
+ currentId: role === 'leader' ? st.currentId : (remoteS?.currentId ?? st.currentId),
124
+ playing: role === 'leader' ? st.playing : (remoteS?.playing ?? false),
125
+ volume: userVolume,
126
+ muted,
127
+ role,
128
+ leaderTabId: role === 'leader' ? tabId : (remoteS?.leaderTabId ?? null),
129
+ leaderGone: role === 'leader' ? false : leaderGone,
130
+ nowPlaying: role === 'leader' ? nowPlaying : (remoteS?.nowPlaying ?? null),
131
+ trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null)
132
+ };
133
+ }
134
+ function ambienceState() {
135
+ const s = sharedState();
136
+ return { currentId: s.currentId, playing: s.playing && !s.leaderGone, volume: s.volume };
137
+ }
138
+ function emitShared() {
139
+ const s = sharedState();
140
+ for (const fn of sharedSubs)
141
+ fn(s);
142
+ const a = ambienceState();
143
+ for (const fn of ambienceSubs)
144
+ fn(a);
145
+ }
146
+ // --- transport -----------------------------------------------------------
147
+ function post(msg) {
148
+ try {
149
+ bc?.postMessage(msg);
150
+ }
151
+ catch {
152
+ /* a closed/detached channel must not take the engine down */
153
+ }
154
+ }
155
+ function publish() {
156
+ if (role !== 'leader')
157
+ return;
158
+ post({ t: 'state', from: tabId, s: sharedState() });
159
+ }
160
+ // --- leadership ----------------------------------------------------------
161
+ /** Rejects when the lock is stolen (Web Locks) or resolved on resign. */
162
+ let lostReject = null;
163
+ const lost = new Promise((_res, rej) => {
164
+ lostReject = rej;
165
+ });
166
+ lost.catch(() => { }); // never an unhandled rejection
167
+ let heartbeatTimer = null;
168
+ function becomeLeader(how) {
169
+ role = 'leader';
170
+ leaderGone = false;
171
+ remoteS = null;
172
+ // Apply the muted state the (fresh) engine can't know about yet.
173
+ if (muted)
174
+ local.setVolume(0);
175
+ else
176
+ local.setVolume(userVolume);
177
+ publish();
178
+ emitShared();
179
+ if (heartbeatTimer)
180
+ clearInterval(heartbeatTimer);
181
+ heartbeatTimer = setInterval(() => {
182
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
183
+ }, heartbeatMs);
184
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
185
+ }
186
+ function resign(reason) {
187
+ if (role !== 'leader')
188
+ return;
189
+ role = 'remote';
190
+ nowPlaying = null;
191
+ nowPlayingIndex = null;
192
+ remoteS = null;
193
+ if (heartbeatTimer) {
194
+ clearInterval(heartbeatTimer);
195
+ heartbeatTimer = null;
196
+ }
197
+ // The audio must not outlive the leadership — fade out, close as usual.
198
+ local.stop();
199
+ lostReject?.(new Error(`lost leadership: ${reason}`));
200
+ emitShared();
201
+ }
202
+ /** Initial election: hold the lock if it's free, else be a remote. */
203
+ function elect() {
204
+ if (!locks) {
205
+ heartbeatElection();
206
+ return;
207
+ }
208
+ void locks
209
+ .request(lockName, { ifAvailable: true }, (lock) => {
210
+ if (!lock) {
211
+ role = 'remote';
212
+ emitShared();
213
+ return;
214
+ }
215
+ becomeLeader('lock');
216
+ return lost; // hold until stolen or destroyed
217
+ })
218
+ .then(() => {
219
+ // Settled = the lock was released (stolen) or never held.
220
+ if (role === 'leader')
221
+ resign('lock-lost');
222
+ })
223
+ .catch(() => {
224
+ if (role === 'leader')
225
+ resign('lock-lost');
226
+ });
227
+ }
228
+ // --- heartbeat fallback election (no Web Locks) ---------------------------
229
+ /** Deterministic tie-break: the lexicographically SMALLEST tabId wins.
230
+ * On a `want` from a bigger tab we claim at once; from a smaller one we
231
+ * defer and let it win. */
232
+ function smaller(other) {
233
+ return other < tabId;
234
+ }
235
+ function heartbeatElection() {
236
+ post({ t: 'want', from: tabId });
237
+ setTimeout(() => {
238
+ if (role !== 'unknown')
239
+ return; // a leader made itself known in time
240
+ becomeLeader('fallback');
241
+ post({ t: 'claim', from: tabId, s: sharedState() });
242
+ }, SHARED_TIMING.CLAIM_GUARD_MS);
243
+ }
244
+ /** A remote (or a leaderless stale tab) claims leadership — Web Locks path.
245
+ * Steal is always granted; the previous leader's held promise rejects and
246
+ * it fades out inside its own resign(). */
247
+ function claimWithLocks() {
248
+ return new Promise((resolve) => {
249
+ let decided = false;
250
+ const decide = (v) => {
251
+ if (!decided) {
252
+ decided = true;
253
+ resolve(v);
254
+ }
255
+ };
256
+ void locks
257
+ .request(lockName, { steal: true }, (lock) => {
258
+ if (lock) {
259
+ becomeLeader('steal');
260
+ decide(true);
261
+ return lost; // hold until stolen again or destroyed
262
+ }
263
+ decide(false);
264
+ })
265
+ .then(() => {
266
+ if (role === 'leader')
267
+ resign('lock-lost');
268
+ }, () => {
269
+ if (role === 'leader')
270
+ resign('lock-lost');
271
+ });
272
+ // Web Locks grants steal promptly; a missed grant resolves false.
273
+ setTimeout(() => decide(false), 1000);
274
+ });
275
+ }
276
+ /** Claim via the heartbeat election: broadcast `claim`, back off if a live
277
+ * leader reasserts within the guard window (or a SMALLER tab claims — the
278
+ * tie-break), else take over. */
279
+ function claimWithHeartbeat() {
280
+ return new Promise((resolve) => {
281
+ let leaderSeen = false;
282
+ const guard = (e) => {
283
+ if ((e.t === 'heartbeat' || e.t === 'state' || (e.t === 'claim' && smaller(e.from))) &&
284
+ e.from !== tabId)
285
+ leaderSeen = true;
286
+ };
287
+ const prevOnMessage = bc?.onmessage ?? null;
288
+ if (bc)
289
+ bc.onmessage = (e) => guard(e.data);
290
+ post({ t: 'claim', from: tabId, s: sharedState() });
291
+ setTimeout(() => {
292
+ if (bc)
293
+ bc.onmessage = prevOnMessage;
294
+ if (leaderSeen) {
295
+ role = 'remote'; // a live leader asserted itself
296
+ emitShared();
297
+ resolve(false);
298
+ }
299
+ else {
300
+ becomeLeader('fallback');
301
+ post({ t: 'claim', from: tabId, s: sharedState() });
302
+ resolve(true);
303
+ }
304
+ }, SHARED_TIMING.CLAIM_GUARD_MS);
305
+ });
306
+ }
307
+ async function claim() {
308
+ if (role === 'leader')
309
+ return true;
310
+ // A remote whose leader is ALIVE must not steal it (Web Locks steal is
311
+ // unconditional) — deferring is the fallback election's job. With locks,
312
+ // play() in a remote always takes over (task spec: tab B starting a
313
+ // scape becomes the leader).
314
+ if (locks)
315
+ return claimWithLocks();
316
+ if (role === 'remote' && !leaderGone && Date.now() - lastBeatAt < staleMs)
317
+ return false; // live leader — play via command instead
318
+ return claimWithHeartbeat();
319
+ }
320
+ // --- remote play waiter ----------------------------------------------------
321
+ const playWaiters = new Set();
322
+ function waitRemotePlay(id) {
323
+ return new Promise((resolve, reject) => {
324
+ const w = { id, resolve, reject };
325
+ playWaiters.add(w);
326
+ setTimeout(() => {
327
+ if (playWaiters.delete(w))
328
+ reject(new Error(`The tab playing ambience did not start "${id}"`));
329
+ }, SHARED_TIMING.REMOTE_PLAY_TIMEOUT_MS);
330
+ });
331
+ }
332
+ // --- messages --------------------------------------------------------------
333
+ function onMsg(msg) {
334
+ if (!msg || typeof msg !== 'object' || msg.from === tabId)
335
+ return;
336
+ switch (msg.t) {
337
+ case 'state':
338
+ case 'heartbeat': {
339
+ lastBeatAt = Date.now();
340
+ const was = JSON.stringify(remoteS);
341
+ remoteS = msg.s;
342
+ leaderGone = false;
343
+ if (role === 'unknown')
344
+ role = 'remote';
345
+ if (JSON.stringify(remoteS) !== was)
346
+ emitShared();
347
+ break;
348
+ }
349
+ case 'claim': {
350
+ lastBeatAt = Date.now();
351
+ // A live leader reasserts immediately so a racing claim backs off.
352
+ if (role === 'leader') {
353
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
354
+ }
355
+ else if (role === 'unknown') {
356
+ // Tie-break: a claim from a bigger tab loses to ours; from a
357
+ // smaller one it wins — we defer and mirror its state.
358
+ if (smaller(msg.from)) {
359
+ remoteS = msg.s;
360
+ role = 'remote';
361
+ emitShared();
362
+ }
363
+ else {
364
+ becomeLeader('fallback');
365
+ post({ t: 'claim', from: tabId, s: sharedState() });
366
+ }
367
+ }
368
+ else {
369
+ remoteS = msg.s;
370
+ emitShared();
371
+ }
372
+ break;
373
+ }
374
+ case 'want': {
375
+ // A live leader answers at once; an unknown tab resolves the tie
376
+ // deterministically (smallest tabId claims, bigger one defers).
377
+ if (role === 'leader') {
378
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
379
+ }
380
+ else if (role === 'unknown' && !smaller(msg.from)) {
381
+ becomeLeader('fallback');
382
+ post({ t: 'claim', from: tabId, s: sharedState() });
383
+ }
384
+ break;
385
+ }
386
+ case 'gone': {
387
+ // The leader tab is closing: mark it gone at once — no staleness
388
+ // wait, and never a surprise autoplay in a background tab.
389
+ if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
390
+ remoteS = { ...remoteS, playing: false, nowPlaying: null };
391
+ leaderGone = true;
392
+ emitShared();
393
+ }
394
+ break;
395
+ }
396
+ case 'cmd': {
397
+ if (role !== 'leader')
398
+ return;
399
+ switch (msg.cmd) {
400
+ case 'stop':
401
+ local.stop();
402
+ break;
403
+ case 'setVolume':
404
+ applyVolume(msg.arg ?? userVolume);
405
+ break;
406
+ case 'toggleMute':
407
+ muted = !muted;
408
+ applyVolume(userVolume);
409
+ savePersisted(storageKey, { volume: userVolume, muted, lastScape: local.state.currentId ?? undefined });
410
+ publish();
411
+ emitShared();
412
+ break;
413
+ case 'play': {
414
+ if (!msg.id)
415
+ return;
416
+ remoteCommand = true;
417
+ void local
418
+ .play(msg.id)
419
+ .catch(() => {
420
+ // The leader could not start it (e.g. suspended context,
421
+ // unknown scape) — publish the unchanged state so the
422
+ // remote's waiter times out and shows the failure.
423
+ publish();
424
+ })
425
+ .finally(() => {
426
+ remoteCommand = false;
427
+ });
428
+ break;
429
+ }
430
+ }
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ /** Which tab last asserted itself (heartbeat/state/claim) — a `gone` from
436
+ * it is acted on. */
437
+ let lastBeatTab = null;
438
+ // --- watchdog: heartbeat silence marks the leader gone ---------------------
439
+ const watchdog = setInterval(() => {
440
+ if (role === 'leader')
441
+ return;
442
+ if (remoteS?.playing &&
443
+ lastBeatAt > 0 &&
444
+ Date.now() - lastBeatAt > staleMs) {
445
+ remoteS = { ...remoteS, playing: false, nowPlaying: null };
446
+ leaderGone = true;
447
+ emitShared();
448
+ }
449
+ }, 500);
450
+ // --- local engine wiring ---------------------------------------------------
451
+ local.subscribe(() => {
452
+ if (role === 'leader')
453
+ publish();
454
+ emitShared();
455
+ });
456
+ // Resolve remote play waiters whenever the shared state flips to playing:
457
+ // hook emitShared (the shared-state notify) to also drain them.
458
+ const notifyWaiters = (s) => {
459
+ if (!s.playing || !s.currentId)
460
+ return;
461
+ for (const w of [...playWaiters]) {
462
+ if (w.id === s.currentId) {
463
+ playWaiters.delete(w);
464
+ w.resolve();
465
+ }
466
+ }
467
+ };
468
+ const emitSharedBase = emitShared;
469
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
470
+ emitShared = () => {
471
+ emitSharedBase();
472
+ notifyWaiters(sharedState());
473
+ };
474
+ // --- pagehide: announce our own death ---------------------------------------
475
+ function onPageHide() {
476
+ if (role === 'leader')
477
+ post({ t: 'gone', from: tabId });
478
+ }
479
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
480
+ window.addEventListener('pagehide', onPageHide);
481
+ }
482
+ // --- engine operations -------------------------------------------------------
483
+ function applyVolume(v) {
484
+ userVolume = Math.min(1, Math.max(0, v));
485
+ local.setVolume(muted ? 0 : userVolume);
486
+ }
487
+ /** Find a registered playlist scape for position handover. */
488
+ function playlistFor(id) {
489
+ const item = local.items().find((i) => i.id === id);
490
+ return item && isPlaylistScape(item) ? item : null;
491
+ }
492
+ async function play(id) {
493
+ if (role === 'leader')
494
+ return local.play(id);
495
+ // Starting a scape in a remote takes the leadership over (task spec) —
496
+ // a Web Locks steal runs inside THIS user gesture, the old leader fades
497
+ // out, never two tabs audible.
498
+ const tookOver = await claim();
499
+ if (tookOver) {
500
+ // Playlist position handover: the same scape, previously playing in
501
+ // the old leader, resumes at that track — fresh, not from zero.
502
+ const s = remoteS;
503
+ const handover = !!s && s.currentId === id && s.playing === true && s.trackIndex != null && s.trackIndex >= 0;
504
+ remoteS = null;
505
+ const p = local.play(id);
506
+ if (handover) {
507
+ const scape = playlistFor(id);
508
+ try {
509
+ scape?.startAt(s.trackIndex);
510
+ }
511
+ catch {
512
+ /* position handover is best-effort */
513
+ }
514
+ }
515
+ return p;
516
+ }
517
+ // Live leader (heartbeat fallback): send the command and wait for the echo.
518
+ post({ t: 'cmd', cmd: 'play', id, from: tabId });
519
+ return waitRemotePlay(id);
520
+ }
521
+ function stop() {
522
+ if (role === 'leader') {
523
+ local.stop(); // publish happens via the engine subscription
524
+ return;
525
+ }
526
+ post({ t: 'cmd', cmd: 'stop', from: tabId });
527
+ // Optimistic: the leader echoes the authoritative state right after.
528
+ if (remoteS)
529
+ remoteS = { ...remoteS, playing: false, nowPlaying: null };
530
+ emitShared();
531
+ }
532
+ function setVolume(v) {
533
+ applyVolume(v);
534
+ savePersisted(storageKey, {
535
+ volume: userVolume,
536
+ muted,
537
+ lastScape: local.state.currentId ?? undefined
538
+ });
539
+ if (role === 'leader')
540
+ publish();
541
+ else
542
+ post({ t: 'cmd', cmd: 'setVolume', arg: userVolume, from: tabId });
543
+ emitShared();
544
+ }
545
+ function setMuted(m) {
546
+ if (m === muted)
547
+ return;
548
+ muted = m;
549
+ local.setVolume(muted ? 0 : userVolume);
550
+ savePersisted(storageKey, {
551
+ volume: userVolume,
552
+ muted,
553
+ lastScape: local.state.currentId ?? undefined
554
+ });
555
+ if (role === 'leader')
556
+ publish();
557
+ else
558
+ post({ t: 'cmd', cmd: 'toggleMute', from: tabId });
559
+ emitShared();
560
+ }
561
+ function toggleMute() {
562
+ setMuted(!muted);
563
+ }
564
+ function destroy() {
565
+ onPageHide();
566
+ if (role === 'leader')
567
+ resign('destroy');
568
+ else
569
+ role = 'remote';
570
+ clearInterval(watchdog);
571
+ if (heartbeatTimer) {
572
+ clearInterval(heartbeatTimer);
573
+ heartbeatTimer = null;
574
+ }
575
+ if (role === 'leader')
576
+ local.kill();
577
+ local.stop();
578
+ if (bc) {
579
+ bc.onmessage = null;
580
+ try {
581
+ bc.close();
582
+ }
583
+ catch {
584
+ /* already closed */
585
+ }
586
+ }
587
+ }
588
+ // Elect on the next microtask so the constructor returns synchronously.
589
+ elect();
590
+ return {
591
+ register: (item) => local.register(item),
592
+ list: () => local.list(),
593
+ items: () => local.items(),
594
+ groups: () => local.groups(),
595
+ play,
596
+ stop,
597
+ kill: () => {
598
+ // A remote has nothing local to kill — tell the leader instead.
599
+ if (role === 'leader')
600
+ local.kill();
601
+ else
602
+ post({ t: 'cmd', cmd: 'stop', from: tabId });
603
+ },
604
+ setVolume,
605
+ setMuted,
606
+ toggleMute,
607
+ setNowPlaying(line, index) {
608
+ nowPlaying = line;
609
+ nowPlayingIndex = line ? (index ?? null) : null;
610
+ if (role === 'leader') {
611
+ publish();
612
+ emitShared();
613
+ }
614
+ },
615
+ get state() {
616
+ return ambienceState();
617
+ },
618
+ get sharedState() {
619
+ return sharedState();
620
+ },
621
+ subscribe(fn) {
622
+ ambienceSubs.add(fn);
623
+ fn(ambienceState());
624
+ return () => ambienceSubs.delete(fn);
625
+ },
626
+ subscribeShared(fn) {
627
+ sharedSubs.add(fn);
628
+ fn(sharedState());
629
+ return () => sharedSubs.delete(fn);
630
+ },
631
+ onBreath: (fn) => local.onBreath(fn),
632
+ destroy
633
+ };
634
+ }
635
+ /** createAmbience() with the `shared` opt-in (jatui-lm1mf): identical to the
636
+ * plain engine until a host passes `{ shared: true }`, which makes playback
637
+ * cross-tab — one leader, all other tabs remotes. Hosts keep the exact
638
+ * Ambience interface (AmbienceToggle needs no changes beyond reading the
639
+ * shared now-playing line). */
640
+ export function createAmbience(options = {}) {
641
+ return options.shared ? createSharedAmbience(options) : createLocalEngine(options);
642
+ }
@@ -121,4 +121,8 @@ export interface AmbienceOptions {
121
121
  isGesture?: () => boolean;
122
122
  /** Context factory, injectable for tests (default: `new AudioContext()`). */
123
123
  createContext?: () => AudioContextLike;
124
+ /** Cross-tab shared player (jatui-lm1mf): one tab (the leader) owns the
125
+ * AudioContext and plays; every other tab renders the same live state and
126
+ * sends commands. See `shared.ts`. */
127
+ shared?: boolean;
124
128
  }
@@ -201,14 +201,15 @@
201
201
  import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
202
202
  import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
203
203
  import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
204
+ import type { SharedAmbience } from '../ambience/shared';
204
205
 
205
206
  let {
206
207
  engine,
207
208
  compact = false,
208
209
  placement = 'bottom-end'
209
210
  }: {
210
- /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
211
- engine: Ambience;
211
+ /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
212
+ engine: Ambience | SharedAmbience;
212
213
  /** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
213
214
  compact?: boolean;
214
215
  /** Which way the menu opens from the trigger. Auto-flips vertically when the viewport has no room on that side. */
@@ -259,6 +260,10 @@
259
260
 
260
261
  let playError = $state(false);
261
262
  let track = $state<PlaylistState['track']>(null);
263
+ /** The shared player's now-playing line (jatui-lm1mf): published by the
264
+ * leader tab, so a remote tab shows the same track attribution without
265
+ * owning the playlist instance. Null on a plain engine. */
266
+ let sharedNow = $state<string | null>(null);
262
267
 
263
268
  // Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
264
269
  // the pending partial notch between wheel events so trackpad pixel deltas
@@ -271,7 +276,7 @@
271
276
  const currentLabel = $derived(currentItem?.label ?? currentId);
272
277
  const groups = $derived(groupScapes(items, engine.groups?.()));
273
278
  const openSection = $derived(groups.find((g) => g.group === subGroup) ?? null);
274
- const trackLine = $derived(playing ? nowPlayingLine(track) : '');
279
+ const trackLine = $derived(playing ? (sharedNow ?? nowPlayingLine(track)) : '');
275
280
  const liveLabel = $derived(
276
281
  playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
277
282
  );
@@ -298,15 +303,33 @@
298
303
  });
299
304
  });
300
305
 
301
- // The current scape is a playlist → follow its track for the attribution line.
306
+ // The shared player's published now-playing line (leader → remotes).
307
+ $effect(() => {
308
+ const e = engine as SharedAmbience;
309
+ if (typeof e.subscribeShared !== 'function') {
310
+ sharedNow = null;
311
+ return;
312
+ }
313
+ return e.subscribeShared((s) => {
314
+ sharedNow = s.nowPlaying;
315
+ });
316
+ });
317
+
318
+ // The current scape is a playlist → follow its track for the attribution line,
319
+ // and (leader side) publish it to the other tabs via the shared engine.
302
320
  $effect(() => {
303
321
  const item = currentItem;
304
322
  if (!isPlaylistScape(item)) {
305
323
  track = null;
324
+ engine.setNowPlaying?.(null);
306
325
  return;
307
326
  }
308
327
  return item.subscribe((s) => {
309
328
  track = s.status === 'playing' ? s.track : null;
329
+ engine.setNowPlaying?.(
330
+ s.status === 'playing' && s.track ? nowPlayingLine(s.track) : null,
331
+ s.index
332
+ );
310
333
  });
311
334
  });
312
335
 
@@ -128,9 +128,10 @@ export declare function wheelVolumeSteps(acc: {
128
128
  */
129
129
  export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
130
130
  import type { Ambience } from '../ambience/types';
131
+ import type { SharedAmbience } from '../ambience/shared';
131
132
  type $$ComponentProps = {
132
- /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
133
- engine: Ambience;
133
+ /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
134
+ engine: Ambience | SharedAmbience;
134
135
  /** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
135
136
  compact?: boolean;
136
137
  /** Which way the menu opens from the trigger. Auto-flips vertically when the viewport has no room on that side. */
@@ -6,6 +6,13 @@
6
6
  (color-mix from one theme colour into base-100, no hex), a single tooltip,
7
7
  a legend and a summary line. One tab stop: arrows move by day/week inside
8
8
  the grid (role="grid"), Enter/Space selects.
9
+
10
+ Fill mode (jatui-pldt4, default): the component is an inline-size container
11
+ and --hm-cell is derived from the container width in pure CSS (cqi + calc,
12
+ no ResizeObserver, no first-paint jump), so a wide card is spanned end to
13
+ end and the footer — summary left, legend right — stays as wide as the
14
+ grid instead of floating at the card edges. fill={false} keeps the exact
15
+ legacy fixed-cell markup and scroll behaviour.
9
16
  -->
10
17
  <script lang="ts" generics="T = { date: string | Date; count: number }">
11
18
  import { untrack } from 'svelte';
@@ -34,6 +41,21 @@
34
41
  levelOf?: (count: number, max: number, levels: number) => number;
35
42
  /** Smaller cells and labels. */
36
43
  compact?: boolean;
44
+ /**
45
+ * Size cells to the host's width (default `true`): `--hm-cell` becomes
46
+ * `(containerWidth - focus padding - weekday gutter) / weeks - gap`,
47
+ * clamped to `--hm-cell-min` (8px) … `--hm-cell-max` (20px) — both
48
+ * overridable in CSS. Below the floor the grid overflows and scrolls
49
+ * horizontally inside the component; above the ceiling the grid keeps its
50
+ * maximum size at the inline start and the footer (summary + legend) is
51
+ * sized to the grid's width, so the legend sits under the grid's right
52
+ * edge rather than the card's. Needs a host that gives the component a
53
+ * definite width (a card body): in a shrink-to-fit host the inline-size
54
+ * container collapses — pass `fill={false}` there. `false` keeps the
55
+ * fixed `--hm-cell` grid (the exact legacy markup) with the same
56
+ * horizontal scroll whenever the weeks don't fit.
57
+ */
58
+ fill?: boolean;
37
59
  showLegend?: boolean;
38
60
  showWeekdays?: boolean;
39
61
  showMonths?: boolean;
@@ -60,6 +82,7 @@
60
82
  levels = 4,
61
83
  levelOf,
62
84
  compact = false,
85
+ fill = true,
63
86
  showLegend = true,
64
87
  showWeekdays = true,
65
88
  showMonths = true,
@@ -165,6 +188,7 @@
165
188
  <div
166
189
  class="jatui-heatmap tone-{tone} {className}"
167
190
  class:is-compact={compact}
191
+ class:is-fill={fill}
168
192
  bind:this={wrapEl}
169
193
  style:--weeks={layout.weeks}
170
194
  >
@@ -255,6 +279,13 @@
255
279
  .jatui-heatmap {
256
280
  --hm-cell: 0.75rem;
257
281
  --hm-gap: 3px;
282
+ /* Root font size and the focus-ring padding as vars so the fill math can
283
+ * reference them without em (em inside an inherited custom property
284
+ * resolves against each USING element's font size — the weekday labels
285
+ * are 0.9em and would disagree with the cells). rem is absolute, so
286
+ * every element resolves the same px. */
287
+ --hm-fs: 0.75rem;
288
+ --hm-pad: 2px;
258
289
  --hm-tone: var(--color-primary);
259
290
  --hm-empty: color-mix(in oklch, var(--color-base-content) 8%, var(--color-base-100));
260
291
  position: relative;
@@ -263,13 +294,56 @@
263
294
  gap: var(--jatui-space-base, 8px);
264
295
  min-width: 0;
265
296
  max-width: 100%;
266
- font-size: 0.75rem;
297
+ font-size: var(--hm-fs);
267
298
  color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
268
299
  }
269
300
  .jatui-heatmap.is-compact {
270
301
  --hm-cell: 0.625rem;
302
+ --hm-fs: 0.6875rem;
271
303
  --hm-gap: 2px;
272
- font-size: 0.6875rem;
304
+ font-size: var(--hm-fs);
305
+ }
306
+
307
+ /* Fill mode (jatui-pldt4): the component is the size container; cells size
308
+ * to the width the host gives it — (container - focus padding - weekday
309
+ * gutter) / weeks - gap — clamped to a readable band (--hm-cell-min …
310
+ * --hm-cell-max). Below the floor the grid overflows and .hm-scroll scrolls
311
+ * (the floor is the only thing that triggers scrolling); above the ceiling
312
+ * the grid keeps its maximum size at the inline start and the footer is
313
+ * sized to the grid's width, so the legend lands under the grid's right
314
+ * edge, never the card's. Month labels and the weekday column keep reading
315
+ * the same --hm-cell, so everything stays on one grid. */
316
+ .jatui-heatmap.is-fill {
317
+ container-type: inline-size;
318
+ }
319
+ .jatui-heatmap.is-fill:has(.hm-weekdays) {
320
+ --hm-label-w: calc(2.25 * var(--hm-fs) + var(--hm-gap));
321
+ }
322
+ .jatui-heatmap.is-fill .hm-months {
323
+ /* A month starting in the final week column hangs its label past the
324
+ * grid's right edge; legacy let the body grow to fit it, but the fill
325
+ * grid is pinned to the container — clip the decorative label at the
326
+ * grid's edge instead of handing the scroller a phantom scrollbar. */
327
+ overflow: hidden;
328
+ }
329
+ .jatui-heatmap.is-fill :is(.hm-scroll, .hm-foot) {
330
+ --hm-cell: clamp(
331
+ var(--hm-cell-min, 8px),
332
+ calc(
333
+ (100cqi - 2 * var(--hm-pad) - var(--hm-label-w, 0px)) / var(--weeks) -
334
+ var(--hm-gap)
335
+ ),
336
+ var(--hm-cell-max, 20px)
337
+ );
338
+ }
339
+ .jatui-heatmap.is-fill .hm-foot {
340
+ width: min(
341
+ calc(
342
+ var(--hm-pad) + var(--hm-label-w, 0px) +
343
+ var(--weeks) * (var(--hm-cell) + var(--hm-gap)) - var(--hm-gap)
344
+ ),
345
+ 100%
346
+ );
273
347
  }
274
348
  .tone-secondary { --hm-tone: var(--color-secondary); }
275
349
  .tone-accent { --hm-tone: var(--color-accent); }
@@ -282,7 +356,7 @@
282
356
  overflow-x: auto;
283
357
  overscroll-behavior-x: contain;
284
358
  /* room for the focus ring */
285
- padding: 2px;
359
+ padding: var(--hm-pad);
286
360
  }
287
361
  .hm-body {
288
362
  display: inline-flex;
@@ -24,6 +24,21 @@ declare function $$render<T = {
24
24
  levelOf?: (count: number, max: number, levels: number) => number;
25
25
  /** Smaller cells and labels. */
26
26
  compact?: boolean;
27
+ /**
28
+ * Size cells to the host's width (default `true`): `--hm-cell` becomes
29
+ * `(containerWidth - focus padding - weekday gutter) / weeks - gap`,
30
+ * clamped to `--hm-cell-min` (8px) … `--hm-cell-max` (20px) — both
31
+ * overridable in CSS. Below the floor the grid overflows and scrolls
32
+ * horizontally inside the component; above the ceiling the grid keeps its
33
+ * maximum size at the inline start and the footer (summary + legend) is
34
+ * sized to the grid's width, so the legend sits under the grid's right
35
+ * edge rather than the card's. Needs a host that gives the component a
36
+ * definite width (a card body): in a shrink-to-fit host the inline-size
37
+ * container collapses — pass `fill={false}` there. `false` keeps the
38
+ * fixed `--hm-cell` grid (the exact legacy markup) with the same
39
+ * horizontal scroll whenever the weeks don't fit.
40
+ */
41
+ fill?: boolean;
27
42
  showLegend?: boolean;
28
43
  showWeekdays?: boolean;
29
44
  showMonths?: boolean;
package/dist/index.d.ts CHANGED
@@ -411,8 +411,9 @@ export { default as ImageReveal } from './components/ImageReveal.svelte';
411
411
  export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
412
412
  export type { ImageRevealPhase, ImageRevealShape, ImageRevealSizeName, ImageRevealToneName, ImageRevealState } from './components/imageReveal';
413
413
  export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
414
- export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
414
+ export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
415
415
  export type { DefaultAmbienceOptions, AmbienceSection } from './ambience';
416
+ export type { SharedAmbience, SharedAmbienceState, SharedAmbienceOptions } from './ambience';
416
417
  export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './ambience';
417
418
  export { createPlaylistScape } from './ambience';
418
419
  export { isPlaylistScape, trackAttribution } from './ambience';
package/dist/index.js CHANGED
@@ -460,7 +460,7 @@ export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize
460
460
  // navigation; createDefaultAmbience() pre-registers every shipped scape
461
461
  // (hibiscus, focus, brown/pink/white noise, rain, waves).
462
462
  export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
463
- export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
463
+ export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
464
464
  // Playlist scape (phase 2) — per-host, never in DEFAULT_SCAPES: build with
465
465
  // createPlaylistScape() and register on the engine yourself.
466
466
  export { createPlaylistScape } from './ambience';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",