@joewinke/jatui 0.15.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.
@@ -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
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The settings registry (mos-5lzns.35) — extension-library's `settings.js`
3
+ * ported: one array declares every standing CHOICE the player's automatic
4
+ * behaviours read, and the settings panel is generated from it, so a knob
5
+ * added here shows up in the UI by existing.
6
+ *
7
+ * Three rules carried over verbatim:
8
+ *
9
+ * · `coerce` never throws and never returns junk. A bool accepts
10
+ * true/"1"/"true" (and their false twins); a range parses and clamps to
11
+ * [min, max]; an enum must match a declared option. Anything else returns
12
+ * undefined and the caller falls back to the default. A corrupted blob in
13
+ * a host's store degrades to defaults, never to a broken player.
14
+ * · The hint is mandatory. The sentence saying what the thing actually does
15
+ * is the entire reason a settings screen exists rather than a list of
16
+ * switches; a spec without one fails the unit suite.
17
+ * · A knob lands WITH its reader. `wired: true` means the `prop` it names is
18
+ * read by VideoPlayer today — a switch that visibly does nothing is the
19
+ * one thing this registry must never contain. (That is why the extension's
20
+ * `night.page`, `night.sunset` and `player.smartSpeedBoost` are absent: the
21
+ * player has no reader for them.)
22
+ *
23
+ * Labels and hints here are the single source for the panel AND any help
24
+ * page a host renders (MOS's /help reads `VIDEO_SETTINGS` directly) — never
25
+ * restate them elsewhere.
26
+ *
27
+ * Ephemeral UI state (transcript layout, volume, whether a pane was left
28
+ * open) is deliberately NOT here: remembering those is not the same thing as
29
+ * being configurable, and mixing them makes a settings screen that reads
30
+ * like a debug dump.
31
+ */
32
+ export type SettingType = 'bool' | 'range' | 'enum';
33
+ export type SettingValue = boolean | number | string;
34
+ /**
35
+ * When a row exists on a given player. Each names the host opt-in and/or
36
+ * data the feature needs — a row for a feature this player doesn't have is
37
+ * a switch that does nothing. The component resolves them (see
38
+ * `SettingAvailability`); the registry only names them.
39
+ */
40
+ export type SettingWhen = 'waveform' | 'speakers' | 'ads' | 'loudness' | 'quiet' | 'limiter' | 'night' | 'nightLuma' | 'flashes' | 'windDown' | 'idle' | 'resume' | 'open';
41
+ export type SettingAvailability = Partial<Record<SettingWhen, boolean>>;
42
+ export interface SettingOption {
43
+ value: string;
44
+ label: string;
45
+ }
46
+ export interface SettingSpec {
47
+ /** Stable storage key — a host's store is keyed by this. Never rename. */
48
+ id: string;
49
+ type: SettingType;
50
+ default: SettingValue;
51
+ /** Panel section heading. Rows keep registry order within their group. */
52
+ group: string;
53
+ /** The row's name. Short, a noun phrase or an imperative. */
54
+ label: string;
55
+ /** What it actually does, in one or two sentences. Mandatory. */
56
+ hint: string;
57
+ /** The VideoPlayer prop this choice drives. */
58
+ prop: string;
59
+ /** Availability gate (see `SettingWhen`). */
60
+ when: SettingWhen;
61
+ /** range only */
62
+ min?: number;
63
+ max?: number;
64
+ step?: number;
65
+ /** range only: how the current value reads next to the slider. */
66
+ format?: (v: number) => string;
67
+ /** enum only */
68
+ options?: SettingOption[];
69
+ /** Which sub-task wired the reader. */
70
+ owner: string;
71
+ /** The reader exists (grep VideoPlayer for `prop`). */
72
+ wired?: boolean;
73
+ }
74
+ /** Groups in panel order. */
75
+ export declare const SETTING_GROUPS: readonly ["Night", "Sound", "Wind-down", "Playback"];
76
+ export declare const VIDEO_SETTINGS: SettingSpec[];
77
+ /** The spec for an id, or undefined. */
78
+ export declare function settingSpec(id: string): SettingSpec | undefined;
79
+ /** Coerce a raw stored value against a spec — never throws, never returns junk. */
80
+ export declare function coerce<T extends SettingSpec>(spec: T, raw: unknown): T['default'] | undefined;
81
+ /** A stored value for an id, coerced; the spec's default when absent or junk. */
82
+ export declare function settingValue(id: string, raw: unknown): SettingValue | undefined;