@joewinke/jatui 0.14.0 → 0.16.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.
@@ -1,25 +1,29 @@
1
1
  /**
2
- * Presence module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.34
3
- * ("wind-down (Idle Detection API), resume-at-sentence, FLIP open").
2
+ * Presence (mos-5lzns.34) — the barrel. Three behaviours about whether you
3
+ * are there and where you were, each an OPTIONAL VideoPlayer prop that is
4
+ * inert when absent (extension-library FEATURES.md §Wind-down, §Resume at a
5
+ * sentence, §Open like an object):
4
6
  *
5
- * What lands here (extension-library FEATURES.md §Wind-down,
6
- * §Resume at a sentence, §Open like an object), as ADVISORS + pure helpers:
7
+ * · `presence/winddown.ts` — pure: the begin decision, fade levels, hint.
8
+ * The desktop says nobody is there → picture and sound fade over 20s and
9
+ * the video pauses. The fade IS the prompt; any input cancels it.
10
+ * · `presence/idle.ts` — the desktop signal: Chromium's Idle Detection API,
11
+ * feature-detected, never prompting unless the host opted in (and then
12
+ * only from a gesture). Elsewhere the player falls back to page presence,
13
+ * reported as `source: 'page'` so the host can label it.
14
+ * · `presence/resume.ts` — resume-at-sentence glue around .27's
15
+ * `advisors/resume-point` (the snapping is NOT duplicated here): the
16
+ * host-supplied store (localStorage default), the data mapping, and the
17
+ * two integration rules — snap when the boundaries land, stand down once
18
+ * the user has taken the wheel.
19
+ * · `presence/flip.ts` — FLIP open/close over WAAPI with the poster painted
20
+ * on every path; a no-op under prefers-reduced-motion except the poster.
7
21
  *
8
- * · wind-down — the desktop says nobody is there (Idle Detection API / user
9
- * activity), the player fades picture and sound over ~20s and pauses. The
10
- * fade IS the prompt — cancelling it is how you come back.
11
- * · resume-at-sentence — snap a resume point back to the nearest sentence /
12
- * speaker turn / chapter within 15s (CLOSEST wins, not most-specific);
13
- * quote the line, never paraphrase; stand down if the user has taken the
14
- * wheel (>2.5s from the stored point). Runs when the transcript lands, not
15
- * at open.
16
- * · FLIP open — the clicked card's rectangle → the player shell, WAAPI, two
17
- * keyframes, transform-origin top-left, poster painted under the video on
18
- * EVERY path (a black rectangle is a bug at any motion setting); no-op
19
- * under prefers-reduced-motion except the poster.
20
- *
21
- * All three are advisors/mechanics: they read no settings, touch no
22
- * <video> outside the callbacks the component passes, and fail toward doing
23
- * nothing.
22
+ * Advisors and mechanics: they read no settings and touch no <video>; the
23
+ * component passes knobs in and applies what comes back, and every one of
24
+ * them fails toward doing nothing.
24
25
  */
