@joewinke/jatui 0.47.0 → 0.49.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). Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
180
+ | `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. A double-click on the picture toggles fullscreen (band transport; with native controls the browser owns the gesture), a buffering spinner shows while the media is waiting, and volume/mute survive a reload through `settingsStore` (else guarded localStorage). **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped). Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `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) |
package/README.md CHANGED
@@ -32,7 +32,7 @@ Key contract points:
32
32
 
33
33
  - **Gesture-gated** — `engine.play(id)` must run inside a click/keydown handler; it rejects otherwise. The AudioContext closes itself ~3 s after stop, and a play inside that window fades back in instead of restarting.
34
34
  - **Scape contract** — `{ id, label, group, description?, build(ctx) }`; `build` connects into `ctx.input` (never straight to `destination`), gets the shared `ctx.breath` clock and a lookahead scheduler. Scapes are re-buildable and tear themselves down on `stop`.
35
- - **Playlist scapes** — per-host, never in the default set. Build one with `createPlaylistScape({ id, label, manifestUrl })` and `engine.register()` it; the manifest is fetched lazily once on the first play, tracks stream through the master chain with gain-ramp crossfades, and the returned `PlaylistScape` adds `state()/subscribe()/skip()/setShuffle()` for the UI. Manifest shape (`title` optional; `license` and `composer` required on every track):
35
+ - **Playlist scapes** — per-host, never in the default set. Build one with `createPlaylistScape({ id, label, manifestUrl })` and `engine.register()` it; the manifest is fetched lazily once on the first play, tracks stream through the master chain with gain-ramp crossfades, and the returned `PlaylistScape` adds `state()/subscribe()/skip()/setShuffle()` for the UI. Manifest shape (`title` optional at the manifest level; every track requires `title`, `composer`, `url` and `license` — tracks missing any are dropped — with `performer` and `sourceUrl` optional):
36
36
 
