@joewinke/jatui 0.52.0 → 0.53.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
@@ -177,7 +177,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
177
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 |
178
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?` |
179
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?` |
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); `/` opens an in-transcript search (only when a transcript exists and focus is not in a text field; the omnibar yields): matches highlight in place with a live `n / m` count (`aria-live`), Enter/Shift+Enter walk next/previous (seek + the line follows), Esc closes the search first in the Esc layering. Right-click a transcript line (or `Menu` / `Shift+F10` on a focused one) for the **line menu** — Play from here, Copy text, Copy text with timestamp (`[0:02] text`), Copy link at this time when `buildTimeLink` supplies the URL builder (without it the item is absent; `?t=` shape via `buildTimeUrl`); Escape closes and returns focus to the line, the player's keys are dead while it floats, and in fill theater Esc closes the menu without leaving the theater. 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?`, `transcriptLineMenu?` (true — right-click/`Menu`/`Shift+F10` on a transcript line opens the line menu; `false` leaves transcript lines to the browser's own context menu), `buildTimeLink?` ((sec) → URL for "Copy link at this time"; the host owns the address shape), `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), `theaterMode?` (`'width'` default — the host widens its page \| `'fill'` — the player becomes a fixed full-viewport dialog: `role="dialog"` + `aria-modal`, page scroll locked, Tab trapped, Esc/the button exits and returns focus, largest 16:9 stage that fits, transcript beside it ≥64rem else below, z from `--jatui-z-theater`; a remembered theater never fills on load, only a viewer gesture), `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); `/` opens an in-transcript search (only when a transcript exists and focus is not in a text field; the omnibar yields): matches highlight in place with a live `n / m` count (`aria-live`), Enter/Shift+Enter walk next/previous (seek + the line follows), Esc closes the search first in the Esc layering. Right-click a transcript line (or `Menu` / `Shift+F10` on a focused one) for the **line menu** — Play from here, Copy text, Copy text with timestamp (`[0:02] text`), Copy link at this time when `buildTimeLink` supplies the URL builder (without it the item is absent; `?t=` shape via `buildTimeUrl`); Escape closes and returns focus to the line, the player's keys are dead while it floats, and in fill theater Esc closes the menu without leaving the theater. A QR button on the transport (when `buildTimeLink` exists) opens a **continue-on-phone** card — the deep link at the current second as a scannable SVG QR; clicking the code copies it (jatui-3k7cp). 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?`, `transcriptLineMenu?` (true — right-click/`Menu`/`Shift+F10` on a transcript line opens the line menu; `false` leaves transcript lines to the browser's own context menu), `buildTimeLink?` ((sec) → URL for "Copy link at this time"; the host owns the address shape), `qrContinue?` (true — the QR button: the host's `buildTimeLink` at the current second as an SVG QR card, click the code to copy; hidden entirely when no `buildTimeLink` — the player never guesses a URL), `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), `theaterMode?` (`'width'` default — the host widens its page \| `'fill'` — the player becomes a fixed full-viewport dialog: `role="dialog"` + `aria-modal`, page scroll locked, Tab trapped, Esc/the button exits and returns focus, largest 16:9 stage that fits, transcript beside it ≥64rem else below, z from `--jatui-z-theater`; a remembered theater never fills on load, only a viewer gesture), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
181
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?` |
182
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?` |
183
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) |
@@ -201,6 +201,7 @@
201
201
  } from '../video/rate';
202
202
  import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
203
203
  import { isContextMenuKey, lineTextWithTime } from '../video/lineMenu';
