@joewinke/jatui 0.51.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) |
@@ -26,6 +26,8 @@
26
26
  export type { ResumePoint } from '../video/advisors/resume-point';
27
27
  // mos-5lzns.35 — settings registry + panel.
28
28
  export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/settings';
29
+ // jatui-8nu4g — frame capture (what the host's "set as poster" hook receives).
30
+ export type { VideoFrameCapture } from '../video/capture';
29
31
  </script>
30
32
 
31
33
  <script lang="ts">
@@ -199,6 +201,18 @@
199
201
  } from '../video/rate';
200
202
  import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
201
203
  import { isContextMenuKey, lineTextWithTime } from '../video/lineMenu';
204
+ import { qrSymbol, shareLinkAt } from '../video/qrShare';
205
+ import {
206
+ CAPTURE_NO_CORS_MESSAGE,
207
+ CAPTURE_NO_FRAME_MESSAGE,
208
+ captureActions,
209
+ captureFilename,
210
+ clipboardImageSupported,
211
+ frameDrawSize,
212
+ isCrossOriginCaptureError,
213
+ type CaptureAction,
214
+ type VideoFrameCapture
215
+ } from '../video/capture';
202
216
  import {
203
217
  biasCells,
204
218
  biasGradient,
@@ -308,6 +322,35 @@
308
322
  followResumeMs = FOLLOW_RESUME_IDLE_MS,
309
323
  transcriptLineMenu = true,
310
324
  buildTimeLink = undefined,
325
+ /**
326
+ * Frame capture (jatui-8nu4g, extension-library parity library.js:9262):
327
+ * a camera button on the band transport and the `C` key open a menu of
328
+ * the frame verbs — Save frame as PNG, Copy frame to clipboard (only
329
+ * where the platform can actually write an image; otherwise save-only),
330
+ * and Set as poster when `onCaptureFrame` is supplied. Video only —
331
+ * audio has no frame to grab. Default on; `false` removes the button
332
+ * and the key.
333
+ */
334
+ frameCapture = true,
335
+ /**
336
+ * The host's capture hook — fires with the grabbed frame (PNG Blob,
337
+ * data URL, playback time, pixel size, suggested name) when the viewer
338
+ * picks "Set as poster"; MOS Studio uploads it and pins the video's
339
+ * poster. Without it the menu item simply doesn't exist. May be async;
340
+ * the player waits for it before reporting done, and a rejection is
341
+ * said, not hidden.
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,
311
354
  theater = $bindable(false),
312
355
  theaterMode = 'width',
313
356
  theaterButton = true,
@@ -510,6 +553,23 @@
510
553
  * simply not on the menu — the player never guesses a URL.
511
554
  */
512
555
  buildTimeLink?: (sec: number) => string;
556
+ /**
557
+ * Frame capture (jatui-8nu4g) — see the destructure note above. Reads
558
+ * the frame through a canvas, which a cross-origin video without CORS
559
+ * taints: the export throws SecurityError and the player SAYS so
560
+ * ("Can't capture this video (no CORS)") instead of failing silently.
561
+ * Pass `crossorigin="anonymous"` for cross-origin media you want
562
+ * frame-readable.
563
+ */
564
+ frameCapture?: boolean;
565
+ /** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
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;
513
573
  /**
514
574
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
515
575
  * only says it wants the page's width; the HOST lays its page out
@@ -1005,6 +1065,60 @@
1005
1065
  let lineMenuY = $state(0);
1006
1066
  let lineMenuSeg = $state<VideoTranscriptSegment | null>(null);
1007
1067
 
1068
+ // ── Frame capture menu (jatui-8nu4g, extension library.js:9262) ────────
1069
+ let captureMenuOpen = $state(false);
1070
+ let captureMenuX = $state(0);
1071
+ let captureMenuY = $state(0);
1072
+ let captureBtnEl: HTMLButtonElement | undefined = $state();
1073
+ /** Feature on: video only, and the host hasn't opted out. */
1074
+ const frameCaptureOn = $derived(frameCapture && !isAudio);
1075
+ /** Can this platform write an IMAGE to the clipboard? Static per page. */
1076
+ const captureClipboardOk = $derived(clipboardImageSupported());
1077
+ /** The verbs on the menu, in order — the pure decision, mapped to items. */
1078
+ const captureMenuActions = $derived(
1079
+ captureActions({ clipboardImage: captureClipboardOk, hostHook: onCaptureFrame != null })
1080
+ );
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
+
1008
1122
  let zoomTimer: ReturnType<typeof setTimeout> | null = null;
1009
1123
  let osdTimer: ReturnType<typeof setTimeout> | null = null;
1010
1124
 
@@ -1489,6 +1603,7 @@
1489
1603
  if (row.when === 'chapters') return sortedChapters.length > 0;
1490
1604
  if (row.when === 'rail') return railOn;
1491
1605
  if (row.when === 'bookmarks') return onAddBookmark != null;
1606
+ if (row.when === 'capture') return frameCaptureOn;
1492
1607
  if (row.when === 'transcript') return sortedSegments.length > 0;
1493
1608
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
1494
1609
  if (row.when === 'night') return nightPresent;
@@ -3228,7 +3343,10 @@
3228
3343
  // menu closes itself, the theater must not leave with it. The
3229
3344
  // panel's own handler may already have closed it by the time this
3230
3345
  // bubble-phase handler runs, hence the target check too.
3231
- !(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
3232
3350
  ) {
3233
3351
  e.preventDefault();
3234
3352
  toggleTheater();
@@ -3249,13 +3367,23 @@
3249
3367
  // Space/Enter on a focused button activates THAT button (play, a
3250
3368
  // speaker chip, a transcript row); toggling playback too would undo it.
3251
3369
  if (tag === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
3252
- // The transcript line menu (jatui-3wl5x) owns the keyboard while it is
3253
- // open: arrows/Enter drive it (its panel preventDefaults what it uses,
3254
- // which the defaultPrevented guard above already yields to), Escape
3255
- // closes it, and nothing else drives the player underneath a floating
3256
- // menu. Tab is the shared ContextMenu's own behaviour (focus moves out
3257
- // with the menu still up; click/Escape dismiss it).
3258
- if (lineMenuOpen) return;
3370
+ // The transcript line menu (jatui-3wl5x) and the frame-capture menu
3371
+ // (jatui-8nu4g) own the keyboard while they are open: arrows/Enter drive
3372
+ // them (their panel preventDefaults what it uses, which the
3373
+ // defaultPrevented guard above already yields to), Escape closes, and
3374
+ // nothing else drives the player underneath a floating menu. Tab is the
3375
+ // shared ContextMenu's own behaviour (focus moves out with the menu
3376
+ // still up; click/Escape dismiss it).
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
+ }
3259
3387
  // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
3260
3388
  // activation) while focus is inside it; without this guard a rail `k`
3261
3389
  // would also toggle play/pause and a rail `j` also seek ±5s.
@@ -3422,6 +3550,15 @@
3422
3550
  cycleNight();
3423
3551
  }
3424
3552
  return;
3553
+ case 'c':
3554
+ case 'C':
3555
+ // Frame capture (jatui-8nu4g): video only; `frameCapture={false}`
3556
+ // removes the key with the button. Off ⇒ not ours — no
3557
+ // preventDefault, the page may keep it.
3558
+ if (!frameCaptureOn) return;
3559
+ e.preventDefault();
3560
+ openCaptureMenuAtTrigger();
3561
+ return;
3425
3562
  case 'Home':
3426
3563
  seekTo(0);
3427
3564
  break;
@@ -3617,6 +3754,157 @@
3617
3754
  return items;
3618
3755
  });
