@joewinke/jatui 0.60.0 → 0.62.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
@@ -84,6 +84,9 @@ Then import:
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
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
+ | `UptimeBar` | One component's 90-day status strip (status-page style): one thin bar per day, coloured by status tone (operational green, degraded amber, partial orange, outage red, `no-data` hatched neutral — never coloured as up), a hover/touch/focus tooltip with date + minutes down + incident note, and the footer "90 days ago ——— 99.97% uptime ——— Today" (uptime % truncated to 2 decimals — never overclaims, e.g. 99.996% shows 99.99% never 100%). `role="grid"` with ONE tab stop and roving focus: arrows move by day, PgUp/PgDn by 7, Home/End to the window ends. Bars size to the container width (pure CSS cqi, clamped 3–14px, `--up-bar-min`/`--up-bar-max`); below the floor the strip scrolls horizontally inside the component, starting at the latest day. Token-only colours (`--jatui-status-*` + `color-mix`). Pure helpers exported (`buildUptime`, `moveUptimeFocus`, `uptimeWord`, `bannerStatus`, `aggregateStatus`, `defaultDownMinutes`, `formatUptimePct`). | `data` (`{date, status, downMinutes?, note?}[]`), `days?` (90), `end?`, `label?`, `formatLabel?`, `showUptime?`/`showRangeLabels?` (true), `locale?`, `class?` |
88
+ | `StatusCard` | One measured component on a status page: name + info tooltip (what is measured), status icon at the right, the 90-day `UptimeBar` strip, and the status word (Normal / Degraded / Outage / No data — from the worst status in the window; `statusWord` overrides). | `name`, `description?`, `data`, `days?`, `end?`, `statusWord?`, `locale?`, `class?` |
89
+ | `StatusBanner` | Status page headline banner: "All systems operational" / degraded / outage, token colours, check / warning / x icon. | `status` ('operational' \| 'degraded' \| 'outage'), `message?`, `info?`, `class?` |
87
90
  | `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
88
91
  | `Card` | Content card container | `title`, `children` (snippet) |
89
92
  | `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?` |
@@ -333,7 +336,7 @@ const rec = createAudioRecorder({
333
336
 
334
337
  ### Ambience (subpath: `jatui/ambience`, components on the root)
335
338
 
336
- `createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = a now-playing row above volume (playing/paused only; playlists: track title — composer · license, elapsed/total progress bar, prev / play-pause / next transport — `prev()` restarts the track when >3 s are in, else goes back one; play-pause uses engine `pause()`/`resume()` which freeze the scape in place — generative scapes get play/pause only) + 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 and exposes `state().position/duration` + `skip()`/`prev()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
339
+ `createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = a now-playing row above volume (playing/paused only; playlists: track title — composer · license, elapsed/total progress bar, prev / play-pause / next transport — `prev()` restarts the track when >3 s are in, else goes back one; play-pause uses engine `pause()`/`resume()` which freeze the scape in place — generative scapes get play/pause only; plus a Download IconButton for tracks whose licence permits redistribution — allowlist CC0/public-domain/CC-BY/CC-BY-SA via `isTrackDownloadable` (NC/ND refused unless the manifest flags `downloadable: true`); the url must survive `safeAmbienceHref` (absolute http/https — before the fetch AND the new-tab CORS-failure fallback; remotes re-validate the leader's publication via `safeSharedTrackDownload`); fetch→Blob→`a[download="Composer - Title.ext"]` (`trackDownloadFilename`); licence + source in the tooltip and the `aria-describedby` line; busy spinner never blocks playback) + 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 and exposes `state().position/duration` + `skip()`/`prev()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration/trackDownload`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `isTrackDownloadable`, `trackLicenseAllowsRedistribution`, `trackDownloadFilename`, `safeSharedTrackDownload`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
337
340
 
338
341
  ### Sound (first-class sibling of motion)
339
342
 
@@ -15,12 +15,19 @@
15
15
  * Accessibility: sets aria-describedby on the host element.
16
16
  * Escape key hides the active tooltip.
17
17
  * Focus events (focusin/focusout) trigger alongside mouse hover.
18
+ * Touch/pen: a tap shows the tooltip immediately — only when the host sets
19
+ * `tapToShow` (default false, so plain buttons and badges don't pop on tap);
20
+ * the next tap elsewhere hides it.
18
21
  */
19
22
  export interface TooltipOptions {
20
23
  text: string;
21
24
  placement?: 'top' | 'bottom' | 'left' | 'right';
22
25
  /** ms to wait before showing (default 400). 0 = instant. */
23
26
  delayMs?: number;
27
+ /** Show the tooltip on a touch/pen TAP (default false — only components
28
+ * that ask for it, e.g. UptimeBar's day bars, pop a tooltip on tap; plain
29
+ * buttons and badges must not). */
30
+ tapToShow?: boolean;
24
31
  }
25
32
  export declare function tooltip(node: HTMLElement, options: TooltipOptions): {
26
33
  update(next: TooltipOptions): void;
@@ -15,6 +15,9 @@
15
15
  * Accessibility: sets aria-describedby on the host element.
16
16
  * Escape key hides the active tooltip.
17
17
  * Focus events (focusin/focusout) trigger alongside mouse hover.
18
+ * Touch/pen: a tap shows the tooltip immediately — only when the host sets
19
+ * `tapToShow` (default false, so plain buttons and badges don't pop on tap);
20
+ * the next tap elsewhere hides it.
18
21
  */
19
22
  const COOL_WINDOW_MS = 500;
20
23
  const GAP = 8;
@@ -173,10 +176,29 @@ export function tooltip(node, options) {
173
176
  if (e.key === 'Escape')
174
177
  hide();
175
178
  }
179
+ let outTap = null;
180
+ function handlePointerdown(e) {
181
+ // Touch/pen: there is no hover — a TAP shows the tooltip. Only when the
182
+ // host asks for it (tapToShow): plain buttons and badges must not pop a
183
+ // tooltip on every tap. The next tap anywhere else hides it again (the
184
+ // tooltip itself has pointer-events: none, so the tap always reaches
185
+ // whatever is under it).
186
+ if (!opts.tapToShow)
187
+ return;
188
+ if (e.pointerType !== 'touch' && e.pointerType !== 'pen')
189
+ return;
190
+ scheduleShow();
191
+ outTap = (e2) => {
192
+ if (!node.contains(e2.target))
193
+ hide();
194
+ };
195
+ document.addEventListener('pointerdown', outTap, { capture: true, once: true });
196
+ }
176
197
  node.addEventListener('mouseenter', scheduleShow);
177
198
  node.addEventListener('mouseleave', hide);
178
199
  node.addEventListener('focusin', scheduleShow);
179
200
  node.addEventListener('focusout', hide);
201
+ node.addEventListener('pointerdown', handlePointerdown);
180
202
  document.addEventListener('keydown', handleKeydown);
181
203
  return {
182
204
  update(next) {
@@ -188,10 +210,16 @@ export function tooltip(node, options) {
188
210
  },
189
211
  destroy() {
190
212
  hide();
213
+ if (outTap) {
214
+ // The once-listener can outlive the node — remove it explicitly.
215
+ document.removeEventListener('pointerdown', outTap, { capture: true });
216
+ outTap = null;
217
+ }
191
218
  node.removeEventListener('mouseenter', scheduleShow);
192
219
  node.removeEventListener('mouseleave', hide);
193
220
  node.removeEventListener('focusin', scheduleShow);
194
221
  node.removeEventListener('focusout', hide);
222
+ node.removeEventListener('pointerdown', handlePointerdown);
195
223
  document.removeEventListener('keydown', handleKeydown);
196
224
  },
197
225
  };
@@ -11,13 +11,13 @@
11
11
  */
12
12
  export { createAmbience, createAmbienceLocal } from './shared';
13
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';
14
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, safeSharedTrackDownload, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager, type SharedTrackDownload } from './shared';
15
15
  export { createDefaultAmbience, DEFAULT_SCAPES, type DefaultAmbienceOptions } from './default';
16
16
  export { hibiscusScape } from './scapes/hibiscus';
17
17
  export { focusScape } from './scapes/focus';
18
18
  export { noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape } from './scapes/noise';
19
19
  export { rain, waves } from './scapes/nature';
20
- export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
20
+ export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './scapes/playlist';
21
21
  export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './scapes/playlist';
22
22
  export { BreathClock, BREATHS_PER_MINUTE } from './breath';
23
23
  export { MASTER } from './master';
@@ -13,7 +13,7 @@
13
13
  // the plain engine until a host passes { shared: true }.
14
14
  export { createAmbience, createAmbienceLocal } from './shared';
15
15
  export { TIMING } from './engine';
16
- export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING } from './shared';
16
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, safeSharedTrackDownload } from './shared';
17
17
  export { createDefaultAmbience, DEFAULT_SCAPES } from './default';
18
18
  // The shipped scapes — hosts that want a hand-picked registry import them
19
19
  // individually instead of using createDefaultAmbience().
@@ -25,7 +25,7 @@ export { rain, waves } from './scapes/nature';
25
25
  // recordings. NOTE: never auto-registered in DEFAULT_SCAPES — a playlist is
26
26
  // per-host (its manifest URL and licensing claims are the host's); build one
27
27
  // with createPlaylistScape() and engine.register() it yourself.
28
- export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
28
+ export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './scapes/playlist';
29
29
  export { BreathClock, BREATHS_PER_MINUTE } from './breath';
30
30
  export { MASTER } from './master';
31
31
  export { ScapeRegistry } from './registry';
@@ -73,6 +73,11 @@ export interface PlaylistTrack {
73
73
  license: string;
74
74
  /** Page where the recording lives — the attribution link target. */
75
75
  sourceUrl?: string;
76
+ /** Explicit download override (jatui-0vxcu): `true` offers the file even
77
+ * when the licence string carries NC/ND terms; `false` hides the download
78
+ * button even for a redistributable licence. Absent: the licence gate
79
+ * (`isTrackDownloadable`) decides from the `license` string. */
80
+ downloadable?: boolean;
76
81
  }
77
82
  export interface PlaylistManifest {
78
83
  title?: string;
@@ -159,3 +164,29 @@ export declare function isPlaylistScape(scape: unknown): scape is PlaylistScape;
159
164
  * (performer only when present and different from the composer).
160
165
  */
161
166
  export declare function trackAttribution(track: PlaylistTrack | null | undefined): string;
167
+ /**
168
+ * Does this LICENCE STRING permit redistributing the file? The download
169
+ * allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
170
+ * and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
171
+ * derivatives) terms is refused — the host's only way past that is an
172
+ * explicit `downloadable: true` on the track (`isTrackDownloadable`).
173
+ * Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
174
+ * "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
175
+ * (default-deny) do not.
176
+ */
177
+ export declare function trackLicenseAllowsRedistribution(license: string | null | undefined): boolean;
178
+ /**
179
+ * May this track be downloaded? The licence gate behind the toggle's
180
+ * download button (jatui-0vxcu): an explicit boolean `downloadable` on the
181
+ * track overrides either way; otherwise the `license` string must permit
182
+ * redistribution (`trackLicenseAllowsRedistribution`). A track without a
183
+ * usable url is never downloadable.
184
+ */
185
+ export declare function isTrackDownloadable(track: PlaylistTrack | null | undefined): boolean;
186
+ /**
187
+ * The download filename — attribution travels with the file:
188
+ * "<Composer> - <Title>.<ext>". The extension comes from the track url
189
+ * (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
190
+ * sanitized for real filesystems and the result stays deterministic.
191
+ */
192
+ export declare function trackDownloadFilename(track: PlaylistTrack | null | undefined): string;
@@ -657,7 +657,8 @@ function isValidTrack(t) {
657
657
  o.license.trim() !== '' &&
658
658
  (o.performer === undefined || typeof o.performer === 'string') &&
659
659
  (o.sourceUrl === undefined ||
660
- (typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')));
660
+ (typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')) &&
661
+ (o.downloadable === undefined || typeof o.downloadable === 'boolean'));
661
662
  }
662
663
  /** Narrow a registered scape to a playlist (to reach its state/subscribe). */
663
664
  export function isPlaylistScape(scape) {
@@ -679,3 +680,83 @@ export function trackAttribution(track) {
679
680
  parts.push(track.license);
680
681
  return parts.filter(Boolean).join(' · ');
681
682
  }
683
+ // ---- download gate + filename (jatui-0vxcu) ---------------------------------
684
+ /**
685
+ * Does this LICENCE STRING permit redistributing the file? The download
686
+ * allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
687
+ * and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
688
+ * derivatives) terms is refused — the host's only way past that is an
689
+ * explicit `downloadable: true` on the track (`isTrackDownloadable`).
690
+ * Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
691
+ * "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
692
+ * (default-deny) do not.
693
+ */
694
+ export function trackLicenseAllowsRedistribution(license) {
695
+ if (typeof license !== 'string' || license.trim() === '')
696
+ return false;
697
+ const l = license.toLowerCase().replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
698
+ // NC / ND terms — as their own tokens ("by nc", "by-nc" → "by nc") or as
699
+ // the spelled-out words.
700
+ if (/(^|\s)(nc|nd|noncommercial|non commercial|noderivs|no derivs|no derivatives)(\s|$)/.test(l))
701
+ return false;
702
+ return /(^|\s)(cc0|cc by|cc by sa|public domain|pd|pdm)(\s|$)/.test(l);
703
+ }
704
+ /**
705
+ * May this track be downloaded? The licence gate behind the toggle's
706
+ * download button (jatui-0vxcu): an explicit boolean `downloadable` on the
707
+ * track overrides either way; otherwise the `license` string must permit
708
+ * redistribution (`trackLicenseAllowsRedistribution`). A track without a
709
+ * usable url is never downloadable.
710
+ */
711
+ export function isTrackDownloadable(track) {
712
+ if (!track || typeof track.url !== 'string' || track.url.trim() === '')
713
+ return false;
714
+ if (typeof track.downloadable === 'boolean')
715
+ return track.downloadable;
716
+ return trackLicenseAllowsRedistribution(track.license);
717
+ }
718
+ /** Cap on one filename part — a "Composer - Title.ext" name stays well under
719
+ * the 255-byte filename floor even with long attribution strings. */
720
+ const FILENAME_PART_MAX = 96;
721
+ /** Extension for a url with no readable one — the Musopen-style manifests
722
+ * this feature targets serve mp3. */
723
+ const DEFAULT_AUDIO_EXT = 'mp3';
724
+ /** One filename part: filesystem-hostile characters and control chars →
725
+ * spaces, runs collapsed, no leading/trailing dots or spaces (Windows
726
+ * rejects both), clamped to {@link FILENAME_PART_MAX}. */
727
+ function sanitizeFilenamePart(part) {
728
+ return part
729
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
730
+ .replace(/[\\/:*?"<>|]/g, ' ')
731
+ .replace(/\s+/g, ' ')
732
+ .trim()
733
+ .replace(/^[. ]+|[. ]+$/g, '')
734
+ .slice(0, FILENAME_PART_MAX)
735
+ .replace(/[. ]+$/g, ''); // the clamp can re-expose a trailing dot
736
+ }
737
+ /** The audio extension of `url`'s last path segment ('' when there is none
738
+ * or it is not 1–5 alphanumeric chars — query strings excluded). */
739
+ function urlAudioExt(url) {
740
+ if (typeof url !== 'string')
741
+ return '';
742
+ const path = url.split(/[?#]/)[0];
743
+ const file = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
744
+ const dot = file.lastIndexOf('.');
745
+ if (dot <= 0)
746
+ return '';
747
+ const ext = file.slice(dot + 1).toLowerCase();
748
+ return /^[a-z0-9]{1,5}$/.test(ext) ? ext : '';
749
+ }
750
+ /**
751
+ * The download filename — attribution travels with the file:
752
+ * "<Composer> - <Title>.<ext>". The extension comes from the track url
753
+ * (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
754
+ * sanitized for real filesystems and the result stays deterministic.
755
+ */
756
+ export function trackDownloadFilename(track) {
757
+ if (!track)
758
+ return '';
759
+ const composer = sanitizeFilenamePart(track.composer) || 'Unknown composer';
760
+ const title = sanitizeFilenamePart(track.title) || 'Untitled';
761
+ return `${composer} - ${title}.${urlAudioExt(track.url) || DEFAULT_AUDIO_EXT}`;
762
+ }
@@ -57,6 +57,31 @@ export declare const SHARED_TIMING: Readonly<{
57
57
  /** A remote play command waits this long (ms) for the leader's echo. */
58
58
  REMOTE_PLAY_TIMEOUT_MS: 2500;
59
59
  }>;
60
+ /** The current track's download info (jatui-0vxcu), published by the leader
61
+ * alongside the now-playing parts — present ONLY when the licence permits
62
+ * redistribution (`isTrackDownloadable`), so a remote tab can offer the
63
+ * same download button without re-deriving the gate or the filename. */
64
+ export interface SharedTrackDownload {
65
+ /** The track's audio url (the leader's manifest entry). */
66
+ url: string;
67
+ /** Ready-made filename — "Composer - Title.ext", built leader-side by
68
+ * `trackDownloadFilename` so attribution travels with the file. */
69
+ filename: string;
70
+ /** The licence string, for the button's tooltip and describedby line. */
71
+ license: string;
72
+ /** Page the recording came from (attribution link target). */
73
+ sourceUrl?: string;
74
+ }
75
+ /**
76
+ * A published download re-validated on the CONSUMING side (jatui-0vxcu):
77
+ * the leader is another tab, not a trusted source — its `url` must survive
78
+ * `safeAmbienceHref` (absolute http/https only: a `javascript:`/`data:` or
79
+ * scheme-relative url from a compromised manifest or a hostile tab would
80
+ * otherwise reach this tab's `window.open` fallback) and the filename and
81
+ * licence must be usable strings. Anything else returns null — the remote
82
+ * tab's download button hides. The validated url replaces the original.
83
+ */
84
+ export declare function safeSharedTrackDownload(download: SharedTrackDownload | null | undefined): SharedTrackDownload | null;
60
85
  /** The live cross-tab state every tab renders. */
61
86
  export interface SharedAmbienceState {
62
87
  /** Currently playing (or preselected) scape id, or null. */
@@ -92,6 +117,10 @@ export interface SharedAmbienceState {
92
117
  /** True while the leader is PAUSED (engine.pause() — the scape is frozen
93
118
  * in place, not torn down; resume continues it). */
94
119
  paused: boolean;
120
+ /** The published track's download info (url + filename + licence), or
121
+ * null when nothing is playing or the licence forbids redistribution
122
+ * (jatui-0vxcu). A remote tab's download button reads this. */
123
+ trackDownload: SharedTrackDownload | null;
95
124
  }
96
125
  /** An Ambience whose playback is shared across tabs (see module doc). */
97
126
  export interface SharedAmbience extends Ambience {
@@ -105,8 +134,10 @@ export interface SharedAmbience extends Ambience {
105
134
  /** Leader-side hook: the host publishes the playing track's attribution
106
135
  * (and playlist position + progress) so remote tabs show it too.
107
136
  * `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
108
- * now-playing row; nulls mean unknown. */
109
- setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null): void;
137
+ * now-playing row; nulls mean unknown. `download` (jatui-0vxcu) carries
138
+ * the track's download info when its licence permits redistribution —
139
+ * null clears it. */
140
+ setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null, download?: SharedTrackDownload | null): void;
110
141
  /** Now-playing-row transport (jatui-153tz): skip / go back one track on a
111
142
  * playlist scape. A remote routes the command to the leader's instance. */
112
143
  skipTrack(id: string): void;
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { createAmbience as createLocalEngine } from './engine';
44
44
  import { isPlaylistScape } from './scapes/playlist';
45
+ import { safeAmbienceHref } from './groups';
45
46
  import { loadPersisted, savePersisted } from './persist';
46
47
  /** BroadcastChannel name — the ambience control channel for the origin. */
47
48
  export const AMBIENCE_CHANNEL = 'jatui:ambience';
@@ -58,6 +59,27 @@ export const SHARED_TIMING = Object.freeze({
58
59
  /** A remote play command waits this long (ms) for the leader's echo. */
59
60
  REMOTE_PLAY_TIMEOUT_MS: 2500
60
61
  });
62
+ /**
63
+ * A published download re-validated on the CONSUMING side (jatui-0vxcu):
64
+ * the leader is another tab, not a trusted source — its `url` must survive
65
+ * `safeAmbienceHref` (absolute http/https only: a `javascript:`/`data:` or
66
+ * scheme-relative url from a compromised manifest or a hostile tab would
67
+ * otherwise reach this tab's `window.open` fallback) and the filename and
68
+ * licence must be usable strings. Anything else returns null — the remote
69
+ * tab's download button hides. The validated url replaces the original.
70
+ */
71
+ export function safeSharedTrackDownload(download) {
72
+ if (!download || typeof download !== 'object')
73
+ return null;
74
+ const url = safeAmbienceHref(download.url);
75
+ if (!url)
76
+ return null;
77
+ if (typeof download.filename !== 'string' || download.filename.trim() === '')
78
+ return null;
79
+ if (typeof download.license !== 'string' || download.license.trim() === '')
80
+ return null;
81
+ return { ...download, url };
82
+ }
61
83
  function randomTabId() {
62
84
  try {
63
85
  return `tab-${crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
@@ -120,6 +142,8 @@ export function createSharedAmbience(options = {}) {
120
142
  let nowPlayingTitle = null;
121
143
  let nowPlayingPosition = null;
122
144
  let nowPlayingDuration = null;
145
+ /** The published track's download info (jatui-0vxcu). */
146
+ let nowPlayingDownload = null;
123
147
  const ambienceSubs = new Set();
124
148
  const sharedSubs = new Set();
125
149
  function sharedState() {
@@ -137,7 +161,8 @@ export function createSharedAmbience(options = {}) {
137
161
  trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null),
138
162
  trackTitle: role === 'leader' ? nowPlayingTitle : (remoteS?.trackTitle ?? null),
139
163
  trackPosition: role === 'leader' ? nowPlayingPosition : (remoteS?.trackPosition ?? null),
140
- trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null)
164
+ trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null),
165
+ trackDownload: role === 'leader' ? nowPlayingDownload : (remoteS?.trackDownload ?? null)
141
166
  };
142
167
  }
143
168
  function ambienceState() {
@@ -206,6 +231,7 @@ export function createSharedAmbience(options = {}) {
206
231
  nowPlayingTitle = null;
207
232
  nowPlayingPosition = null;
208
233
  nowPlayingDuration = null;
234
+ nowPlayingDownload = null;
209
235
  remoteS = null;
210
236
  if (heartbeatTimer) {
211
237
  clearInterval(heartbeatTimer);
@@ -404,7 +430,7 @@ export function createSharedAmbience(options = {}) {
404
430
  // The leader tab is closing: mark it gone at once — no staleness
405
431
  // wait, and never a surprise autoplay in a background tab.
406
432
  if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
407
- remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
433
+ remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
408
434
  leaderGone = true;
409
435
  emitShared();
410
436
  }
@@ -487,7 +513,7 @@ export function createSharedAmbience(options = {}) {
487
513
  if (remoteS?.playing &&
488
514
  lastBeatAt > 0 &&
489
515
  Date.now() - lastBeatAt > staleMs) {
490
- remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
516
+ remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
491
517
  leaderGone = true;
492
518
  emitShared();
493
519
  }
@@ -571,7 +597,7 @@ export function createSharedAmbience(options = {}) {
571
597
  post({ t: 'cmd', cmd: 'stop', from: tabId });
572
598
  // Optimistic: the leader echoes the authoritative state right after.
573
599
  if (remoteS)
574
- remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
600
+ remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
575
601
  emitShared();
576
602
  }
577
603
  /** Freeze in place (the scape is not torn down; a playlist keeps its track
@@ -697,12 +723,13 @@ export function createSharedAmbience(options = {}) {
697
723
  setVolume,
698
724
  setMuted,
699
725
  toggleMute,
700
- setNowPlaying(line, index, position, duration, title) {
726
+ setNowPlaying(line, index, position, duration, title, download) {
701
727
  nowPlaying = line;
702
728
  nowPlayingIndex = line ? (index ?? null) : null;
703
729
  nowPlayingPosition = line ? (position ?? null) : null;
704
730
  nowPlayingDuration = line ? (duration ?? null) : null;
705
731
  nowPlayingTitle = line ? (title ?? null) : null;
732
+ nowPlayingDownload = line ? (download ?? null) : null;
706
733
  if (role === 'leader') {
707
734
  publish();
708
735
  emitShared();