37
37
  ```json
38
38
  {
@@ -19,6 +19,10 @@
19
19
  * play/pause, time, speed and volume, so the native control bar (a
20
20
  * second timeline) never renders. Audio has no stage at all
21
21
  * (`kind="audio"`); video keeps its stage at its intrinsic ratio.
22
+ * - video breaks out of the reading column like a wide table (def.wide
23
+ * agrees), and its theater fills the browser window
24
+ * (`theaterMode="fill"`) — an artifact page has no wider layout to
25
+ * switch to. Audio stays in the column.
22
26
  * - resumeKey ties "continue where you left off" to this artifact block,
23
27
  * so a review round survives closing the tab.
24
28
  *
@@ -78,7 +82,7 @@
78
82
  }
79
83
  </script>
80
84
 
81
- <section class="artifact-transcript w-full">
85
+ <section class="artifact-transcript w-full" class:tr-wide={isVideo}>
82
86
  {#if params.title || params.note}
83
87
  <header class="mb-3">
84
88
  {#if params.title}
@@ -109,6 +113,7 @@
109
113
  chapters={vChapters}
110
114
  markers={vMarkers}
111
115
  transcriptPosition={isVideo ? "side" : "below"}
116
+ theaterMode={isVideo ? "fill" : "width"}
112
117
  transcriptMaxHeight="24rem"
113
118
  {resumeKey}
114
119
  />
@@ -167,3 +172,16 @@
167
172
  </section>
168
173
  {/if}
169
174
  </section>
175
+
176
+ <style>
177
+ /* Video: break out of the artifact page's reading column the way a wide
178
+ table does (TableBlock's .tb-wide — the same formula, so the two line
179
+ up): centred on the column, capped at 80rem, 1.5rem inside the page
180
+ view's MEASURED width (--artifact-page-w). Only that page opts in;
181
+ pinned/embedded hosts keep the block in its column. */
182
+ :global([data-artifact-page]) .tr-wide {
183
+ --tr-w: max(100%, min(var(--artifact-page-w, 100%) - 3rem, 80rem));
184
+ width: var(--tr-w);
185
+ margin-inline: calc((100% - var(--tr-w)) / 2);
186
+ }
187
+ </style>
@@ -81,6 +81,9 @@ const def = {
81
81
  component: TranscriptBlock,
82
82
  // mos-8fvt9: the section's Listen + comment sit on the heading row.
83
83
  hostsTools: (p) => Boolean(p.title),
84
+ // jatui-c5uob: video breaks out of the reading column (TranscriptBlock's
85
+ // .tr-wide); audio stays in it. media.type defaults to audio.
86
+ wide: (p) => p.media?.type === "video",
84
87
  example: EXAMPLE,
85
88
  };
86
89
  export default def;
@@ -256,6 +256,7 @@
256
256
  } from '../video/presence';
257
257
  import type { ResumePoint } from '../video/advisors/resume-point';
258
258
  import { idleDetectionSupported } from '../video/presence';
259
+ import { lockPageScroll, resolveTheaterMode, trapFillTab, type TheaterMode } from '../video/theaterFill';
259
260
  import {
260
261
  coerce,
261
262
  createSettingsSession,
@@ -301,6 +302,7 @@
301
302
  transcriptMaxHeight,
302
303
  followResumeMs = FOLLOW_RESUME_IDLE_MS,
303
304
  theater = $bindable(false),
305
+ theaterMode = 'width',
304
306
  theaterButton = true,
305
307
  onTheaterChange,
306
308
  /**
@@ -495,6 +497,18 @@
495
497
  * host's default; a remembered choice wins.
496
498
  */
497
499
  theater?: boolean;
500
+ /**
501
+ * What theater does (jatui-c5uob). `'width'` (default) is the above:
502
+ * the host widens its page. `'fill'` makes the player itself a fixed
503
+ * full-viewport dialog (`role="dialog"`, `aria-modal`): the page stops
504
+ * scrolling, Tab stays inside the player, Escape or the theater button
505
+ * exits and gives focus back. The video is the largest 16:9 box that
506
+ * fits (other ratios letterbox), a transcript sits beside it from
507
+ * 64rem and below it under that. Fullscreen stays monitor fullscreen.
508
+ * A REMEMBERED theater never opens the overlay on load — only the
509
+ * viewer's own gesture (button, `t`, settings panel) fills the window.
510
+ */
511
+ theaterMode?: TheaterMode;
498
512
  /** Show the theater button + `t` key (video only; default true). */
499
513
  theaterButton?: boolean;
500
514
  /** The viewer toggled theater mode (button, `t`, settings panel). */
@@ -2338,7 +2352,7 @@
2338
2352
  let rootEl: HTMLDivElement | undefined = $state();
2339
2353
 
2340
2354
  /** Land one coerced value: bindables are written, everything else overlays its prop. */
2341
- function applySetting(id: string, v: SettingValue) {
2355
+ function applySetting(id: string, v: SettingValue, source?: 'user' | 'store') {
2342
2356
  if (id === 'night.mode') {
2343
2357
  if (night !== undefined && night !== v) {
2344
2358
  night = v as NightMode;
@@ -2352,6 +2366,7 @@
2352
2366
  }
2353
2367
  if (id === 'player.theater') {
2354
2368
  if (!settingsStore) localTheaterStore.set(id, v);
2369
+ if (source === 'user') fillRequested = v === true;
2355
2370
  if (theater !== v) {
2356
2371
  theater = v as boolean;
2357
2372
  onTheaterChange?.(theater);
@@ -2368,7 +2383,7 @@
2368
2383
  createSettingsSession({
2369
2384
  store,
2370
2385
  onValue: (id, v, source) => {
2371
- applySetting(id, v);
2386
+ applySetting(id, v, source);
2372
2387
  if (source === 'user') onSettingChange?.(id, v);
2373
2388
  }
2374
2389
  })
@@ -2388,6 +2403,31 @@
2388
2403
  const localTheaterStore = localSettingsStore();
2389
2404
  const theaterAvailable = $derived(!isAudio && theaterButton);
2390
2405
  const theaterOn = $derived(theaterAvailable && theater);
2406
+ // Fill mode (jatui-c5uob): the overlay is open only after a viewer
2407
+ // gesture in THIS page — the remembered `player.theater` alone never
2408
+ // covers the page on load. `theaterActive` is what the root and the
2409
+ // button show: in fill mode, whether the overlay is up.
2410
+ const fillMode = $derived(resolveTheaterMode(theaterMode) === 'fill');
2411
+ let fillRequested = $state(false);
2412
+ const filling = $derived(fillMode && theaterOn && fillRequested);
2413
+ const theaterActive = $derived(fillMode ? filling : theaterOn);
2414
+ $effect(() => {
2415
+ if (!theaterOn && fillRequested) fillRequested = false;
2416
+ });
2417
+ // While filling: the page under the overlay doesn't scroll, and focus
2418
+ // goes back where it was when the overlay closes.
2419
+ $effect(() => {
2420
+ if (!filling || typeof document === 'undefined') return;
2421
+ const returnTo = document.activeElement as HTMLElement | null;
2422
+ const release = lockPageScroll(document);
2423
+ const root = untrack(() => rootEl);
2424
+ if (root && !root.contains(document.activeElement)) root.focus();
2425
+ return () => {
2426
+ release();
2427
+ const back = returnTo && returnTo.isConnected ? returnTo : untrack(() => rootEl);
2428
+ back?.focus({ preventScroll: true });
2429
+ };
2430
+ });
2391
2431
  $effect(() => {
2392
2432
  if (!theaterAvailable || settingsStore) return;
2393
2433
  untrack(() => {
@@ -2398,7 +2438,8 @@
2398
2438
  });
2399
2439
  function toggleTheater() {
2400
2440
  if (!theaterAvailable) return;
2401
- const next = !theaterOn;
2441
+ const next = !theaterActive;
2442
+ if (fillMode) fillRequested = next;
2402
2443
  if (settingsSession) settingsSession.change('player.theater', next);
2403
2444
  else applySetting('player.theater', next);
2404
2445
  flashOsd(next ? 'Theater mode' : 'Default view');
@@ -3069,6 +3110,31 @@
3069
3110
  * ADVERTISED when the data exists — a documented key that does nothing is
3070
3111
  * the one thing a keymap must never be.
3071
3112
  */
3113
+ /** Fill theater (jatui-c5uob): Tab stays inside the player, and Escape
3114
+ * exits — after the overlays that own Escape (help, search, settings,
3115
+ * band zoom) have had it, and never while the stage is fullscreen. */
3116
+ function onRootKeyDownFill(e: KeyboardEvent) {
3117
+ if (filling && rootEl) {
3118
+ if (e.key === 'Tab') {
3119
+ trapFillTab(rootEl, e);
3120
+ return;
3121
+ }
3122
+ if (
3123
+ e.key === 'Escape' &&
3124
+ !e.defaultPrevented &&
3125
+ !keysOpen &&
3126
+ !searchOpen &&
3127
+ !settingsOpen &&
3128
+ !isFullscreen
3129
+ ) {
3130
+ e.preventDefault();
3131
+ toggleTheater();
3132
+ return;
3133
+ }
3134
+ }
3135
+ onRootKeyDown(e);
3136
+ }
3137
+
3072
3138
  function onRootKeyDown(e: KeyboardEvent) {
3073
3139
  // The band's slider handler (and the help overlay) own their keys and
3074
3140
  // preventDefault them — without this guard an ArrowLeft with the band
@@ -3514,9 +3580,12 @@
3514
3580
  <div
3515
3581
  class="vp-root {className}"
3516
3582
  bind:this={rootEl}
3517
- class:vp-side={transcriptLayout === 'side' && !theaterOn && sortedSegments.length > 0}
3518
- class:vp-theater={theaterOn}
3519
- data-theater={theaterAvailable ? String(theaterOn) : undefined}
3583
+ class:vp-side={transcriptLayout === 'side' && !theaterActive && sortedSegments.length > 0}
3584
+ class:vp-theater={theaterActive}
3585
+ class:vp-fill={filling}
3586
+ class:vp-fill-transcript={filling && sortedSegments.length > 0 && transcriptLayout !== 'hidden'}
3587
+ data-theater={theaterAvailable ? String(theaterActive) : undefined}
3588
+ data-theater-mode={theaterAvailable ? (fillMode ? 'fill' : 'width') : undefined}
3520
3589
  class:vp-rail-side={railOn}
3521
3590
  class:vp-one-axis={nativeBarHidden}
3522
3591
  class:vp-band-transport={bandTransport}
@@ -3526,10 +3595,11 @@
3526
3595
  class:vp-night-surround={surroundMix > 0.001}
3527
3596
  style:--vp-night-surround={surroundMix > 0.001 ? surroundMix.toFixed(3) : undefined}
3528
3597
  data-band-axis={currentBandState.axis}
3529
- role="region"
3530
- aria-label={isAudio ? 'Audio player' : 'Video player'}
3598
+ role={filling ? 'dialog' : 'region'}
3599
+ aria-modal={filling ? 'true' : undefined}
3600
+ aria-label={isAudio ? 'Audio player' : filling ? 'Video player, theater' : 'Video player'}
3531
3601
  tabindex="-1"
3532
- onkeydown={onRootKeyDown}
3602
+ onkeydown={onRootKeyDownFill}
3533
3603
  onpointerdowncapture={maybeAskIdle}
3534
3604
  onpointerupcapture={maybeAskIdle}
3535
3605
  onclickcapture={maybeAskIdle}
@@ -3878,10 +3948,10 @@
3878
3948
  <button
3879
3949
  type="button"
3880
3950
  class="vp-theater-btn"
3881
- class:active={theaterOn}
3951
+ class:active={theaterActive}
3882
3952
  aria-label="Theater mode"
3883
- aria-pressed={theaterOn}
3884
- title="{theaterOn ? 'Default view' : 'Theater mode'} · t"
3953
+ aria-pressed={theaterActive}
3954
+ title="{theaterActive ? (fillMode ? 'Exit theater · t or Esc' : 'Default view · t') : 'Theater mode · t'}"
3885
3955
  onclick={toggleTheater}
3886
3956
  >
3887
3957
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true">
@@ -4606,6 +4676,137 @@
4606
4676
  }
4607
4677
  }
4608
4678
 
4679
+ /* ── Fill theater (jatui-c5uob) ──────────────────────────────────────
4680
+ theaterMode="fill": the player IS the overlay — fixed to the viewport
4681
+ above the page (z from --jatui-z-theater). The stage is the largest
4682
+ 16:9 box its cell can hold (size container units: 100cqw wide, or
4683
+ 100cqh × 16/9 when the height is what runs out); a source of another
4684
+ ratio letterboxes inside it. A transcript sits beside the video from
4685
+ 64rem and below it under that; the chapter rail keeps its column.
4686
+ Placed after the rail rules so the same-specificity grid wins. */
4687
+ .vp-root.vp-fill {
4688
+ position: fixed;
4689
+ inset: 0;
4690
+ z-index: var(--jatui-z-theater, 9040);
4691
+ display: grid;
4692
+ grid-template-columns: minmax(0, 1fr);
4693
+ grid-template-rows: minmax(0, 1fr);
4694
+ grid-auto-rows: auto;
4695
+ grid-template-areas: 'main' 'rail' 'below' 'transcript';
4696
+ align-items: stretch;
4697
+ gap: var(--jatui-space-lg, 12px);
4698
+ margin: 0;
4699
+ border: 0;
4700
+ border-radius: 0;
4701
+ padding: var(--jatui-space-xl, 16px);
4702
+ background: var(--color-base-300);
4703
+ overflow: hidden;
4704
+ }
4705
+
4706
+ .vp-root.vp-fill > .vp-main {
4707
+ grid-area: main;
4708
+ min-height: 0;
4709
+ }
4710
+
4711
+ .vp-root.vp-fill > .vp-chapters {
4712
+ grid-area: rail;
4713
+ }
4714
+
4715
+ .vp-root.vp-fill > .vp-below-stage {
4716
+ grid-area: below;
4717
+ }
4718
+
4719
+ .vp-root.vp-fill > .vp-transcript-wrap {
4720
+ grid-area: transcript;
4721
+ min-height: 0;
4722
+ max-height: 40vh;
4723
+ max-height: 40dvh;
4724
+ }
4725
+
4726
+ .vp-root.vp-fill .vp-transcript {
4727
+ max-height: none;
4728
+ flex: 1 1 auto;
4729
+ min-height: 0;
4730
+ }
4731
+
4732
+ .vp-fill .vp-stage-wrap {
4733
+ flex: 1 1 0;
4734
+ min-height: 0;
4735
+ container-type: size;
4736
+ display: flex;
4737
+ flex-direction: column;
4738
+ align-items: center;
4739
+ justify-content: center;
4740
+ }
4741
+
4742
+ .vp-fill .vp-stage,
4743
+ .vp-fill .vp-stage.vp-stage-locked {
4744
+ flex: none;
4745
+ width: min(100cqw, 100cqh * 16 / 9);
4746
+ aspect-ratio: 16 / 9;
4747
+ background: var(--color-neutral);
4748
+ border-radius: var(--jatui-radius-field, 0.25rem);
4749
+ overflow: hidden;
4750
+ }
4751
+
4752
+ .vp-fill .vp-stage .vp-video {
4753
+ position: absolute;
4754
+ inset: 0;
4755
+ width: 100%;
4756
+ height: 100%;
4757
+ object-fit: contain;
4758
+ border-radius: 0;
4759
+ }
4760
+
4761
+ @media (min-width: 64rem) {
4762
+ .vp-root.vp-fill.vp-fill-transcript {
4763
+ grid-template-columns: minmax(0, 1fr) minmax(20rem, 28rem);
4764
+ grid-template-rows: minmax(0, 1fr) auto;
4765
+ grid-template-areas:
4766
+ 'main transcript'
4767
+ 'below transcript';
4768
+ }
4769
+
4770
+ .vp-root.vp-fill.vp-rail-side {
4771
+ grid-template-columns: minmax(0, 1fr) minmax(11rem, 14rem);
4772
+ grid-template-rows: minmax(0, 1fr) auto;
4773
+ grid-template-areas:
4774
+ 'main rail'
4775
+ 'below rail';
4776
+ }
4777
+
4778
+ .vp-root.vp-fill.vp-fill-transcript.vp-rail-side {
4779
+ grid-template-columns: minmax(0, 1fr) minmax(20rem, 28rem) minmax(11rem, 14rem);
4780
+ grid-template-areas:
4781
+ 'main transcript rail'
4782
+ 'below transcript rail';
4783
+ }
4784
+
4785
+ .vp-root.vp-fill > .vp-transcript-wrap {
4786
+ max-height: none;
4787
+ }
4788
+
4789
+ .vp-root.vp-fill.vp-rail-side > .vp-chapters {
4790
+ position: static;
4791
+ max-height: none;
4792
+ min-height: 0;
4793
+ overflow-y: auto;
4794
+ }
4795
+ }
4796
+
4797
+ /* Entrance: a short fade, only when motion is welcome. */
4798
+ @media (prefers-reduced-motion: no-preference) {
4799
+ .vp-root.vp-fill {
4800
+ animation: vp-fill-in var(--motion-enter, 200ms) var(--motion-glide, ease-out);
4801
+ }
4802
+ }
4803
+
4804
+ @keyframes vp-fill-in {
4805
+ from {
4806
+ opacity: 0;
4807
+ }
4808
+ }
4809
+
4609
4810
  .vp-stage {
4610
4811
  position: relative;
4611
4812
  }
@@ -75,6 +75,7 @@ import { type NightMode, type NightWindow } from '../video/night/clock';
75
75
  import { type AudioBrokenReason, type AudioState } from '../video/audio';
76
76
  import { type FlipSource, type VideoResumeStore, type WindDownState } from '../video/presence';
77
77
  import type { ResumePoint } from '../video/advisors/resume-point';
78
+ import { type TheaterMode } from '../video/theaterFill';
78
79
  import { type SettingValue, type VideoSettingsStore } from '../video/settings';
79
80
  type $$ComponentProps = {
80
81
  src: string;
@@ -202,6 +203,18 @@ type $$ComponentProps = {
202
203
  * host's default; a remembered choice wins.
203
204
  */
204
205
  theater?: boolean;
206
+ /**
207
+ * What theater does (jatui-c5uob). `'width'` (default) is the above:
208
+ * the host widens its page. `'fill'` makes the player itself a fixed
209
+ * full-viewport dialog (`role="dialog"`, `aria-modal`): the page stops
210
+ * scrolling, Tab stays inside the player, Escape or the theater button
211
+ * exits and gives focus back. The video is the largest 16:9 box that
212
+ * fits (other ratios letterbox), a transcript sits beside it from
213
+ * 64rem and below it under that. Fullscreen stays monitor fullscreen.
214
+ * A REMEMBERED theater never opens the overlay on load — only the
215
+ * viewer's own gesture (button, `t`, settings panel) fills the window.
216
+ */
217
+ theaterMode?: TheaterMode;
205
218
  /** Show the theater button + `t` key (video only; default true). */
206
219
  theaterButton?: boolean;
207
220
  /** The viewer toggled theater mode (button, `t`, settings panel). */
package/dist/index.d.ts CHANGED
@@ -24,6 +24,8 @@ export { LISTEN_KEYS } from './listen/keys';
24
24
  export { LISTEN_SPEEDS, SPEED_FLOOR, SPEED_CEIL, clampSpeed, stepSpeed, formatListenSpeed } from './listen/prefs';
25
25
  export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, AudioState, AudioStatus, AudioBrokenReason, VideoAdSpan, RateReport, WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource, } from './components/VideoPlayer.svelte';
26
26
  export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
27
+ export type { TheaterMode } from './video/theaterFill';
28
+ export { lockPageScroll, trapFillTab, fillFocusables, resolveTheaterMode } from './video/theaterFill';
27
29
  export { idleDetectionSupported, idleDetectionPermission, requestIdleDetection, localResumeStore, flipOpen, flipClose, clearPoster, type IdlePermission } from './video/presence';
28
30
  export { VIDEO_SETTINGS, SETTING_GROUPS, settingSpec, settingValue, coerce as coerceVideoSetting, memorySettingsStore, panelGroups as videoSettingsGroups, formatSettingValue, type VideoSettingsStore, type SettingSpec as VideoSettingSpec, type SettingValue as VideoSettingValue, type SettingOption as VideoSettingOption, type SettingWhen as VideoSettingWhen, type SettingAvailability as VideoSettingAvailability } from './video/settings';
29
31
  export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ export { LISTEN_SPEEDS, SPEED_FLOOR, SPEED_CEIL, clampSpeed, stepSpeed, formatLi
43
43
  // under `speakerPace` (manual beats measured), for a host drawing its own
44
44
  // per-speaker speed UI.
45
45
  export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
46
+ export { lockPageScroll, trapFillTab, fillFocusables, resolveTheaterMode } from './video/theaterFill';
46
47
  // Presence (mos-5lzns.34): the permission call a host makes from the click
47
48
  // that enables wind-down's desktop signal (the API needs a user gesture),
48
49
  // the default resume store, and the FLIP pair for a host animating its own
@@ -28,6 +28,10 @@
28
28
 
29
29
  /* Border — depth comes from tonal surfaces + 1px borders, not shadows */
30
30
  --jatui-border-w: 1px;
31
+
32
+ /* Stacking — full-viewport surfaces (VideoPlayer's fill theater). A
33
+ host's --z-lightbox tier wins; 9040 matches MediaLightbox's default. */
34
+ --jatui-z-theater: var(--z-lightbox, 9040);
31
35
  }
32
36
 
33
37
  /*
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Browser-fill theater (jatui-c5uob, upstreamed from MOS mos-wyjyh).
3
+ *
4
+ * `theaterMode="fill"` turns VideoPlayer's theater into a fixed,
5
+ * full-viewport dialog instead of asking the host to widen its page. The
6
+ * component owns the markup and CSS; this module holds the DOM plumbing a
7
+ * modal surface needs, kept framework-free so it can be tested on its own:
8
+ *
9
+ * - `resolveTheaterMode` — the prop's never-throws coercion;
10
+ * - `fillFocusables` / `trapFillTab` — Tab and Shift+Tab cycle inside the
11
+ * player while it fills the window;
12
+ * - `lockPageScroll` — the page under the overlay doesn't scroll. Counted,
13
+ * so two filled players (or a player and a host modal using the same
14
+ * lock) release it only when the last one lets go.
15
+ */
16
+ export type TheaterMode = 'width' | 'fill';
17
+ /** Unknown or missing values are today's behaviour: `'width'`. */
18
+ export declare function resolveTheaterMode(v: unknown): TheaterMode;
19
+ /** Tab stops inside `root`, in document order. Hidden, inert and
20
+ * `tabindex="-1"` elements are skipped. */
21
+ export declare function fillFocusables(root: Element): HTMLElement[];
22
+ /**
23
+ * Keep Tab inside `root`. Call it from a keydown handler; it returns true
24
+ * (and preventDefaults) only when it moved focus itself — at the first or
25
+ * last stop, when focus has escaped the root, or when the root has no stops
26
+ * at all. Otherwise the browser's own Tab order is left to run.
27
+ */
28
+ export declare function trapFillTab(root: HTMLElement, e: KeyboardEvent): boolean;
29
+ /**
30
+ * Stop the page from scrolling behind a full-window surface. The scrollbar's
31
+ * width is put back as padding so the page doesn't shift sideways. Returns
32
+ * the release function; calling it twice is harmless.
33
+ */
34
+ export declare function lockPageScroll(doc: Document): () => void;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Browser-fill theater (jatui-c5uob, upstreamed from MOS mos-wyjyh).
3
+ *
4
+ * `theaterMode="fill"` turns VideoPlayer's theater into a fixed,
5
+ * full-viewport dialog instead of asking the host to widen its page. The
6
+ * component owns the markup and CSS; this module holds the DOM plumbing a
7
+ * modal surface needs, kept framework-free so it can be tested on its own:
8
+ *
9
+ * - `resolveTheaterMode` — the prop's never-throws coercion;
10
+ * - `fillFocusables` / `trapFillTab` — Tab and Shift+Tab cycle inside the
11
+ * player while it fills the window;
12
+ * - `lockPageScroll` — the page under the overlay doesn't scroll. Counted,
13
+ * so two filled players (or a player and a host modal using the same
14
+ * lock) release it only when the last one lets go.
15
+ */
16
+ /** Unknown or missing values are today's behaviour: `'width'`. */
17
+ export function resolveTheaterMode(v) {
18
+ return v === 'fill' ? 'fill' : 'width';
19
+ }
20
+ const FOCUSABLE = [
21
+ 'a[href]',
22
+ 'area[href]',
23
+ 'button:not([disabled])',
24
+ 'input:not([disabled]):not([type="hidden"])',
25
+ 'select:not([disabled])',
26
+ 'textarea:not([disabled])',
27
+ 'video[controls]',
28
+ 'audio[controls]',
29
+ '[contenteditable=""]',
30
+ '[contenteditable="true"]',
31
+ '[tabindex]'
32
+ ].join(',');
33
+ /** Tab stops inside `root`, in document order. Hidden, inert and
34
+ * `tabindex="-1"` elements are skipped. */
35
+ export function fillFocusables(root) {
36
+ const out = [];
37
+ for (const el of root.querySelectorAll(FOCUSABLE)) {
38
+ if (el.tabIndex < 0)
39
+ continue;
40
+ if (el.closest('[hidden],[inert]'))
41
+ continue;
42
+ // checkVisibility catches display:none / visibility:hidden ancestors;
43
+ // engines without it (jsdom) count the element as visible.
44
+ const check = el.checkVisibility;
45
+ if (check && !check.call(el, { visibilityProperty: true }))
46
+ continue;
47
+ out.push(el);
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * Keep Tab inside `root`. Call it from a keydown handler; it returns true
53
+ * (and preventDefaults) only when it moved focus itself — at the first or
54
+ * last stop, when focus has escaped the root, or when the root has no stops
55
+ * at all. Otherwise the browser's own Tab order is left to run.
56
+ */
57
+ export function trapFillTab(root, e) {
58
+ if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey)
59
+ return false;
60
+ const stops = fillFocusables(root);
61
+ const active = root.ownerDocument.activeElement;
62
+ const inside = !!active && root.contains(active);
63
+ if (stops.length === 0) {
64
+ e.preventDefault();
65
+ root.focus();
66
+ return true;
67
+ }
68
+ const first = stops[0];
69
+ const last = stops[stops.length - 1];
70
+ let next = null;
71
+ if (!inside || active === root)
72
+ next = e.shiftKey ? last : first;
73
+ else if (e.shiftKey && active === first)
74
+ next = last;
75
+ else if (!e.shiftKey && active === last)
76
+ next = first;
77
+ if (!next)
78
+ return false;
79
+ e.preventDefault();
80
+ next.focus();
81
+ return true;
82
+ }
83
+ const locks = new WeakMap();
84
+ /**
85
+ * Stop the page from scrolling behind a full-window surface. The scrollbar's
86
+ * width is put back as padding so the page doesn't shift sideways. Returns
87
+ * the release function; calling it twice is harmless.
88
+ */
89
+ export function lockPageScroll(doc) {
90
+ const html = doc.documentElement;
91
+ let state = locks.get(doc);
92
+ if (!state) {
93
+ const win = doc.defaultView;
94
+ const gutter = win ? Math.max(0, win.innerWidth - html.clientWidth) : 0;
95
+ state = { count: 0, overflow: html.style.overflow, paddingRight: html.style.paddingRight };
96
+ locks.set(doc, state);
97
+ html.style.overflow = 'hidden';
98
+ if (gutter > 0)
99
+ html.style.paddingRight = `${gutter}px`;
100
+ }
101
+ state.count++;
102
+ let released = false;
103
+ return () => {
104
+ if (released)
105
+ return;
106
+ released = true;
107
+ const s = locks.get(doc);
108
+ if (!s)
109
+ return;
110
+ s.count--;
111
+ if (s.count > 0)
112
+ return;
113
+ html.style.overflow = s.overflow;
114
+ html.style.paddingRight = s.paddingRight;
115
+ locks.delete(doc);
116
+ };
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -87,6 +87,11 @@
87
87
  "types": "./dist/artifact-blocks/index.d.ts",
88
88
  "svelte": "./dist/artifact-blocks/index.js",
89
89
  "default": "./dist/artifact-blocks/index.js"
90
+ },
91
+ "./ambience": {
92
+ "types": "./dist/ambience/index.d.ts",
93
+ "svelte": "./dist/ambience/index.js",
94
+ "default": "./dist/ambience/index.js"
90
95
  }
91
96
  },
92
97
  "files": [