204
+ import { qrSymbol, shareLinkAt } from '../video/qrShare';
204
205
  import {
205
206
  CAPTURE_NO_CORS_MESSAGE,
206
207
  CAPTURE_NO_FRAME_MESSAGE,
@@ -340,6 +341,16 @@
340
341
  * said, not hidden.
341
342
  */
342
343
  onCaptureFrame = undefined,
344
+ /**
345
+ * Continue on phone (jatui-3k7cp, extension-library parity
346
+ * library.js:4635): a QR button on the band transport opens a card with
347
+ * the deep link AT THE CURRENT SECOND as a scannable QR code — the
348
+ * host's `buildTimeLink` builds it (MOS builds its watch URL with
349
+ * `?t=`); clicking the code copies the link. Hidden entirely when the
350
+ * host supplies no `buildTimeLink` — the player never guesses a URL.
351
+ * Default on; `false` removes the button.
352
+ */
353
+ qrContinue = true,
343
354
  theater = $bindable(false),
344
355
  theaterMode = 'width',
345
356
  theaterButton = true,
@@ -553,6 +564,12 @@
553
564
  frameCapture?: boolean;
554
565
  /** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
555
566
  onCaptureFrame?: (capture: VideoFrameCapture) => void | Promise<void>;
567
+ /**
568
+ * Continue on phone (jatui-3k7cp) — see the destructure note above.
569
+ * Needs `buildTimeLink`: the URL is the host's (the player never
570
+ * guesses one), so without a builder there is simply no QR button.
571
+ */
572
+ qrContinue?: boolean;
556
573
  /**
557
574
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
558
575
  * only says it wants the page's width; the HOST lays its page out
@@ -1062,6 +1079,46 @@
1062
1079
  captureActions({ clipboardImage: captureClipboardOk, hostHook: onCaptureFrame != null })
1063
1080
  );
1064
1081
 
1082
+ // ── Continue on phone (jatui-3k7cp, extension library.js:4635) ───────
1083
+ let qrOpen = $state(false);
1084
+ let qrBtnEl: HTMLButtonElement | undefined = $state();
1085
+ /** The link the open symbol was built from — frozen at open so the code
1086
+ * under the phone's camera never changes mid-scan. */
1087
+ let qrLink = $state('');
1088
+ /** The second the link was frozen at — the card's own clock. */
1089
+ let qrSec = $state(0);
1090
+ /** Feature on: the host must own the URL (buildTimeLink). Without a
1091
+ * builder there is nothing to encode — the player never guesses one —
1092
+ * so the button simply doesn't exist. */
1093
+ const qrShareOn = $derived(qrContinue && typeof buildTimeLink === 'function');
1094
+ /** The symbol for the frozen link. Null ⇒ the card says the link is
1095
+ * beyond the encoder's ceiling and keeps the copy verb working. */
1096
+ const qrSym = $derived(qrOpen && qrLink ? qrSymbol(qrLink) : null);
1097
+
1098
+ /** Open the QR card: freeze the host's link at the CURRENT SECOND, then
1099
+ * show the card. A builder that answers nothing (or throws) is said on
1100
+ * the OSD — never a silent no-op, never an empty card. */
1101
+ function openQrCard() {
1102
+ const link = shareLinkAt(buildTimeLink, currentTime);
1103
+ if (!link) {
1104
+ flashOsd('No link to share');
1105
+ announce('No link to share — the host did not supply one');
1106
+ return;
1107
+ }
1108
+ qrLink = link;
1109
+ qrSec = Math.max(0, Math.floor(currentTime));
1110
+ qrOpen = true;
1111
+ }
1112
+
1113
+ /** Clicking the code copies the link (and closes — the scan or the
1114
+ * paste is the point; the card's job is done). The failure is said. */
1115
+ async function copyQrLink() {
1116
+ const ok = await copyText(qrLink);
1117
+ flashOsd(ok ? 'Link copied' : 'Copy failed');
1118
+ announce(ok ? 'Link copied to clipboard' : 'Copy failed');
1119
+ if (ok) qrOpen = false;
1120
+ }
1121
+
1065
1122
  let zoomTimer: ReturnType<typeof setTimeout> | null = null;
1066
1123
  let osdTimer: ReturnType<typeof setTimeout> | null = null;
1067
1124
 
@@ -3286,7 +3343,10 @@
3286
3343
  // menu closes itself, the theater must not leave with it. The
3287
3344
  // panel's own handler may already have closed it by the time this
3288
3345
  // bubble-phase handler runs, hence the target check too.
3289
- !(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]')))
3346
+ !(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]'))) &&
3347
+ // The continue-on-phone card (jatui-3k7cp) owns Escape while it
3348
+ // is open — Escape closes the card, never the theater.
3349
+ !qrOpen
3290
3350
  ) {
3291
3351
  e.preventDefault();
3292
3352
  toggleTheater();
@@ -3315,6 +3375,15 @@
3315
3375
  // shared ContextMenu's own behaviour (focus moves out with the menu
3316
3376
  // still up; click/Escape dismiss it).
3317
3377
  if (lineMenuOpen || captureMenuOpen) return;
3378
+ // The continue-on-phone card (jatui-3k7cp) owns Escape while it is
3379
+ // open; nothing else drives the player underneath it.
3380
+ if (qrOpen) {
3381
+ if (e.key === 'Escape') {
3382
+ e.preventDefault();
3383
+ qrOpen = false;
3384
+ }
3385
+ return;
3386
+ }
3318
3387
  // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
3319
3388
  // activation) while focus is inside it; without this guard a rail `k`
3320
3389
  // would also toggle play/pause and a rail `j` also seek ±5s.
@@ -4489,6 +4558,29 @@
4489
4558
  </svg>
4490
4559
  </button>
4491
4560
  {/if}
4561
+ {#if qrShareOn}
4562
+ <!-- Continue on phone (jatui-3k7cp): opens the QR card for the
4563
+ host's buildTimeLink at the current second. A share verb,
4564
+ so it sits beside the capture verb at the end of the
4565
+ cluster. Works for audio too — the link is the point,
4566
+ not the picture. -->
4567
+ <button
4568
+ type="button"
4569
+ class="vp-tbtn"
4570
+ bind:this={qrBtnEl}
4571
+ aria-haspopup="dialog"
4572
+ aria-expanded={qrOpen}
4573
+ aria-label="Continue on phone"
4574
+ title="Continue on phone"
4575
+ onclick={openQrCard}
4576
+ >
4577
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
4578
+ <!-- QR glyph: three finder squares + a few data modules -->
4579
+ <path d="M3.5 3.5h6v6h-6zM14.5 3.5h6v6h-6zM3.5 14.5h6v6h-6z" />
4580
+ <path d="M14.5 14.5h2.5v2.5h-2.5zM19 14.5h1.5v1.5H19zM14.5 19h1.5v1.5h-1.5zM18.5 18.5h2v2h-2z" fill="currentColor" stroke="none" />
4581
+ </svg>
4582
+ </button>
4583
+ {/if}
4492
4584
  {#if !isAudio}
4493
4585
  <button
4494
4586
  type="button"
@@ -4900,6 +4992,55 @@
4900
4992
  header={formatTime(currentTime, useHours)}
4901
4993
  />
4902
4994
  {/if}
4995
+
4996
+ {#if qrShareOn && qrOpen}
4997
+ <!-- Continue-on-phone card (jatui-3k7cp, extension library.js:4635):
4998
+ the same overlay family as the `?` help card — centered over the
4999
+ player, big enough to scan from arm's length, backdrop click or
5000
+ Escape closes. The link was FROZEN at open (qrLink), so the code
5001
+ under the phone's camera never changes mid-scan. The code is a real
5002
+ button: click it (or press Enter/Space on it) and the link is on
5003
+ the clipboard. Rendered as SVG <path> data from qrSymbol() — never
5004
+ {@html} of untrusted text. The black-on-white symbol is deliberate:
5005
+ QR contrast rules are about the SCANNER, not the theme, and a
5006
+ theme-tinted code scans far worse in dim rooms. -->
5007
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
5008
+ <div class="vp-qr-backdrop" onclick={(e) => { if (e.target === e.currentTarget) qrOpen = false; }}>
5009
+ <div class="vp-qr-card" role="dialog" aria-label="Continue on phone">
5010
+ <div class="vp-qr-title">Continue on phone</div>
5011
+ <div class="vp-qr-time">{formatTime(qrSec, useHours)}</div>
5012
+ {#if qrSym}
5013
+ <button
5014
+ type="button"
5015
+ class="vp-qr-code"
5016
+ aria-label="Copy the link: {qrLink}"
5017
+ title="Click to copy the link"
5018
+ onclick={copyQrLink}
5019
+ >
5020
+ <svg
5021
+ viewBox="0 0 {qrSym.size} {qrSym.size}"
5022
+ shape-rendering="crispEdges"
5023
+ role="img"
5024
+ aria-hidden="true"
5025
+ >
5026
+ <rect width="{qrSym.size}" height="{qrSym.size}" fill="#ffffff" />
5027
+ <path d={qrSym.path} fill="#000000" />
5028
+ </svg>
5029
+ <span class="vp-qr-hint">Click to copy the link</span>
5030
+ </button>
5031
+ <div class="vp-qr-link" title={qrLink}>{qrLink}</div>
5032
+ {:else}
5033
+ <!-- The encoder's ceiling (version 10) — said, with the copy
5034
+ verb kept working: the QR is the affordance, the link is
5035
+ the point. -->
5036
+ <p class="vp-qr-fallback">
5037
+ This link is too long for a QR code.
5038
+ </p>
5039
+ <button type="button" class="vp-qr-copy" onclick={copyQrLink}>Copy the link instead</button>
5040
+ {/if}
5041
+ </div>
5042
+ </div>
5043
+ {/if}
4903
5044
  </div>
4904
5045
 
4905
5046
  <style>
@@ -6241,6 +6382,112 @@
6241
6382
  box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
6242
6383
  }
6243
6384
 
6385
+ /* ── Continue on phone (jatui-3k7cp) — the QR card ─────────────────── */
6386
+ /* Same overlay family as the `?` help card: centered over the player,
6387
+ backdrop click / Escape closes, focus stays in the root. */
6388
+ .vp-qr-backdrop {
6389
+ position: absolute;
6390
+ inset: 0;
6391
+ display: flex;
6392
+ align-items: center;
6393
+ justify-content: center;
6394
+ background: color-mix(in oklch, var(--color-base-100) 70%, transparent);
6395
+ z-index: 10;
6396
+ border-radius: inherit;
6397
+ }
6398
+
6399
+ .vp-qr-card {
6400
+ display: flex;
6401
+ flex-direction: column;
6402
+ align-items: center;
6403
+ gap: var(--jatui-space-sm, 4px);
6404
+ max-width: min(20rem, 90%);
6405
+ padding: var(--jatui-space-lg, 12px);
6406
+ background: var(--color-base-100);
6407
+ border: var(--jatui-border-w, 1px) solid
6408
+ color-mix(in oklch, var(--color-base-content) 18%, transparent);
6409
+ border-radius: var(--jatui-radius-box, 0.5rem);
6410
+ color: var(--color-base-content);
6411
+ box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
6412
+ }
6413
+
6414
+ .vp-qr-title {
6415
+ font-weight: 600;
6416
+ font-size: 0.875rem;
6417
+ }
6418
+
6419
+ .vp-qr-time {
6420
+ font-size: 0.75rem;
6421
+ opacity: 0.65;
6422
+ font-variant-numeric: tabular-nums;
6423
+ }
6424
+
6425
+ /* The code itself is a real button — click copies. The black-on-white
6426
+ symbol is FIXED colour, not theme tokens: QR contrast is about the
6427
+ scanner, not the palette (a theme-tinted code scans badly in dim
6428
+ rooms). Crisp edges keep modules square at every scale. */
6429
+ .vp-qr-code {
6430
+ display: flex;
6431
+ flex-direction: column;
6432
+ align-items: center;
6433
+ gap: var(--jatui-space-xs, 2px);
6434
+ padding: var(--jatui-space-sm, 4px);
6435
+ border-radius: var(--jatui-radius-box, 0.5rem);
6436
+ background: transparent;
6437
+ border: none;
6438
+ cursor: pointer;
6439
+ }
6440
+
6441
+ .vp-qr-code:focus-visible {
6442
+ outline: 2px solid var(--color-primary);
6443
+ outline-offset: 2px;
6444
+ }
6445
+
6446
+ .vp-qr-code svg {
6447
+ width: 13rem;
6448
+ height: 13rem;
6449
+ max-width: 100%;
6450
+ display: block;
6451
+ border-radius: 0.25rem;
6452
+ }
6453
+
6454
+ .vp-qr-hint {
6455
+ font-size: 0.6875rem;
6456
+ opacity: 0.6;
6457
+ }
6458
+
6459
+ .vp-qr-link {
6460
+ max-width: 100%;
6461
+ overflow: hidden;
6462
+ text-overflow: ellipsis;
6463
+ white-space: nowrap;
6464
+ font-size: 0.6875rem;
6465
+ opacity: 0.75;
6466
+ direction: rtl;
6467
+ text-align: left;
6468
+ }
6469
+
6470
+ .vp-qr-fallback {
6471
+ font-size: 0.8125rem;
6472
+ opacity: 0.8;
6473
+ margin: var(--jatui-space-sm, 4px) 0 0;
6474
+ }
6475
+
6476
+ .vp-qr-copy {
6477
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
6478
+ border-radius: var(--jatui-radius-field, 0.5rem);
6479
+ border: var(--jatui-border-w, 1px) solid
6480
+ color-mix(in oklch, var(--color-base-content) 25%, transparent);
6481
+ background: transparent;
6482
+ color: inherit;
6483
+ font-size: 0.8125rem;
6484
+ cursor: pointer;
6485
+ }
6486
+
6487
+ .vp-qr-copy:hover {
6488
+ background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
6489
+ }
6490
+
6244
6491
  .vp-keys-title {
6245
6492
  font-size: 0.66rem;
6246
6493
  font-weight: 700;
@@ -222,6 +222,12 @@ type $$ComponentProps = {
222
222
  frameCapture?: boolean;
223
223
  /** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
224
224
  onCaptureFrame?: (capture: VideoFrameCapture) => void | Promise<void>;
225
+ /**
226
+ * Continue on phone (jatui-3k7cp) — see the destructure note above.
227
+ * Needs `buildTimeLink`: the URL is the host's (the player never
228
+ * guesses one), so without a builder there is simply no QR button.
229
+ */
230
+ qrContinue?: boolean;
225
231
  /**
226
232
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
227
233
  * only says it wants the page's width; the HOST lays its page out
@@ -22,6 +22,7 @@ props + callback surfaces.
22
22
  | `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved) + `buildTimeUrl` (jatui-3wl5x) — the `?t=` URL builder hosts pass as `buildTimeLink` (MOS's "copy link at this time" shape). |
23
23
  | `capture.ts` | **jatui-8nu4g** (done) | The frame-capture menu's pure pieces (extension-library parity, library.js:9262): `captureFilename` (the frame clock as the name — `frame-1m30s.png`, junk times clamp to 0), `clipboardImageSupported` (secure context + `navigator.clipboard.write` + `ClipboardItem` — false ⇒ save-only degrade, the Copy row is OMITTED, never dead), `captureActions` (which verbs the menu carries — save always, copy only where the platform can write an image, poster only when the host supplied `onCaptureFrame`), `frameDrawSize` (null before metadata — a 0×0 canvas is never a valid capture), `isCrossOriginCaptureError` (the SecurityError NAME is the contract — a tainted canvas throws SecurityError out of toBlob/toDataURL itself) and `VideoFrameCapture` (what the host's "set as poster" hook receives: blob, dataUrl, timeSec, width, height, name). The component owns the canvas and the `<video>` (drawImage / toBlob / toDataURL / download / clipboard calls live there); a cross-origin video without CORS is SAID — `CAPTURE_NO_CORS_MESSAGE` on the OSD + polite region, never a silent no-op. |
24
24
  | `lineMenu.ts` | **jatui-3wl5x** | The transcript line right-click menu's pure pieces: `isContextMenuKey` (the keyboard's right-click — Context-menu key or Shift+F10) and `lineTextWithTime` (`[1:23] text`, the row's own clock). The component owns the DOM — which row opened it, cursor vs row anchoring, focus return — and the menu itself is the shared `ContextMenu`. |
25
+ | `qrShare.ts` | **jatui-3k7cp** | "Continue on phone" pure pieces (extension-library parity, library.js:4635): `qrSymbol` — the QR of a link as SVG PATH DATA over `advisors/qr`'s zero-dep encoder (dark modules run-merged into one `path` the test re-parses; quiet zone 4; null, never a throw, when the text can't be a symbol — the component says "too long" and keeps copy working), and `shareLinkAt` (the host's `buildTimeLink` at the FLOORED current second; every broken shape degrades to null). The URL is the host's — no builder, no button. The component owns the card: centered like the `?` help overlay, link frozen at open so the code under the phone's camera never changes mid-scan, clicking the code copies, SVG only — never `{@html}` of untrusted text. |
25
26
  | `transcriptSearch.ts` | **jatui-xmtnv** (E25/E57) | The transcript search + wheel line-scrub pure helpers: `searchHits` (case-insensitive, non-overlapping), `highlightRowHtml` (`<mark>`s around matches, everything else escaped — host transcript text is never markup), `pickMatch` (Enter walks matches from the playhead, wrapping), `lineScrubTarget` + `transcriptWheelDecision`/`effectiveWheelDelta` (Shift+wheel steps a line per ~100px notch; a plain wheel is never claimed). The component owns the box, focus, Esc layering and the `n / m` count. |
26
27
  | `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. |
27
28
  | `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. |
@@ -16,5 +16,5 @@
16
16
  * | `speaker-rate` | each speaker's measured wpm, the rates that even them out | rate composer's `speaker` advisor (.32) |
17
17
  * | `resume-point` | the nearest real boundary at-or-before a stored time + the line to say | resume-at-sentence (.34) |
18
18
  * | `quiet-listening` | the loudness-compensated tone graph + how much of it is wanted | audio graph (.31) |
19
- * | `qr` | the deep link as a scannable symbol | "continue on phone" (host UI) |
19
+ * | `qr` | the deep link as a scannable symbol | "continue on phone" (jatui-3k7cp, via `../qrShare`) |
20
20
  */
@@ -0,0 +1,55 @@
1
+ /**
2
+ * "Continue on phone" (jatui-3k7cp — extension-library parity, library.js:4635
3
+ * / lib/qr.js): the video you are watching moves from the desk to a phone
4
+ * mid-sentence. The player shows a QR of the deep link AT THE CURRENT SECOND —
5
+ * the host's `buildTimeLink` builder (the same one the transcript line menu's
6
+ * "Copy link at this time" uses; MOS builds its watch URL with `?t=`) — the
7
+ * viewer points a phone camera at it, and clicking the symbol copies the link.
8
+ *
9
+ * The URL is the HOST's: the player never guesses one, and the whole feature
10
+ * is absent when no builder is supplied. The encoder underneath is the
11
+ * zero-dependency ISO 18004 port in `advisors/qr.ts` (deliberately no npm
12
+ * dependency — see that file's header for why).
13
+ *
14
+ * The pure pieces live here so they stay node-testable; the component owns
15
+ * the DOM: the button, the card, the clipboard write.
16
+ *
17
+ * The symbol is rendered as SVG PATH DATA — dark modules become one `path`
18
+ * element the host stylesheet/theme can never reflow — never `{@html}` of
19
+ * untrusted text.
20
+ */
21
+ import { type EccLevel } from './advisors/qr';
22
+ /** The spec's minimum quiet zone — 4 modules of light around the symbol. */
23
+ export declare const QR_QUIET_ZONE = 4;
24
+ export interface QrSymbol {
25
+ /** SVG viewBox side in modules: the symbol plus the quiet zone on both sides. */
26
+ size: number;
27
+ /**
28
+ * Path data covering exactly the dark modules, one subpath per horizontal
29
+ * run (`M{x} {y}h{w}v1h-{w}z`), coordinates offset by the quiet zone so
30
+ * the viewBox is `0 0 size size`. Designed to be re-parsed: the unit test
31
+ * reconstructs the grid from this string and compares it against
32
+ * `qrMatrix`'s own modules.
33
+ */
34
+ path: string;
35
+ }
36
+ /**
37
+ * The QR symbol for `text` as SVG geometry, or null when it cannot exist —
38
+ * non-string/empty input, or text beyond the encoder's ceiling (version 10
39
+ * at this ECC; ~213 bytes at M). Null, never a throw: the component SAYS
40
+ * "too long" and keeps the copy verb working — a missing symbol must not
41
+ * take the whole action down.
42
+ */
43
+ export declare function qrSymbol(text: unknown, opts?: {
44
+ ecc?: EccLevel;
45
+ quietZone?: number;
46
+ }): QrSymbol | null;
47
+ /**
48
+ * The shareable link at a playback second, from the host's builder. Floors
49
+ * the second (the link's promise is "picks up at 1:23", not "1:22.87" — same
50
+ * convention as deepLink.ts's writes), and degrades to null rather than
51
+ * throwing: a builder that isn't a function, a non-finite time, a builder
52
+ * that throws, or one that answers empty all mean "no link to share", which
53
+ * the component says instead of opening an empty card.
54
+ */
55
+ export declare function shareLinkAt(build: unknown, sec: number): string | null;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * "Continue on phone" (jatui-3k7cp — extension-library parity, library.js:4635
3
+ * / lib/qr.js): the video you are watching moves from the desk to a phone
4
+ * mid-sentence. The player shows a QR of the deep link AT THE CURRENT SECOND —
5
+ * the host's `buildTimeLink` builder (the same one the transcript line menu's
6
+ * "Copy link at this time" uses; MOS builds its watch URL with `?t=`) — the
7
+ * viewer points a phone camera at it, and clicking the symbol copies the link.
8
+ *
9
+ * The URL is the HOST's: the player never guesses one, and the whole feature
10
+ * is absent when no builder is supplied. The encoder underneath is the
11
+ * zero-dependency ISO 18004 port in `advisors/qr.ts` (deliberately no npm
12
+ * dependency — see that file's header for why).
13
+ *
14
+ * The pure pieces live here so they stay node-testable; the component owns
15
+ * the DOM: the button, the card, the clipboard write.
16
+ *
17
+ * The symbol is rendered as SVG PATH DATA — dark modules become one `path`
18
+ * element the host stylesheet/theme can never reflow — never `{@html}` of
19
+ * untrusted text.
20
+ */
21
+ import { qrMatrix } from './advisors/qr';
22
+ /** The spec's minimum quiet zone — 4 modules of light around the symbol. */
23
+ export const QR_QUIET_ZONE = 4;
24
+ /**
25
+ * The QR symbol for `text` as SVG geometry, or null when it cannot exist —
26
+ * non-string/empty input, or text beyond the encoder's ceiling (version 10
27
+ * at this ECC; ~213 bytes at M). Null, never a throw: the component SAYS
28
+ * "too long" and keeps the copy verb working — a missing symbol must not
29
+ * take the whole action down.
30
+ */
31
+ export function qrSymbol(text, opts) {
32
+ if (typeof text !== 'string' || text === '')
33
+ return null;
34
+ let quiet = Math.trunc(Number(opts?.quietZone));
35
+ if (!Number.isFinite(quiet) || quiet < 0)
36
+ quiet = QR_QUIET_ZONE;
37
+ quiet = Math.min(quiet, 8);
38
+ let matrix;
39
+ try {
40
+ matrix = qrMatrix(text, { ecc: opts?.ecc ?? 'M' });
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ const { size, modules } = matrix;
46
+ let path = '';
47
+ for (let y = 0; y < size; y++) {
48
+ const row = modules[y];
49
+ let x = 0;
50
+ while (x < size) {
51
+ if (!row[x]) {
52
+ x++;
53
+ continue;
54
+ }
55
+ let run = 1;
56
+ while (x + run < size && row[x + run])
57
+ run++;
58
+ path += `M${x + quiet} ${y + quiet}h${run}v1h-${run}z`;
59
+ x += run;
60
+ }
61
+ }
62
+ return { size: size + quiet * 2, path };
63
+ }
64
+ /**
65
+ * The shareable link at a playback second, from the host's builder. Floors
66
+ * the second (the link's promise is "picks up at 1:23", not "1:22.87" — same
67
+ * convention as deepLink.ts's writes), and degrades to null rather than
68
+ * throwing: a builder that isn't a function, a non-finite time, a builder
69
+ * that throws, or one that answers empty all mean "no link to share", which
70
+ * the component says instead of opening an empty card.
71
+ */
72
+ export function shareLinkAt(build, sec) {
73
+ if (typeof build !== 'function')
74
+ return null;
75
+ const s = Number(sec);
76
+ if (!Number.isFinite(s) || s < 0)
77
+ return null;
78
+ let link;
79
+ try {
80
+ link = build(Math.floor(s));
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ return typeof link === 'string' && link !== '' ? link : null;
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",