3619
3756
 
3757
+ // ── Frame capture (jatui-8nu4g, extension library.js:9262) ─────────────
3758
+ // The camera button (band transport) and the `C` key both open ONE menu
3759
+ // of frame verbs. The extension also burned a caption into the frame and
3760
+ // ran OCR on it — deliberately not ported (the player has no OCR budget;
3761
+ // hosts that want it can run it on the hook's dataUrl).
3762
+
3763
+ /** Open the menu anchored to the camera button (a click) — or, on a
3764
+ * native transport with no button, the stage (`C` still works there);
3765
+ * the position:fixed panel is viewport-clamped by floating-ui, so the
3766
+ * anchor is a hint, not a law. */
3767
+ function openCaptureMenuAtTrigger() {
3768
+ const rect =
3769
+ captureBtnEl?.getBoundingClientRect() ??
3770
+ stageWrapEl?.getBoundingClientRect() ??
3771
+ rootEl?.getBoundingClientRect();
3772
+ captureMenuX = rect?.left ?? 0;
3773
+ captureMenuY = rect ? rect.bottom + 6 : 0;
3774
+ captureMenuOpen = true;
3775
+ }
3776
+
3777
+ /** The current frame as a canvas at natural size. Null when there is
3778
+ * nothing to grab yet — no metadata (videoWidth is 0 until it) or no
3779
+ * decoded frame (drawImage refuses to make up pixels). Callers SAY
3780
+ * null — never a silent no-op. */
3781
+ function captureCanvas(): { canvas: HTMLCanvasElement; width: number; height: number } | null {
3782
+ const size = frameDrawSize(videoEl);
3783
+ if (!size || !videoEl) return null;
3784
+ const canvas = document.createElement('canvas');
3785
+ canvas.width = size.width;
3786
+ canvas.height = size.height;
3787
+ const ctx = canvas.getContext('2d');
3788
+ if (!ctx) return null;
3789
+ try {
3790
+ ctx.drawImage(videoEl, 0, 0, size.width, size.height);
3791
+ } catch {
3792
+ return null; // no decoded frame yet (readyState too low)
3793
+ }
3794
+ return { canvas, width: size.width, height: size.height };
3795
+ }
3796
+
3797
+ /** canvas.toBlob as a promise. A tainted canvas throws SecurityError out
3798
+ * of toBlob itself — re-thrown here so every verb can check the NAME. */
3799
+ function canvasToPng(canvas: HTMLCanvasElement): Promise<Blob> {
3800
+ return new Promise((resolve, reject) => {
3801
+ try {
3802
+ canvas.toBlob(
3803
+ (blob) => (blob ? resolve(blob) : reject(new Error('toBlob produced no blob'))),
3804
+ 'image/png'
3805
+ );
3806
+ } catch (e) {
3807
+ reject(e);
3808
+ }
3809
+ });
3810
+ }
3811
+
3812
+ function flashNoFrame() {
3813
+ flashOsd(CAPTURE_NO_FRAME_MESSAGE);
3814
+ announce('No frame yet — wait until the video has loaded a picture');
3815
+ }
3816
+
3817
+ /** The taint verdict, said twice: OSD for the eye, polite region for the
3818
+ * screen reader — a silent failure would read as a broken button. */
3819
+ function flashNoCors() {
3820
+ flashOsd(CAPTURE_NO_CORS_MESSAGE);
3821
+ announce(`${CAPTURE_NO_CORS_MESSAGE} — the video host must allow cross-origin reads (CORS) to export frames.`);
3822
+ }
3823
+
3824
+ function flashCaptureFailed(what: string) {
3825
+ flashOsd(`${what} failed`);
3826
+ announce(`${what} failed`);
3827
+ }
3828
+
3829
+ /** Save frame as PNG: draw → toBlob → anchor download. A cross-origin
3830
+ * video without CORS throws SecurityError out of toBlob itself —
3831
+ * caught, said, never silent. */
3832
+ function saveFrame() {
3833
+ const c = captureCanvas();
3834
+ if (!c) return flashNoFrame();
3835
+ try {
3836
+ c.canvas.toBlob((blob) => {
3837
+ if (!blob) return flashCaptureFailed('Capture');
3838
+ const url = URL.createObjectURL(blob);
3839
+ const a = document.createElement('a');
3840
+ a.href = url;
3841
+ a.download = captureFilename(currentTime);
3842
+ document.body.appendChild(a);
3843
+ a.click();
3844
+ a.remove();
3845
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
3846
+ flashOsd('Frame saved');
3847
+ announce('Frame saved as PNG');
3848
+ }, 'image/png');
3849
+ } catch (e) {
3850
+ if (isCrossOriginCaptureError(e)) flashNoCors();
3851
+ else flashCaptureFailed('Capture');
3852
+ }
3853
+ }
3854
+
3855
+ /** Copy frame: ClipboardItem carrying a PROMISE for the blob — the write
3856
+ * call lands inside the click's user-gesture budget while the pixels
3857
+ * encode async (Safari's gesture timer is the constraint). Unsupported
3858
+ * platforms never get here: the item is omitted, save-only degrade. */
3859
+ async function copyFrame() {
3860
+ const c = captureCanvas();
3861
+ if (!c) return flashNoFrame();
3862
+ try {
3863
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': canvasToPng(c.canvas) })]);
3864
+ flashOsd('Copied');
3865
+ announce('Frame copied to clipboard');
3866
+ } catch (e) {
3867
+ if (isCrossOriginCaptureError(e)) return flashNoCors();
3868
+ flashCaptureFailed('Copy');
3869
+ }
3870
+ }
3871
+
3872
+ /** Set as poster: hand the frame to the host. MOS Studio uploads the
3873
+ * blob and pins the video's poster; other hosts own their meaning. The
3874
+ * host's rejection is said, not hidden. */
3875
+ async function sendFrameToHost() {
3876
+ const c = captureCanvas();
3877
+ if (!c) return flashNoFrame();
3878
+ try {
3879
+ const blob = await canvasToPng(c.canvas);
3880
+ const dataUrl = c.canvas.toDataURL('image/png');
3881
+ const capture: VideoFrameCapture = {
3882
+ blob,
3883
+ dataUrl,
3884
+ timeSec: currentTime,
3885
+ width: c.width,
3886
+ height: c.height,
3887
+ name: captureFilename(currentTime)
3888
+ };
3889
+ await onCaptureFrame?.(capture);
3890
+ flashOsd('Frame captured');
3891
+ announce('Frame sent to host');
3892
+ } catch (e) {
3893
+ if (isCrossOriginCaptureError(e)) return flashNoCors();
3894
+ flashCaptureFailed('Set as poster');
3895
+ }
3896
+ }
3897
+
3898
+ /** The menu's items for the verbs this page can actually run. */
3899
+ const captureMenuItems = $derived.by<ContextMenuItem[]>(() => {
3900
+ const verbs: Record<CaptureAction, ContextMenuItem> = {
3901
+ save: { label: 'Save frame as PNG', icon: '⤓', onSelect: saveFrame },
3902
+ copy: { label: 'Copy frame to clipboard', icon: '⧉', onSelect: copyFrame },
3903
+ poster: { label: 'Set as poster', icon: '🖼️', onSelect: sendFrameToHost }
3904
+ };
3905
+ return captureMenuActions.map((a) => verbs[a]);
3906
+ });
3907
+
3620
3908
  // ── Transcript search (jatui-xmtnv) ───────────────────────────────────
