@joewinke/jatui 0.2.30 → 0.2.32

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.
@@ -84,6 +84,16 @@ export function cycle(list, current, dir) {
84
84
  const BUMP_DURATION_MS = 280;
85
85
  const RAIL_FOCUS_CLASS = 'rail-focus';
86
86
  const RAIL_CYCLED_CLASS = 'rail-cycled';
87
+ /** `<input>` types whose vertical arrows natively step the value (spinner / picker). */
88
+ const STEPPER_INPUT_TYPES = new Set([
89
+ 'number',
90
+ 'range',
91
+ 'date',
92
+ 'time',
93
+ 'month',
94
+ 'week',
95
+ 'datetime-local',
96
+ ]);
87
97
  function resolveOrder(opts) {
88
98
  return typeof opts.order === 'function' ? opts.order() : opts.order;
89
99
  }
@@ -213,6 +223,13 @@ function makeController(optsRef) {
213
223
  const isTextField = !!el &&
214
224
  (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true);
215
225
  const isMultiline = !!el && (tag === 'TEXTAREA' || el.isContentEditable === true);
226
+ const inputType = tag === 'INPUT' ? (el.type || '').toLowerCase() : '';
227
+ // A <select> cycles its value with arrows on BOTH axes, and a radio moves
228
+ // within its `name` group on both axes — the rail must yield entirely or it
229
+ // silently drives an unrelated rung while the user thinks they're picking a value.
230
+ const ownsBothArrowAxes = tag === 'SELECT' || inputType === 'radio';
231
+ const blocksVerticalArrows = isMultiline || ownsBothArrowAxes || STEPPER_INPUT_TYPES.has(inputType);
232
+ const blocksHorizontalArrows = isTextField || ownsBothArrowAxes;
216
233
  const hasMod = event.metaKey || event.ctrlKey || event.altKey;
217
234
  // Modal trap — stop non-text keystrokes from bubbling to window-level page
218
235
  // listeners (e.g., a listNav attached to `window`). Text-field keystrokes
@@ -245,13 +262,13 @@ function makeController(optsRef) {
245
262
  const popoverOpen = opts.isPopoverOpen?.() ?? false;
246
263
  if (!hasMod && !popoverOpen) {
247
264
  // Vertical: ArrowDown / j step down; ArrowUp / k step up.
248
- // j / k are blocked in ANY text field; arrows are blocked only inside
249
- // multiline fields so the caret can still move line-by-line.
265
+ // j / k are blocked in ANY text field (they'd type a letter); arrows are
266
+ // blocked wherever the control owns them natively.
250
267
  const goDown = event.key === 'ArrowDown' || event.key === 'j';
251
268
  const goUp = event.key === 'ArrowUp' || event.key === 'k';
252
269
  if (goDown || goUp) {
253
270
  const isVimKey = event.key === 'j' || event.key === 'k';
254
- const blocked = isVimKey ? isTextField : isMultiline;
271
+ const blocked = isVimKey ? isTextField : blocksVerticalArrows;
255
272
  if (!blocked) {
256
273
  event.preventDefault();
257
274
  moveRung(goDown ? 1 : -1);
@@ -261,7 +278,7 @@ function makeController(optsRef) {
261
278
  // Horizontal: ArrowLeft / ArrowRight dispatch to `onAction`.
262
279
  // Skipped inside text fields so caret-left / caret-right keep working.
263
280
  if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
264
- !isTextField) {
281
+ !blocksHorizontalArrows) {
265
282
  event.preventDefault();
266
283
  const attr = resolveAttr(opts);
267
284
  // Prefer the element that actually has DOM focus, then fall back to
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Flythrough — a decorative, aspect-locked motion embed for seedance/kenburns
4
+ * flythrough clips (the "camera drifts over the scene" B-roll that heads a
5
+ * lesson or a section). Presentation primitive per the unified-video-pipeline
6
+ * PRD, sibling to <VideoPlayer> (the full player) and <LessonEmbed>.
7
+ *
8
+ * It is NOT a player: no controls, no scrubber, no audio. A flythrough is
9
+ * ambient — it plays muted, loops, and asks for no interaction. That framing
10
+ * decides every behaviour here:
11
+ *
12
+ * · It autoplays muted + loops by default (that's what "flythrough" means).
13
+ * · Under `prefers-reduced-motion: reduce` it does NOT play — it shows a still
14
+ * poster instead. Ambient motion is exactly the motion that preference asks
15
+ * to suppress, and a flythrough always has a poster to fall back TO
16
+ * (`reducedMotionPoster` overrides `poster` for that still if the host wants
17
+ * a hand-picked frame rather than the video's cover art).
18
+ * · The box is aspect-locked from the first frame (poster convention) so the
19
+ * poster → decoded-frame swap is a cross-fade in place, never a layout shove.
20
+ *
21
+ * Contract (shared asset conventions): takes only a decode-checked `src` and a
22
+ * `poster.jpg` beside it. No ffmpeg in the browser; the clip is already CFR,
23
+ * decode-clean and web-encoded upstream (`jat-video`).
24
+ */
25
+ import { onMount, untrack } from 'svelte';
26
+
27
+ let {
28
+ src,
29
+ poster = null,
30
+ aspect = '16:9',
31
+ reducedMotionPoster = null,
32
+ autoplay = true,
33
+ loop = true,
34
+ alt = '',
35
+ class: className = ''
36
+ }: {
37
+ /** Decode-checked flythrough clip. */
38
+ src: string;
39
+ /** Cover art beside the clip — shown until the first frame decodes, and the
40
+ * still shown outright under reduced-motion (unless `reducedMotionPoster`
41
+ * supplies a different frame). */
42
+ poster?: string | null;
43
+ /** `"W:H"` | ratio number | null/"auto". Reserved from the first frame. */
44
+ aspect?: string | null;
45
+ /** A hand-picked still for the reduced-motion fallback. Falls back to `poster`. */
46
+ reducedMotionPoster?: string | null;
47
+ /** Play on mount (muted). Vetoed by `prefers-reduced-motion`. Default true. */
48
+ autoplay?: boolean;
49
+ /** Loop the clip. Default true — a flythrough is ambient. */
50
+ loop?: boolean;
51
+ /** Alt text for the reduced-motion still (a11y — the still is the content then). */
52
+ alt?: string;
53
+ class?: string;
54
+ } = $props();
55
+
56
+ const aspectRatioCss = $derived.by(() => {
57
+ const a = aspect;
58
+ if (a == null) return null;
59
+ const trimmed = String(a).trim();
60
+ if (trimmed === '' || trimmed === 'auto') return null;
61
+ const colon = trimmed.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
62
+ if (colon) {
63
+ const w = parseFloat(colon[1]);
64
+ const h = parseFloat(colon[2]);
65
+ if (w > 0 && h > 0) return `${w} / ${h}`;
66
+ }
67
+ const num = Number(trimmed);
68
+ if (Number.isFinite(num) && num > 0) return String(num);
69
+ return null;
70
+ });
71
+
72
+ let videoEl: HTMLVideoElement | undefined = $state();
73
+ let reduceMotion = $state(false);
74
+
75
+ /** Under reduce we render a poster still and never mount playback. */
76
+ const showStill = $derived(reduceMotion);
77
+ const stillSrc = $derived(reducedMotionPoster ?? poster);
78
+
79
+ onMount(() => {
80
+ let stop: (() => void) | undefined;
81
+ if (typeof window !== 'undefined' && window.matchMedia) {
82
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
83
+ reduceMotion = mq.matches;
84
+ const onChange = () => (reduceMotion = mq.matches);
85
+ mq.addEventListener('change', onChange);
86
+ stop = () => mq.removeEventListener('change', onChange);
87
+ }
88
+ return () => stop?.();
89
+ });
90
+
91
+ // Start playback once the element exists and motion is allowed. Imperative (not
92
+ // the native attribute) so a mid-session flip to "reduce" can pause it and a
93
+ // flip away can resume — the poster-still swap is handled by `showStill`.
94
+ let started = false;
95
+ $effect(() => {
96
+ const v = videoEl;
97
+ const play = autoplay && !reduceMotion && !showStill;
98
+ untrack(() => {
99
+ if (!v) return;
100
+ if (play && !started) {
101
+ started = true;
102
+ v.muted = true;
103
+ void v.play().catch(() => {});
104
+ } else if (!play && !v.paused) {
105
+ v.pause();
106
+ }
107
+ });
108
+ });
109
+ </script>
110
+
111
+ <div
112
+ class="ft-root {className}"
113
+ class:ft-locked={aspectRatioCss != null}
114
+ style={aspectRatioCss != null ? `--ft-aspect: ${aspectRatioCss};` : undefined}
115
+ >
116
+ {#if showStill}
117
+ <!-- Reduced-motion fallback: the flythrough is content-free decoration, so the
118
+ still IS the presentation. alt is host-supplied (usually empty for pure
119
+ decoration). -->
120
+ {#if stillSrc}
121
+ <img class="ft-still" src={stillSrc} {alt} />
122
+ {:else}
123
+ <div class="ft-still ft-still-empty" aria-hidden="true"></div>
124
+ {/if}
125
+ {:else}
126
+ <video
127
+ bind:this={videoEl}
128
+ {src}
129
+ {loop}
130
+ muted
131
+ playsinline
132
+ poster={poster ?? undefined}
133
+ class="ft-video"
134
+ aria-hidden="true"
135
+ ></video>
136
+ {/if}
137
+ </div>
138
+
139
+ <style>
140
+ /* Aspect-locked from the first frame (poster convention): the box owns its
141
+ ratio before the video decodes, so poster → frame is an in-place swap. */
142
+ .ft-root {
143
+ position: relative;
144
+ width: 100%;
145
+ overflow: hidden;
146
+ background: var(--color-neutral);
147
+ border-radius: var(--jatui-radius-box, 0.5rem);
148
+ }
149
+
150
+ .ft-locked {
151
+ aspect-ratio: var(--ft-aspect);
152
+ }
153
+
154
+ .ft-video,
155
+ .ft-still {
156
+ display: block;
157
+ width: 100%;
158
+ height: 100%;
159
+ object-fit: cover;
160
+ }
161
+
162
+ /* When the box is aspect-locked the media fills it absolutely; unlocked, the
163
+ media's intrinsic size drives the box height. */
164
+ .ft-locked .ft-video,
165
+ .ft-locked .ft-still {
166
+ position: absolute;
167
+ inset: 0;
168
+ }
169
+
170
+ .ft-still-empty {
171
+ background: var(--color-base-300, var(--color-neutral));
172
+ }
173
+ </style>
@@ -0,0 +1,22 @@
1
+ type $$ComponentProps = {
2
+ /** Decode-checked flythrough clip. */
3
+ src: string;
4
+ /** Cover art beside the clip — shown until the first frame decodes, and the
5
+ * still shown outright under reduced-motion (unless `reducedMotionPoster`
6
+ * supplies a different frame). */
7
+ poster?: string | null;
8
+ /** `"W:H"` | ratio number | null/"auto". Reserved from the first frame. */
9
+ aspect?: string | null;
10
+ /** A hand-picked still for the reduced-motion fallback. Falls back to `poster`. */
11
+ reducedMotionPoster?: string | null;
12
+ /** Play on mount (muted). Vetoed by `prefers-reduced-motion`. Default true. */
13
+ autoplay?: boolean;
14
+ /** Loop the clip. Default true — a flythrough is ambient. */
15
+ loop?: boolean;
16
+ /** Alt text for the reduced-motion still (a11y — the still is the content then). */
17
+ alt?: string;
18
+ class?: string;
19
+ };
20
+ declare const Flythrough: import("svelte").Component<$$ComponentProps, {}, "">;
21
+ type Flythrough = ReturnType<typeof Flythrough>;
22
+ export default Flythrough;
@@ -0,0 +1,227 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * The demo-assemble `collection.json` shape (pinned from mardukos
4
+ * `scripts/demo-assemble.mjs` writer + `welcome/[token]` reader, per
5
+ * docs/edl-contract.md). Fields the writer emits with `_url` suffixes are
6
+ * accepted alongside the reader's shorter aliases, so a manifest from either
7
+ * side of the contract loads without a translation step.
8
+ */
9
+
10
+ /** One beat's timing within a cut (for a future chapter rail; carried through). */
11
+ export interface CollectionBeat {
12
+ id: string | number;
13
+ name?: string;
14
+ start_sec?: number;
15
+ end_sec?: number;
16
+ }
17
+
18
+ /** A named output variant — one playable video. */
19
+ export interface CollectionCut {
20
+ id: string;
21
+ label?: string;
22
+ /** Assemble writes `blurb`; audience copy for the picker. */
23
+ blurb?: string;
24
+ duration_sec?: number;
25
+ /** The mp4. Writer/reader both use `url` (minio or repo-relative path). */
26
+ url: string;
27
+ /** Cover art. Writer emits `poster_url`; `poster` accepted as an alias. */
28
+ poster_url?: string;
29
+ poster?: string;
30
+ beats?: CollectionBeat[];
31
+ }
32
+
33
+ /** The `collection.json` manifest. */
34
+ export interface Collection {
35
+ template?: string;
36
+ prospect?: string | null;
37
+ brand_name?: string;
38
+ primary_color?: string;
39
+ cuts: CollectionCut[];
40
+ }
41
+
42
+ function cutPoster(c: CollectionCut): string | null {
43
+ return c.poster_url ?? c.poster ?? null;
44
+ }
45
+ </script>
46
+
47
+ <script lang="ts">
48
+ /**
49
+ * LessonEmbed — renders a demo-assemble `collection.json`: a picker across the
50
+ * named cuts with a <VideoPlayer> for the selected one. Presentation primitive
51
+ * per the unified-video-pipeline PRD; the third of the trio beside <VideoPlayer>
52
+ * and <Flythrough>.
53
+ *
54
+ * A `collection.json` is several cuts of the SAME demo (a 30s teaser, a 2min
55
+ * walkthrough, an audience-specific edit). The host hands the parsed manifest;
56
+ * this component owns the "which cut" selection and delegates playback to
57
+ * <VideoPlayer> — so a cut gets the full player (aspect-lock, poster, captions,
58
+ * keyboard) for free, and this file only adds the picker.
59
+ *
60
+ * Contract: takes only decode-checked assets already referenced by the manifest
61
+ * (each cut's `url` + `poster`). No fetching, no ffmpeg — the manifest is the
62
+ * input, exactly as `demo-assemble` emitted it.
63
+ */
64
+ import VideoPlayer from './VideoPlayer.svelte';
65
+ // Collection / CollectionCut are declared in the `module` script above and are
66
+ // already in scope here — no import (that would conflict with the local decl).
67
+
68
+ let {
69
+ collection,
70
+ cut = null,
71
+ aspect = '16:9',
72
+ class: className = ''
73
+ }: {
74
+ collection: Collection;
75
+ /** Preselect a cut by `id`. Falls back to the first cut. */
76
+ cut?: string | null;
77
+ /** Aspect passed through to the inner <VideoPlayer>. */
78
+ aspect?: string | null;
79
+ class?: string;
80
+ } = $props();
81
+
82
+ const cuts = $derived(
83
+ Array.isArray(collection?.cuts)
84
+ ? collection.cuts.filter((c): c is CollectionCut => !!c && typeof c.url === 'string')
85
+ : []
86
+ );
87
+
88
+ // Selected cut id. Seeded from `cut` when it names a real cut, else the first.
89
+ // `$state` initialiser reads props once; a later `cut` change is reconciled by
90
+ // the effect below rather than re-running the initialiser.
91
+ let selectedId = $state<string | null>(null);
92
+ $effect(() => {
93
+ const ids = cuts.map((c) => c.id);
94
+ const wanted = cut && ids.includes(cut) ? cut : null;
95
+ // Only (re)seed when the current selection is gone or a valid `cut` arrives.
96
+ if (selectedId == null || !ids.includes(selectedId)) {
97
+ selectedId = wanted ?? ids[0] ?? null;
98
+ } else if (wanted && wanted !== selectedId && cut === wanted) {
99
+ // Host explicitly asked for a different cut via the prop.
100
+ selectedId = wanted;
101
+ }
102
+ });
103
+
104
+ const active = $derived(cuts.find((c) => c.id === selectedId) ?? cuts[0] ?? null);
105
+
106
+ function fmtDuration(s: number | undefined): string {
107
+ if (!Number.isFinite(s) || (s ?? 0) <= 0) return '';
108
+ const total = Math.round(s as number);
109
+ const m = Math.floor(total / 60);
110
+ const sec = total % 60;
111
+ return m > 0 ? `${m}:${String(sec).padStart(2, '0')}` : `${sec}s`;
112
+ }
113
+
114
+ function poster(c: CollectionCut): string | undefined {
115
+ return c.poster_url ?? c.poster ?? undefined;
116
+ }
117
+ </script>
118
+
119
+ <div class="le-root {className}">
120
+ {#if cuts.length === 0}
121
+ <div class="le-empty">No cuts in this collection.</div>
122
+ {:else}
123
+ {#if cuts.length > 1}
124
+ <!-- Cut picker: a tab row. Each cut is one edit of the same demo, so the
125
+ picker reads as "which version", not "which video". -->
126
+ <div class="le-picker" role="tablist" aria-label="Demo cuts">
127
+ {#each cuts as c (c.id)}
128
+ <button
129
+ type="button"
130
+ role="tab"
131
+ class="le-tab"
132
+ class:active={c.id === selectedId}
133
+ aria-selected={c.id === selectedId}
134
+ onclick={() => (selectedId = c.id)}
135
+ >
136
+ <span class="le-tab-label">{c.label ?? c.id}</span>
137
+ {#if fmtDuration(c.duration_sec)}
138
+ <span class="le-tab-dur">{fmtDuration(c.duration_sec)}</span>
139
+ {/if}
140
+ {#if c.blurb}<span class="le-tab-blurb">{c.blurb}</span>{/if}
141
+ </button>
142
+ {/each}
143
+ </div>
144
+ {/if}
145
+
146
+ {#if active}
147
+ <!-- keyed so switching cuts remounts the player (a fresh source + poster,
148
+ not a src swap mid-playback). -->
149
+ {#key active.id}
150
+ <VideoPlayer src={active.url} poster={poster(active)} {aspect} />
151
+ {/key}
152
+ {/if}
153
+ {/if}
154
+ </div>
155
+
156
+ <style>
157
+ .le-root {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: var(--jatui-space-lg, 12px);
161
+ min-width: 0;
162
+ }
163
+
164
+ .le-empty {
165
+ padding: var(--jatui-space-xl, 16px);
166
+ text-align: center;
167
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
168
+ background: var(--color-base-200);
169
+ border-radius: var(--jatui-radius-box, 0.5rem);
170
+ font-size: 0.85rem;
171
+ }
172
+
173
+ .le-picker {
174
+ display: flex;
175
+ flex-wrap: wrap;
176
+ gap: var(--jatui-space-sm, 4px);
177
+ }
178
+
179
+ .le-tab {
180
+ display: flex;
181
+ flex-direction: column;
182
+ align-items: flex-start;
183
+ gap: var(--jatui-space-xs, 2px);
184
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-lg, 12px);
185
+ background: color-mix(in oklch, var(--color-base-content) 6%, transparent);
186
+ border: var(--jatui-border-w, 1px) solid transparent;
187
+ border-radius: var(--jatui-radius-selector, 0.375rem);
188
+ color: var(--color-base-content);
189
+ text-align: left;
190
+ cursor: pointer;
191
+ transition:
192
+ background var(--motion-fast, 150ms) var(--motion-glide, ease-out),
193
+ border-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
194
+ }
195
+
196
+ .le-tab:hover {
197
+ background: color-mix(in oklch, var(--color-base-content) 12%, transparent);
198
+ }
199
+
200
+ .le-tab.active {
201
+ background: color-mix(in oklch, var(--color-primary) 12%, transparent);
202
+ border-color: var(--color-primary);
203
+ }
204
+
205
+ .le-tab-label {
206
+ font-size: 0.8rem;
207
+ font-weight: 600;
208
+ }
209
+
210
+ .le-tab-dur {
211
+ font-size: 0.66rem;
212
+ font-variant-numeric: tabular-nums;
213
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
214
+ }
215
+
216
+ .le-tab-blurb {
217
+ font-size: 0.7rem;
218
+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
219
+ max-width: 16rem;
220
+ }
221
+
222
+ @media (prefers-reduced-motion: reduce) {
223
+ .le-tab {
224
+ transition: none;
225
+ }
226
+ }
227
+ </style>
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The demo-assemble `collection.json` shape (pinned from mardukos
3
+ * `scripts/demo-assemble.mjs` writer + `welcome/[token]` reader, per
4
+ * docs/edl-contract.md). Fields the writer emits with `_url` suffixes are
5
+ * accepted alongside the reader's shorter aliases, so a manifest from either
6
+ * side of the contract loads without a translation step.
7
+ */
8
+ /** One beat's timing within a cut (for a future chapter rail; carried through). */
9
+ export interface CollectionBeat {
10
+ id: string | number;
11
+ name?: string;
12
+ start_sec?: number;
13
+ end_sec?: number;
14
+ }
15
+ /** A named output variant — one playable video. */
16
+ export interface CollectionCut {
17
+ id: string;
18
+ label?: string;
19
+ /** Assemble writes `blurb`; audience copy for the picker. */
20
+ blurb?: string;
21
+ duration_sec?: number;
22
+ /** The mp4. Writer/reader both use `url` (minio or repo-relative path). */
23
+ url: string;
24
+ /** Cover art. Writer emits `poster_url`; `poster` accepted as an alias. */
25
+ poster_url?: string;
26
+ poster?: string;
27
+ beats?: CollectionBeat[];
28
+ }
29
+ /** The `collection.json` manifest. */
30
+ export interface Collection {
31
+ template?: string;
32
+ prospect?: string | null;
33
+ brand_name?: string;
34
+ primary_color?: string;
35
+ cuts: CollectionCut[];
36
+ }
37
+ type $$ComponentProps = {
38
+ collection: Collection;
39
+ /** Preselect a cut by `id`. Falls back to the first cut. */
40
+ cut?: string | null;
41
+ /** Aspect passed through to the inner <VideoPlayer>. */
42
+ aspect?: string | null;
43
+ class?: string;
44
+ };
45
+ declare const LessonEmbed: import("svelte").Component<$$ComponentProps, {}, "">;
46
+ type LessonEmbed = ReturnType<typeof LessonEmbed>;
47
+ export default LessonEmbed;
@@ -95,9 +95,10 @@
95
95
  * Identifies the existing asset-store row being edited, if any. Forwarded
96
96
  * to /api/media/edit and /api/media/remove-bg so the host app's
97
97
  * kind='record' safety check (assets that must never be AI pixel-edited)
98
- * can run server-side. Omit for generate-only or non-asset-store usage —
99
- * the check is skipped entirely when absent, matching every call site
100
- * that predates this prop.
98
+ * can run server-side. Safe to omit for a genuinely untracked image
99
+ * (freshly generated, not yet saved) — a host that fail-closes this
100
+ * check (as mardukos does) derives the id from the source URL instead
101
+ * when it's absent, so this is a convenience, not the only signal.
101
102
  */
102
103
  assetId?: string
103
104
  }>()
@@ -390,6 +391,7 @@
390
391
  const fd = new FormData()
391
392
  fd.append("image", blob, "source.png")
392
393
  if (assetId) fd.append("assetId", assetId)
394
+ fd.append("sourceUrl", sourceUrl)
393
395
  const res = await fetch("/api/media/remove-bg", { method: "POST", body: fd })
394
396
  if (!res.ok) {
395
397
  const msg = await res.text().catch(() => res.statusText)
@@ -7,9 +7,10 @@ type $$ComponentProps = {
7
7
  * Identifies the existing asset-store row being edited, if any. Forwarded
8
8
  * to /api/media/edit and /api/media/remove-bg so the host app's
9
9
  * kind='record' safety check (assets that must never be AI pixel-edited)
10
- * can run server-side. Omit for generate-only or non-asset-store usage —
11
- * the check is skipped entirely when absent, matching every call site
12
- * that predates this prop.
10
+ * can run server-side. Safe to omit for a genuinely untracked image
11
+ * (freshly generated, not yet saved) — a host that fail-closes this
12
+ * check (as mardukos does) derives the id from the source URL instead
13
+ * when it's absent, so this is a convenience, not the only signal.
13
14
  */
14
15
  assetId?: string;
15
16
  };
@@ -116,9 +116,13 @@
116
116
  let {
117
117
  src,
118
118
  poster = null,
119
+ aspect = '16:9',
120
+ autoplay = false,
121
+ loop = false,
119
122
  captionsSrc = null,
120
123
  captionsLabel = 'Captions',
121
124
  captionsLang = 'en',
125
+ captionsDefault = true,
122
126
  chapters = [],
123
127
  speakers = [],
124
128
  transcript = [],
@@ -135,9 +139,42 @@
135
139
  }: {
136
140
  src: string;
137
141
  poster?: string | null;
142
+ /**
143
+ * Aspect ratio for the video stage — presentation-primitive prop (PRD).
144
+ * `"16:9"` | `"4:3"` | `"1:1"` | `"9:16"` | `"21:9"`, a bare number ratio
145
+ * (`"1.85"`), or `null`/`"auto"` to let the video's own intrinsic ratio drive
146
+ * the box. The stage reserves this ratio from the first frame so poster →
147
+ * video swap never shoves layout (the poster convention: cover art fills the
148
+ * locked box until the first decoded frame paints over it).
149
+ */
150
+ aspect?: string | null;
151
+ /**
152
+ * Autoplay muted on mount — presentation-primitive prop (PRD). Honoured only
153
+ * when the user has NOT asked to reduce motion; under `prefers-reduced-motion`
154
+ * the video stays paused on its poster (autoplaying video is motion). Autoplay
155
+ * forces `muted` regardless (browsers block unmuted autoplay).
156
+ */
157
+ autoplay?: boolean;
158
+ /** Loop playback — presentation-primitive prop (PRD). */
159
+ loop?: boolean;
138
160
  captionsSrc?: string | null;
139
161
  captionsLabel?: string;
140
162
  captionsLang?: string;
163
+ /**
164
+ * Should the captions <track> start ENABLED (`default` attribute)?
165
+ *
166
+ * `true` (default) preserves the historical behaviour: a track with a src
167
+ * shows on load. Pass `false` when the video already has captions burned
168
+ * into its frames (e.g. an academy episode render) — the track is still
169
+ * present and toggleable from the native menu, but not painted on top of
170
+ * the burned-in text, which would double every caption.
171
+ *
172
+ * Long-term direction (see the double-caption bug, jat-abwim): renders
173
+ * should STOP burning captions into the frames and rely on this
174
+ * toggleable VTT track alone. Until episodes are re-rendered without
175
+ * burned-in text, a burned-in episode passes `captionsDefault={false}`.
176
+ */
177
+ captionsDefault?: boolean;
141
178
  chapters?: VideoChapter[];
142
179
  speakers?: VideoSpeakerTurn[];
143
180
  transcript?: VideoTranscriptSegment[];
@@ -161,6 +198,37 @@
161
198
  class?: string;
162
199
  } = $props();
163
200
 
201
+ /**
202
+ * `aspect` → a CSS `aspect-ratio` value. Accepts `"W:H"`, a bare ratio number,
203
+ * or null/"auto" (⇒ no lock; the video's intrinsic ratio drives the box). Shared
204
+ * with <Flythrough>/<LessonEmbed> conceptually, kept inline so the primitive has
205
+ * no cross-file coupling for one small pure function.
206
+ */
207
+ const aspectRatioCss = $derived.by(() => {
208
+ const a = aspect;
209
+ if (a == null) return null;
210
+ const trimmed = String(a).trim();
211
+ if (trimmed === '' || trimmed === 'auto') return null;
212
+ const colon = trimmed.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
213
+ if (colon) {
214
+ const w = parseFloat(colon[1]);
215
+ const h = parseFloat(colon[2]);
216
+ if (w > 0 && h > 0) return `${w} / ${h}`;
217
+ }
218
+ const num = Number(trimmed);
219
+ if (Number.isFinite(num) && num > 0) return String(num);
220
+ return null;
221
+ });
222
+
223
+ // prefers-reduced-motion gate for autoplay: an autoplaying clip IS motion, so
224
+ // under the reduce preference the video holds on its poster instead. Tracked
225
+ // live (media query listener) so a mid-session preference flip is honoured, and
226
+ // SSR-safe (matchMedia only exists in the browser; defaults to "not reduced").
227
+ let reduceMotion = $state(false);
228
+ // Autoplay forces muted — browsers refuse to autoplay audible video, and a
229
+ // silent presentation clip is the point of the primitive anyway.
230
+ const effectiveAutoplay = $derived(autoplay && !reduceMotion);
231
+
164
232
  // ── Element refs + media state ──────────────────────────────────────────
165
233
  let videoEl: HTMLVideoElement | undefined = $state();
166
234
  let canvasEl: HTMLCanvasElement | undefined = $state();
@@ -675,13 +743,49 @@
675
743
  clearColorCache();
676
744
  themeTick++;
677
745
  });
746
+
747
+ // Live reduced-motion tracking — drives the autoplay gate. A flip toward
748
+ // "reduce" mid-playback pauses the clip on its current frame; a flip away
749
+ // lets a queued autoplay fire (the effect below owns actually starting it).
750
+ let stopReduce: (() => void) | undefined;
751
+ if (typeof window !== 'undefined' && window.matchMedia) {
752
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
753
+ reduceMotion = mq.matches;
754
+ const onChange = () => (reduceMotion = mq.matches);
755
+ mq.addEventListener('change', onChange);
756
+ stopReduce = () => mq.removeEventListener('change', onChange);
757
+ }
758
+
678
759
  return () => {
679
760
  stopTheme();
761
+ stopReduce?.();
680
762
  if (zoomTimer) clearTimeout(zoomTimer);
681
763
  if (osdTimer) clearTimeout(osdTimer);
682
764
  };
683
765
  });
684
766
 
767
+ // Autoplay is applied imperatively (a queued `.play()` that the reduced-motion
768
+ // gate can veto) rather than via the native `autoplay` attribute, so a
769
+ // mid-session preference flip is respected instead of being a mount-only
770
+ // decision. The `.play()` promise can reject (no user gesture, tab backgrounded)
771
+ // — that's fine, the poster stays and the controls remain.
772
+ let didAutoplay = false;
773
+ $effect(() => {
774
+ const v = videoEl;
775
+ const go = effectiveAutoplay;
776
+ untrack(() => {
777
+ if (!v) return;
778
+ if (go && !didAutoplay) {
779
+ didAutoplay = true;
780
+ v.muted = true;
781
+ void v.play().catch(() => {});
782
+ } else if (!go && reduceMotion && didAutoplay && !v.paused) {
783
+ // Preference flipped to reduce after we'd started — settle on a frame.
784
+ v.pause();
785
+ }
786
+ });
787
+ });
788
+
685
789
  // ── Band pointer handlers ───────────────────────────────────────────────
686
790
  function canvasSec(e: MouseEvent): { sec: number; x: number; width: number } | null {
687
791
  const canvas = canvasEl;
@@ -943,7 +1047,12 @@
943
1047
  >
944
1048
  <div class="vp-main">
945
1049
  <!-- svelte-ignore a11y_no_static_element_interactions -->
946
- <div class="vp-stage" onwheel={onStageWheel}>
1050
+ <div
1051
+ class="vp-stage"
1052
+ class:vp-stage-locked={aspectRatioCss != null}
1053
+ style={aspectRatioCss != null ? `--vp-aspect: ${aspectRatioCss};` : undefined}
1054
+ onwheel={onStageWheel}
1055
+ >
947
1056
  <video
948
1057
  bind:this={videoEl}
949
1058
  bind:currentTime
@@ -951,13 +1060,20 @@
951
1060
  ontimeupdate={handleTimeUpdate}
952
1061
  onloadedmetadata={onLoadedMetadata}
953
1062
  {src}
1063
+ {loop}
1064
+ muted={effectiveAutoplay || undefined}
954
1065
  poster={poster ?? undefined}
955
1066
  controls
1067
+ playsinline
956
1068
  class="vp-video"
957
1069
  >
958
1070
  <!-- A captions track is always present for a11y; without a src it is
959
- an empty, inert track rather than a missing affordance. -->
960
- <track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc} />
1071
+ an empty, inert track rather than a missing affordance. It starts
1072
+ ENABLED only when there's a src AND the host opts in via
1073
+ captionsDefault (true by default). A burned-in-captions video
1074
+ passes captionsDefault={false} so the track stays available in the
1075
+ menu but isn't painted on top of the frames' own text (jat-abwim). -->
1076
+ <track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc && captionsDefault} />
961
1077
  </video>
962
1078
  <div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
963
1079
  </div>
@@ -1202,6 +1318,27 @@
1202
1318
  position: relative;
1203
1319
  }
1204
1320
 
1321
+ /* Aspect-lock: the stage reserves --vp-aspect from the first frame, so the
1322
+ poster → decoded-video swap never resizes the box (the poster convention).
1323
+ The video is absolutely filled into the locked box with object-fit: contain,
1324
+ so a source whose intrinsic ratio differs letterboxes instead of stretching. */
1325
+ .vp-stage-locked {
1326
+ aspect-ratio: var(--vp-aspect);
1327
+ width: 100%;
1328
+ background: var(--color-neutral);
1329
+ border-radius: var(--jatui-radius-field, 0.25rem);
1330
+ overflow: hidden;
1331
+ }
1332
+
1333
+ .vp-stage-locked .vp-video {
1334
+ position: absolute;
1335
+ inset: 0;
1336
+ width: 100%;
1337
+ height: 100%;
1338
+ object-fit: contain;
1339
+ border-radius: 0;
1340
+ }
1341
+
1205
1342
  .vp-video {
1206
1343
  display: block;
1207
1344
  width: 100%;
@@ -47,9 +47,42 @@ export interface VideoSpeakerTalkTime {
47
47
  type $$ComponentProps = {
48
48
  src: string;
49
49
  poster?: string | null;
50
+ /**
51
+ * Aspect ratio for the video stage — presentation-primitive prop (PRD).
52
+ * `"16:9"` | `"4:3"` | `"1:1"` | `"9:16"` | `"21:9"`, a bare number ratio
53
+ * (`"1.85"`), or `null`/`"auto"` to let the video's own intrinsic ratio drive
54
+ * the box. The stage reserves this ratio from the first frame so poster →
55
+ * video swap never shoves layout (the poster convention: cover art fills the
56
+ * locked box until the first decoded frame paints over it).
57
+ */
58
+ aspect?: string | null;
59
+ /**
60
+ * Autoplay muted on mount — presentation-primitive prop (PRD). Honoured only
61
+ * when the user has NOT asked to reduce motion; under `prefers-reduced-motion`
62
+ * the video stays paused on its poster (autoplaying video is motion). Autoplay
63
+ * forces `muted` regardless (browsers block unmuted autoplay).
64
+ */
65
+ autoplay?: boolean;
66
+ /** Loop playback — presentation-primitive prop (PRD). */
67
+ loop?: boolean;
50
68
  captionsSrc?: string | null;
51
69
  captionsLabel?: string;
52
70
  captionsLang?: string;
71
+ /**
72
+ * Should the captions <track> start ENABLED (`default` attribute)?
73
+ *
74
+ * `true` (default) preserves the historical behaviour: a track with a src
75
+ * shows on load. Pass `false` when the video already has captions burned
76
+ * into its frames (e.g. an academy episode render) — the track is still
77
+ * present and toggleable from the native menu, but not painted on top of
78
+ * the burned-in text, which would double every caption.
79
+ *
80
+ * Long-term direction (see the double-caption bug, jat-abwim): renders
81
+ * should STOP burning captions into the frames and rely on this
82
+ * toggleable VTT track alone. Until episodes are re-rendered without
83
+ * burned-in text, a burned-in episode passes `captionsDefault={false}`.
84
+ */
85
+ captionsDefault?: boolean;
53
86
  chapters?: VideoChapter[];
54
87
  speakers?: VideoSpeakerTurn[];
55
88
  transcript?: VideoTranscriptSegment[];
package/dist/index.d.ts CHANGED
@@ -13,6 +13,9 @@ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelt
13
13
  export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
14
14
  export { default as VideoPlayer } from './components/VideoPlayer.svelte';
15
15
  export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, } from './components/VideoPlayer.svelte';
16
+ export { default as Flythrough } from './components/Flythrough.svelte';
17
+ export { default as LessonEmbed } from './components/LessonEmbed.svelte';
18
+ export type { Collection, CollectionCut, CollectionBeat, } from './components/LessonEmbed.svelte';
16
19
  export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
17
20
  export type { ItemKind, TranscriptItem, FileTouchedEntry, SignalNode, LinkedSession, WorkBlockClient, WorkTranscriptItemClient, } from './components/session-nav/transcriptModel';
18
21
  export { default as UserAvatar } from './components/UserAvatar.svelte';
package/dist/index.js CHANGED
@@ -13,6 +13,13 @@ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelt
13
13
  // optional so a bare mp4 still gets the full playback UX. TranscriptPlayer
14
14
  // stays exported unchanged — meetings already depends on it.
15
15
  export { default as VideoPlayer } from './components/VideoPlayer.svelte';
16
+ // Presentation-primitive video trio (unified-video-pipeline PRD): the frozen
17
+ // surface academy + mardukos consume. VideoPlayer (above) doubles as the full
18
+ // player AND the presentation player — extended with aspect/autoplay/loop +
19
+ // aspect-lock + reduced-motion; Flythrough is the ambient motion embed; and
20
+ // LessonEmbed renders a demo-assemble collection.json (cut picker + player).
21
+ export { default as Flythrough } from './components/Flythrough.svelte';
22
+ export { default as LessonEmbed } from './components/LessonEmbed.svelte';
16
23
  // Data models — Session transcript (from JAT IDE, jat-jj79f.16 G1)
17
24
  export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
18
25
  // Components — Universal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",