@joewinke/jatui 0.52.0 → 0.54.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?`, `bookmarkQuickAdd?` (true — with `onAddBookmark` wired, `false` hides only the built-in "+ Bookmark at …" row; the `B` key and its `?` help entry stay, for a host that owns the bookmark UI), `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,
@@ -372,6 +383,7 @@
372
383
  onMarkerClick = undefined,
373
384
  onTimeUpdate = undefined,
374
385
  onAddBookmark = undefined,
386
+ bookmarkQuickAdd = true,
375
387
  onBookmarkContext = undefined,
376
388
  night = $bindable(),
377
389
  nightDepth = $bindable(NIGHT_DEPTH_DEFAULT),
@@ -553,6 +565,12 @@
553
565
  frameCapture?: boolean;
554
566
  /** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
555
567
  onCaptureFrame?: (capture: VideoFrameCapture) => void | Promise<void>;
568
+ /**
569
+ * Continue on phone (jatui-3k7cp) — see the destructure note above.
570
+ * Needs `buildTimeLink`: the URL is the host's (the player never
571
+ * guesses one), so without a builder there is simply no QR button.
572
+ */
573
+ qrContinue?: boolean;
556
574
  /**
557
575
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
558
576
  * only says it wants the page's width; the HOST lays its page out
@@ -621,6 +639,14 @@
621
639
  onTimeUpdate?: (video: HTMLVideoElement) => void;
622
640
  /** Present ⇒ the band grows an "add bookmark at current time" control. */
623
641
  onAddBookmark?: (playbackSec: number, label: string) => void;
642
+ /**
643
+ * Show the built-in quick-add row (the label input + "+ Bookmark at …"
644
+ * button) when `onAddBookmark` is wired — default `true`, so existing
645
+ * hosts see no change. `false` hides ONLY the row: the `B` key and its
646
+ * `?` help entry stay wired to `onAddBookmark`, for a host that owns the
647
+ * bookmark UI itself (MOS's watch page) and just wants the keypress.
648
+ */
649
+ bookmarkQuickAdd?: boolean;
624
650
  /** Right-click on the band. The host owns the panel; this only hit-tests. */
625
651
  onBookmarkContext?: (
626
652
  sec: number,
@@ -1062,6 +1088,46 @@
1062
1088
  captureActions({ clipboardImage: captureClipboardOk, hostHook: onCaptureFrame != null })
1063
1089
  );
1064
1090
 
1091
+ // ── Continue on phone (jatui-3k7cp, extension library.js:4635) ───────
1092
+ let qrOpen = $state(false);
1093
+ let qrBtnEl: HTMLButtonElement | undefined = $state();
1094
+ /** The link the open symbol was built from — frozen at open so the code
1095
+ * under the phone's camera never changes mid-scan. */
1096
+ let qrLink = $state('');
1097
+ /** The second the link was frozen at — the card's own clock. */
1098
+ let qrSec = $state(0);
1099
+ /** Feature on: the host must own the URL (buildTimeLink). Without a
1100
+ * builder there is nothing to encode — the player never guesses one —
1101
+ * so the button simply doesn't exist. */
1102
+ const qrShareOn = $derived(qrContinue && typeof buildTimeLink === 'function');
1103
+ /** The symbol for the frozen link. Null ⇒ the card says the link is
1104
+ * beyond the encoder's ceiling and keeps the copy verb working. */
1105
+ const qrSym = $derived(qrOpen && qrLink ? qrSymbol(qrLink) : null);
1106
+
1107
+ /** Open the QR card: freeze the host's link at the CURRENT SECOND, then
1108
+ * show the card. A builder that answers nothing (or throws) is said on
1109
+ * the OSD — never a silent no-op, never an empty card. */
1110
+ function openQrCard() {
1111
+ const link = shareLinkAt(buildTimeLink, currentTime);
1112
+ if (!link) {
1113
+ flashOsd('No link to share');
1114
+ announce('No link to share — the host did not supply one');
1115
+ return;
1116
+ }
1117
+ qrLink = link;
1118
+ qrSec = Math.max(0, Math.floor(currentTime));
1119
+ qrOpen = true;
1120
+ }
1121
+
1122
+ /** Clicking the code copies the link (and closes — the scan or the
1123
+ * paste is the point; the card's job is done). The failure is said. */
1124
+ async function copyQrLink() {
1125
+ const ok = await copyText(qrLink);
1126
+ flashOsd(ok ? 'Link copied' : 'Copy failed');
1127
+ announce(ok ? 'Link copied to clipboard' : 'Copy failed');
1128
+ if (ok) qrOpen = false;
1129
+ }
1130
+
1065
1131
  let zoomTimer: ReturnType<typeof setTimeout> | null = null;