3621
3909
  /** `/`: open the box (showing the transcript if it was hidden) and hand
3622
3910
  * it focus once it mounts — the effect below lands it. */
@@ -4250,6 +4538,49 @@
4250
4538
  aria-valuetext="{Math.round((mutedNow ? 0 : volumeLevel) * 100)}%"
4251
4539
  oninput={onVolumeInput}
4252
4540
  />
4541
+ {#if frameCaptureOn}
4542
+ <!-- Frame capture (jatui-8nu4g): opens the frame verbs menu
4543
+ (save PNG / copy / set as poster). A picture verb, so it
4544
+ sits with the picture verbs at the end of the cluster. -->
4545
+ <button
4546
+ type="button"
4547
+ class="vp-tbtn"
4548
+ bind:this={captureBtnEl}
4549
+ aria-haspopup="menu"
4550
+ aria-expanded={captureMenuOpen}
4551
+ aria-label="Capture frame"
4552
+ title="Capture frame · C"
4553
+ onclick={openCaptureMenuAtTrigger}
4554
+ >
4555
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4556
+ <path d="M3 8.6A1.6 1.6 0 0 1 4.6 7h3.4l1.3-2.2h5.4L16 7h3.4A1.6 1.6 0 0 1 21 8.6v8.8a1.6 1.6 0 0 1-1.6 1.6H4.6A1.6 1.6 0 0 1 3 17.4z" />
4557
+ <circle cx="12" cy="13" r="3.2" />
4558
+ </svg>
4559
+ </button>
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}
4253
4584
  {#if !isAudio}
4254
4585
  <button
4255
4586
  type="button"
@@ -4645,6 +4976,71 @@
4645
4976
  onClose={() => (lineMenuSeg = null)}
4646
4977
  />
4647
4978
  {/if}
4979
+
4980
+ {#if frameCaptureOn}
4981
+ <!-- Frame-capture menu (jatui-8nu4g, extension library.js:9262): ONE
4982
+ panel, opened at the camera button (click) or wherever the `C` key
4983
+ was pressed from (anchored to the stage on a native transport).
4984
+ Rendered inside the root so the fill-theater Tab trap keeps cycling
4985
+ through its items; position:fixed + floating-ui place it wherever
4986
+ this spot sits in the layout. The header is the frame's own clock. -->
4987
+ <ContextMenu
4988
+ bind:open={captureMenuOpen}
4989
+ x={captureMenuX}
4990
+ y={captureMenuY}
4991
+ items={captureMenuItems}
4992
+ header={formatTime(currentTime, useHours)}
4993
+ />
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}
4648
5044
  </div>
4649
5045
 
4650
5046
  <style>
@@ -5986,6 +6382,112 @@
5986
6382
  box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
5987
6383
  }
