@joewinke/jatui 0.43.0 → 0.45.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
@@ -92,6 +92,7 @@ Then import:
92
92
  | `LoadingSpinner` | Spinner animation | `size`, `color` |
93
93
  | `Sparkline` | Inline mini chart | `data: number[]`, `width`, `height`, `color` |
94
94
  | `LazyImage` | Image with lazy loading + placeholder | `src`, `alt`, `placeholder` |
95
+ | `ImageReveal` | Holds an image slot while the image doesn't exist yet (a logo lookup, an AI generation): a token-tinted ripple (`role="status"`, `aria-busy`) while `src` is null and while the bytes decode, then blur-to-sharp on `--motion-*` + `onReveal`. `src=''` or a failed load → calm initials from `alt` (or an image glyph) — never a broken `<img>`. Ripple is `.jatui-motion-ambient`: calm / reduced motion = static tint, instant swap. Clean-room (BoardUI's image-gen effect is Pro). Pure helpers: `imageRevealPhase`, `imageRevealInitials`. | `src` (`string \| null`), `alt`, `shape` (`square\|rounded\|circle`), `size` (`xs…xl` \| px \| CSS \| `fill`), `tone` (semantic or CSS colour), `label`, `initials?`, `fit?`, `phase` (bindable, read-only), `onReveal?(src)`, `onError?(src)`, `fallback?` (snippet) |
95
96
  | `Empty` | Zero-state surface — v1 thin copy/children block still works; pass `title` for the v2 hero layout (upstreamed from the IDE's EmptyState): named `glyph` (11 curated Heroicons), `size` (sm/md/lg), `pulse` (reduced-motion gated, tinted with `--color-primary`), `icon` snippet override, CTA `actions` snippet, `description`, extra `children` below. | `title` (opts into v2), `description`, `glyph`, `size`, `pulse`, `icon`/`actions` (snippets), `copy`, `class`, `children` |
96
97
  | `ErrorState` | Standard "this failed" surface (upstreamed from the IDE's ErrorState) — error-token themed icon, optional Retry button, extra `actions`, collapsible `details` block for raw error output. Centered card mirroring `Empty` v2. | `title`, `message`, `details`, `retryLabel`, `onRetry`, `size`, `actions` (snippet) |
97
98
  | `HunkDiffView` | Side-by-side diff with line numbers, red/green backgrounds, strikethrough on removed lines, inline word-level highlights via the `diff` lib. Used for reviewing YAML/text changes per hunk. | `oldText: string \| null`, `newText: string \| null`, `unified?: boolean` |
@@ -176,7 +177,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
176
177
  | `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. Items take optional `progress` (0–100): an upload ring draws around the tile and a tiny % occupies the remove-× corner, cross-fading into the × at 100 (same slot, no layout shift; per-tile `role="progressbar"`). | `items: ThumbnailItem[]` (`{id, url?, alt, progress?}`), `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
177
178
  | `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
178
179
  | `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
179
- | `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped). Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
180
+ | `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. A double-click on the picture toggles fullscreen (band transport; with native controls the browser owns the gesture), a buffering spinner shows while the media is waiting, and volume/mute survive a reload through `settingsStore` (else guarded localStorage). **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped). Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
180
181
  | `MediaCapture` | One-tap audio/selfie-video/screen(+mic) recording — a host-agnostic PRIMITIVE. Owns permission requests, MIME negotiation, the record/stop state machine, live self-preview, and mixes screen+mic audio into one track via AudioContext. Never uploads anything — hands the finished clip to `onCapture` as a Blob and the host decides what to do with it. `onCapture` may be async; the component shows "processing" until it resolves. | `onCapture: (result: MediaCaptureResult) => void \| Promise<void>`, `modes?` (default all three), `maxDurationMs?` (default 120000), `label?`, `hint?`, `compact?`, `disabled?` |
181
182
  | `MultimodalCaptureField` | "Upload a file, or record yourself going over it" as one input surface — composes `FileDropzone` (capped to one file) with `MediaCapture`. File and recording are independent (either, both, or neither); neither half uploads anything, same "validate and hand back" contract as `FileDropzone`. | `onFileSelected?`, `onFileRemoved?`, `onCapture?`, `onRecordingRemoved?`, `accept?`, `maxFileSize?`, `modes?`, `maxDurationMs?`, `file?`/`fileUrl?`/`fileName?`, `recordingSummary?` |
182
183
  | `ListenControl` | "Listen to this page" — a compact pill that expands into a TWO-ROW playback bar over the host's PER-SENTENCE audio (TTS): row 1 = play/pause · chapter chip + current sentence (full remaining width) · ONE settings pill ("1.25× · Sarah" → speed 0.75–2× + voice menu) · close; row 2 = prev/next sentence and −15s/+15s · a document-wide progress bar with chapter ticks (a hatched tail marks the not-yet-synthesized part), time, close. The host supplies an async `source` (sentences/paragraphs with ids → audio URLs, streamed page by page so playback starts on the first chunk while the rest synthesizes; pages merge by id so late URLs fill in) and gets `onSegmentChange(id)` at every boundary for highlight + scroll-follow. Media Session (lock-screen/headset controls, one document-wide position), keyboard scoped to the player (Space/K, ←/→ sentences, Shift+←/→ ±15s, [ / ] speed, Escape), speed+voice persisted in localStorage, `bind:api` for hosts wiring their own global keys, reduced-motion aware, token-only theming, never overflows its host (border-box, `max-width: 100%`, `min-width: 0` chain), fixed sticky mini-bar (same two rows) on narrow screens. Internals in `src/lib/listen/` (one timeline across per-sentence files — `buildTimeline` offsets; see `video/` for the doctrine). Demo synthesizes real WAVs locally. | `source`, `voices?`, `voiceId?` (bindable), `chapters?`, `title?`, `triggerLabel?`, `open?` (bindable), `preload?`, `estimateSec?`, `storageKey?`, `onSegmentChange?`, `onOpenChange?`, `api?` (bindable), `class?`, `children?` (custom trigger) |
@@ -15,7 +15,11 @@ const EXAMPLE = {
15
15
  meta: [
16
16
  { label: "asked by", value: "Support lead" },
17
17
  { label: "priority", value: "P1", tone: "warning" },
18
- { label: "task", value: "ops-12ab3", href: "/tasks?task=ops-12ab3" },
18
+ // https://example.com on purpose (like primaryAction below): the docs-site
19
+ // showcase renders this example and the prerender crawler follows every
20
+ // root-relative href — a fictional /tasks route 404s and fails the build
21
+ // (jatui-bg4u5). Off-origin https is skipped by the crawler.
22
+ { label: "task", value: "ops-12ab3", href: "https://example.com/tasks?task=ops-12ab3" },
19
23
  ],
20
24
  primaryAction: { label: "Open the training plan", url: "https://example.com/training" },
21
25
  sections: [
@@ -4,11 +4,16 @@ import { checklistSchema, } from "./schema.js";
4
4
  const EXAMPLE = {
5
5
  title: "2026-10-08 deploy: UAT",
6
6
  teamMode: true,
7
+ // Row urls point at https://example.com ON PURPOSE: the docs-site showcase
8
+ // renders this example and SvelteKit's prerender crawler follows every
9
+ // root-relative href (fictional /admin/... paths 404 and fail the build —
10
+ // jatui-bg4u5). Off-origin https is skipped by the crawler and renders the
11
+ // external/new-tab branch, so the example still demos test links.
7
12
  rows: [
8
13
  {
9
14
  id: "board-day-move",
10
15
  title: "Scheduling board day-move gestures + bulk move",
11
- url: "/admin/scheduling/board",
16
+ url: "https://example.com/admin/scheduling/board",
12
17
  owner: "LateClay769",
13
18
  note: "Drag a card to another day (spring arrows), then bulk-move 3 cards; expect the undo toast.",
14
19
  status: "untested",
@@ -16,7 +21,7 @@ const EXAMPLE = {
16
21
  {
17
22
  id: "team-roles",
18
23
  title: "Per-member app roles (multi-role, primary)",
19
- url: "/admin/team/members",
24
+ url: "https://example.com/admin/team/members",
20
25
  owner: "jw",
21
26
  note: "Give a member two roles, change the primary, sign in as them and check the sidebar.",
22
27
  status: "pass",
@@ -24,7 +29,7 @@ const EXAMPLE = {
24
29
  {
25
30
  id: "blocks-phase1",
26
31
  title: "Blocks artifacts render at /a/<slug>",
27
- url: "/a/blocks-smoke",
32
+ url: "https://example.com/a/blocks-smoke",
28
33
  owner: "agent",
29
34
  note: "Publish a markdown + stats + callout page as blocks; open its URL signed in.",
30
35
  status: "untested",
@@ -32,14 +37,14 @@ const EXAMPLE = {
32
37
  {
33
38
  id: "caller-id-trusthub",
34
39
  title: "Caller ID card shows real Trust Hub caller-NAME state",
35
- url: "/admin/voice/calls",
40
+ url: "https://example.com/admin/voice/calls",
36
41
  owner: "agent",
37
42
  status: "blocked",
38
43
  },
39
44
  {
40
45
  id: "pe-booking-confirm",
41
46
  title: "Booking confirmation email shows the visit window",
42
- url: "/admin/scheduling/bookings",
47
+ url: "https://example.com/admin/scheduling/bookings",
43
48
  owner: "agent",
44
49
  note: "Fixed the timezone on the window; book a slot and check the email.",
45
50
  status: "ready",
@@ -61,6 +61,12 @@ const def = {
61
61
  ],
62
62
  },
63
63
  ],
64
+ // Example links point at https://example.com ON PURPOSE: the docs-site
65
+ // showcase renders this example and SvelteKit's prerender crawler follows
66
+ // every root-relative href (a fictional /admin/... path 404s and fails the
67
+ // build — jatui-bg4u5). Off-origin https is skipped by the crawler
68
+ // (is_root_relative) and renders the external/new-tab link branch, so the
69
+ // example still demos link columns without breaking prerender.
64
70
  rows: [
65
71
  {
66
72
  invoice: "INV-1042",
@@ -69,7 +75,7 @@ const def = {
69
75
  due: "2026-09-14",
70
76
  days: 42,
71
77
  status: "overdue",
72
- url: "/admin/billing/invoices/INV-1042",
78
+ url: "https://example.com/admin/billing/invoices/INV-1042",
73
79
  },
74
80
  {
75
81
  invoice: "INV-1043",
@@ -78,7 +84,7 @@ const def = {
78
84
  due: "2026-10-02",
79
85
  days: 6,
80
86
  status: "current",
81
- url: "/admin/billing/invoices/INV-1043",
87
+ url: "https://example.com/admin/billing/invoices/INV-1043",
82
88
  },
83
89
  {
84
90
  invoice: "INV-1044",
@@ -87,7 +93,7 @@ const def = {
87
93
  due: "2026-10-11",
88
94
  days: 0,
89
95
  status: "sent",
90
- url: "/admin/billing/invoices/INV-1044",
96
+ url: "https://example.com/admin/billing/invoices/INV-1044",
91
97
  },
92
98
  ],
93
99
  pageSize: 50,
@@ -0,0 +1,353 @@
1
+ <!--
2
+ ImageReveal — a frame that shows a "generating" ripple until its image
3
+ exists, then brings the image in blur-to-sharp. Built for slots whose image
4
+ arrives late: a logo being looked up, an AI generation, a thumbnail still
5
+ rendering.
6
+
7
+ src null / undefined → generating: ripple on the tone, role="status",
8
+ aria-busy
9
+ src set, not decoded → still the ripple (no half-loaded image is shown)
10
+ src loaded → blur + slight scale → sharp, ripple fades; onReveal
11
+ src '' or onerror → calm initials (from alt) or an image glyph — never a
12
+ broken image
13
+
14
+ Colours are one tone mixed with color-mix (no hex). The ripple is a
15
+ decorative loop: .jatui-motion-ambient, and under data-motion="calm" or
16
+ prefers-reduced-motion it becomes a static tint and the image swaps in
17
+ without the blur.
18
+
19
+ Clean-room (jatui-vui9q): BoardUI's image-generation effect is a Pro item;
20
+ nothing from it was read or copied.
21
+ -->
22
+ <script lang="ts">
23
+ import type { Snippet } from 'svelte';
24
+ import {
25
+ IMAGE_REVEAL_RADIUS,
26
+ imageRevealBusy,
27
+ imageRevealInitials,
28
+ imageRevealPhase,
29
+ imageRevealSize,
30
+ imageRevealTone,
31
+ type ImageRevealPhase,
32
+ type ImageRevealShape,
33
+ type ImageRevealSizeName,
34
+ type ImageRevealToneName
35
+ } from './imageReveal';
36
+
37
+ interface Props {
38
+ /** null / undefined = still generating. '' = nothing to show (fallback). */
39
+ src?: string | null;
40
+ /** Alt text for the image; also the source of the fallback initials. */
41
+ alt?: string;
42
+ shape?: ImageRevealShape;
43
+ /** Named size, px number, any CSS length, or 'fill' (100% width). Always square. */
44
+ size?: ImageRevealSizeName | number | (string & {});
45
+ /** DaisyUI semantic name or any CSS colour. */
46
+ tone?: ImageRevealToneName | (string & {});
47
+ /** Screen-reader status while generating. */
48
+ label?: string;
49
+ /** Override the letters derived from `alt`. '' forces the glyph. */
50
+ initials?: string;
51
+ /** object-fit for the image. Logos usually want 'contain'. */
52
+ fit?: 'cover' | 'contain';
53
+ /** Read-only: the current phase. */
54
+ phase?: ImageRevealPhase;
55
+ /** Fires once per src, when that image has loaded. */
56
+ onReveal?: (src: string) => void;
57
+ /** Fires once per src, when that image failed to load. */
58
+ onError?: (src: string) => void;
59
+ /** Replaces the initials / glyph fallback. */
60
+ fallback?: Snippet;
61
+ class?: string;
62
+ }
63
+
64
+ let {
65
+ src = null,
66
+ alt = '',
67
+ shape = 'rounded',
68
+ size = 'md',
69
+ tone = 'primary',
70
+ label = 'Generating image',
71
+ initials,
72
+ fit = 'cover',
73
+ phase = $bindable('generating'),
74
+ onReveal,
75
+ onError,
76
+ fallback,
77
+ class: className = ''
78
+ }: Props = $props();
79
+
80
+ let loadedSrc = $state<string | null>(null);
81
+ let failedSrc = $state<string | null>(null);
82
+ let img = $state<HTMLImageElement | null>(null);
83
+
84
+ const current = $derived(imageRevealPhase({ src, loadedSrc, failedSrc }));
85
+ const busy = $derived(imageRevealBusy(current));
86
+ const letters = $derived(initials ?? imageRevealInitials(alt));
87
+
88
+ $effect.pre(() => {
89
+ phase = current;
90
+ });
91
+
92
+ function loaded(from: string) {
93
+ if (from !== src || loadedSrc === from) return;
94
+ loadedSrc = from;
95
+ onReveal?.(from);
96
+ }
97
+
98
+ function failed(from: string) {
99
+ if (from !== src || failedSrc === from) return;
100
+ failedSrc = from;
101
+ onError?.(from);
102
+ }
103
+
104
+ // An image served from cache (or loaded before hydration) can finish before
105
+ // the load listener exists, so read its state once it is in the DOM.
106
+ $effect(() => {
107
+ if (!img || !src) return;
108
+ const el = img;
109
+ const from = src;
110
+ if (el.complete) {
111
+ if (el.naturalWidth > 0) loaded(from);
112
+ else if (el.currentSrc || el.getAttribute('src')) failed(from);
113
+ }
114
+ });
115
+ </script>
116
+
117
+ <div
118
+ class="jatui-image-reveal is-{current} {className}"
119
+ style:--jatui-ir-tone={imageRevealTone(tone)}
120
+ style:--jatui-ir-size={imageRevealSize(size)}
121
+ style:--jatui-ir-radius={IMAGE_REVEAL_RADIUS[shape]}
122
+ aria-busy={busy}
123
+ data-phase={current}
124
+ >
125
+ <span class="jatui-ir-status" role="status">{busy ? label : ''}</span>
126
+
127
+ {#if current !== 'fallback'}
128
+ <div class="jatui-ir-ripple" class:jatui-motion-ambient={busy} aria-hidden="true">
129
+ <span class="jatui-ir-ring"></span>
130
+ <span class="jatui-ir-ring"></span>
131
+ <span class="jatui-ir-ring"></span>
132
+ <span class="jatui-ir-core"></span>
133
+ </div>
134
+ {/if}
135
+
136
+ {#if src && current !== 'fallback'}
137
+ {#key src}
138
+ <img
139
+ bind:this={img}
140
+ class="jatui-ir-img"
141
+ class:is-shown={current === 'revealed'}
142
+ style:object-fit={fit}
143
+ {src}
144
+ {alt}
145
+ decoding="async"
146
+ onload={() => loaded(src!)}
147
+ onerror={() => failed(src!)}
148
+ />
149
+ {/key}
150
+ {:else if current === 'fallback'}
151
+ {#if fallback}
152
+ <div class="jatui-ir-fallback">{@render fallback()}</div>
153
+ {:else if letters}
154
+ <div class="jatui-ir-fallback" role={alt ? 'img' : undefined} aria-label={alt || undefined}>
155
+ <span class="jatui-ir-initials" aria-hidden="true">{letters}</span>
156
+ </div>
157
+ {:else}
158
+ <div class="jatui-ir-fallback" role={alt ? 'img' : undefined} aria-label={alt || undefined}>
159
+ <svg class="jatui-ir-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
160
+ <rect x="3" y="4" width="18" height="16" rx="2" />
161
+ <circle cx="9" cy="10" r="1.75" />
162
+ <path d="m21 16-5-5-9 9" stroke-linecap="round" stroke-linejoin="round" />
163
+ </svg>
164
+ </div>
165
+ {/if}
166
+ {/if}
167
+ </div>
168
+
169
+ <style>
170
+ .jatui-image-reveal {
171
+ --jatui-ir-reveal: calc(var(--motion-move, 300ms) * 2);
172
+ position: relative;
173
+ display: inline-grid;
174
+ place-items: center;
175
+ width: var(--jatui-ir-size);
176
+ max-width: 100%;
177
+ aspect-ratio: 1;
178
+ overflow: hidden;
179
+ isolation: isolate;
180
+ container-type: inline-size;
181
+ border-radius: var(--jatui-ir-radius);
182
+ background: color-mix(in oklab, var(--jatui-ir-tone) 7%, var(--color-base-100, Canvas));
183
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--jatui-ir-tone) 14%, transparent);
184
+ vertical-align: middle;
185
+ }
186
+
187
+ .jatui-ir-status {
188
+ position: absolute;
189
+ width: 1px;
190
+ height: 1px;
191
+ margin: -1px;
192
+ overflow: hidden;
193
+ clip-path: inset(50%);
194
+ white-space: nowrap;
195
+ }
196
+
197
+ /* ---- generating: ripple ---- */
198
+ .jatui-ir-ripple {
199
+ position: absolute;
200
+ inset: 0;
201
+ display: grid;
202
+ place-items: center;
203
+ background: radial-gradient(
204
+ circle at 50% 50%,
205
+ color-mix(in oklab, var(--jatui-ir-tone) 16%, transparent) 0%,
206
+ transparent 70%
207
+ );
208
+ transition: opacity var(--jatui-ir-reveal) var(--motion-glide, ease-out);
209
+ }
210
+
211
+ .jatui-ir-ring,
212
+ .jatui-ir-core {
213
+ grid-area: 1 / 1;
214
+ border-radius: 9999px;
215
+ }
216
+
217
+ .jatui-ir-ring {
218
+ width: 90%;
219
+ aspect-ratio: 1;
220
+ border: max(1px, 2cqi) solid
221
+ color-mix(in oklab, var(--jatui-ir-tone) 45%, transparent);
222
+ opacity: 0;
223
+ transform: scale(0.2);
224
+ animation: jatui-ir-ripple 2400ms var(--motion-glide, ease-out) infinite;
225
+ }
226
+
227
+ .jatui-ir-ring:nth-child(2) {
228
+ animation-delay: 800ms;
229
+ }
230
+
231
+ .jatui-ir-ring:nth-child(3) {
232
+ animation-delay: 1600ms;
233
+ }
234
+
235
+ .jatui-ir-core {
236
+ width: 22%;
237
+ aspect-ratio: 1;
238
+ background: color-mix(in oklab, var(--jatui-ir-tone) 55%, transparent);
239
+ filter: blur(4cqi);
240
+ animation: jatui-ir-breathe 2400ms ease-in-out infinite;
241
+ }
242
+
243
+ @keyframes jatui-ir-ripple {
244
+ 0% {
245
+ opacity: 0;
246
+ transform: scale(0.2);
247
+ }
248
+ 15% {
249
+ opacity: 1;
250
+ }
251
+ 100% {
252
+ opacity: 0;
253
+ transform: scale(1.35);
254
+ }
255
+ }
256
+
257
+ @keyframes jatui-ir-breathe {
258
+ 0%,
259
+ 100% {
260
+ opacity: 0.55;
261
+ transform: scale(0.85);
262
+ }
263
+ 50% {
264
+ opacity: 1;
265
+ transform: scale(1.1);
266
+ }
267
+ }
268
+
269
+ /* ---- reveal ---- */
270
+ .jatui-ir-img {
271
+ position: absolute;
272
+ inset: 0;
273
+ width: 100%;
274
+ height: 100%;
275
+ opacity: 0;
276
+ filter: blur(calc(12cqi + 4px));
277
+ transform: scale(1.06);
278
+ transition:
279
+ opacity var(--jatui-ir-reveal) var(--motion-glide, ease-out),
280
+ filter var(--jatui-ir-reveal) var(--motion-glide, ease-out),
281
+ transform var(--jatui-ir-reveal) var(--motion-glide, ease-out);
282
+ }
283
+
284
+ .jatui-ir-img.is-shown {
285
+ opacity: 1;
286
+ filter: none;
287
+ transform: none;
288
+ }
289
+
290
+ .is-revealed .jatui-ir-ripple {
291
+ opacity: 0;
292
+ visibility: hidden;
293
+ transition:
294
+ opacity var(--jatui-ir-reveal) var(--motion-glide, ease-out),
295
+ visibility 0s linear var(--jatui-ir-reveal);
296
+ }
297
+
298
+ .is-revealed {
299
+ background: transparent;
300
+ box-shadow: none;
301
+ }
302
+
303
+ /* ---- fallback ---- */
304
+ .jatui-ir-fallback {
305
+ position: absolute;
306
+ inset: 0;
307
+ display: grid;
308
+ place-items: center;
309
+ color: color-mix(in oklab, var(--jatui-ir-tone) 75%, var(--color-base-content, CanvasText));
310
+ }
311
+
312
+ .jatui-ir-initials {
313
+ font-weight: 600;
314
+ font-size: 36cqi;
315
+ line-height: 1;
316
+ letter-spacing: 0.02em;
317
+ }
318
+
319
+ .jatui-ir-glyph {
320
+ width: 42%;
321
+ height: 42%;
322
+ opacity: 0.7;
323
+ }
324
+
325
+ /* ---- calm / reduced motion: static tint, instant swap ---- */
326
+ :global([data-motion='calm']) .jatui-ir-ring,
327
+ :global([data-motion='calm']) .jatui-ir-core,
328
+ .jatui-image-reveal:global([data-motion='calm']) .jatui-ir-ring,
329
+ .jatui-image-reveal:global([data-motion='calm']) .jatui-ir-core {
330
+ animation: none;
331
+ }
332
+ :global([data-motion='calm']) .jatui-ir-ring,
333
+ .jatui-image-reveal:global([data-motion='calm']) .jatui-ir-ring {
334
+ opacity: 0;
335
+ }
336
+ :global([data-motion='calm']) .jatui-image-reveal,
337
+ .jatui-image-reveal:global([data-motion='calm']) {
338
+ --jatui-ir-reveal: 0s;
339
+ }
340
+
341
+ @media (prefers-reduced-motion: reduce) {
342
+ .jatui-image-reveal {
343
+ --jatui-ir-reveal: 0s;
344
+ }
345
+ .jatui-ir-ring,
346
+ .jatui-ir-core {
347
+ animation: none;
348
+ }
349
+ .jatui-ir-ring {
350
+ opacity: 0;
351
+ }
352
+ }
353
+ </style>
@@ -0,0 +1,31 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type ImageRevealPhase, type ImageRevealShape, type ImageRevealSizeName, type ImageRevealToneName } from './imageReveal';
3
+ interface Props {
4
+ /** null / undefined = still generating. '' = nothing to show (fallback). */
5
+ src?: string | null;
6
+ /** Alt text for the image; also the source of the fallback initials. */
7
+ alt?: string;
8
+ shape?: ImageRevealShape;
9
+ /** Named size, px number, any CSS length, or 'fill' (100% width). Always square. */
10
+ size?: ImageRevealSizeName | number | (string & {});
11
+ /** DaisyUI semantic name or any CSS colour. */
12
+ tone?: ImageRevealToneName | (string & {});
13
+ /** Screen-reader status while generating. */
14
+ label?: string;
15
+ /** Override the letters derived from `alt`. '' forces the glyph. */
16
+ initials?: string;
17
+ /** object-fit for the image. Logos usually want 'contain'. */
18
+ fit?: 'cover' | 'contain';
19
+ /** Read-only: the current phase. */
20
+ phase?: ImageRevealPhase;
21
+ /** Fires once per src, when that image has loaded. */
22
+ onReveal?: (src: string) => void;
23
+ /** Fires once per src, when that image failed to load. */
24
+ onError?: (src: string) => void;
25
+ /** Replaces the initials / glyph fallback. */
26
+ fallback?: Snippet;
27
+ class?: string;
28
+ }
29
+ declare const ImageReveal: import("svelte").Component<Props, {}, "phase">;
30
+ type ImageReveal = ReturnType<typeof ImageReveal>;
31
+ export default ImageReveal;
@@ -66,6 +66,11 @@
66
66
  * · The keyboard map (video/keys.ts) is the single source the keydown
67
67
  * handler and the `?` overlay both read — a key that exists is documented,
68
68
  * a documented key exists.
69
+ * · Extension parity (mos-telti, jatui-xz6so): a double-click on the picture
70
+ * toggles fullscreen (E10, where the player owns the controls); a buffering
71
+ * spinner covers `waiting` (E13); volume and mute persist across reloads
72
+ * through the settings store (E12 — remembered media state, never panel
73
+ * rows).
69
74
  *
70
75
  * Internals live in `src/lib/video/` (band / keys / rate / deepLink /
71
76
  * settings + types/time/model; audio, night and presence are contracts for
@@ -257,7 +262,10 @@
257
262
  localSettingsStore,
258
263
  panelGroups,
259
264
  settingSpec,
265
+ SETTINGS_WRITE_DELAY_MS,
260
266
  VIDEO_SETTINGS,
267
+ REMEMBERED_MUTED,
268
+ REMEMBERED_VOLUME,
261
269
  type SettingAvailability,
262
270
  type SettingsSession,
263
271
  type SettingValue,
@@ -799,11 +807,25 @@
799
807
  let volumeLevel = $state(1);
800
808
  let mutedNow = $state(false);
801
809
  let isFullscreen = $state(false);
810
+ // E13 (jatui-xz6so) — extension library.js:5843: playback is waiting for
811
+ // data. `waiting` raises it; `playing`/`canplay` clear it (canplay also
812
+ // ends a stall the viewer paused through).
813
+ let buffering = $state(false);
802
814
  function syncVolume() {
803
815
  if (!videoEl) return;
804
816
  volumeLevel = videoEl.volume;
805
817
  mutedNow = videoEl.muted;
806
818
  }
819
+ /** E12 (jatui-xz6so): the user moved volume or mute. Mirrored into state
820
+ * here, and remembered where media state is persisted — directly from the
821
+ * gesture, NOT via the element's volumechange, which engines may delay or
822
+ * batch past the moment the next store read resolves. A gesture made
823
+ * before that read resolves wins (the settings session's rule). */
824
+ function userVolumeGesture() {
825
+ syncVolume();
826
+ mediaTouched = true;
827
+ rememberMediaState();
828
+ }
807
829
  function togglePlay() {
808
830
  const v = videoEl;
809
831
  if (!v) return;
@@ -816,6 +838,7 @@
816
838
  v.muted = !v.muted;
817
839
  if (!v.muted && v.volume === 0) v.volume = 0.5;
818
840
  showVolumeOsd(v.volume, v.muted);
841
+ userVolumeGesture();
819
842
  }
820
843
  function onVolumeInput(e: Event) {
821
844
  const v = videoEl;
@@ -824,12 +847,23 @@
824
847
  v.volume = Math.min(1, Math.max(0, next));
825
848
  v.muted = next === 0;
826
849
  showVolumeOsd(v.volume, v.muted);
850
+ userVolumeGesture();
827
851
  }
828
852
  function toggleFullscreen() {
829
853
  if (typeof document === 'undefined') return;
830
854
  if (document.fullscreenElement) void document.exitFullscreen?.();
831
855
  else void stageWrapEl?.requestFullscreen?.();
832
856
  }
857
+ /** E10 (jatui-xz6so) — extension library.js:5836: a double-click on the
858
+ * picture toggles fullscreen. Bound only where the player owns the
859
+ * controls: with native controls the browser already does this on the
860
+ * video itself, and a second toggle would fight it. The double-click's
861
+ * two clicks toggle play and un-toggle it, so playback ends where it
862
+ * started — only the view changes. */
863
+ function onStageDblClick(e: MouseEvent) {
864
+ e.preventDefault();
865
+ toggleFullscreen();
866
+ }
833
867
  /** The speed button: the listening speeds people actually pick, in
834
868
  * order, wrapping back to 1×. `-`/`=` still walk the fine staircase. */
835
869
  const SPEED_CYCLE = [1, 1.25, 1.5, 1.75, 2];
@@ -2038,6 +2072,15 @@
2038
2072
  // mid-session preference flip is respected instead of being a mount-only
2039
2073
  // decision. The `.play()` promise can reject (no user gesture, tab backgrounded)
2040
2074
  // — that's fine, the poster stays and the controls remain.
2075
+ //
2076
+ // The mandated mute is set HERE, right before the play, and nowhere else:
2077
+ // it used to ride the element as `muted={effectiveAutoplay || undefined}`,
2078
+ // which Svelte compiles into the stage's SHARED template effect — re-run by
2079
+ // every unrelated state change (clock, OSD, aria values) — and each re-run
2080
+ // reset `video.muted` to false, silently un-muting the viewer a moment
2081
+ // after every mute (jatui-xz6so: surfaced by volume/mute persistence, which
2082
+ // needs the choice to stick long enough to save). A mute the viewer makes
2083
+ // on an autoplaying clip now stays made.
2041
2084
  let didAutoplay = false;
2042
2085
  $effect(() => {
2043
2086
  const v = videoEl;
@@ -2343,6 +2386,100 @@
2343
2386
  flashOsd(next ? 'Theater mode' : 'Default view');
2344
2387
  }
2345
2388
 
2389
+ // ── Remembered volume & mute (E12, jatui-xz6so) ──────────────────────
2390
+ // Extension library.js:5768 — the volume level and the mute choice survive
2391
+ // a reload, persisted through the same store the settings registry writes
2392
+ // (the host's `settingsStore`, else this browser's localStorage — the
2393
+ // theater split; `settings/remembered.ts` holds the ids and the
2394
+ // never-throws coercion). Two write paths: the player's own gestures hook
2395
+ // directly (userVolumeGesture — an engine may delay or batch volumechange
2396
+ // past the moment a pending store read resolves, and the gesture must win),
2397
+ // and the element's `volumechange` covers the one path the player can't
2398
+ // see — the native bar's own volume control. The writes that are the
2399
+ // PLAYER's, not the user's, never persist: the wind-down fade's ~400
2400
+ // volume steps, and the load-time restore below.
2401
+ const localMediaStore = localSettingsStore();
2402
+ let mediaRestoring = false;
2403
+ let mediaTouched = false;
2404
+ let mediaWriteTimer: ReturnType<typeof setTimeout> | null = null;
2405
+
2406
+ function onVolumeChange() {
2407
+ syncVolume();
2408
+ // A restore writes first and explains later; the fade's writes are ours.
2409
+ if (mediaRestoring || windPhase === 'fading') return;
2410
+ mediaTouched = true;
2411
+ rememberMediaState();
2412
+ }
2413
+
2414
+ /** A burst of wheel steps or a dragged slider is one write, not one per tick. */
2415
+ function rememberMediaState() {
2416
+ const store = settingsStore ?? localMediaStore;
2417
+ if (mediaWriteTimer) clearTimeout(mediaWriteTimer);
2418
+ mediaWriteTimer = setTimeout(() => flushMediaState(store), SETTINGS_WRITE_DELAY_MS);
2419
+ }
2420
+
2421
+ function flushMediaState(store: VideoSettingsStore) {
2422
+ mediaWriteTimer = null;
2423
+ const v = videoEl;
2424
+ if (!v) return;
2425
+ // Onto the 0.05 gesture grid, so 0.35000000000000003 never reaches a store.
2426
+ writeRemembered(store, REMEMBERED_VOLUME.id, Math.round(v.volume * 20) / 20);
2427
+ writeRemembered(store, REMEMBERED_MUTED.id, v.muted);
2428
+ }
2429
+
2430
+ function writeRemembered(store: VideoSettingsStore, id: string, value: SettingValue) {
2431
+ try {
2432
+ const r = store.set(id, value);
2433
+ if (r && typeof (r as Promise<void>).catch === 'function') (r as Promise<void>).catch(() => {});
2434
+ } catch {
2435
+ /* a store that throws is a store that didn't save — never an error in the player */
2436
+ }
2437
+ }
2438
+
2439
+ // The load-time read, once per store identity: a junk or throwing store
2440
+ // reads as nothing stored, and a choice made before the read resolves wins
2441
+ // (the settings session's rule, mirrored here). An autoplaying clip keeps
2442
+ // the browser-mandated mute — unmuting it is the viewer's gesture; the
2443
+ // restored level is already in place for when they do.
2444
+ $effect(() => {
2445
+ const store = settingsStore ?? localMediaStore;
2446
+ let cancelled = false;
2447
+ untrack(() => {
2448
+ void Promise.all([store.get(REMEMBERED_VOLUME.id), store.get(REMEMBERED_MUTED.id)])
2449
+ .then(([rawVolume, rawMuted]) => {
2450
+ if (cancelled || mediaTouched) return;
2451
+ applyRememberedMedia(REMEMBERED_VOLUME.coerce(rawVolume), REMEMBERED_MUTED.coerce(rawMuted));
2452
+ })
2453
+ .catch(() => {});
2454
+ });
2455
+ return () => {
2456
+ cancelled = true;
2457
+ };
2458
+ });
2459
+
2460
+ function applyRememberedMedia(volume: number | undefined, muted: boolean | undefined) {
2461
+ const v = videoEl;
2462
+ if (!v) return;
2463
+ mediaRestoring = true;
2464
+ if (volume !== undefined) v.volume = volume;
2465
+ if (muted !== undefined && !effectiveAutoplay) v.muted = muted;
2466
+ // The element's volumechange may fire synchronously or be queued — cover
2467
+ // both before the restore's own writes can read as a user choice.
2468
+ setTimeout(() => (mediaRestoring = false), 0);
2469
+ }
2470
+
2471
+ // Unmount sends whatever is pending, like the session's flush. The store
2472
+ // captured here is the one the pending writes were destined for.
2473
+ $effect(() => {
2474
+ const store = settingsStore ?? localMediaStore;
2475
+ return () => {
2476
+ if (mediaWriteTimer) {
2477
+ clearTimeout(mediaWriteTimer);
2478
+ flushMediaState(store);
2479
+ }
2480
+ };
2481
+ });
2482
+
2346
2483
  /** A viewer choice from outside the panel (N, the moon wheel) — saved like a panel change. */
2347
2484
  function rememberSetting(id: string, v: SettingValue) {
2348
2485
  settingsSession?.change(id, v);
@@ -2857,6 +2994,7 @@
2857
2994
  videoEl.volume = next;
2858
2995
  if (next > 0) videoEl.muted = false;
2859
2996
  showVolumeOsd(next, next === 0);
2997
+ userVolumeGesture();
2860
2998
  }
2861
2999
 
2862
3000
  /**
@@ -2939,14 +3077,17 @@
2939
3077
  v.volume = Math.min(1, v.volume + 0.05);
2940
3078
  if (v.volume > 0) v.muted = false;
2941
3079
  showVolumeOsd(v.volume, v.muted);
3080
+ userVolumeGesture();
2942
3081
  break;
2943
3082
  case 'ArrowDown':
2944
3083
  v.volume = Math.max(0, v.volume - 0.05);
2945
3084
  showVolumeOsd(v.volume, v.muted);
3085
+ userVolumeGesture();
2946
3086
  break;
2947
3087
  case 'm':
2948
3088
  v.muted = !v.muted;
2949
3089
  showVolumeOsd(v.volume, v.muted);
3090
+ userVolumeGesture();
2950
3091
  break;
2951
3092
  case '-':
2952
3093
  case '_':
@@ -3363,14 +3504,18 @@
3363
3504
  style:--vp-night-vig={nightLayersOn && nightFrame ? nightFrame.levels.vig.toFixed(3) : undefined}
3364
3505
  style:--vp-wind-down={windProgress > 0 ? windProgress.toFixed(3) : undefined}
3365
3506
  onwheel={onStageWheel}
3507
+ ondblclick={bandTransport && !isAudio ? onStageDblClick : undefined}
3366
3508
  >
3367
3509
  <video
3368
3510
  bind:this={videoEl}
3369
3511
  bind:currentTime
3370
3512
  bind:duration
3371
3513
  bind:paused
3372
- onvolumechange={syncVolume}
3514
+ onvolumechange={onVolumeChange}
3373
3515
  ontimeupdate={handleTimeUpdate}
3516
+ onwaiting={() => (buffering = true)}
3517
+ onplaying={() => (buffering = false)}
3518
+ oncanplay={() => (buffering = false)}
3374
3519
  onloadedmetadata={onLoadedMetadata}
3375
3520
  onseeked={() => {
3376
3521
  syncUrlNow();
@@ -3395,7 +3540,6 @@
3395
3540
  {src}
3396
3541
  {loop}
3397
3542
  crossorigin={crossorigin ?? undefined}
3398
- muted={effectiveAutoplay || undefined}
3399
3543
  poster={isAudio ? undefined : (poster ?? undefined)}
3400
3544
  controls={!bandTransport}
3401
3545
  playsinline
@@ -3432,6 +3576,27 @@
3432
3576
  OSD, which carries the hint. Gone the instant progress is 0. -->
3433
3577
  <div class="vp-wind-veil" aria-hidden="true"></div>
3434
3578
  {/if}
3579
+ {#if buffering && !isAudio}
3580
+ <!-- Buffering spinner (E13, jatui-xz6so): playback is waiting for
3581
+ data — extension library.js:5843. Decorative (aria-hidden): the
3582
+ media element and the timeline already report the stall to AT.
3583
+ After the night and wind-down veils in DOM order, so a dimmed
3584
+ picture never hides it. -->
3585
+ <div class="vp-buffering" aria-hidden="true">
3586
+ <svg
3587
+ class="vp-buffering-ring"
3588
+ viewBox="0 0 24 24"
3589
+ width="26"
3590
+ height="26"
3591
+ fill="none"
3592
+ stroke="currentColor"
3593
+ stroke-width="2.5"
3594
+ stroke-linecap="round"
3595
+ >
3596
+ <path d="M21 12a9 9 0 1 1-9-9" />
3597
+ </svg>
3598
+ </div>
3599
+ {/if}
3435
3600
  <div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
3436
3601
  <!-- Volume OSD (jatui-pu698): speaker glyph + level bar + %, centred
3437
3602
  near the top of the stage so a wheel/keyboard volume gesture is
@@ -4486,6 +4651,47 @@
4486
4651
  min-width: 3.5rem;
4487
4652
  }
4488
4653
 
4654
+ /* ── Buffering spinner (E13, jatui-xz6so) ─────────────────────────── */
4655
+ /* Playback is waiting for data: a small ring, dead centre of the stage,
4656
+ on the same token surface as the volume OSD so it is legible on any
4657
+ frame. Appearing is an opacity fade only — reduced-motion safe — and
4658
+ the ROTATION alone is gated off under prefers-reduced-motion (a static
4659
+ ring still reads as "working"; nothing slides or scales). */
4660
+ .vp-buffering {
4661
+ position: absolute;
4662
+ top: 50%;
4663
+ left: 50%;
4664
+ transform: translate(-50%, -50%);
4665
+ display: grid;
4666
+ place-items: center;
4667
+ width: 56px;
4668
+ height: 56px;
4669
+ border-radius: 999px;
4670
+ background: color-mix(in oklch, var(--color-base-100) 85%, transparent);
4671
+ backdrop-filter: blur(8px);
4672
+ color: var(--color-base-content);
4673
+ pointer-events: none;
4674
+ z-index: 10;
4675
+ box-shadow: 0 4px 16px color-mix(in oklch, black 20%, transparent);
4676
+ animation: vp-buffer-in var(--motion-fast, 150ms) var(--motion-glide, ease-out);
4677
+ }
4678
+
4679
+ .vp-buffering-ring {
4680
+ animation: vp-buffer-spin 0.9s linear infinite;
4681
+ }
4682
+
4683
+ @keyframes vp-buffer-spin {
4684
+ to {
4685
+ transform: rotate(360deg);
4686
+ }
4687
+ }
4688
+
4689
+ @keyframes vp-buffer-in {
4690
+ from {
4691
+ opacity: 0;
4692
+ }
4693
+ }
4694
+
4489
4695
  /* The band owns its height from the first frame so a waveform or speaker
4490
4696
  turns arriving minutes in fade into space that is already theirs. */
4491
4697
  .vp-band {
@@ -5264,6 +5470,16 @@
5264
5470
  transition: none;
5265
5471
  }
5266
5472
 
5473
+ /* E13: the spinner's fade-in and its rotation both stand down — a
5474
+ static ring still reads as "working". */
5475
+ .vp-buffering {
5476
+ animation: none;
5477
+ }
5478
+
5479
+ .vp-buffering-ring {
5480
+ animation: none;
5481
+ }
5482
+
5267
5483
  .vp-play:hover,
5268
5484
  .vp-play:active {
5269
5485
  transform: none;
@@ -46,6 +46,11 @@ export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/set
46
46
  * · The keyboard map (video/keys.ts) is the single source the keydown
47
47
  * handler and the `?` overlay both read — a key that exists is documented,
48
48
  * a documented key exists.
49
+ * · Extension parity (mos-telti, jatui-xz6so): a double-click on the picture
50
+ * toggles fullscreen (E10, where the player owns the controls); a buffering
51
+ * spinner covers `waiting` (E13); volume and mute persist across reloads
52
+ * through the settings store (E12 — remembered media state, never panel
53
+ * rows).
49
54
  *
50
55
  * Internals live in `src/lib/video/` (band / keys / rate / deepLink /
51
56
  * settings + types/time/model; audio, night and presence are contracts for
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Pure helpers behind ImageReveal: which phase the frame is in, the initials
3
+ * fallback, and the size / shape / tone lookups. No DOM, so
4
+ * tests/unit/imageReveal.test.ts runs them in node.
5
+ *
6
+ * Clean-room (jatui-vui9q): BoardUI's "AI Image Generation" item is in the
7
+ * Pro tier, so no BoardUI source was read or copied.
8
+ */
9
+ /**
10
+ * generating — no src yet (`null`/`undefined`): the ripple runs.
11
+ * loading — a src arrived but the bytes haven't decoded: the ripple keeps
12
+ * running, the image waits underneath at blur + opacity 0.
13
+ * revealed — the image loaded: blur-to-sharp, the ripple fades out.
14
+ * fallback — `src === ''` or the image failed: calm initials / empty glyph.
15
+ */
16
+ export type ImageRevealPhase = 'generating' | 'loading' | 'revealed' | 'fallback';
17
+ export type ImageRevealShape = 'square' | 'rounded' | 'circle';
18
+ export type ImageRevealSizeName = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'fill';
19
+ /** DaisyUI semantic colour names accepted as a `tone`. */
20
+ export type ImageRevealToneName = 'primary' | 'secondary' | 'accent' | 'neutral' | 'info' | 'success' | 'warning' | 'error';
21
+ export interface ImageRevealState {
22
+ src: string | null | undefined;
23
+ /** The src whose load event fired (null if none has). */
24
+ loadedSrc: string | null;
25
+ /** The src whose error event fired (null if none has). */
26
+ failedSrc: string | null;
27
+ }
28
+ /**
29
+ * Phase from the current src plus what the browser reported for it. Load and
30
+ * error are keyed by src, so a new src starts over at `loading` without the
31
+ * component having to reset flags.
32
+ */
33
+ export declare function imageRevealPhase({ src, loadedSrc, failedSrc }: ImageRevealState): ImageRevealPhase;
34
+ /** True while the frame is still waiting for something to show. */
35
+ export declare function imageRevealBusy(phase: ImageRevealPhase): boolean;
36
+ /**
37
+ * Up to two letters from the alt text: "Acme Corp" → "AC", "acme" → "A",
38
+ * "the-north-face" → "TN". Symbols and digits-only words are skipped, so a
39
+ * logo alt like "Acme logo" gives "AL" — callers who want "A" pass
40
+ * `initials` explicitly.
41
+ */
42
+ export declare function imageRevealInitials(alt: string | null | undefined): string;
43
+ export declare const IMAGE_REVEAL_SIZES: Record<Exclude<ImageRevealSizeName, 'fill'>, number>;
44
+ /**
45
+ * A CSS length for the frame's width. Named sizes and numbers are px; any
46
+ * other string (`'10rem'`, `'50%'`) passes through; `'fill'` is 100%.
47
+ */
48
+ export declare function imageRevealSize(size: ImageRevealSizeName | number | (string & {})): string;
49
+ export declare const IMAGE_REVEAL_RADIUS: Record<ImageRevealShape, string>;
50
+ /** A semantic name → its DaisyUI variable; any other string is a CSS colour. */
51
+ export declare function imageRevealTone(tone: ImageRevealToneName | (string & {})): string;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pure helpers behind ImageReveal: which phase the frame is in, the initials
3
+ * fallback, and the size / shape / tone lookups. No DOM, so
4
+ * tests/unit/imageReveal.test.ts runs them in node.
5
+ *
6
+ * Clean-room (jatui-vui9q): BoardUI's "AI Image Generation" item is in the
7
+ * Pro tier, so no BoardUI source was read or copied.
8
+ */
9
+ /**
10
+ * Phase from the current src plus what the browser reported for it. Load and
11
+ * error are keyed by src, so a new src starts over at `loading` without the
12
+ * component having to reset flags.
13
+ */
14
+ export function imageRevealPhase({ src, loadedSrc, failedSrc }) {
15
+ if (src == null)
16
+ return 'generating';
17
+ if (src.trim() === '' || failedSrc === src)
18
+ return 'fallback';
19
+ if (loadedSrc === src)
20
+ return 'revealed';
21
+ return 'loading';
22
+ }
23
+ /** True while the frame is still waiting for something to show. */
24
+ export function imageRevealBusy(phase) {
25
+ return phase === 'generating' || phase === 'loading';
26
+ }
27
+ /**
28
+ * Up to two letters from the alt text: "Acme Corp" → "AC", "acme" → "A",
29
+ * "the-north-face" → "TN". Symbols and digits-only words are skipped, so a
30
+ * logo alt like "Acme logo" gives "AL" — callers who want "A" pass
31
+ * `initials` explicitly.
32
+ */
33
+ export function imageRevealInitials(alt) {
34
+ if (!alt)
35
+ return '';
36
+ const words = alt
37
+ .split(/[\s\-_./]+/)
38
+ .map((w) => w.match(/\p{L}/u)?.[0] ?? '')
39
+ .filter(Boolean);
40
+ return words
41
+ .slice(0, 2)
42
+ .join('')
43
+ .toLocaleUpperCase();
44
+ }
45
+ export const IMAGE_REVEAL_SIZES = {
46
+ xs: 32,
47
+ sm: 48,
48
+ md: 64,
49
+ lg: 96,
50
+ xl: 128
51
+ };
52
+ /**
53
+ * A CSS length for the frame's width. Named sizes and numbers are px; any
54
+ * other string (`'10rem'`, `'50%'`) passes through; `'fill'` is 100%.
55
+ */
56
+ export function imageRevealSize(size) {
57
+ if (typeof size === 'number')
58
+ return `${size}px`;
59
+ if (size === 'fill')
60
+ return '100%';
61
+ if (size in IMAGE_REVEAL_SIZES)
62
+ return `${IMAGE_REVEAL_SIZES[size]}px`;
63
+ return size;
64
+ }
65
+ export const IMAGE_REVEAL_RADIUS = {
66
+ square: '0',
67
+ rounded: 'var(--jatui-radius-box, var(--radius-box, 0.5rem))',
68
+ circle: '9999px'
69
+ };
70
+ const TONE_NAMES = new Set([
71
+ 'primary',
72
+ 'secondary',
73
+ 'accent',
74
+ 'neutral',
75
+ 'info',
76
+ 'success',
77
+ 'warning',
78
+ 'error'
79
+ ]);
80
+ /** A semantic name → its DaisyUI variable; any other string is a CSS colour. */
81
+ export function imageRevealTone(tone) {
82
+ return TONE_NAMES.has(tone) ? `var(--color-${tone})` : tone;
83
+ }
package/dist/index.d.ts CHANGED
@@ -400,3 +400,6 @@ export { default as SettingsSection } from './components/settings/SettingsSectio
400
400
  export { default as SettingsRow } from './components/settings/SettingsRow.svelte';
401
401
  export type { SettingsRowContext } from './components/settings/SettingsRow.svelte';
402
402
  export * from './data/grid/index';
403
+ export { default as ImageReveal } from './components/ImageReveal.svelte';
404
+ export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
405
+ export type { ImageRevealPhase, ImageRevealShape, ImageRevealSizeName, ImageRevealToneName, ImageRevealState } from './components/imageReveal';
package/dist/index.js CHANGED
@@ -435,3 +435,5 @@ export { default as SegmentedControl } from './components/SegmentedControl.svelt
435
435
  export { default as SettingsSection } from './components/settings/SettingsSection.svelte';
436
436
  export { default as SettingsRow } from './components/settings/SettingsRow.svelte';
437
437
  export * from './data/grid/index';
438
+ export { default as ImageReveal } from './components/ImageReveal.svelte';
439
+ export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
@@ -20,7 +20,7 @@ props + callback surfaces.
20
20
  | `keys.ts` | .28 (done) | The keyboard map — `PLAYER_KEYS` (the `?` overlay renders it), `chapterJump`, `FRAME_STEP_SEC`. Every key that exists is in the map; a key not in the map doesn't exist. |
21
21
  | `rate.ts` + `rate/` | .28 contract — **.32** (done) | `rate.ts` is the barrel. `compose.ts` — the contract (`composeRate`, clamp [0.0625, 16], `shouldWriteRate`'s 0.001 deadband with an exact return to base), the `SPEED_STEPS` staircase, `formatSpeedLabel`. `speaker.ts` — props → `advisors/speaker-rate`; manual rates keyed by NAME always beat measured. `silence.ts` — waveform → `advisors/smart-speed` map + boost + the OSD saving claim. `ads.ts` — `adSpans` ∪ `ad: true` turns; skip only when PLAYBACK runs into an ad, never when a seek lands in one. `tick.ts` — the component's own 120ms loop's one-tick decision. None of them write `playbackRate`; they advise. |
22
22
  | `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
23
- | `settings.ts` + `settings/` | **.35** (done) — knobs added by .30–.34 | `settings.ts` is the barrel. `registry.ts` — `VIDEO_SETTINGS`: id, group, label, **mandatory hint**, the prop it drives, the availability gate (`when`); `coerce` never throws (hostile values, throwing `valueOf`, Symbols → undefined). Labels/hints are the single source for the panel AND a host help page. `store.ts` — the host-supplied `VideoSettingsStore` (`get`/`set`, either async; jatui never persists on its own) and `createSettingsSession`: coerced reads, a choice made on screen beats a late read, per-id debounced writes flushed on close. `panel.ts` — `panelGroups` (rows only for features this player has — omitted, never disabled), value formatting, radio stepping. `SettingsPanel.svelte` — the generated dialog (switch / radio group / range, focus trap, Esc/`,` close, focus handed back). In the component a stored choice overlays its prop but never creates a presence-gated feature; `night`/`nightDepth` are written through their bindings and `N`/the moon wheel are saved back. The one permission ask is `sleep.desktopIdle` turned on, from that click. A knob lands WITH its reader. |
23
+ | `settings.ts` + `settings/` | **.35** (done) — knobs added by .30–.34 | `settings.ts` is the barrel. `registry.ts` — `VIDEO_SETTINGS`: id, group, label, **mandatory hint**, the prop it drives, the availability gate (`when`); `coerce` never throws (hostile values, throwing `valueOf`, Symbols → undefined). Labels/hints are the single source for the panel AND a host help page. `store.ts` — the host-supplied `VideoSettingsStore` (`get`/`set`, either async; jatui never persists on its own) and `createSettingsSession`: coerced reads, a choice made on screen beats a late read, per-id debounced writes flushed on close. `panel.ts` — `panelGroups` (rows only for features this player has — omitted, never disabled), value formatting, radio stepping. `SettingsPanel.svelte` — the generated dialog (switch / radio group / range, focus trap, Esc/`,` close, focus handed back). `remembered.ts` (jatui-xz6so) — remembered MEDIA state (E12: volume + mute survive a reload), coerced like settings and persisted through the same store split (`settingsStore`, else guarded localStorage) but deliberately never panel rows: remembering is not configuring. In the component a stored choice overlays its prop but never creates a presence-gated feature; `night`/`nightDepth` are written through their bindings and `N`/the moon wheel are saved back. The one permission ask is `sleep.desktopIdle` turned on, from that click. A knob lands WITH its reader. |
24
24
  | `audio.ts` + `audio/` | **.31** (done) | `audio.ts` is the barrel. `rider.ts` — pure: RMS dBFS (silence → null), the 12 dB relative-gated loud estimate (rise 0.08 / forget 0.01), want-gain clamped 0.3–1.35, the asymmetric ride (down 0.15 / up 0.02), limiter constants + `compressorMakeupDb`/`limiterTrim` (DynamicsCompressor's automatic makeup, cancelled by a trim node), `routeSafety` (only blob:/data: or a `crossorigin` load — a same-origin URL may redirect cross-origin and route to SILENCE). `graph.ts` — `createAudioSession`: source → gain → quiet chain → limiter → trim → destination, analyser on the source. Context first, route last: the element is taken only when a feature is on, the source is route-safe and `ctx.state === 'running'` (gestures resume + retry; never navigator.userActivation). After routing it watches: an unsafe src or bit-exact zeros while audibly playing ⇒ `broken` (`unsafe-src` / `silent`) via `onBroken`. A construction throw is a permanent `build-failed` (context closed if the element wasn't taken; kept with source → destination if it was). Level off cancels before it sets; limiter moves are ramped. The element never enters the module — the component passes a `route` closure. |
25
25
  | `night.ts` + `night/` | **.33** (done) | `night.ts` is the barrel. `meter.ts` — 32×18 frame bytes → Rec.709 luma + blown share, asymmetric smoothing, bias-light cells from the same bytes. `levels.ts` — luma → brightness/saturation/warmth/vignette off `NIGHT_FLOOR` 0.34, blind fixed level, the light-theme surround. `clock.ts` — local-clock `auto` window + the `N` auto/on/off cycle (no hyprsunset). `track.ts` — prediction (only ever adds dimming), flash intervals, `composeNight` (darker at once, brighter released per tick — never a jump). |
26
26
  | `presence.ts` + `presence/` | **.34** (done) | `presence.ts` is the barrel. `winddown.ts` — pure: `shouldBeginWindDown` (a locked screen skips the wait; a desktop source with no reading holds — never guess), the 20s linear fade levels (volume `1−p`, picture `1−0.92p`), the hint (the page source says it is guessing from less). `idle.ts` — Chromium's Idle Detection API, feature-detected (`IdleDetector` + secure context); `startDesktopWatch` never prompts (start() rejects unless already granted ⇒ page fallback), `requestIdleDetection` is the ONE prompting call and the player makes it only with `idleDetection` on, from a gesture inside it. `resume.ts` — the host-supplied `VideoResumeStore` (localStorage default; jatui never calls an API), the data → advisor mapping, and `planResumeSnap` (wait for boundaries, drop once the user moved > 2.5s, skip `exact`/`start`); the snapping itself is `advisors/resume-point`, not re-derived. `flip.ts` — `open-transition.js` ported: FLIP over WAAPI, two keyframes, origin restated in both, poster on every path, a no-op under prefers-reduced-motion except the poster, `flipClose` holds and the next open releases on every path. |
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Remembered media state (E12, jatui-xz6so) — extension-library's player
3
+ * settings registry (library.js:5768) ported: the volume level and the mute
4
+ * choice survive a reload.
5
+ *
6
+ * Deliberately NOT rows in `VIDEO_SETTINGS` (mos-5lzns.35's doctrine stands:
7
+ * remembering is not configuring — a settings panel that lists the current
8
+ * volume reads like a debug dump, and every player already carries a volume
9
+ * control on its transport or the native bar). They ARE coerced like settings
10
+ * and persisted through the SAME plumbing: the host's `VideoSettingsStore`
11
+ * when one is given, else this browser's `localSettingsStore()` — the exact
12
+ * split `player.theater` uses. The storage ids sit in the same `player.*`
13
+ * namespace, so a host's store keys stay one family.
14
+ */
15
+ export interface RememberedSpec<T> {
16
+ /** Stable storage key — a host's store is keyed by this. Never rename. */
17
+ id: string;
18
+ /** What a player uses when the store holds nothing usable. */
19
+ default: T;
20
+ /**
21
+ * A stored value that can't be used (junk, wrong shape, a throwing
22
+ * `valueOf`) reads as nothing stored — never junk, never a throw. Same
23
+ * discipline as the registry's `coerce`.
24
+ */
25
+ coerce(raw: unknown): T | undefined;
26
+ }
27
+ /** The playback level, 0–1 on the player's 0.05 gesture grid. */
28
+ export declare const REMEMBERED_VOLUME: RememberedSpec<number>;
29
+ /** Whether playback starts muted. */
30
+ export declare const REMEMBERED_MUTED: RememberedSpec<boolean>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Remembered media state (E12, jatui-xz6so) — extension-library's player
3
+ * settings registry (library.js:5768) ported: the volume level and the mute
4
+ * choice survive a reload.
5
+ *
6
+ * Deliberately NOT rows in `VIDEO_SETTINGS` (mos-5lzns.35's doctrine stands:
7
+ * remembering is not configuring — a settings panel that lists the current
8
+ * volume reads like a debug dump, and every player already carries a volume
9
+ * control on its transport or the native bar). They ARE coerced like settings
10
+ * and persisted through the SAME plumbing: the host's `VideoSettingsStore`
11
+ * when one is given, else this browser's `localSettingsStore()` — the exact
12
+ * split `player.theater` uses. The storage ids sit in the same `player.*`
13
+ * namespace, so a host's store keys stay one family.
14
+ */
15
+ /** The playback level, 0–1 on the player's 0.05 gesture grid. */
16
+ export const REMEMBERED_VOLUME = {
17
+ id: 'player.volume',
18
+ default: 1,
19
+ coerce(raw) {
20
+ try {
21
+ // Only numbers and numeric strings: Number() on an object runs its
22
+ // valueOf/toString, and on a Symbol it throws — same guard as the registry.
23
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
24
+ if (!Number.isFinite(n))
25
+ return undefined;
26
+ return Math.min(1, Math.max(0, n));
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ };
33
+ /** Whether playback starts muted. */
34
+ export const REMEMBERED_MUTED = {
35
+ id: 'player.muted',
36
+ default: false,
37
+ coerce(raw) {
38
+ if (raw === true || raw === 1 || raw === '1' || raw === 'true')
39
+ return true;
40
+ if (raw === false || raw === 0 || raw === '0' || raw === 'false')
41
+ return false;
42
+ return undefined;
43
+ }
44
+ };
@@ -9,6 +9,8 @@
9
9
  * session that reads/writes through it. jatui never persists on its own.
10
10
  * · `panel.ts` — which rows a given player can offer, and how values read.
11
11
  * · `SettingsPanel.svelte` — the panel, generated wholly from the registry.
12
+ * · `remembered.ts` — remembered media state (volume, mute — E12), coerced
13
+ * like settings and persisted through the same store, but never panel rows.
12
14
  *
13
15
  * Nothing under src/lib/video/ reads a setting directly: the component maps
14
16
  * each value onto the prop it names and the feature reads that — the
@@ -17,3 +19,4 @@
17
19
  export * from './settings/registry';
18
20
  export * from './settings/store';
19
21
  export * from './settings/panel';
22
+ export * from './settings/remembered';
@@ -9,6 +9,8 @@
9
9
  * session that reads/writes through it. jatui never persists on its own.
10
10
  * · `panel.ts` — which rows a given player can offer, and how values read.
11
11
  * · `SettingsPanel.svelte` — the panel, generated wholly from the registry.
12
+ * · `remembered.ts` — remembered media state (volume, mute — E12), coerced
13
+ * like settings and persisted through the same store, but never panel rows.
12
14
  *
13
15
  * Nothing under src/lib/video/ reads a setting directly: the component maps
14
16
  * each value onto the prop it names and the feature reads that — the
@@ -17,3 +19,4 @@
17
19
  export * from './settings/registry';
18
20
  export * from './settings/store';
19
21
  export * from './settings/panel';
22
+ export * from './settings/remembered';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",