@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,331 @@
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
+ const pct = (v) => Math.round(v * 100) + '%';
33
+ /** Groups in panel order. */
34
+ export const SETTING_GROUPS = ['Night', 'Sound', 'Wind-down', 'Playback'];
35
+ export const VIDEO_SETTINGS = [
36
+ // ── Night (mos-5lzns.33) — video/night/* ────────────────────────────────
37
+ {
38
+ id: 'night.mode',
39
+ group: 'Night',
40
+ label: 'Night dimming',
41
+ type: 'enum',
42
+ options: [
43
+ { value: 'auto', label: 'After dark' },
44
+ { value: 'on', label: 'Always' },
45
+ { value: 'off', label: 'Off' }
46
+ ],
47
+ default: 'auto',
48
+ prop: 'night',
49
+ when: 'night',
50
+ hint: 'Measures how bright the picture actually is, about once a second, and pulls the level down in proportion — warming it as it goes. A dark scene passes through untouched. After dark follows the local clock — 20:00 to 07:00, or 18:00 to 08:00 when your system prefers a dark theme. N cycles it anywhere in the player.',
51
+ owner: 'mos-5lzns.33',
52
+ wired: true
53
+ },
54
+ {
55
+ id: 'night.depth',
56
+ group: 'Night',
57
+ label: 'How far it goes',
58
+ type: 'range',
59
+ min: 0.2,
60
+ max: 1,
61
+ step: 0.1,
62
+ default: 0.7,
63
+ format: pct,
64
+ prop: 'nightDepth',
65
+ when: 'night',
66
+ hint: 'The far end of the dial takes a full-white frame down to about half brightness. Scrolling the moon button on the player does the same thing.',
67
+ owner: 'mos-5lzns.33',
68
+ wired: true
69
+ },
70
+ {
71
+ id: 'night.bias',
72
+ group: 'Night',
73
+ label: 'Bias light',
74
+ type: 'bool',
75
+ default: true,
76
+ prop: 'biasLight',
77
+ when: 'night',
78
+ hint: "A soft glow of the frame's own colours spilled behind the player while night dimming acts. What strains your eyes in a dark room is the gap between the screen and the wall behind it, not the screen alone — filling that gap in lets you keep more picture.",
79
+ owner: 'mos-5lzns.33',
80
+ wired: true
81
+ },
82
+ {
83
+ id: 'player.predictive',
84
+ group: 'Night',
85
+ label: 'See bright cuts coming',
86
+ type: 'bool',
87
+ default: true,
88
+ prop: 'predictive',
89
+ when: 'nightLuma',
90
+ hint: "This video has been scanned for a per-second light track, so night dimming can start easing a moment BEFORE a cut to white instead of reacting after it lands. It only ever adds dimming. The same track draws the light ribbon on the timeline.",
91
+ owner: 'mos-5lzns.33',
92
+ wired: true
93
+ },
94
+ {
95
+ id: 'player.strobeGuard',
96
+ group: 'Night',
97
+ label: 'Smooth hard flashing',
98
+ type: 'bool',
99
+ default: true,
100
+ prop: 'strobeGuard',
101
+ when: 'flashes',
102
+ hint: 'Rapid light/dark alternation — strobes, camera flashes, some transitions — is held down to about half brightness while it lasts, day or night. It reduces flashing; it cannot promise a video is safe. Reads as a photosensitivity guard because it is one.',
103
+ owner: 'mos-5lzns.33',
104
+ wired: true
105
+ },
106
+ // ── Sound (mos-5lzns.31) — video/audio/* ────────────────────────────────
107
+ {
108
+ id: 'audio.level',
109
+ group: 'Sound',
110
+ label: 'Loudness matching',
111
+ type: 'bool',
112
+ default: true,
113
+ prop: 'loudness',
114
+ when: 'loudness',
115
+ hint: 'Rides the gain toward a steady target, so a whispered documentary and a video that opens on a blasting intro sting arrive at the same volume. Quiet passages are lifted a little, loud ones held back — the same idea as night dimming, one sense over.',
116
+ owner: 'mos-5lzns.31',
117
+ wired: true
118
+ },
119
+ {
120
+ id: 'audio.target',
121
+ group: 'Sound',
122
+ label: 'Target loudness',
123
+ type: 'range',
124
+ min: -30,
125
+ max: -12,
126
+ step: 2,
127
+ default: -20,
128
+ format: (v) => v + ' dB',
129
+ prop: 'loudnessTarget',
130
+ when: 'loudness',
131
+ hint: 'Where matching aims. At −20 an ordinary video is left alone and a quiet one is lifted by a few dB at most, while a blaring one comes down as far as it needs to. Turning down has far more room than turning up — matching never becomes an amplifier.',
132
+ owner: 'mos-5lzns.31',
133
+ wired: true
134
+ },
135
+ {
136
+ id: 'audio.quiet',
137
+ group: 'Sound',
138
+ label: 'Quiet listening',
139
+ type: 'bool',
140
+ default: true,
141
+ prop: 'quietListening',
142
+ when: 'quiet',
143
+ hint: "At low volume your ears lose the bottom and top of the spectrum, so dialogue goes muddy and you keep nudging the volume up and back down. This compensates that tilt, so speech stays clear at a level that won't wake the house. Comes in as the volume drops, and sits partly on after dark.",
144
+ owner: 'mos-5lzns.31',
145
+ wired: true
146
+ },
147
+ {
148
+ id: 'audio.limiter',
149
+ group: 'Sound',
150
+ label: 'Tame sudden peaks',
151
+ type: 'bool',
152
+ default: true,
153
+ prop: 'limiter',
154
+ when: 'limiter',
155
+ hint: "A limiter across the output, so a jump-scare or an ad break can't spike past the level you set. Costs nothing when nothing is peaking.",
156
+ owner: 'mos-5lzns.31',
157
+ wired: true
158
+ },
159
+ // ── Wind-down (mos-5lzns.34) — video/presence/* ─────────────────────────
160
+ {
161
+ id: 'sleep.enabled',
162
+ group: 'Wind-down',
163
+ label: "Wind down when you're gone",
164
+ type: 'bool',
165
+ default: true,
166
+ prop: 'windDown',
167
+ when: 'windDown',
168
+ hint: 'Fades the picture and sound out over twenty seconds and pauses, keeping your place. The fade is the prompt: any input at all cancels it and brings everything straight back.',
169
+ owner: 'mos-5lzns.34',
170
+ wired: true
171
+ },
172
+ {
173
+ id: 'sleep.after',
174
+ group: 'Wind-down',
175
+ label: 'Wait this long first',
176
+ type: 'range',
177
+ min: 10,
178
+ max: 90,
179
+ step: 5,
180
+ default: 30,
181
+ format: (v) => v + ' min',
182
+ prop: 'windDownAfter',
183
+ when: 'windDown',
184
+ hint: "How long nobody has to be there before the fade begins. Without the desktop signal below this counts input on this page only — weaker evidence, since a video legitimately plays for an hour without anyone touching a key.",
185
+ owner: 'mos-5lzns.34',
186
+ wired: true
187
+ },
188
+ {
189
+ id: 'sleep.nightOnly',
190
+ group: 'Wind-down',
191
+ label: 'Only after dark',
192
+ type: 'bool',
193
+ default: true,
194
+ prop: 'windDownNightOnly',
195
+ when: 'windDown',
196
+ hint: "Falling asleep mid-video is a night problem. During the day, a long quiet stretch usually just means you're watching.",
197
+ owner: 'mos-5lzns.34',
198
+ wired: true
199
+ },
200
+ {
201
+ id: 'sleep.desktopIdle',
202
+ group: 'Wind-down',
203
+ label: 'Watch the whole desktop',
204
+ type: 'bool',
205
+ default: false,
206
+ prop: 'idleDetection',
207
+ when: 'idle',
208
+ hint: 'Measures idle time across the whole computer instead of this page, and a locked screen skips the wait entirely. Turning it on asks the browser for permission — once, from that click. Declined, it falls back to page input.',
209
+ owner: 'mos-5lzns.34',
210
+ wired: true
211
+ },
212
+ // ── Playback (.30 / .32 / .34) ──────────────────────────────────────────
213
+ {
214
+ id: 'player.smartSpeed',
215
+ group: 'Playback',
216
+ label: 'Speed through silence',
217
+ type: 'bool',
218
+ default: false,
219
+ prop: 'smartSpeed',
220
+ when: 'waveform',
221
+ hint: "A flat 1.5× turns everyone into a chipmunk. This reads the waveform instead and only speeds up where nothing is being said, holding your chosen pace through speech — so a talk loses its dead air and none of its voice. The speed readout keeps showing the pace you chose.",
222
+ owner: 'mos-5lzns.32',
223
+ wired: true
224
+ },
225
+ {
226
+ id: 'player.speakerRate',
227
+ group: 'Playback',
228
+ label: 'Even out speaking pace',
229
+ type: 'bool',
230
+ default: false,
231
+ prop: 'speakerPace',
232
+ when: 'speakers',
233
+ hint: "Some people talk much faster than others. This measures each speaker's words per minute from the transcript and nudges playback per speaker so everyone lands at one comfortable pace — loudness matching, for speed. Anyone already close is left exactly alone.",
234
+ owner: 'mos-5lzns.32',
235
+ wired: true
236
+ },
237
+ {
238
+ id: 'player.skipAds',
239
+ group: 'Playback',
240
+ label: 'Skip ad reads',
241
+ type: 'bool',
242
+ default: false,
243
+ prop: 'skipAds',
244
+ when: 'ads',
245
+ hint: "When playback runs into a sponsor read it jumps to the end, and says so. Seeking into one yourself is honoured — only playing into an ad skips it.",
246
+ owner: 'mos-5lzns.32',
247
+ wired: true
248
+ },
249
+ {
250
+ id: 'player.resumeSnap',
251
+ group: 'Playback',
252
+ label: 'Resume at a sentence',
253
+ type: 'bool',
254
+ default: true,
255
+ prop: 'resumeSnap',
256
+ when: 'resume',
257
+ hint: "Coming back to a video drops you mid-word, so you scrub backwards to get your bearings. This snaps the resume point back to the nearest sentence, speaker turn or chapter within fifteen seconds, and says what you're walking back into.",
258
+ owner: 'mos-5lzns.34',
259
+ wired: true
260
+ },
261
+ {
262
+ id: 'player.openMotion',
263
+ group: 'Playback',
264
+ label: 'Open like an object',
265
+ type: 'bool',
266
+ default: true,
267
+ prop: 'openMotion',
268
+ when: 'open',
269
+ hint: "The card you clicked expands into the player, with its own thumbnail painted underneath so there's never a black flash while the first frame decodes. Off, or with reduced motion, the thumbnail still paints — that half isn't decoration.",
270
+ owner: 'mos-5lzns.34',
271
+ wired: true
272
+ },
273
+ {
274
+ id: 'player.oneAxis',
275
+ group: 'Playback',
276
+ label: 'Scrub on the waveform',
277
+ type: 'bool',
278
+ default: true,
279
+ prop: 'oneAxis',
280
+ when: 'waveform',
281
+ hint: 'One time axis instead of two. The waveform is always on screen and already seeks, so the progress bar stands down and hands it the job — and comes back as an overview while the waveform is zoomed. Turn this off to keep both bars.',
282
+ owner: 'mos-5lzns.30',
283
+ wired: true
284
+ }
285
+ ];
286
+ const BY_ID = new Map(VIDEO_SETTINGS.map((s) => [s.id, s]));
287
+ /** The spec for an id, or undefined. */
288
+ export function settingSpec(id) {
289
+ return BY_ID.get(id);
290
+ }
291
+ /** Coerce a raw stored value against a spec — never throws, never returns junk. */
292
+ export function coerce(spec, raw) {
293
+ try {
294
+ if (!spec || raw == null)
295
+ return undefined;
296
+ if (spec.type === 'bool') {
297
+ if (raw === true || raw === 1 || raw === '1' || raw === 'true')
298
+ return true;
299
+ if (raw === false || raw === 0 || raw === '0' || raw === 'false')
300
+ return false;
301
+ return undefined;
302
+ }
303
+ if (spec.type === 'range') {
304
+ // Only numbers and numeric strings: Number() on an object runs its
305
+ // valueOf/toString, and on a Symbol it throws.
306
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
307
+ if (!Number.isFinite(n))
308
+ return undefined;
309
+ const min = Number.isFinite(spec.min) ? spec.min : -Infinity;
310
+ const max = Number.isFinite(spec.max) ? spec.max : Infinity;
311
+ return Math.min(max, Math.max(min, n));
312
+ }
313
+ if (spec.type === 'enum') {
314
+ if (typeof raw !== 'string')
315
+ return undefined;
316
+ return spec.options?.some((o) => o.value === raw) ? raw : undefined;
317
+ }
318
+ return undefined;
319
+ }
320
+ catch {
321
+ return undefined;
322
+ }
323
+ }
324
+ /** A stored value for an id, coerced; the spec's default when absent or junk. */
325
+ export function settingValue(id, raw) {
326
+ const spec = BY_ID.get(id);
327
+ if (!spec)
328
+ return undefined;
329
+ const v = coerce(spec, raw);
330
+ return v === undefined ? spec.default : v;
331
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Where settings live (mos-5lzns.35) — host-supplied, exactly like
3
+ * `VideoResumeStore`. jatui never touches localStorage or calls an API for
4
+ * settings: a host with per-user server state (MOS's user prefs) passes its
5
+ * own adapter; a host that passes none gets choices that last as long as the
6
+ * player does.
7
+ *
8
+ * `createSettingsSession` is the one place the player reads and writes
9
+ * through that adapter, and holds the three ordering rules:
10
+ *
11
+ * · Every value that comes back is coerced against its spec — a store
12
+ * holding junk (or throwing, or rejecting) reads as "nothing stored".
13
+ * · A change the user makes before the load resolves WINS. The late read
14
+ * must never undo a choice that was made on screen.
15
+ * · Writes are debounced per id: a range dragged across twenty steps is one
16
+ * write, not twenty. `flush()` (unmount) sends whatever is pending.
17
+ */
18
+ import { type SettingSpec, type SettingValue } from './registry';
19
+ /**
20
+ * Host persistence: `get(id)` returns whatever was stored (any shape — it
21
+ * is coerced), `set(id, value)` stores a value that has already been
22
+ * coerced. Either may be async; a throw or rejection is swallowed.
23
+ */
24
+ export interface VideoSettingsStore {
25
+ get(id: string): unknown | Promise<unknown>;
26
+ set(id: string, value: SettingValue): void | Promise<void>;
27
+ }
28
+ export type SettingValues = Record<string, SettingValue>;
29
+ export interface SettingsSession {
30
+ /** Resolves once every stored value has been read (never rejects). */
31
+ ready: Promise<void>;
32
+ /** The user changed a setting. Returns the coerced value, or undefined if it was junk. */
33
+ change(id: string, raw: unknown): SettingValue | undefined;
34
+ /** Send pending writes now. */
35
+ flush(): void;
36
+ /** Stop: pending writes are flushed, late loads are ignored. */
37
+ close(): void;
38
+ }
39
+ export interface SettingsSessionOpts {
40
+ store?: VideoSettingsStore | null;
41
+ /** Every value that lands — from the store or the user — coerced. */
42
+ onValue: (id: string, value: SettingValue, source: 'store' | 'user') => void;
43
+ specs?: SettingSpec[];
44
+ /** Debounce per id, ms (default 400). */
45
+ writeDelayMs?: number;
46
+ }
47
+ export declare const SETTINGS_WRITE_DELAY_MS = 400;
48
+ export declare function createSettingsSession(opts: SettingsSessionOpts): SettingsSession;
49
+ /**
50
+ * A plain in-memory store — for demos and tests, and for a host that wants
51
+ * to hold the values itself (read them back with `.values`).
52
+ */
53
+ export declare function memorySettingsStore(initial?: Record<string, unknown>): VideoSettingsStore & {
54
+ values: Record<string, unknown>;
55
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Where settings live (mos-5lzns.35) — host-supplied, exactly like
3
+ * `VideoResumeStore`. jatui never touches localStorage or calls an API for
4
+ * settings: a host with per-user server state (MOS's user prefs) passes its
5
+ * own adapter; a host that passes none gets choices that last as long as the
6
+ * player does.
7
+ *
8
+ * `createSettingsSession` is the one place the player reads and writes
9
+ * through that adapter, and holds the three ordering rules:
10
+ *
11
+ * · Every value that comes back is coerced against its spec — a store
12
+ * holding junk (or throwing, or rejecting) reads as "nothing stored".
13
+ * · A change the user makes before the load resolves WINS. The late read
14
+ * must never undo a choice that was made on screen.
15
+ * · Writes are debounced per id: a range dragged across twenty steps is one
16
+ * write, not twenty. `flush()` (unmount) sends whatever is pending.
17
+ */
18
+ import { coerce, VIDEO_SETTINGS } from './registry';
19
+ export const SETTINGS_WRITE_DELAY_MS = 400;
20
+ export function createSettingsSession(opts) {
21
+ const specs = opts.specs ?? VIDEO_SETTINGS;
22
+ const byId = new Map(specs.map((s) => [s.id, s]));
23
+ const store = opts.store ?? null;
24
+ const delay = Number.isFinite(opts.writeDelayMs) ? Math.max(0, opts.writeDelayMs) : SETTINGS_WRITE_DELAY_MS;
25
+ const touched = new Set();
26
+ const pending = new Map();
27
+ const timers = new Map();
28
+ let closed = false;
29
+ const write = (id) => {
30
+ timers.delete(id);
31
+ if (!pending.has(id))
32
+ return;
33
+ const v = pending.get(id);
34
+ pending.delete(id);
35
+ if (!store)
36
+ return;
37
+ try {
38
+ const r = store.set(id, v);
39
+ if (r && typeof r.catch === 'function')
40
+ r.catch(() => { });
41
+ }
42
+ catch {
43
+ /* a store that throws is a store that didn't save — never an error in the player */
44
+ }
45
+ };
46
+ const readOne = async (spec) => {
47
+ if (!store)
48
+ return;
49
+ let raw;
50
+ try {
51
+ raw = await store.get(spec.id);
52
+ }
53
+ catch {
54
+ return;
55
+ }
56
+ if (closed || touched.has(spec.id))
57
+ return;
58
+ const v = coerce(spec, raw);
59
+ if (v === undefined)
60
+ return;
61
+ try {
62
+ opts.onValue(spec.id, v, 'store');
63
+ }
64
+ catch {
65
+ /* the host's handler is not ours to crash on */
66
+ }
67
+ };
68
+ const flush = () => {
69
+ for (const [id, t] of timers) {
70
+ clearTimeout(t);
71
+ write(id);
72
+ }
73
+ timers.clear();
74
+ };
75
+ const ready = store ? Promise.all(specs.map(readOne)).then(() => undefined) : Promise.resolve();
76
+ return {
77
+ ready,
78
+ change(id, raw) {
79
+ const spec = byId.get(id);
80
+ if (!spec || closed)
81
+ return undefined;
82
+ const v = coerce(spec, raw);
83
+ if (v === undefined)
84
+ return undefined;
85
+ touched.add(id);
86
+ opts.onValue(id, v, 'user');
87
+ if (store) {
88
+ pending.set(id, v);
89
+ const t = timers.get(id);
90
+ if (t)
91
+ clearTimeout(t);
92
+ timers.set(id, setTimeout(() => write(id), delay));
93
+ }
94
+ return v;
95
+ },
96
+ flush,
97
+ close() {
98
+ flush();
99
+ closed = true;
100
+ }
101
+ };
102
+ }
103
+ /**
104
+ * A plain in-memory store — for demos and tests, and for a host that wants
105
+ * to hold the values itself (read them back with `.values`).
106
+ */
107
+ export function memorySettingsStore(initial = {}) {
108
+ const values = { ...initial };
109
+ return {
110
+ values,
111
+ get: (id) => values[id],
112
+ set: (id, v) => {
113
+ values[id] = v;
114
+ }
115
+ };
116
+ }
@@ -1,50 +1,19 @@
1
1
  /**
2
- * Settings plumbing for VideoPlayer (mos-5lzns.28) — ported discipline from
3
- * extension-library's `settings.js`, adapted to a prop-driven component.
2
+ * Settings for VideoPlayer — the barrel (mos-5lzns.28 skeleton, filled in by
3
+ * mos-5lzns.35). The pieces live in `settings/`:
4
4
  *
5
- * The extension's registry exists so the settings panel is generated from one
6
- * array and "a setting added there appears in the UI by existing". jatui's
7
- * player is prop-driven (the HOST owns the panel), so this module owns the
8
- * smaller half of the contract that still matters here:
5
+ * · `registry.ts` — `VIDEO_SETTINGS`: every standing choice the player's
6
+ * automatic behaviours read, with its label, hint, group, the prop it
7
+ * drives and the availability gate. `coerce` never throws.
8
+ * · `store.ts` — the host-supplied `VideoSettingsStore` (get/set) and the
9
+ * session that reads/writes through it. jatui never persists on its own.
10
+ * · `panel.ts` — which rows a given player can offer, and how values read.
11
+ * · `SettingsPanel.svelte` — the panel, generated wholly from the registry.
9
12
  *
10
- * · `coerce` never throws and never returns junk. A bool accepts
11
- * true/"1"/"true"; a range parses and clamps to [min, max]; an enum must
12
- * match a declared option or the value is discarded. Anything
13
- * unrecognised returns undefined and the caller falls back to the
14
- * declared default. A corrupted blob degrades to defaults, not to a
15
- * broken player.
16
- * · One registry (`VIDEO_SETTINGS`) lists every knob the player's automatic
17
- * behaviours read, with the sub-task that owns each. A knob that is here
18
- * but unread by any module is a switch that visibly does nothing — the
19
- * extension calls that "declared but not yet wired" and refuses to fix it
20
- * quietly. Keep the same discipline: land the knob WITH its reader.
21
- *
22
- * Nothing under src/lib/video/ reads a setting directly except through the
23
- * value its owning feature passes in as an `opts` argument — the advisor
24
- * rule (see README). That keeps every module node-testable and keeps the
25
- * arbitration in the component.
26
- */
27
- export type SettingType = 'bool' | 'range' | 'enum';
28
- export interface SettingSpec {
29
- id: string;
30
- type: SettingType;
31
- default: boolean | number | string;
32
- /** range only */
33
- min?: number;
34
- max?: number;
35
- step?: number;
36
- /** enum only */
37
- options?: string[];
38
- /** Which sub-task wires the reader. A spec without a live reader is a lie. */
39
- owner: string;
40
- /** Set true once the owning task lands (check: grep the module for the id). */
41
- wired?: boolean;
42
- }
43
- /**
44
- * The registry. Populated as the behaviours land — .30 oneAxis, .31 audio,
45
- * .32 rate/speaker/silence, .33 night, .34 presence. An empty registry is
46
- * the honest state while every behaviour is prop-driven.
13
+ * Nothing under src/lib/video/ reads a setting directly: the component maps
14
+ * each value onto the prop it names and the feature reads that — the
15
+ * advisor rule (see README).
47
16
  */
48
- export declare const VIDEO_SETTINGS: SettingSpec[];
49
- /** Coerce a raw stored value against a spec — never throws, never returns junk. */
50
- export declare function coerce<T extends SettingSpec>(spec: T, raw: unknown): T['default'] | undefined;
17
+ export * from './settings/registry';
18
+ export * from './settings/store';
19
+ export * from './settings/panel';