5988
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
+
5989
6491
  .vp-keys-title {
5990
6492
  font-size: 0.66rem;
5991
6493
  font-weight: 700;
@@ -9,6 +9,7 @@ export type { VideoAdSpan, RateReport } from '../video/rate';
9
9
  export type { WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource } from '../video/presence';
10
10
  export type { ResumePoint } from '../video/advisors/resume-point';
11
11
  export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/settings';
12
+ export type { VideoFrameCapture } from '../video/capture';
12
13
  /**
13
14
  * VideoPlayer — one video surface for everything, from a bare mp4 to a
14
15
  * fully-analysed recording.
@@ -71,6 +72,7 @@ import { type BandState } from '../video/band/axis';
71
72
  import { type VideoLumaTrack } from '../video/band/luma';
72
73
  import { type VideoMarker } from '../video/band/markers';
73
74
  import { type RateReport, type VideoAdSpan } from '../video/rate';
75
+ import { type VideoFrameCapture } from '../video/capture';
74
76
  import { type NightMode, type NightWindow } from '../video/night/clock';
75
77
  import { type AudioBrokenReason, type AudioState } from '../video/audio';
76
78
  import { type FlipSource, type VideoResumeStore, type WindDownState } from '../video/presence';
@@ -209,6 +211,23 @@ type $$ComponentProps = {
209
211
  * simply not on the menu — the player never guesses a URL.
210
212
  */
211
213
  buildTimeLink?: (sec: number) => string;
214
+ /**
215
+ * Frame capture (jatui-8nu4g) — see the destructure note above. Reads
216
+ * the frame through a canvas, which a cross-origin video without CORS
217
+ * taints: the export throws SecurityError and the player SAYS so
218
+ * ("Can't capture this video (no CORS)") instead of failing silently.
219
+ * Pass `crossorigin="anonymous"` for cross-origin media you want
220
+ * frame-readable.
221
+ */
222
+ frameCapture?: boolean;
223
+ /** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
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;
212
231
  /**
213
232
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
214
233
  * only says it wants the page's width; the HOST lays its page out
@@ -20,7 +20,9 @@ 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. `manual` (E73, jatui-p3m3u) — the viewer's remembered per-speaker speeds from the chip menu, multiplying with the automatic advisor; the composer clamps the product. None of them write `playbackRate`; they advise. |
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
+ | `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. |
23
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. |
24
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. |
25
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. |
26
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,90 @@
1
+ /**
2
+ * VideoPlayer frame capture (jatui-8nu4g — extension-library parity,
3
+ * library.js:9262) — the capture menu's PURE pieces, per the advisor
4
+ * doctrine: no DOM here, no element, no settings reads. The component owns
5
+ * the canvas and the `<video>` (the drawImage / toBlob / toDataURL /
6
+ * download / clipboard calls live there); everything that DECIDES lives in
7
+ * this file so it stays node-testable.
8
+ *
9
+ * The verbs: Save frame as PNG (always), Copy frame to clipboard (only
10
+ * where the platform can actually write an image — secure context +
11
+ * `navigator.clipboard.write` + `ClipboardItem` — otherwise the item is
12
+ * OMITTED, never a dead menu row), and Set as poster (only when the host
13
+ * supplies `onCaptureFrame`; the host owns what "poster" means).
14
+ *
15
+ * Cross-origin video without CORS taints the canvas: toBlob/toDataURL
16
+ * throw SecurityError. The component catches that and says so —
17
+ * `CAPTURE_NO_CORS_MESSAGE` — never a silent no-op.
18
+ */
19
+ /** The OSD + polite-region message for a tainted canvas (no CORS). */
20
+ export declare const CAPTURE_NO_CORS_MESSAGE = "Can't capture this video (no CORS)";
21
+ /** The message when there is no decodable frame to grab yet. */
22
+ export declare const CAPTURE_NO_FRAME_MESSAGE = "No frame yet";
23
+ /**
24
+ * The suggested download name for the frame at `sec`: `frame-1m30s.png`,
25
+ * `frame-1h02m30s.png` on long videos, seconds zero-padded. A junk time
26
+ * (negative, NaN) clamps to 0 — a filename never carries NaN.
27
+ */
28
+ export declare function captureFilename(sec: number): string;
29
+ /** The pieces of the platform a clipboard IMAGE write needs. */
30
+ export interface ClipboardImageEnv {
31
+ /** `navigator.clipboard?.write` — undefined off a secure context. */
32
+ clipboardWrite?: unknown;
33
+ /** `window.isSecureContext`. */
34
+ isSecureContext?: boolean;
35
+ /** The global `ClipboardItem` constructor. */
36
+ clipboardItem?: unknown;
37
+ }
38
+ /**
39
+ * Can this platform write an image to the clipboard? A clipboard image
40
+ * write needs a secure context (navigator.clipboard only exists on HTTPS /
41
+ * localhost), the `write` method, and the `ClipboardItem` constructor.
42
+ * False ⇒ the component degrades to save-only: the Copy item is omitted,
43
+ * never rendered dead.
44
+ */
45
+ export declare function clipboardImageSupported(env?: ClipboardImageEnv): boolean;
46
+ /** The capture menu's verbs, in menu order. */
47
+ export type CaptureAction = 'save' | 'copy' | 'poster';
48
+ /**
49
+ * Which verbs the capture menu carries. Save is always there; Copy only
50
+ * where the platform can write an image (degrade to save-only); the poster
51
+ * hook only when the host supplied `onCaptureFrame`. Omitted — never
52
+ * disabled — matching the settings-panel doctrine.
53
+ */
54
+ export declare function captureActions(opts?: {
55
+ clipboardImage?: boolean;
56
+ hostHook?: boolean;
57
+ }): CaptureAction[];
58
+ /** What `onCaptureFrame` receives when the viewer picks "Set as poster". */
59
+ export interface VideoFrameCapture {
60
+ /** The frame as an `image/png` Blob — upload it, attach it, store it. */
61
+ blob: Blob;
62
+ /** The same frame as a PNG data URL — preview without an objectURL lifecycle. */
63
+ dataUrl: string;
64
+ /** Playback time the frame was grabbed at (seconds). */
65
+ timeSec: number;
66
+ /** Natural frame dimensions in px. */
67
+ width: number;
68
+ height: number;
69
+ /** Suggested filename — `captureFilename(timeSec)`. */
70
+ name: string;
71
+ }
72
+ /**
73
+ * The natural frame size to draw at, or null before metadata has landed
74
+ * (videoWidth/videoHeight are 0 until then — there is nothing to grab yet,
75
+ * and a 0×0 canvas is never a valid capture).
76
+ */
77
+ export declare function frameDrawSize(video: {
78
+ videoWidth?: number | null;
79
+ videoHeight?: number | null;
80
+ }): {
81
+ width: number;
82
+ height: number;
83
+ } | null;
84
+ /**
85
+ * A SecurityError from the canvas means the media's bytes are not readable
86
+ * from this origin (cross-origin without CORS) — the export path, not the
87
+ * player, is refusing. Other engines word it differently; the NAME is the
88
+ * contract.
89
+ */
90
+ export declare function isCrossOriginCaptureError(e: unknown): boolean;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * VideoPlayer frame capture (jatui-8nu4g — extension-library parity,
3
+ * library.js:9262) — the capture menu's PURE pieces, per the advisor
4
+ * doctrine: no DOM here, no element, no settings reads. The component owns
5
+ * the canvas and the `<video>` (the drawImage / toBlob / toDataURL /
6
+ * download / clipboard calls live there); everything that DECIDES lives in
7
+ * this file so it stays node-testable.
8
+ *
9
+ * The verbs: Save frame as PNG (always), Copy frame to clipboard (only
10
+ * where the platform can actually write an image — secure context +
11
+ * `navigator.clipboard.write` + `ClipboardItem` — otherwise the item is
12
+ * OMITTED, never a dead menu row), and Set as poster (only when the host
13
+ * supplies `onCaptureFrame`; the host owns what "poster" means).
14
+ *
15
+ * Cross-origin video without CORS taints the canvas: toBlob/toDataURL
16
+ * throw SecurityError. The component catches that and says so —
17
+ * `CAPTURE_NO_CORS_MESSAGE` — never a silent no-op.
18
+ */
19
+ /** The OSD + polite-region message for a tainted canvas (no CORS). */
20
+ export const CAPTURE_NO_CORS_MESSAGE = "Can't capture this video (no CORS)";
21
+ /** The message when there is no decodable frame to grab yet. */
22
+ export const CAPTURE_NO_FRAME_MESSAGE = 'No frame yet';
23
+ /**
24
+ * The suggested download name for the frame at `sec`: `frame-1m30s.png`,
25
+ * `frame-1h02m30s.png` on long videos, seconds zero-padded. A junk time
26
+ * (negative, NaN) clamps to 0 — a filename never carries NaN.
27
+ */
28
+ export function captureFilename(sec) {
29
+ const t = Number.isFinite(sec) ? Math.max(0, Math.floor(sec)) : 0;
30
+ const h = Math.floor(t / 3600);
31
+ const m = Math.floor((t % 3600) / 60);
32
+ const s = t % 60;
33
+ const ss = String(s).padStart(2, '0');
34
+ const clock = h > 0 ? `${h}h${String(m).padStart(2, '0')}m${ss}s` : `${m}m${ss}s`;
35
+ return `frame-${clock}.png`;
36
+ }
37
+ /**
38
+ * Reads the real platform (SSR/node safe — every global behind a typeof).
39
+ * Isolated from the predicate so tests inject fakes instead of stubbing
40
+ * globals.
41
+ */
42
+ function currentClipboardImageEnv() {
43
+ if (typeof navigator === 'undefined')
44
+ return { isSecureContext: false, clipboardItem: undefined };
45
+ const clip = navigator.clipboard;
46
+ let item = undefined;
47
+ try {
48
+ // Reference the global indirectly: a bare `typeof ClipboardItem` is
49
+ // fine, but keeping it in one place documents that ONLY its existence
50
+ // matters here, not what it can hold.
51
+ item = typeof ClipboardItem === 'function' ? ClipboardItem : undefined;
52
+ }
53
+ catch {
54
+ item = undefined;
55
+ }
56
+ return {
57
+ clipboardWrite: clip?.write,
58
+ isSecureContext: typeof window !== 'undefined' ? window.isSecureContext === true : false,
59
+ clipboardItem: item
60
+ };
61
+ }
62
+ /**
63
+ * Can this platform write an image to the clipboard? A clipboard image
64
+ * write needs a secure context (navigator.clipboard only exists on HTTPS /
65
+ * localhost), the `write` method, and the `ClipboardItem` constructor.
66
+ * False ⇒ the component degrades to save-only: the Copy item is omitted,
67
+ * never rendered dead.
68
+ */
69
+ export function clipboardImageSupported(env = currentClipboardImageEnv()) {
70
+ return typeof env.clipboardWrite === 'function' && env.isSecureContext === true && typeof env.clipboardItem === 'function';
71
+ }
72
+ /**
73
+ * Which verbs the capture menu carries. Save is always there; Copy only
74
+ * where the platform can write an image (degrade to save-only); the poster
75
+ * hook only when the host supplied `onCaptureFrame`. Omitted — never
76
+ * disabled — matching the settings-panel doctrine.
77
+ */
78
+ export function captureActions(opts = {}) {
79
+ const actions = ['save'];
80
+ if (opts.clipboardImage)
81
+ actions.push('copy');
82
+ if (opts.hostHook)
83
+ actions.push('poster');
84
+ return actions;
85
+ }
86
+ /**
87
+ * The natural frame size to draw at, or null before metadata has landed
88
+ * (videoWidth/videoHeight are 0 until then — there is nothing to grab yet,
89
+ * and a 0×0 canvas is never a valid capture).
90
+ */
91
+ export function frameDrawSize(video) {
92
+ const w = video?.videoWidth ?? 0;
93
+ const h = video?.videoHeight ?? 0;
94
+ return w > 0 && h > 0 ? { width: w, height: h } : null;
95
+ }
96
+ /**
97
+ * A SecurityError from the canvas means the media's bytes are not readable
98
+ * from this origin (cross-origin without CORS) — the export path, not the
99
+ * player, is refusing. Other engines word it differently; the NAME is the
100
+ * contract.
101
+ */
102
+ export function isCrossOriginCaptureError(e) {
103
+ return e instanceof DOMException && e.name === 'SecurityError';
104
+ }
@@ -11,8 +11,9 @@
11
11
  * none yet).
