@joewinke/jatui 0.14.0 → 0.15.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,145 @@
1
+ /**
2
+ * The desktop signal for wind-down (mos-5lzns.34): Chromium's Idle Detection
3
+ * API (`IdleDetector`) — system-wide user idle + screen locked, the browser's
4
+ * near-exact equivalent of the extension's `hyprctl cursorpos` + `hyprlock`.
5
+ *
6
+ * Three rules, all load-bearing:
7
+ *
8
+ * 1. **Feature-detect, never assume.** `IdleDetector` exists only in
9
+ * Chromium, only in a secure context, and only where the
10
+ * `idle-detection` permissions policy allows it (a cross-origin iframe
11
+ * without `allow="idle-detection"` is refused). Every entry point here
12
+ * fails toward "no desktop signal", which the caller turns into the
13
+ * labelled page-presence fallback — never into a guess.
14
+ * 2. **Never prompt uninvited.** `requestIdleDetection()` is the ONLY call
15
+ * that can surface a permission prompt, and it is only ever made because
16
+ * the host opted in (the player's `idleDetection` prop). It needs a user
17
+ * gesture (transient activation) — call it from the click that enables
18
+ * the setting. `startDesktopWatch()` never asks: it starts only when the
19
+ * permission is already granted.
20
+ * 3. **The caller decides.** Like the extension's host (`desktop_idle()`
21
+ * reports two raw facts and keeps no state), this reports `{ idle,
22
+ * locked }` and nothing else. How long the video has been playing, and
23
+ * whether that adds up to absence, is the component's call
24
+ * (./winddown.ts).
25
+ *
26
+ * The global is injectable (`g`) so node:test can drive a fake detector —
27
+ * the same seam the audio graph uses for AudioContext.
28
+ */
29
+ /** The API's floor: `threshold` below 60s is rejected by the browser. */
30
+ export const IDLE_THRESHOLD_MIN_MS = 60_000;
31
+ function ctorOf(g) {
32
+ if (!g || typeof g !== 'object')
33
+ return null;
34
+ if (g.isSecureContext === false)
35
+ return null;
36
+ const C = g.IdleDetector;
37
+ return typeof C === 'function' ? C : null;
38
+ }
39
+ const defaultGlobals = () => typeof globalThis === 'undefined' ? undefined : globalThis;
40
+ /** Does this browser have the Idle Detection API at all? (Chromium, secure context.) */
41
+ export function idleDetectionSupported(g = defaultGlobals()) {
42
+ return ctorOf(g) !== null;
43
+ }
44
+ /**
45
+ * The current permission state WITHOUT prompting. `'unsupported'` when the
46
+ * API or the Permissions API is missing, or the query throws (an unknown
47
+ * permission name throws in browsers that don't know it).
48
+ */
49
+ export async function idleDetectionPermission(g = defaultGlobals()) {
50
+ if (!ctorOf(g))
51
+ return 'unsupported';
52
+ const query = g?.navigator?.permissions?.query;
53
+ if (typeof query !== 'function')
54
+ return 'unsupported';
55
+ try {
56
+ const r = await query.call(g.navigator.permissions, { name: 'idle-detection' });
57
+ const s = r?.state;
58
+ return s === 'granted' || s === 'denied' || s === 'prompt' ? s : 'unsupported';
59
+ }
60
+ catch {
61
+ return 'unsupported';
62
+ }
63
+ }
64
+ /**
65
+ * Ask for the permission. MUST be called from a user gesture, and only
66
+ * because the user opted in — this is the one call that can show a prompt.
67
+ * Resolves `'granted'` / `'denied'`, or `'unsupported'` (no API, or the
68
+ * browser refused to ask: no activation, policy-blocked frame).
69
+ */
70
+ export async function requestIdleDetection(g = defaultGlobals()) {
71
+ const C = ctorOf(g);
72
+ if (!C || typeof C.requestPermission !== 'function')
73
+ return 'unsupported';
74
+ try {
75
+ const s = await C.requestPermission();
76
+ return s === 'granted' ? 'granted' : 'denied';
77
+ }
78
+ catch {
79
+ return 'unsupported';
80
+ }
81
+ }
82
+ /** Map the API's two strings onto the two facts. Unknown ⇒ null (no reading). */
83
+ export function toDesktopSignal(userState, screenState) {
84
+ if (userState !== 'active' && userState !== 'idle')
85
+ return null;
86
+ return { idle: userState === 'idle', locked: screenState === 'locked' };
87
+ }
88
+ /**
89
+ * Start watching the desktop. Never prompts: `start()` rejects with
90
+ * NotAllowedError unless the permission is already granted, and that
91
+ * rejection becomes `ready → false`. `thresholdMs` is clamped up to the
92
+ * API's 60s floor. `onChange` fires with each new reading.
93
+ */
94
+ export function startDesktopWatch(opts) {
95
+ const g = opts.g ?? defaultGlobals();
96
+ const C = ctorOf(g);
97
+ let latest = null;
98
+ let stopped = false;
99
+ if (!C)
100
+ return { ready: Promise.resolve(false), read: () => null, stop: () => { } };
101
+ const AC = g?.AbortController ?? (typeof AbortController === 'function' ? AbortController : undefined);
102
+ const abort = AC ? new AC() : null;
103
+ let detector = null;
104
+ const onChange = () => {
105
+ if (stopped || !detector)
106
+ return;
107
+ latest = toDesktopSignal(detector.userState, detector.screenState);
108
+ opts.onChange(latest);
109
+ };
110
+ const t = Number(opts.thresholdMs);
111
+ const threshold = Math.max(IDLE_THRESHOLD_MIN_MS, Number.isFinite(t) ? t : IDLE_THRESHOLD_MIN_MS);
112
+ const ready = (async () => {
113
+ try {
114
+ detector = new C();
115
+ detector.addEventListener('change', onChange);
116
+ await detector.start({ threshold, signal: abort?.signal });
117
+ if (stopped)
118
+ return false;
119
+ onChange(); // the state as of start — no change event is owed for it
120
+ return true;
121
+ }
122
+ catch {
123
+ detector?.removeEventListener?.('change', onChange);
124
+ detector = null;
125
+ return false;
126
+ }
127
+ })();
128
+ return {
129
+ ready,
130
+ read: () => latest,
131
+ stop() {
132
+ if (stopped)
133
+ return;
134
+ stopped = true;
135
+ latest = null;
136
+ try {
137
+ abort?.abort();
138
+ }
139
+ catch {
140
+ /* already aborted */
141
+ }
142
+ detector?.removeEventListener?.('change', onChange);
143
+ }
144
+ };
145
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Resume at a sentence — the component-side glue (mos-5lzns.34) around
3
+ * `advisors/resume-point` (.27). The snapping itself — CLOSEST wins, the 15s
4
+ * ceiling, ads never a landing, the quoted line — lives in the advisor and
5
+ * is NOT re-derived here. This file only:
6
+ *
7
+ * · defines the host-supplied persistence adapter (`VideoResumeStore`) and
8
+ * its localStorage default — jatui never calls an API; a host that keeps
9
+ * resume points server-side (MOS's `video_user_state`) passes its own;
10
+ * · maps the player's `startSec/endSec` data onto the advisor's extension
11
+ * spellings (`start/end/text/speaker`, `name/ad`, `start/title`);
12
+ * · holds the two library.js integration decisions as pure functions:
13
+ * (1) the snap runs when the boundaries LAND, not at open, and
14
+ * (2) it stands down if the user has taken the wheel (> 2.5s from the
15
+ * stored point by the time the data arrives).
16
+ */
17
+ import { type ResumeCtx, type ResumePoint } from '../advisors/resume-point';
18
+ import type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment } from '../types';
19
+ /** You moved the playhead yourself — a snap now would yank it out from under you. */
20
+ export declare const RESUME_WHEEL_SEC = 2.5;
21
+ /** The advisor's hard ceiling, passed explicitly so the contract is visible here. */
22
+ export declare const RESUME_MAX_BACK = 15;
23
+ /** How often playback writes the position while it plays. */
24
+ export declare const RESUME_SAVE_MS = 5000;
25
+ /** Below this there is nothing to resume (the advisor's START_FLOOR). */
26
+ export declare const RESUME_FLOOR_SEC = 2;
27
+ /** Within this of the end, the video is finished — clear the point. */
28
+ export declare const RESUME_TAIL_SEC = 10;
29
+ /**
30
+ * Where resume points live. Host-supplied; either method may be async. A
31
+ * `save(key, null)` clears the point (the video was finished, or never
32
+ * really started). Both are called defensively: a throw or a rejection is
33
+ * swallowed and reads as "nothing stored".
34
+ */
35
+ export interface VideoResumeStore {
36
+ load(key: string): number | null | undefined | Promise<number | null | undefined>;
37
+ save(key: string, sec: number | null): void | Promise<void>;
38
+ }
39
+ /**
40
+ * The default store: this browser's localStorage, one entry per key. Every
41
+ * access is guarded — private mode, a full quota, or SSR degrade to "nothing
42
+ * stored", never to a thrown error in the player.
43
+ */
44
+ export declare function localResumeStore(storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null): VideoResumeStore;
45
+ /** A stored value → a resumable second, or null. Never throws. */
46
+ export declare function coerceResumeSec(raw: unknown): number | null;
47
+ /**
48
+ * What to persist for a position: the second itself, or null (clear) when
49
+ * the video is effectively unstarted or finished. Junk ⇒ undefined (write
50
+ * nothing — never overwrite a good point with garbage).
51
+ */
52
+ export declare function resumeToSave(t: unknown, duration: unknown): number | null | undefined;
53
+ /** The player's data → the advisor's input (extension spellings). */
54
+ export declare function toResumeCtx(transcript: readonly VideoTranscriptSegment[] | null | undefined, speakers: readonly VideoSpeakerTurn[] | null | undefined, chapters: readonly VideoChapter[] | null | undefined): ResumeCtx;
55
+ /** Is there anything to snap to yet? The snap waits for this. */
56
+ export declare function hasBoundaries(ctx: ResumeCtx): boolean;
57
+ /** What the player should do with a pending snap. */
58
+ export type ResumeSnapPlan = {
59
+ kind: 'wait';
60
+ } | {
61
+ kind: 'drop';
62
+ why: 'wheel' | 'no-move';
63
+ } | {
64
+ kind: 'seek';
65
+ point: ResumePoint;
66
+ };
67
+ /**
68
+ * The library.js `applyResumeSnap`, pure. `stored` is the second the player
69
+ * resumed at; `current` where the playhead is now.
70
+ * · no boundaries yet ⇒ wait (it runs when they land, not at open);
71
+ * · the user moved > 2.5s ⇒ drop, never yank the playhead;
72
+ * · "exact"/"start", or a landing that isn't meaningfully earlier ⇒ drop
73
+ * (no seek, and so no label — a toast on a no-op trains you to ignore it);
74
+ * · otherwise seek to the advisor's point and say its line.
75
+ */
76
+ export declare function planResumeSnap(stored: number, current: number, ctx: ResumeCtx): ResumeSnapPlan;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Resume at a sentence — the component-side glue (mos-5lzns.34) around
3
+ * `advisors/resume-point` (.27). The snapping itself — CLOSEST wins, the 15s
4
+ * ceiling, ads never a landing, the quoted line — lives in the advisor and
5
+ * is NOT re-derived here. This file only:
6
+ *
7
+ * · defines the host-supplied persistence adapter (`VideoResumeStore`) and
8
+ * its localStorage default — jatui never calls an API; a host that keeps
9
+ * resume points server-side (MOS's `video_user_state`) passes its own;
10
+ * · maps the player's `startSec/endSec` data onto the advisor's extension
11
+ * spellings (`start/end/text/speaker`, `name/ad`, `start/title`);
12
+ * · holds the two library.js integration decisions as pure functions:
13
+ * (1) the snap runs when the boundaries LAND, not at open, and
14
+ * (2) it stands down if the user has taken the wheel (> 2.5s from the
15
+ * stored point by the time the data arrives).
16
+ */
17
+ import { snapResume } from '../advisors/resume-point';
18
+ /** You moved the playhead yourself — a snap now would yank it out from under you. */
19
+ export const RESUME_WHEEL_SEC = 2.5;
20
+ /** The advisor's hard ceiling, passed explicitly so the contract is visible here. */
21
+ export const RESUME_MAX_BACK = 15;
22
+ /** How often playback writes the position while it plays. */
23
+ export const RESUME_SAVE_MS = 5_000;
24
+ /** Below this there is nothing to resume (the advisor's START_FLOOR). */
25
+ export const RESUME_FLOOR_SEC = 2;
26
+ /** Within this of the end, the video is finished — clear the point. */
27
+ export const RESUME_TAIL_SEC = 10;
28
+ const LS_PREFIX = 'jatui.video.resume:';
29
+ /**
30
+ * The default store: this browser's localStorage, one entry per key. Every
31
+ * access is guarded — private mode, a full quota, or SSR degrade to "nothing
32
+ * stored", never to a thrown error in the player.
33
+ */
34
+ export function localResumeStore(storage) {
35
+ const ls = () => {
36
+ if (storage !== undefined)
37
+ return storage;
38
+ try {
39
+ return typeof localStorage === 'undefined' ? null : localStorage;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ };
45
+ return {
46
+ load(key) {
47
+ try {
48
+ const raw = ls()?.getItem(LS_PREFIX + key);
49
+ return coerceResumeSec(raw);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ },
55
+ save(key, sec) {
56
+ try {
57
+ const s = ls();
58
+ if (!s)
59
+ return;
60
+ if (sec == null)
61
+ s.removeItem(LS_PREFIX + key);
62
+ else
63
+ s.setItem(LS_PREFIX + key, String(Math.round(sec * 10) / 10));
64
+ }
65
+ catch {
66
+ /* quota / private mode — resume is a convenience, never an error */
67
+ }
68
+ }
69
+ };
70
+ }
71
+ /** A stored value → a resumable second, or null. Never throws. */
72
+ export function coerceResumeSec(raw) {
73
+ if (raw == null || raw === '')
74
+ return null;
75
+ const n = typeof raw === 'number' ? raw : Number(raw);
76
+ return Number.isFinite(n) && n >= RESUME_FLOOR_SEC ? n : null;
77
+ }
78
+ /**
79
+ * What to persist for a position: the second itself, or null (clear) when
80
+ * the video is effectively unstarted or finished. Junk ⇒ undefined (write
81
+ * nothing — never overwrite a good point with garbage).
82
+ */
83
+ export function resumeToSave(t, duration) {
84
+ const s = Number(t);
85
+ if (!Number.isFinite(s) || s < 0)
86
+ return undefined;
87
+ if (s < RESUME_FLOOR_SEC)
88
+ return null;
89
+ const d = Number(duration);
90
+ if (Number.isFinite(d) && d > 0 && s >= d - Math.min(RESUME_TAIL_SEC, d * 0.03 + 1))
91
+ return null;
92
+ return s;
93
+ }
94
+ /** The player's data → the advisor's input (extension spellings). */
95
+ export function toResumeCtx(transcript, speakers, chapters) {
96
+ return {
97
+ transcript: (transcript ?? []).map((s) => ({
98
+ start: s.startSec,
99
+ end: s.endSec,
100
+ text: s.text,
101
+ speaker: s.speakerName ?? undefined
102
+ })),
103
+ speakers: (speakers ?? []).map((t) => ({
104
+ start: t.startSec,
105
+ end: t.endSec,
106
+ name: t.speakerName ?? undefined,
107
+ ad: t.ad === true
108
+ })),
109
+ chapters: (chapters ?? []).map((c) => ({ start: c.startSec, title: c.title }))
110
+ };
111
+ }
112
+ /** Is there anything to snap to yet? The snap waits for this. */
113
+ export function hasBoundaries(ctx) {
114
+ const n = (x) => (Array.isArray(x) ? x.length : 0);
115
+ return n(ctx.transcript) + n(ctx.speakers) + n(ctx.chapters) > 0;
116
+ }
117
+ /**
118
+ * The library.js `applyResumeSnap`, pure. `stored` is the second the player
119
+ * resumed at; `current` where the playhead is now.
120
+ * · no boundaries yet ⇒ wait (it runs when they land, not at open);
121
+ * · the user moved > 2.5s ⇒ drop, never yank the playhead;
122
+ * · "exact"/"start", or a landing that isn't meaningfully earlier ⇒ drop
123
+ * (no seek, and so no label — a toast on a no-op trains you to ignore it);
124
+ * · otherwise seek to the advisor's point and say its line.
125
+ */
126
+ export function planResumeSnap(stored, current, ctx) {
127
+ if (!Number.isFinite(stored) || !Number.isFinite(current))
128
+ return { kind: 'drop', why: 'no-move' };
129
+ if (Math.abs(current - stored) > RESUME_WHEEL_SEC)
130
+ return { kind: 'drop', why: 'wheel' };
131
+ if (!hasBoundaries(ctx))
132
+ return { kind: 'wait' };
133
+ const point = snapResume(stored, ctx, { maxBack: RESUME_MAX_BACK });
134
+ if (point.reason === 'exact' || point.reason === 'start')
135
+ return { kind: 'drop', why: 'no-move' };
136
+ if (point.at >= stored - 0.05)
137
+ return { kind: 'drop', why: 'no-move' };
138
+ return { kind: 'seek', point };
139
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Wind-down — the pure half (mos-5lzns.34). extension-library FEATURES.md
3
+ * §Wind-down, re-based from `hyprctl cursorpos` + `hyprlock` onto the
4
+ * browser's two signals (see ./idle.ts for how they are gathered).
5
+ *
6
+ * The signal question is the whole design. Page input cannot answer it on
7
+ * its own: a video legitimately plays for two hours with nobody touching a
8
+ * key, so "no input here" describes watching exactly as well as it
9
+ * describes sleeping. The DESKTOP can answer it — Chromium's Idle Detection
10
+ * API reports system-wide user idle and a locked screen. Everywhere else the
11
+ * player falls back to page presence, and says so (`source: 'page'`): the
12
+ * behaviour is the same, the evidence is weaker, and the host must be able
13
+ * to label it as such.
14
+ *
15
+ * It never just stops. `WIND_DOWN_FADE_MS` of frames: volume falls linearly
16
+ * to zero while a neutral veil over the picture rises to `0.92·p` — a veil,
17
+ * never the video's own opacity, which would let whatever is behind the
18
+ * player (the page, the bias light) show through and BRIGHTEN it. Any input
19
+ * cancels, restores the saved volume, and playback carries on. The fade IS
20
+ * the prompt — a gentler question than a modal asking whether you are alive.
21
+ *
22
+ * Nothing here touches the element or reads a setting; the component passes
23
+ * the knobs in and applies what comes back.
24
+ */
25
+ /** The fade itself — twenty seconds, and the fade is the prompt. */
26
+ export declare const WIND_DOWN_FADE_MS = 20000;
27
+ /** The veil's opacity at the end of the fade (the picture keeps 1 − 0.92). */
28
+ export declare const WIND_DOWN_DEPTH = 0.92;
29
+ /** Minutes of absence before the fade begins — the extension's `sleep.after`. */
30
+ export declare const WIND_DOWN_AFTER_DEFAULT = 30;
31
+ export declare const WIND_DOWN_AFTER_MIN = 10;
32
+ export declare const WIND_DOWN_AFTER_MAX = 90;
33
+ /**
34
+ * How often the component re-asks the question while armed and playing.
35
+ * Shorter than the extension's 60s because the answer is now local and
36
+ * free; the decision still only ever acts on a threshold of minutes.
37
+ */
38
+ export declare const WIND_DOWN_PROBE_MS = 15000;
39
+ /**
40
+ * Where the evidence of absence came from.
41
+ * · `desktop` — Idle Detection API: system-wide idle + screen lock. Full fidelity.
42
+ * · `page` — no input on this page. Lower fidelity, labelled as such.
43
+ */
44
+ export type PresenceSource = 'desktop' | 'page';
45
+ /** The desktop's two raw facts (IdleDetector's userState/screenState). */
46
+ export interface DesktopSignal {
47
+ idle: boolean;
48
+ locked: boolean;
49
+ }
50
+ export type WindDownPhase = 'off' | 'watching' | 'fading' | 'paused';
51
+ /** Reported to the host on real change only. */
52
+ export interface WindDownState {
53
+ phase: WindDownPhase;
54
+ /** Which evidence the player is using right now; null when the feature is off. */
55
+ source: PresenceSource | null;
56
+ }
57
+ /** Clamp a minutes knob to the declared range; junk falls back to the default. */
58
+ export declare function windDownAfterMs(minutes: unknown): number;
59
+ export interface WindDownInput {
60
+ /** Feature on, and (if night-only) night armed. */
61
+ armed: boolean;
62
+ playing: boolean;
63
+ /** Already fading or already paused by us — nothing more to begin. */
64
+ active: boolean;
65
+ source: PresenceSource;
66
+ /** Latest desktop facts; null when the desktop source has no reading yet. */
67
+ desktop: DesktopSignal | null;
68
+ /** ms since the last input on the page. */
69
+ quietMs: number;
70
+ /** The threshold, ms (windDownAfterMs). */
71
+ afterMs: number;
72
+ /**
73
+ * The document is hidden. Page presence holds while it is: silence on a
74
+ * background tab says nothing about whether you are at the machine —
75
+ * you may be busy in another one. The desktop source ignores it (a
76
+ * locked screen hides the document, and that is exactly its case).
77
+ */
78
+ hidden?: boolean;
79
+ }
80
+ /**
81
+ * Should the fade begin now? Never guesses: a desktop source with no reading
82
+ * yet holds (the extension's "no desktop signal → never wind down"), and
83
+ * junk numbers hold.
84
+ *
85
+ * A locked screen skips the wait entirely — that is not an inference, it is
86
+ * a statement. Desktop idle already carries the threshold (the detector is
87
+ * started with it), so `idle` alone is sufficient there.
88
+ */
89
+ export declare function shouldBeginWindDown(i: WindDownInput): boolean;
90
+ /** A desktop reading that says someone is there — cancels a fade in progress. */
91
+ export declare function desktopPresent(d: DesktopSignal | null | undefined): boolean;
92
+ /** Fade progress 0–1 from elapsed ms. Junk ⇒ 0 (nothing faded). */
93
+ export declare function windDownProgress(elapsedMs: unknown, fadeMs?: number): number;
94
+ /**
95
+ * The levels at progress p: the volume factor (× the volume saved at the
96
+ * start of the fade), the veil's opacity, and what that leaves of the
97
+ * picture. Linear on purpose — the extension's, and a sleeping viewer is not
98
+ * tracking a curve.
99
+ */
100
+ export declare function windDownLevels(p: unknown): {
101
+ volume: number;
102
+ veil: number;
103
+ picture: number;
104
+ };
105
+ /** The visible surface. The page source says it is guessing from less. */
106
+ export declare function windDownHint(phase: 'fading' | 'paused', source: PresenceSource): string;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Wind-down — the pure half (mos-5lzns.34). extension-library FEATURES.md
3
+ * §Wind-down, re-based from `hyprctl cursorpos` + `hyprlock` onto the
4
+ * browser's two signals (see ./idle.ts for how they are gathered).
5
+ *
6
+ * The signal question is the whole design. Page input cannot answer it on
7
+ * its own: a video legitimately plays for two hours with nobody touching a
8
+ * key, so "no input here" describes watching exactly as well as it
9
+ * describes sleeping. The DESKTOP can answer it — Chromium's Idle Detection
10
+ * API reports system-wide user idle and a locked screen. Everywhere else the
11
+ * player falls back to page presence, and says so (`source: 'page'`): the
12
+ * behaviour is the same, the evidence is weaker, and the host must be able
13
+ * to label it as such.
14
+ *
15
+ * It never just stops. `WIND_DOWN_FADE_MS` of frames: volume falls linearly
16
+ * to zero while a neutral veil over the picture rises to `0.92·p` — a veil,
17
+ * never the video's own opacity, which would let whatever is behind the
18
+ * player (the page, the bias light) show through and BRIGHTEN it. Any input
19
+ * cancels, restores the saved volume, and playback carries on. The fade IS
20
+ * the prompt — a gentler question than a modal asking whether you are alive.
21
+ *
22
+ * Nothing here touches the element or reads a setting; the component passes
23
+ * the knobs in and applies what comes back.
24
+ */
25
+ /** The fade itself — twenty seconds, and the fade is the prompt. */
26
+ export const WIND_DOWN_FADE_MS = 20_000;
27
+ /** The veil's opacity at the end of the fade (the picture keeps 1 − 0.92). */
28
+ export const WIND_DOWN_DEPTH = 0.92;
29
+ /** Minutes of absence before the fade begins — the extension's `sleep.after`. */
30
+ export const WIND_DOWN_AFTER_DEFAULT = 30;
31
+ export const WIND_DOWN_AFTER_MIN = 10;
32
+ export const WIND_DOWN_AFTER_MAX = 90;
33
+ /**
34
+ * How often the component re-asks the question while armed and playing.
35
+ * Shorter than the extension's 60s because the answer is now local and
36
+ * free; the decision still only ever acts on a threshold of minutes.
37
+ */
38
+ export const WIND_DOWN_PROBE_MS = 15_000;
39
+ /** Clamp a minutes knob to the declared range; junk falls back to the default. */
40
+ export function windDownAfterMs(minutes) {
41
+ const n = typeof minutes === 'number' ? minutes : Number(minutes);
42
+ const m = Number.isFinite(n)
43
+ ? Math.min(WIND_DOWN_AFTER_MAX, Math.max(WIND_DOWN_AFTER_MIN, n))
44
+ : WIND_DOWN_AFTER_DEFAULT;
45
+ return m * 60_000;
46
+ }
47
+ /**
48
+ * Should the fade begin now? Never guesses: a desktop source with no reading
49
+ * yet holds (the extension's "no desktop signal → never wind down"), and
50
+ * junk numbers hold.
51
+ *
52
+ * A locked screen skips the wait entirely — that is not an inference, it is
53
+ * a statement. Desktop idle already carries the threshold (the detector is
54
+ * started with it), so `idle` alone is sufficient there.
55
+ */
56
+ export function shouldBeginWindDown(i) {
57
+ if (!i || !i.armed || !i.playing || i.active)
58
+ return false;
59
+ if (i.source === 'desktop') {
60
+ const d = i.desktop;
61
+ if (!d)
62
+ return false;
63
+ return d.locked === true || d.idle === true;
64
+ }
65
+ if (i.hidden === true)
66
+ return false;
67
+ const quiet = Number(i.quietMs);
68
+ const after = Number(i.afterMs);
69
+ if (!Number.isFinite(quiet) || !Number.isFinite(after) || after <= 0)
70
+ return false;
71
+ return quiet >= after;
72
+ }
73
+ /** A desktop reading that says someone is there — cancels a fade in progress. */
74
+ export function desktopPresent(d) {
75
+ return !!d && d.idle === false && d.locked === false;
76
+ }
77
+ /** Fade progress 0–1 from elapsed ms. Junk ⇒ 0 (nothing faded). */
78
+ export function windDownProgress(elapsedMs, fadeMs = WIND_DOWN_FADE_MS) {
79
+ const e = Number(elapsedMs);
80
+ const f = Number(fadeMs);
81
+ if (!Number.isFinite(e) || e <= 0 || !Number.isFinite(f) || f <= 0)
82
+ return 0;
83
+ return Math.min(1, e / f);
84
+ }
85
+ /**
86
+ * The levels at progress p: the volume factor (× the volume saved at the
87
+ * start of the fade), the veil's opacity, and what that leaves of the
88
+ * picture. Linear on purpose — the extension's, and a sleeping viewer is not
89
+ * tracking a curve.
90
+ */
91
+ export function windDownLevels(p) {
92
+ const n = Number(p);
93
+ const q = Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
94
+ const veil = WIND_DOWN_DEPTH * q;
95
+ return { volume: 1 - q, veil, picture: 1 - veil };
96
+ }
97
+ /** The visible surface. The page source says it is guessing from less. */
98
+ export function windDownHint(phase, source) {
99
+ if (phase === 'paused')
100
+ return 'Paused — you were away';
101
+ return source === 'desktop'
102
+ ? 'Winding down — move anything to stay'
103
+ : 'Winding down (no activity on this page) — move anything to stay';
104
+ }
@@ -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';