25
- export declare const PRESENCE_NEUTRAL = 1;
26
+ export { WIND_DOWN_FADE_MS, WIND_DOWN_DEPTH, WIND_DOWN_AFTER_DEFAULT, WIND_DOWN_AFTER_MIN, WIND_DOWN_AFTER_MAX, WIND_DOWN_PROBE_MS, windDownAfterMs, shouldBeginWindDown, desktopPresent, windDownProgress, windDownLevels, windDownHint, type PresenceSource, type DesktopSignal, type WindDownPhase, type WindDownState, type WindDownInput } from './presence/winddown';
27
+ export { IDLE_THRESHOLD_MIN_MS, idleDetectionSupported, idleDetectionPermission, requestIdleDetection, startDesktopWatch, toDesktopSignal, type IdlePermission, type DesktopWatch } from './presence/idle';
28
+ export { RESUME_WHEEL_SEC, RESUME_MAX_BACK, RESUME_SAVE_MS, RESUME_FLOOR_SEC, RESUME_TAIL_SEC, localResumeStore, coerceResumeSec, resumeToSave, toResumeCtx, hasBoundaries, planResumeSnap, type VideoResumeStore, type ResumeSnapPlan } from './presence/resume';
29
+ export { FLIP_EASE, FLIP_OPEN_MS, FLIP_CLOSE_MS, FLIP_FADE_WITH_POSTER, FLIP_FADE_BARE, FLIP_POSTER_CLASS, flipOpen, flipClose, clearPoster, posterBackground, rectOf, onScreen, flipGeometry, awayFrame, homeFrame, type FlipSource, type FlipRect } from './presence/flip';
@@ -1,25 +1,29 @@
1
1
  /**
2
- * Presence module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.34
3
- * ("wind-down (Idle Detection API), resume-at-sentence, FLIP open").
2
+ * Presence (mos-5lzns.34) — the barrel. Three behaviours about whether you
3
+ * are there and where you were, each an OPTIONAL VideoPlayer prop that is
4
+ * inert when absent (extension-library FEATURES.md §Wind-down, §Resume at a
5
+ * sentence, §Open like an object):
4
6
  *
5
- * What lands here (extension-library FEATURES.md §Wind-down,
6
- * §Resume at a sentence, §Open like an object), as ADVISORS + pure helpers:
7
+ * · `presence/winddown.ts` — pure: the begin decision, fade levels, hint.
8
+ * The desktop says nobody is there → picture and sound fade over 20s and
9
+ * the video pauses. The fade IS the prompt; any input cancels it.
10
+ * · `presence/idle.ts` — the desktop signal: Chromium's Idle Detection API,
11
+ * feature-detected, never prompting unless the host opted in (and then
12
+ * only from a gesture). Elsewhere the player falls back to page presence,
13
+ * reported as `source: 'page'` so the host can label it.
14
+ * · `presence/resume.ts` — resume-at-sentence glue around .27's
15
+ * `advisors/resume-point` (the snapping is NOT duplicated here): the
16
+ * host-supplied store (localStorage default), the data mapping, and the
17
+ * two integration rules — snap when the boundaries land, stand down once
18
+ * the user has taken the wheel.
19
+ * · `presence/flip.ts` — FLIP open/close over WAAPI with the poster painted
20
+ * on every path; a no-op under prefers-reduced-motion except the poster.
7
21
  *
8
- * · wind-down — the desktop says nobody is there (Idle Detection API / user
9
- * activity), the player fades picture and sound over ~20s and pauses. The
10
- * fade IS the prompt — cancelling it is how you come back.
11
- * · resume-at-sentence — snap a resume point back to the nearest sentence /
12
- * speaker turn / chapter within 15s (CLOSEST wins, not most-specific);
13
- * quote the line, never paraphrase; stand down if the user has taken the
14
- * wheel (>2.5s from the stored point). Runs when the transcript lands, not
15
- * at open.
16
- * · FLIP open — the clicked card's rectangle → the player shell, WAAPI, two
17
- * keyframes, transform-origin top-left, poster painted under the video on
18
- * EVERY path (a black rectangle is a bug at any motion setting); no-op
19
- * under prefers-reduced-motion except the poster.
20
- *
21
- * All three are advisors/mechanics: they read no settings, touch no
22
- * <video> outside the callbacks the component passes, and fail toward doing
23
- * nothing.
22
+ * Advisors and mechanics: they read no settings and touch no <video>; the
23
+ * component passes knobs in and applies what comes back, and every one of
24
+ * them fails toward doing nothing.
24
25
  */