1066
1132
  let osdTimer: ReturnType<typeof setTimeout> | null = null;
1067
1133
 
@@ -3286,7 +3352,10 @@
3286
3352
  // menu closes itself, the theater must not leave with it. The
3287
3353
  // panel's own handler may already have closed it by the time this
3288
3354
  // bubble-phase handler runs, hence the target check too.
3289
- !(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]')))
3355
+ !(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]'))) &&
3356
+ // The continue-on-phone card (jatui-3k7cp) owns Escape while it
3357
+ // is open — Escape closes the card, never the theater.
3358
+ !qrOpen
3290
3359
  ) {
3291
3360
  e.preventDefault();
3292
3361
  toggleTheater();
@@ -3315,6 +3384,15 @@
3315
3384
  // shared ContextMenu's own behaviour (focus moves out with the menu
3316
3385
  // still up; click/Escape dismiss it).
3317
3386
  if (lineMenuOpen || captureMenuOpen) return;
3387
+ // The continue-on-phone card (jatui-3k7cp) owns Escape while it is
3388
+ // open; nothing else drives the player underneath it.
3389
+ if (qrOpen) {
3390
+ if (e.key === 'Escape') {
3391
+ e.preventDefault();
3392
+ qrOpen = false;
3393
+ }
3394
+ return;
3395
+ }
3318
3396
  // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
3319
3397
  // activation) while focus is inside it; without this guard a rail `k`
3320
3398
  // would also toggle play/pause and a rail `j` also seek ±5s.
@@ -4489,6 +4567,29 @@
4489
4567
  </svg>
4490
4568
  </button>
4491
4569
  {/if}
4570
+ {#if qrShareOn}
4571
+ <!-- Continue on phone (jatui-3k7cp): opens the QR card for the
4572
+ host's buildTimeLink at the current second. A share verb,
4573
+ so it sits beside the capture verb at the end of the
4574
+ cluster. Works for audio too — the link is the point,
4575
+ not the picture. -->
4576
+ <button
4577
+ type="button"
4578
+ class="vp-tbtn"
4579
+ bind:this={qrBtnEl}
4580
+ aria-haspopup="dialog"
4581
+ aria-expanded={qrOpen}
4582
+ aria-label="Continue on phone"
4583
+ title="Continue on phone"
4584
+ onclick={openQrCard}
4585
+ >
4586
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
4587
+ <!-- QR glyph: three finder squares + a few data modules -->
4588
+ <path d="M3.5 3.5h6v6h-6zM14.5 3.5h6v6h-6zM3.5 14.5h6v6h-6z" />
4589
+ <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" />
4590
+ </svg>
4591
+ </button>
4592
+ {/if}
4492
4593
  {#if !isAudio}
4493
4594
  <button
4494
4595
  type="button"
@@ -4611,7 +4712,7 @@
4611
4712
  </div>
4612
4713
  {/if}
4613
4714
 
4614
- {#if onAddBookmark}
4715
+ {#if onAddBookmark && bookmarkQuickAdd}
4615
4716
  <div class="vp-bookmark-add">
4616
4717
  <label for="vp-bookmark-label">Bookmark label</label>
4617
4718
  <input
@@ -4900,6 +5001,55 @@
4900
5001
  header={formatTime(currentTime, useHours)}
4901
5002
  />
4902
5003
  {/if}
5004
+
5005
+ {#if qrShareOn && qrOpen}
5006
+ <!-- Continue-on-phone card (jatui-3k7cp, extension library.js:4635):
5007
+ the same overlay family as the `?` help card — centered over the
5008
+ player, big enough to scan from arm's length, backdrop click or
5009
+ Escape closes. The link was FROZEN at open (qrLink), so the code
5010
+ under the phone's camera never changes mid-scan. The code is a real
5011
+ button: click it (or press Enter/Space on it) and the link is on
5012
+ the clipboard. Rendered as SVG <path> data from qrSymbol() — never
5013
+ {@html} of untrusted text. The black-on-white symbol is deliberate:
5014
+ QR contrast rules are about the SCANNER, not the theme, and a
5015
+ theme-tinted code scans far worse in dim rooms. -->
5016
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
5017
+ <div class="vp-qr-backdrop" onclick={(e) => { if (e.target === e.currentTarget) qrOpen = false; }}>
5018
+ <div class="vp-qr-card" role="dialog" aria-label="Continue on phone">
5019
+ <div class="vp-qr-title">Continue on phone</div>
5020
+ <div class="vp-qr-time">{formatTime(qrSec, useHours)}</div>
5021
+ {#if qrSym}
5022
+ <button
5023
+ type="button"
5024
+ class="vp-qr-code"
5025
+ aria-label="Copy the link: {qrLink}"
5026
+ title="Click to copy the link"
5027
+ onclick={copyQrLink}
5028
+ >
5029
+ <svg
5030
+ viewBox="0 0 {qrSym.size} {qrSym.size}"
5031
+ shape-rendering="crispEdges"
5032
+ role="img"
5033
+ aria-hidden="true"
5034
+ >
5035
+ <rect width="{qrSym.size}" height="{qrSym.size}" fill="#ffffff" />
5036
+ <path d={qrSym.path} fill="#000000" />
5037
+ </svg>
5038
+ <span class="vp-qr-hint">Click to copy the link</span>
5039
+ </button>
5040
+ <div class="vp-qr-link" title={qrLink}>{qrLink}</div>
5041
+ {:else}
5042
+ <!-- The encoder's ceiling (version 10) — said, with the copy
5043
+ verb kept working: the QR is the affordance, the link is
5044
+ the point. -->
5045
+ <p class="vp-qr-fallback">
5046
+ This link is too long for a QR code.
5047
+ </p>
5048
+ <button type="button" class="vp-qr-copy" onclick={copyQrLink}>Copy the link instead</button>
5049
+ {/if}
5050
+ </div>
5051
+ </div>
5052
+ {/if}
4903
5053
  </div>
4904
5054
 
4905
5055
  <style>
@@ -5983,7 +6133,8 @@
5983
6133
  outline-offset: 1px;
5984
6134
  }
5985
6135
 
5986
- /* Add-bookmark row — only rendered when the host wired onAddBookmark. */
6136
+ /* Quick-add bookmark row — rendered when the host wired onAddBookmark AND
6137
+ did not hide it with bookmarkQuickAdd={false} (the B key still works). */
5987
6138
  .vp-bookmark-add {
5988
6139
  display: flex;
5989
6140
  align-items: center;
@@ -6241,6 +6392,112 @@
6241
6392
  box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
6242
6393
  }
6243
6394
 
6395
+ /* ── Continue on phone (jatui-3k7cp) — the QR card ─────────────────── */
6396
+ /* Same overlay family as the `?` help card: centered over the player,
6397
+ backdrop click / Escape closes, focus stays in the root. */
6398
+ .vp-qr-backdrop {
6399
+ position: absolute;
6400
+ inset: 0;
6401
+ display: flex;
6402
+ align-items: center;
6403
+ justify-content: center;
6404
+ background: color-mix(in oklch, var(--color-base-100) 70%, transparent);
6405
+ z-index: 10;
6406
+ border-radius: inherit;
6407
+ }
6408
+
6409
+ .vp-qr-card {
6410
+ display: flex;
6411
+ flex-direction: column;
6412
+ align-items: center;
6413
+ gap: var(--jatui-space-sm, 4px);
6414
+ max-width: min(20rem, 90%);
6415
+ padding: var(--jatui-space-lg, 12px);
6416
+ background: var(--color-base-100);
6417
+ border: var(--jatui-border-w, 1px) solid
6418
+ color-mix(in oklch, var(--color-base-content) 18%, transparent);
6419
+ border-radius: var(--jatui-radius-box, 0.5rem);
6420
+ color: var(--color-base-content);
6421
+ box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
6422
+ }
6423
+
6424
+ .vp-qr-title {
6425
+ font-weight: 600;
6426
+ font-size: 0.875rem;
6427
+ }
6428
+
6429
+ .vp-qr-time {
6430
+ font-size: 0.75rem;
6431
+ opacity: 0.65;
6432
+ font-variant-numeric: tabular-nums;
6433
+ }
6434
+
6435
+ /* The code itself is a real button — click copies. The black-on-white
6436
+ symbol is FIXED colour, not theme tokens: QR contrast is about the
6437
+ scanner, not the palette (a theme-tinted code scans badly in dim
6438
+ rooms). Crisp edges keep modules square at every scale. */
6439
+ .vp-qr-code {
6440
+ display: flex;
6441
+ flex-direction: column;
6442
+ align-items: center;
6443
+ gap: var(--jatui-space-xs, 2px);
6444
+ padding: var(--jatui-space-sm, 4px);
6445
+ border-radius: var(--jatui-radius-box, 0.5rem);
6446
+ background: transparent;
6447
+ border: none;
6448
+ cursor: pointer;
6449
+ }
6450
+
6451
+ .vp-qr-code:focus-visible {
6452
+ outline: 2px solid var(--color-primary);
6453
+ outline-offset: 2px;
6454
+ }
6455
+
6456
+ .vp-qr-code svg {
6457
+ width: 13rem;
6458
+ height: 13rem;
6459
+ max-width: 100%;
6460
+ display: block;
6461
+ border-radius: 0.25rem;
6462
+ }
6463
+
6464
+ .vp-qr-hint {
6465
+ font-size: 0.6875rem;
6466
+ opacity: 0.6;
6467
+ }
6468
+
6469
+ .vp-qr-link {
6470
+ max-width: 100%;
6471
+ overflow: hidden;
6472
+ text-overflow: ellipsis;
6473
+ white-space: nowrap;
6474
+ font-size: 0.6875rem;
6475
+ opacity: 0.75;
6476
+ direction: rtl;
6477
+ text-align: left;
6478
+ }
6479
+
6480
+ .vp-qr-fallback {
6481
+ font-size: 0.8125rem;
6482
+ opacity: 0.8;
6483
+ margin: var(--jatui-space-sm, 4px) 0 0;
6484
+ }
6485
+
6486
+ .vp-qr-copy {
6487
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
6488
+ border-radius: var(--jatui-radius-field, 0.5rem);
6489
+ border: var(--jatui-border-w, 1px) solid
6490
+ color-mix(in oklch, var(--color-base-content) 25%, transparent);
6491
+ background: transparent;
6492
+ color: inherit;
6493
+ font-size: 0.8125rem;
6494
+ cursor: pointer;
6495
+ }
6496
+
6497
+ .vp-qr-copy:hover {
6498
+ background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
6499
+ }
6500
+
6244
6501
  .vp-keys-title {
6245
6502
  font-size: 0.66rem;
6246
6503
  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
@@ -290,6 +296,14 @@ type $$ComponentProps = {
290
296
  onTimeUpdate?: (video: HTMLVideoElement) => void;
291
297
  /** Present ⇒ the band grows an "add bookmark at current time" control. */
292
298
  onAddBookmark?: (playbackSec: number, label: string) => void;
299
+ /**
300
+ * Show the built-in quick-add row (the label input + "+ Bookmark at …"
301
+ * button) when `onAddBookmark` is wired — default `true`, so existing
302
+ * hosts see no change. `false` hides ONLY the row: the `B` key and its
303
+ * `?` help entry stay wired to `onAddBookmark`, for a host that owns the
304
+ * bookmark UI itself (MOS's watch page) and just wants the keypress.
305
+ */
306
+ bookmarkQuickAdd?: boolean;
293
307
  /** Right-click on the band. The host owns the panel; this only hit-tests. */
294
308
  onBookmarkContext?: (sec: number, existing: VideoBookmark | null, clientX: number, clientY: number) => void;
295
309
  /**
@@ -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.54.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",