12
12
  * Deliberately NOT ported: F/M (favourite/file-under — library-app verbs,
13
13
  * not player verbs). N (night cycle) landed with the night module
14
- * (mos-5lzns.33) and `,` (settings) with the settings panel
15
- * (mos-5lzns.35), each gated on the host opting in.
14
+ * (mos-5lzns.33), `,` (settings) with the settings panel (mos-5lzns.35) and
15
+ * C (frame capture) with the capture menu (jatui-8nu4g, extension
16
+ * library.js:9262), each gated on the host opting in.
16
17
  */
17
18
  export interface PlayerKeyRow {
18
19
  /** Keycap label as shown in the help overlay — `/`-separated alternates. */
@@ -22,7 +23,7 @@ export interface PlayerKeyRow {
22
23
  * Availability gate: the row is only advertised (and the key only bound)
23
24
  * when the feature's data exists. Undefined ⇒ always active.
24
25
  */
25
- when?: 'chapters' | 'rail' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings' | 'theater';
26
+ when?: 'chapters' | 'rail' | 'bookmarks' | 'capture' | 'transcript' | 'comments' | 'night' | 'settings' | 'theater';
26
27
  }
27
28
  /** Order follows the help overlay: transport, seek, volume, speed, structure. */
28
29
  export declare const PLAYER_KEYS: PlayerKeyRow[];
@@ -11,8 +11,9 @@
11
11
  * none yet).