25
- export const PRESENCE_NEUTRAL = 1;
26
+ export { WIND_DOWN_FADE_MS, WIND_DOWN_DEPTH, WIND_DOWN_AFTER_DEFAULT, WIND_DOWN_AFTER_MIN, WIND_DOWN_AFTER_MAX, WIND_DOWN_PROBE_MS, windDownAfterMs, shouldBeginWindDown, desktopPresent, windDownProgress, windDownLevels, windDownHint } from './presence/winddown';
27
+ export { IDLE_THRESHOLD_MIN_MS, idleDetectionSupported, idleDetectionPermission, requestIdleDetection, startDesktopWatch, toDesktopSignal } from './presence/idle';
28
+ export { RESUME_WHEEL_SEC, RESUME_MAX_BACK, RESUME_SAVE_MS, RESUME_FLOOR_SEC, RESUME_TAIL_SEC, localResumeStore, coerceResumeSec, resumeToSave, toResumeCtx, hasBoundaries, planResumeSnap } from './presence/resume';
29
+ export { FLIP_EASE, FLIP_OPEN_MS, FLIP_CLOSE_MS, FLIP_FADE_WITH_POSTER, FLIP_FADE_BARE, FLIP_POSTER_CLASS, flipOpen, flipClose, clearPoster, posterBackground, rectOf, onScreen, flipGeometry, awayFrame, homeFrame } from './presence/flip';
@@ -0,0 +1,414 @@
1
+ <script module lang="ts">
2
+ let nextId = 0;
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ /**
7
+ * The VideoPlayer settings panel (mos-5lzns.35) — extension-library's
8
+ * `openSettings()` ported. Generated wholly from `VIDEO_SETTINGS` via
9
+ * `panelGroups`: every row is a label, one control, and the sentence saying
10
+ * what the thing does. The sentence is not decoration — it is the reason
11
+ * this screen exists rather than a list of switches.
12
+ *
13
+ * Keyboard: focus lands on the first control and Tab cycles inside the
14
+ * dialog; Esc (or `,`) closes and hands focus back. Controls follow the
15
+ * ARIA APG patterns — a switch, a radio group (arrows move the choice), a
16
+ * native range. It is a FORM, not a list: arrow keys belong to the
17
+ * controls (a range and a radio group both need them), so there is no
18
+ * row-level listNav to fight them.
19
+ *
20
+ * Values are live, not on close: these are things you judge by watching
21
+ * the result, so the result has to move while you drag.
22
+ *
23
+ * Token-only chrome; the only motion (a short fade) is dropped under
24
+ * prefers-reduced-motion.
25
+ */
26
+ import { onMount } from 'svelte';
27
+ import type { SettingSpec, SettingValue } from './registry';
28
+ import { formatSettingValue, stepOption, type SettingsPanelGroup } from './panel';
29
+
30
+ let {
31
+ groups,
32
+ values,
33
+ onchange,
34
+ onclose,
35
+ helpHref = null,
36
+ persisted = false
37
+ }: {
38
+ groups: SettingsPanelGroup[];
39
+ /** Effective value per id — what the player is doing right now. */
40
+ values: Record<string, SettingValue>;
41
+ onchange: (id: string, value: SettingValue) => void;
42
+ onclose: () => void;
43
+ /** A host help page explaining all of this ("How all of this works →"). */
44
+ helpHref?: string | null;
45
+ /** The host supplied a store — the footer says choices are remembered. */
46
+ persisted?: boolean;
47
+ } = $props();
48
+
49
+ const uid = `vp-set-${++nextId}`;
50
+ let cardEl: HTMLDivElement | undefined = $state();
51
+
52
+ function rowId(spec: SettingSpec) {
53
+ return `${uid}-${spec.id.replace(/[^a-z0-9]/gi, '-')}`;
54
+ }
55
+
56
+ function focusables(): HTMLElement[] {
57
+ if (!cardEl) return [];
58
+ return Array.from(
59
+ cardEl.querySelectorAll<HTMLElement>('button:not([tabindex="-1"]), input, a[href]')
60
+ ).filter((el) => !el.hasAttribute('disabled'));
61
+ }
62
+
63
+ onMount(() => {
64
+ const first = cardEl?.querySelector<HTMLElement>('.vp-set-control button:not([tabindex="-1"]), .vp-set-control input');
65
+ (first ?? cardEl)?.focus();
66
+ });
67
+
68
+ function onKeyDown(e: KeyboardEvent) {
69
+ // The player's own shortcuts must not fire from inside the dialog
70
+ // (Space on a switch would also play/pause).
71
+ e.stopPropagation();
72
+ if (e.key === 'Escape' || (e.key === ',' && (e.target as HTMLElement)?.tagName !== 'INPUT')) {
73
+ e.preventDefault();
74
+ onclose();
75
+ return;
76
+ }
77
+ if (e.key === 'Tab') {
78
+ const els = focusables();
79
+ if (!els.length) return;
80
+ const i = els.indexOf(document.activeElement as HTMLElement);
81
+ const next = e.shiftKey ? (i <= 0 ? els.length - 1 : i - 1) : i === els.length - 1 ? 0 : i + 1;
82
+ e.preventDefault();
83
+ els[next].focus();
84
+ }
85
+ }
86
+
87
+ function onRadioKey(e: KeyboardEvent, spec: SettingSpec) {
88
+ const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
89
+ if (!dir) return;
90
+ e.preventDefault();
91
+ const next = stepOption(spec, values[spec.id], dir);
92
+ if (next === undefined) return;
93
+ onchange(spec.id, next);
94
+ const group = (e.currentTarget as HTMLElement).closest('[role="radiogroup"]');
95
+ queueMicrotask(() => group?.querySelector<HTMLElement>(`[data-value="${next}"]`)?.focus());
96
+ }
97
+ </script>
98
+
99
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
100
+ <div class="vp-set-backdrop" onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}>
101
+ <div
102
+ class="vp-set"
103
+ role="dialog"
104
+ aria-modal="true"
105
+ aria-labelledby="{uid}-title"
106
+ tabindex="-1"
107
+ bind:this={cardEl}
108
+ onkeydown={onKeyDown}
109
+ >
110
+ <div class="vp-set-head">
111
+ <h2 class="vp-set-title" id="{uid}-title">Settings</h2>
112
+ <button type="button" class="vp-set-close" aria-label="Close settings" onclick={onclose}>
113
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
114
+ <path d="M6 6l12 12M18 6 6 18" />
115
+ </svg>
116
+ </button>
117
+ </div>
118
+
119
+ <div class="vp-set-body">
120
+ {#each groups as g (g.group)}
121
+ <section class="vp-set-group" aria-labelledby="{uid}-g-{g.group}">
122
+ <h3 class="vp-set-group-title" id="{uid}-g-{g.group}">{g.group}</h3>
123
+ {#each g.rows as spec (spec.id)}
124
+ {@const id = rowId(spec)}
125
+ {@const value = values[spec.id] ?? spec.default}
126
+ <div class="vp-set-row" data-setting={spec.id}>
127
+ <span class="vp-set-label" id="{id}-label">{spec.label}</span>
128
+ <div class="vp-set-control">
129
+ {#if spec.type === 'bool'}
130
+ <button
131
+ type="button"
132
+ class="vp-set-switch"
133
+ role="switch"
134
+ aria-checked={value === true}
135
+ aria-labelledby="{id}-label"
136
+ aria-describedby="{id}-hint"
137
+ onclick={() => onchange(spec.id, value !== true)}
138
+ >
139
+ <span class="vp-set-knob" aria-hidden="true"></span>
140
+ <span class="vp-set-switch-text">{value === true ? 'On' : 'Off'}</span>
141
+ </button>
142
+ {:else if spec.type === 'enum'}
143
+ <div
144
+ class="vp-set-seg"
145
+ role="radiogroup"
146
+ aria-labelledby="{id}-label"
147
+ aria-describedby="{id}-hint"
148
+ >
149
+ {#each spec.options ?? [] as opt (opt.value)}
150
+ <button
151
+ type="button"
152
+ role="radio"
153
+ data-value={opt.value}
154
+ aria-checked={value === opt.value}
155
+ tabindex={value === opt.value ? 0 : -1}
156
+ onclick={() => onchange(spec.id, opt.value)}
157
+ onkeydown={(e) => onRadioKey(e, spec)}
158
+ >
159
+ {opt.label}
160
+ </button>
161
+ {/each}
162
+ </div>
163
+ {:else}
164
+ <input
165
+ type="range"
166
+ class="vp-set-range"
167
+ min={spec.min}
168
+ max={spec.max}
169
+ step={spec.step}
170
+ value={value}
171
+ aria-labelledby="{id}-label"
172
+ aria-describedby="{id}-hint"
173
+ aria-valuetext={formatSettingValue(spec, value)}
174
+ oninput={(e) => onchange(spec.id, Number((e.currentTarget as HTMLInputElement).value))}
175
+ />
176
+ <span class="vp-set-value" aria-hidden="true">{formatSettingValue(spec, value)}</span>
177
+ {/if}
178
+ </div>
179
+ <p class="vp-set-hint" id="{id}-hint">{spec.hint}</p>
180
+ </div>
181
+ {/each}
182
+ </section>
183
+ {:else}
184
+ <p class="vp-set-empty">Nothing on this video can be adjusted yet — settings appear as the features they control arrive.</p>
185
+ {/each}
186
+ </div>
187
+
188
+ <div class="vp-set-foot">
189
+ <span>{persisted ? 'Your choices are remembered.' : 'Choices last while this player is open.'} Esc closes.</span>
190
+ {#if helpHref}
191
+ <a class="vp-set-help" href={helpHref}>How all of this works →</a>
192
+ {/if}
193
+ </div>
194
+ </div>
195
+ </div>
196
+
197
+ <style>
198
+ .vp-set-backdrop {
199
+ position: absolute;
200
+ inset: 0;
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ padding: var(--jatui-space-lg, 12px);
205
+ background: color-mix(in oklch, var(--color-base-100) 70%, transparent);
206
+ z-index: 11;
207
+ border-radius: inherit;
208
+ animation: vp-set-in var(--motion-quick, 150ms) var(--motion-glide, ease-out);
209
+ }
210
+
211
+ .vp-set {
212
+ width: min(28rem, 100%);
213
+ max-height: 100%;
214
+ display: flex;
215
+ flex-direction: column;
216
+ background: var(--color-base-100);
217
+ color: var(--color-base-content);
218
+ border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 18%, transparent);
219
+ border-radius: var(--jatui-radius-box, 0.5rem);
220
+ box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
221
+ outline: none;
222
+ }
223
+
224
+ .vp-set-head {
225
+ display: flex;
226
+ align-items: center;
227
+ justify-content: space-between;
228
+ padding: var(--jatui-space-md, 6px) var(--jatui-space-lg, 12px);
229
+ border-bottom: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 10%, transparent);
230
+ }
231
+
232
+ .vp-set-title {
233
+ margin: 0;
234
+ font-size: 0.85rem;
235
+ font-weight: 700;
236
+ }
237
+
238
+ .vp-set-close {
239
+ display: inline-flex;
240
+ padding: var(--jatui-space-xs, 2px);
241
+ background: none;
242
+ border: none;
243
+ color: inherit;
244
+ border-radius: var(--jatui-radius-field, 0.25rem);
245
+ cursor: pointer;
246
+ }
247
+
248
+ .vp-set-body {
249
+ overflow-y: auto;
250
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-lg, 12px) var(--jatui-space-md, 6px);
251
+ }
252
+
253
+ .vp-set-group-title {
254
+ margin: var(--jatui-space-md, 6px) 0 var(--jatui-space-xs, 2px);
255
+ font-size: 0.66rem;
256
+ font-weight: 700;
257
+ text-transform: uppercase;
258
+ letter-spacing: 0.04em;
259
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
260
+ }
261
+
262
+ .vp-set-row {
263
+ display: grid;
264
+ grid-template-columns: minmax(0, 1fr) auto;
265
+ align-items: center;
266
+ gap: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
267
+ padding: var(--jatui-space-sm, 4px) 0;
268
+ border-top: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 6%, transparent);
269
+ }
270
+
271
+ .vp-set-group-title + .vp-set-row {
272
+ border-top: none;
273
+ }
274
+
275
+ .vp-set-label {
276
+ font-size: 0.78rem;
277
+ font-weight: 600;
278
+ }
279
+
280
+ .vp-set-control {
281
+ display: flex;
282
+ align-items: center;
283
+ gap: var(--jatui-space-sm, 4px);
284
+ }
285
+
286
+ .vp-set-hint {
287
+ grid-column: 1 / -1;
288
+ margin: 0;
289
+ font-size: 0.7rem;
290
+ line-height: 1.4;
291
+ color: color-mix(in oklch, var(--color-base-content) 68%, transparent);
292
+ }
293
+
294
+ /* Switch: the state reads in text AND position, never colour alone. */
295
+ .vp-set-switch {
296
+ display: inline-flex;
297
+ align-items: center;
298
+ gap: var(--jatui-space-sm, 4px);
299
+ padding: 2px var(--jatui-space-sm, 4px) 2px 2px;
300
+ font: inherit;
301
+ font-size: 0.7rem;
302
+ color: inherit;
303
+ background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
304
+ border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 20%, transparent);
305
+ border-radius: 999px;
306
+ cursor: pointer;
307
+ }
308
+
309
+ .vp-set-knob {
310
+ width: 0.8rem;
311
+ height: 0.8rem;
312
+ border-radius: 999px;
313
+ background: color-mix(in oklch, var(--color-base-content) 45%, transparent);
314
+ transition: transform var(--motion-quick, 150ms) var(--motion-glide, ease-out);
315
+ }
316
+
317
+ .vp-set-switch[aria-checked='true'] {
318
+ background: color-mix(in oklch, var(--color-primary) 22%, transparent);
319
+ border-color: color-mix(in oklch, var(--color-primary) 55%, transparent);
320
+ }
321
+
322
+ .vp-set-switch[aria-checked='true'] .vp-set-knob {
323
+ background: var(--color-primary);
324
+ transform: translateX(0.15rem);
325
+ }
326
+
327
+ .vp-set-switch-text {
328
+ min-width: 1.6em;
329
+ text-align: left;
330
+ }
331
+
332
+ .vp-set-seg {
333
+ display: inline-flex;
334
+ border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 20%, transparent);
335
+ border-radius: var(--jatui-radius-field, 0.25rem);
336
+ overflow: hidden;
337
+ }
338
+
339
+ .vp-set-seg button {
340
+ padding: 2px var(--jatui-space-md, 6px);
341
+ font: inherit;
342
+ font-size: 0.7rem;
343
+ color: inherit;
344
+ background: none;
345
+ border: none;
346
+ cursor: pointer;
347
+ }
348
+
349
+ .vp-set-seg button + button {
350
+ border-left: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 20%, transparent);
351
+ }
352
+
353
+ .vp-set-seg button[aria-checked='true'] {
354
+ background: color-mix(in oklch, var(--color-primary) 22%, transparent);
355
+ font-weight: 700;
356
+ }
357
+
358
+ .vp-set-range {
359
+ width: 7rem;
360
+ accent-color: var(--color-primary);
361
+ }
362
+
363
+ .vp-set-value {
364
+ min-width: 3.2em;
365
+ font-size: 0.7rem;
366
+ font-variant-numeric: tabular-nums;
367
+ text-align: right;
368
+ }
369
+
370
+ .vp-set-switch:focus-visible,
371
+ .vp-set-seg button:focus-visible,
372
+ .vp-set-range:focus-visible,
373
+ .vp-set-close:focus-visible,
374
+ .vp-set-help:focus-visible {
375
+ outline: 2px solid var(--color-primary);
376
+ outline-offset: 1px;
377
+ }
378
+
379
+ .vp-set-empty {
380
+ margin: var(--jatui-space-md, 6px) 0;
381
+ font-size: 0.75rem;
382
+ color: color-mix(in oklch, var(--color-base-content) 68%, transparent);
383
+ }
384
+
385
+ .vp-set-foot {
386
+ display: flex;
387
+ flex-wrap: wrap;
388
+ justify-content: space-between;
389
+ gap: var(--jatui-space-sm, 4px);
390
+ padding: var(--jatui-space-md, 6px) var(--jatui-space-lg, 12px);
391
+ font-size: 0.66rem;
392
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
393
+ border-top: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 10%, transparent);
394
+ }
395
+
396
+ .vp-set-help {
397
+ color: var(--color-primary);
398
+ }
399
+
400
+ @keyframes vp-set-in {
401
+ from {
402
+ opacity: 0;
403
+ }
404
+ }
405
+
406
+ @media (prefers-reduced-motion: reduce) {
407
+ .vp-set-backdrop {
408
+ animation: none;
409
+ }
410
+ .vp-set-knob {
411
+ transition: none;
412
+ }
413
+ }
414
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { SettingValue } from './registry';
2
+ import { type SettingsPanelGroup } from './panel';
3
+ type $$ComponentProps = {
4
+ groups: SettingsPanelGroup[];
5
+ /** Effective value per id — what the player is doing right now. */
6
+ values: Record<string, SettingValue>;
7
+ onchange: (id: string, value: SettingValue) => void;
8
+ onclose: () => void;
9
+ /** A host help page explaining all of this ("How all of this works →"). */
10
+ helpHref?: string | null;
11
+ /** The host supplied a store — the footer says choices are remembered. */
12
+ persisted?: boolean;
13
+ };
14
+ declare const SettingsPanel: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type SettingsPanel = ReturnType<typeof SettingsPanel>;
16
+ export default SettingsPanel;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The settings panel's model (mos-5lzns.35) — pure, so what the panel shows
3
+ * is node-testable without a DOM. The component renders exactly what these
4
+ * return; nothing about which rows exist is decided in markup.
5
+ */
6
+ import { type SettingAvailability, type SettingSpec, type SettingValue } from './registry';
7
+ export interface SettingsPanelGroup {
8
+ group: string;
9
+ rows: SettingSpec[];
10
+ }
11
+ /**
12
+ * The rows this player can honestly offer, grouped in panel order. A row
13
+ * whose `when` is not true in `available` is omitted — never shown disabled:
14
+ * a switch for a feature this player doesn't have is noise, and a disabled
15
+ * one invites the question it can't answer.
16
+ */
17
+ export declare function panelGroups(available: SettingAvailability, specs?: SettingSpec[]): SettingsPanelGroup[];
18
+ /** How a value reads in the panel (the range's readout, an enum's label, On/Off). */
19
+ export declare function formatSettingValue(spec: SettingSpec, value: SettingValue): string;
20
+ /**
21
+ * Radio-group arrow keys: the next option in `dir`, wrapping (APG radio
22
+ * pattern). An unknown current value starts from the first option.
23
+ */
24
+ export declare function stepOption(spec: SettingSpec, current: SettingValue, dir: 1 | -1): string | undefined;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The settings panel's model (mos-5lzns.35) — pure, so what the panel shows
3
+ * is node-testable without a DOM. The component renders exactly what these
4
+ * return; nothing about which rows exist is decided in markup.
5
+ */
6
+ import { SETTING_GROUPS, VIDEO_SETTINGS } from './registry';
7
+ /**
8
+ * The rows this player can honestly offer, grouped in panel order. A row
9
+ * whose `when` is not true in `available` is omitted — never shown disabled:
10
+ * a switch for a feature this player doesn't have is noise, and a disabled
11
+ * one invites the question it can't answer.
12
+ */
13
+ export function panelGroups(available, specs = VIDEO_SETTINGS) {
14
+ const order = [...SETTING_GROUPS];
15
+ for (const s of specs)
16
+ if (!order.includes(s.group))
17
+ order.push(s.group);
18
+ const out = [];
19
+ for (const group of order) {
20
+ const rows = specs.filter((s) => s.group === group && s.wired !== false && available[s.when] === true);
21
+ if (rows.length)
22
+ out.push({ group, rows });
23
+ }
24
+ return out;
25
+ }
26
+ /** How a value reads in the panel (the range's readout, an enum's label, On/Off). */
27
+ export function formatSettingValue(spec, value) {
28
+ try {
29
+ if (spec.type === 'bool')
30
+ return value ? 'On' : 'Off';
31
+ if (spec.type === 'enum')
32
+ return spec.options?.find((o) => o.value === value)?.label ?? String(value);
33
+ const n = typeof value === 'number' ? value : Number(value);
34
+ if (!Number.isFinite(n))
35
+ return '';
36
+ // Round to the step so 0.30000000000000004 never reaches the screen.
37
+ const step = spec.step && spec.step > 0 ? spec.step : 0;
38
+ const decimals = step ? Math.max(0, Math.min(6, (String(step).split('.')[1] ?? '').length)) : 2;
39
+ const shown = Number(n.toFixed(decimals));
40
+ return spec.format ? spec.format(shown) : String(shown);
41
+ }
42
+ catch {
43
+ return '';
44
+ }
45
+ }
46
+ /**
47
+ * Radio-group arrow keys: the next option in `dir`, wrapping (APG radio
48
+ * pattern). An unknown current value starts from the first option.
49
+ */
50
+ export function stepOption(spec, current, dir) {
51
+ const opts = spec.options ?? [];
52
+ if (!opts.length)
53
+ return undefined;
54
+ const i = opts.findIndex((o) => o.value === current);
55
+ if (i < 0)
56
+ return opts[0].value;
57
+ return opts[(i + dir + opts.length) % opts.length].value;
58
+ }