12
12
  * Deliberately NOT ported: F/M (favourite/file-under — library-app verbs,
13
13
  * not player verbs). N (night cycle) landed with the night module
14
- * (mos-5lzns.33) and `,` (settings) with the settings panel
15
- * (mos-5lzns.35), each gated on the host opting in.
14
+ * (mos-5lzns.33), `,` (settings) with the settings panel (mos-5lzns.35) and
15
+ * C (frame capture) with the capture menu (jatui-8nu4g, extension
16
+ * library.js:9262), each gated on the host opting in.
16
17
  */
17
18
  /** Order follows the help overlay: transport, seek, volume, speed, structure. */
18
19
  export const PLAYER_KEYS = [
@@ -32,6 +33,10 @@ export const PLAYER_KEYS = [
32
33
  // and "people's reactions to it" sit under one hand (mos-5lzns.30).
33
34
  { keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
34
35
  { keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
36
+ // C (jatui-8nu4g, extension library.js:9262) — the frame-capture menu:
37
+ // save the current frame as PNG, copy it (where the platform can write
38
+ // images), and set as poster when the host supplies the hook. Video only.
39
+ { keys: 'C', description: 'Frame capture: save PNG / copy / set as poster', when: 'capture' },
35
40
  // t = theater, YouTube's key; the transcript moved to Shift+T (jatui-ee2ty).
36
41
  { keys: 't', description: 'Theater mode on / off', when: 'theater' },
37
42
  { keys: 'Shift+T', description: 'Cycle transcript layout: side / below / hidden', when: 'transcript' },
@@ -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.51.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",