@tristan2828/ui-foundation 4.15.0 → 4.17.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.
- package/README.md +1 -1
- package/conventions/AGENTS.md +14 -0
- package/conventions/docs/reminders.md +85 -0
- package/conventions/docs/timed-screens.md +155 -0
- package/dist/api/gateway/push.d.ts +8 -0
- package/dist/api/gateway/push.js +25 -0
- package/dist/api/schema.d.ts +148 -0
- package/dist/components/app/allow-reminders.d.ts +9 -0
- package/dist/components/app/allow-reminders.js +11 -0
- package/dist/components/app/session-view.d.ts +19 -0
- package/dist/components/app/session-view.js +68 -0
- package/dist/hooks/use-muted.d.ts +15 -0
- package/dist/hooks/use-muted.js +73 -0
- package/dist/hooks/use-prefers-reduced-motion.d.ts +2 -0
- package/dist/hooks/use-prefers-reduced-motion.js +17 -0
- package/dist/hooks/use-push-permission.d.ts +72 -0
- package/dist/hooks/use-push-permission.js +154 -0
- package/dist/hooks/use-session-clock.d.ts +42 -0
- package/dist/hooks/use-session-clock.js +111 -0
- package/dist/hooks/use-wake-lock.d.ts +18 -0
- package/dist/hooks/use-wake-lock.js +70 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/lib/audio-cues.d.ts +52 -0
- package/dist/lib/audio-cues.js +138 -0
- package/dist/mocks/index.d.ts +1 -0
- package/dist/mocks/index.js +1 -0
- package/dist/mocks/push.d.ts +9 -0
- package/dist/mocks/push.js +40 -0
- package/openapi/foundation.yaml +99 -3
- package/package.json +1 -1
- package/vite/index.d.ts +27 -0
- package/vite/index.js +2 -0
- package/vite/push-sw.js +46 -0
- package/vite/pwa.d.ts +18 -0
- package/vite/pwa.js +230 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
2
|
+
export function createMutedStore(getStorage) {
|
|
3
|
+
// Values this page set, for when storage refuses them.
|
|
4
|
+
const memory = new Map();
|
|
5
|
+
const listeners = new Map();
|
|
6
|
+
const notify = (key) => {
|
|
7
|
+
for (const listener of listeners.get(key) ?? [])
|
|
8
|
+
listener();
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
get(key) {
|
|
12
|
+
const kept = memory.get(key);
|
|
13
|
+
if (kept !== undefined)
|
|
14
|
+
return kept;
|
|
15
|
+
try {
|
|
16
|
+
const stored = getStorage()?.getItem(key);
|
|
17
|
+
if (stored === 'true')
|
|
18
|
+
return true;
|
|
19
|
+
if (stored === 'false')
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Storage blocked: unmuted.
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
},
|
|
27
|
+
set(key, muted) {
|
|
28
|
+
memory.set(key, muted);
|
|
29
|
+
try {
|
|
30
|
+
getStorage()?.setItem(key, String(muted));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Storage blocked or full: remembered in memory for this page only.
|
|
34
|
+
}
|
|
35
|
+
notify(key);
|
|
36
|
+
},
|
|
37
|
+
subscribe(key, listener) {
|
|
38
|
+
let set = listeners.get(key);
|
|
39
|
+
if (!set)
|
|
40
|
+
listeners.set(key, (set = new Set()));
|
|
41
|
+
set.add(listener);
|
|
42
|
+
return () => set.delete(listener);
|
|
43
|
+
},
|
|
44
|
+
changedElsewhere(key) {
|
|
45
|
+
memory.delete(key);
|
|
46
|
+
notify(key);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const browserStore = createMutedStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
51
|
+
let listeningForOtherTabs = false;
|
|
52
|
+
function listenForOtherTabs() {
|
|
53
|
+
if (listeningForOtherTabs || typeof window === 'undefined')
|
|
54
|
+
return;
|
|
55
|
+
listeningForOtherTabs = true;
|
|
56
|
+
window.addEventListener('storage', (event) => {
|
|
57
|
+
if (event.key !== null)
|
|
58
|
+
browserStore.changedElsewhere(event.key);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `[muted, setMuted]`, remembered on this device under `key` (name it for
|
|
63
|
+
* the app and screen: `"my-app:timer:muted"`). Unmuted until set.
|
|
64
|
+
*/
|
|
65
|
+
export function useMuted(key) {
|
|
66
|
+
const subscribe = useCallback((listener) => {
|
|
67
|
+
listenForOtherTabs();
|
|
68
|
+
return browserStore.subscribe(key, listener);
|
|
69
|
+
}, [key]);
|
|
70
|
+
const muted = useSyncExternalStore(subscribe, () => browserStore.get(key), () => false);
|
|
71
|
+
const setMuted = useCallback((next) => browserStore.set(key, next), [key]);
|
|
72
|
+
return [muted, setMuted];
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
// Whether the person asked their system for less motion, kept current as
|
|
3
|
+
// they change it. A screen swaps movement (a sweeping ring, a breathing
|
|
4
|
+
// circle) for a fade or a still state while it's true.
|
|
5
|
+
const QUERY = '(prefers-reduced-motion: reduce)';
|
|
6
|
+
function subscribe(onChange) {
|
|
7
|
+
if (typeof window === 'undefined' || !window.matchMedia)
|
|
8
|
+
return () => { };
|
|
9
|
+
const query = window.matchMedia(QUERY);
|
|
10
|
+
query.addEventListener('change', onChange);
|
|
11
|
+
return () => query.removeEventListener('change', onChange);
|
|
12
|
+
}
|
|
13
|
+
const getSnapshot = () => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(QUERY).matches;
|
|
14
|
+
/** True while the system asks for reduced motion; false where it can't be read (and on the server). */
|
|
15
|
+
export function usePrefersReducedMotion() {
|
|
16
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
|
17
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type PushSubscriptionRequest } from '../api/gateway/push.js';
|
|
2
|
+
/**
|
|
3
|
+
* - `unsupported`: this browser has no push.
|
|
4
|
+
* - `needs-install`: iPhone or iPad in a browser tab; push works once the
|
|
5
|
+
* app is added to the Home Screen and opened from there.
|
|
6
|
+
* - `mock`: the page runs on MSW; reminders need the real backend.
|
|
7
|
+
* - `blocked`: the person said no; only the browser's site settings undo it.
|
|
8
|
+
* - `off`: can be turned on. `on`: this device gets reminders.
|
|
9
|
+
*/
|
|
10
|
+
export type PushStatus = 'unsupported' | 'needs-install' | 'mock' | 'blocked' | 'off' | 'on';
|
|
11
|
+
type PushRegistration = {
|
|
12
|
+
active: {
|
|
13
|
+
scriptURL: string;
|
|
14
|
+
} | null;
|
|
15
|
+
pushManager: Pick<PushManager, 'getSubscription' | 'subscribe'>;
|
|
16
|
+
};
|
|
17
|
+
/** What createPushController needs from the browser and the backend. */
|
|
18
|
+
export type PushEnvironment = {
|
|
19
|
+
notification: {
|
|
20
|
+
permission: NotificationPermission;
|
|
21
|
+
requestPermission: () => Promise<NotificationPermission>;
|
|
22
|
+
} | undefined;
|
|
23
|
+
serviceWorker: {
|
|
24
|
+
register: (url: string, options: RegistrationOptions) => Promise<unknown>;
|
|
25
|
+
getRegistration: () => Promise<PushRegistration | undefined>;
|
|
26
|
+
ready: () => Promise<PushRegistration>;
|
|
27
|
+
/** The script of the worker controlling this page, if any. */
|
|
28
|
+
controllerScript: () => string | undefined;
|
|
29
|
+
} | undefined;
|
|
30
|
+
/** `'PushManager' in window`. */
|
|
31
|
+
pushSupported: boolean;
|
|
32
|
+
/** Opened from the Home Screen (display-mode: standalone). */
|
|
33
|
+
standalone: boolean;
|
|
34
|
+
/** An iPhone or iPad, whatever the browser says it is. */
|
|
35
|
+
appleMobile: boolean;
|
|
36
|
+
api: {
|
|
37
|
+
getPublicKey: () => Promise<string>;
|
|
38
|
+
save: (subscription: PushSubscriptionRequest) => Promise<number>;
|
|
39
|
+
remove: (id: number) => Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export type PushController = {
|
|
43
|
+
status: () => Promise<PushStatus>;
|
|
44
|
+
/** Asks the browser, subscribes, and saves it. Call it straight from a click. */
|
|
45
|
+
enable: () => Promise<PushStatus>;
|
|
46
|
+
/** Stops sending to this device, and unsubscribes it. */
|
|
47
|
+
disable: () => Promise<PushStatus>;
|
|
48
|
+
};
|
|
49
|
+
export type PushControllerOptions = {
|
|
50
|
+
/** Where pwa() serves the worker. Default `/push-sw.js`. */
|
|
51
|
+
serviceWorkerUrl?: string;
|
|
52
|
+
};
|
|
53
|
+
export declare function createPushController(env: PushEnvironment, options?: PushControllerOptions): PushController;
|
|
54
|
+
export declare const PUSH_STATUS_QUERY_KEY: readonly ["ui-foundation", "push-status"];
|
|
55
|
+
/** What usePushPermission returns, and what AllowReminders shows. */
|
|
56
|
+
export type PushPermission = {
|
|
57
|
+
/** Undefined while it's being worked out. */
|
|
58
|
+
status: PushStatus | undefined;
|
|
59
|
+
enable: () => void;
|
|
60
|
+
disable: () => void;
|
|
61
|
+
/** Turning on or off is under way. */
|
|
62
|
+
pending: boolean;
|
|
63
|
+
/** Why the last enable or disable failed, in words to show. */
|
|
64
|
+
error: string | null;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Whether this device gets reminders, and `enable`/`disable` for one
|
|
68
|
+
* button (AllowReminders is that button, with the words around it).
|
|
69
|
+
* `enable` asks the browser for permission: only ever call it from a click.
|
|
70
|
+
*/
|
|
71
|
+
export declare function usePushPermission(options?: PushControllerOptions): PushPermission;
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { deletePushSubscription, getPushPublicKey, savePushSubscription, } from '../api/gateway/push.js';
|
|
3
|
+
const isMockWorker = (script) => !!script && script.includes('mockServiceWorker');
|
|
4
|
+
function base64UrlToBytes(value) {
|
|
5
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
|
6
|
+
const binary = atob(base64);
|
|
7
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
8
|
+
for (let i = 0; i < binary.length; i++)
|
|
9
|
+
bytes[i] = binary.charCodeAt(i);
|
|
10
|
+
return bytes;
|
|
11
|
+
}
|
|
12
|
+
function sameKey(current, wanted) {
|
|
13
|
+
if (!current)
|
|
14
|
+
return false;
|
|
15
|
+
const bytes = new Uint8Array(current);
|
|
16
|
+
return bytes.length === wanted.length && bytes.every((byte, i) => byte === wanted[i]);
|
|
17
|
+
}
|
|
18
|
+
function toRequest(subscription) {
|
|
19
|
+
const json = subscription.toJSON();
|
|
20
|
+
const p256dh = json.keys?.p256dh;
|
|
21
|
+
const auth = json.keys?.auth;
|
|
22
|
+
if (!json.endpoint || !p256dh || !auth)
|
|
23
|
+
throw new Error('The browser gave a subscription without its keys.');
|
|
24
|
+
return { endpoint: json.endpoint, expirationTime: json.expirationTime ?? null, keys: { p256dh, auth } };
|
|
25
|
+
}
|
|
26
|
+
export function createPushController(env, options = {}) {
|
|
27
|
+
const workerUrl = options.serviceWorkerUrl ?? '/push-sw.js';
|
|
28
|
+
// What can be told without waiting on anything, so enable() can ask
|
|
29
|
+
// the browser in the same task as the click.
|
|
30
|
+
const knownNow = () => {
|
|
31
|
+
if (env.appleMobile && !env.standalone)
|
|
32
|
+
return 'needs-install';
|
|
33
|
+
if (!env.notification || !env.serviceWorker || !env.pushSupported)
|
|
34
|
+
return 'unsupported';
|
|
35
|
+
if (isMockWorker(env.serviceWorker.controllerScript()))
|
|
36
|
+
return 'mock';
|
|
37
|
+
if (env.notification.permission === 'denied')
|
|
38
|
+
return 'blocked';
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
const ours = (registration) => registration?.active && !isMockWorker(registration.active.scriptURL) ? registration : undefined;
|
|
42
|
+
return {
|
|
43
|
+
async status() {
|
|
44
|
+
const known = knownNow();
|
|
45
|
+
if (known)
|
|
46
|
+
return known;
|
|
47
|
+
const registration = await env.serviceWorker.getRegistration();
|
|
48
|
+
if (isMockWorker(registration?.active?.scriptURL))
|
|
49
|
+
return 'mock';
|
|
50
|
+
if (env.notification.permission !== 'granted')
|
|
51
|
+
return 'off';
|
|
52
|
+
const subscription = await ours(registration)?.pushManager.getSubscription();
|
|
53
|
+
return subscription ? 'on' : 'off';
|
|
54
|
+
},
|
|
55
|
+
async enable() {
|
|
56
|
+
const known = knownNow();
|
|
57
|
+
if (known)
|
|
58
|
+
return known;
|
|
59
|
+
const permission = await env.notification.requestPermission();
|
|
60
|
+
if (permission !== 'granted')
|
|
61
|
+
return permission === 'denied' ? 'blocked' : 'off';
|
|
62
|
+
await env.serviceWorker.register(workerUrl, { scope: '/' });
|
|
63
|
+
const registration = await env.serviceWorker.ready();
|
|
64
|
+
const key = base64UrlToBytes(await env.api.getPublicKey());
|
|
65
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
66
|
+
// Made with another key (the backend's was replaced): it can't be
|
|
67
|
+
// sent to any more, so start again.
|
|
68
|
+
if (subscription && !sameKey(subscription.options.applicationServerKey, key)) {
|
|
69
|
+
await subscription.unsubscribe();
|
|
70
|
+
subscription = null;
|
|
71
|
+
}
|
|
72
|
+
subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
|
|
73
|
+
await env.api.save(toRequest(subscription));
|
|
74
|
+
return 'on';
|
|
75
|
+
},
|
|
76
|
+
async disable() {
|
|
77
|
+
const subscription = await ours(await env.serviceWorker?.getRegistration())?.pushManager.getSubscription();
|
|
78
|
+
if (subscription) {
|
|
79
|
+
// Saving again is how the device learns its id (the same endpoint
|
|
80
|
+
// answers the same one), so nothing is kept on the device.
|
|
81
|
+
const id = await env.api.save(toRequest(subscription));
|
|
82
|
+
await env.api.remove(id);
|
|
83
|
+
await subscription.unsubscribe();
|
|
84
|
+
}
|
|
85
|
+
return 'off';
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function browserEnvironment() {
|
|
90
|
+
const hasWindow = typeof window !== 'undefined';
|
|
91
|
+
const container = hasWindow && 'serviceWorker' in navigator ? navigator.serviceWorker : undefined;
|
|
92
|
+
const ua = hasWindow ? navigator.userAgent : '';
|
|
93
|
+
return {
|
|
94
|
+
notification: hasWindow && 'Notification' in window ? Notification : undefined,
|
|
95
|
+
serviceWorker: container && {
|
|
96
|
+
register: (url, options) => container.register(url, options),
|
|
97
|
+
getRegistration: () => container.getRegistration(),
|
|
98
|
+
ready: () => container.ready,
|
|
99
|
+
controllerScript: () => container.controller?.scriptURL,
|
|
100
|
+
},
|
|
101
|
+
pushSupported: hasWindow && 'PushManager' in window,
|
|
102
|
+
standalone: hasWindow &&
|
|
103
|
+
(window.matchMedia?.('(display-mode: standalone)').matches ||
|
|
104
|
+
navigator.standalone === true),
|
|
105
|
+
// iPadOS says it's a Mac; a Mac has no touch screen.
|
|
106
|
+
appleMobile: /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && hasWindow && navigator.maxTouchPoints > 1),
|
|
107
|
+
api: { getPublicKey: getPushPublicKey, save: savePushSubscription, remove: deletePushSubscription },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function messageOf(error) {
|
|
111
|
+
const appError = error;
|
|
112
|
+
if (appError && typeof appError.kind === 'string') {
|
|
113
|
+
if (appError.kind === 'notfound')
|
|
114
|
+
return "This app's backend doesn't send reminders yet.";
|
|
115
|
+
if (appError.kind === 'network')
|
|
116
|
+
return "Couldn't reach the app. Check the connection and try again.";
|
|
117
|
+
return appError.message ?? 'Something went wrong. Try again.';
|
|
118
|
+
}
|
|
119
|
+
return "The browser couldn't turn on reminders. Try again, or check its notification settings.";
|
|
120
|
+
}
|
|
121
|
+
export const PUSH_STATUS_QUERY_KEY = ['ui-foundation', 'push-status'];
|
|
122
|
+
/**
|
|
123
|
+
* Whether this device gets reminders, and `enable`/`disable` for one
|
|
124
|
+
* button (AllowReminders is that button, with the words around it).
|
|
125
|
+
* `enable` asks the browser for permission: only ever call it from a click.
|
|
126
|
+
*/
|
|
127
|
+
export function usePushPermission(options = {}) {
|
|
128
|
+
const queryClient = useQueryClient();
|
|
129
|
+
const controller = createPushController(browserEnvironment(), options);
|
|
130
|
+
const { data: status } = useQuery({
|
|
131
|
+
queryKey: PUSH_STATUS_QUERY_KEY,
|
|
132
|
+
queryFn: () => controller.status(),
|
|
133
|
+
staleTime: Infinity,
|
|
134
|
+
});
|
|
135
|
+
const settle = (next) => queryClient.setQueryData(PUSH_STATUS_QUERY_KEY, next);
|
|
136
|
+
// Each takes the run already started: enable() must ask the browser in
|
|
137
|
+
// the click itself, and mutate() may call its function a tick later.
|
|
138
|
+
const enable = useMutation({ mutationFn: (run) => run, onSuccess: settle });
|
|
139
|
+
const disable = useMutation({ mutationFn: (run) => run, onSuccess: settle });
|
|
140
|
+
const failed = enable.error ?? disable.error;
|
|
141
|
+
return {
|
|
142
|
+
status,
|
|
143
|
+
enable: () => {
|
|
144
|
+
disable.reset();
|
|
145
|
+
enable.mutate(controller.enable());
|
|
146
|
+
},
|
|
147
|
+
disable: () => {
|
|
148
|
+
enable.reset();
|
|
149
|
+
disable.mutate(controller.disable());
|
|
150
|
+
},
|
|
151
|
+
pending: enable.isPending || disable.isPending,
|
|
152
|
+
error: failed ? messageOf(failed) : null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type SessionStatus = 'idle' | 'running' | 'done';
|
|
2
|
+
export type SessionClockState = {
|
|
3
|
+
/** Milliseconds counted so far, paused time left out; `endsAt` at most. */
|
|
4
|
+
elapsed: number;
|
|
5
|
+
/** `idle` until `start()`, `running` until `endsAt` is reached, then `done`. */
|
|
6
|
+
status: SessionStatus;
|
|
7
|
+
/** Paused by `pause()`, while running. */
|
|
8
|
+
paused: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type SessionClockStore = {
|
|
11
|
+
start: () => void;
|
|
12
|
+
pause: () => void;
|
|
13
|
+
resume: () => void;
|
|
14
|
+
/** Back to `idle` at zero. */
|
|
15
|
+
stop: () => void;
|
|
16
|
+
/** Whether time may count at all (the hook's `running`); false holds the clock still without pausing it. */
|
|
17
|
+
setRunning: (running: boolean) => void;
|
|
18
|
+
setEndsAt: (endsAt: number | undefined) => void;
|
|
19
|
+
/** Reads the time; the hook calls it each animation frame. */
|
|
20
|
+
tick: () => void;
|
|
21
|
+
subscribe: (listener: () => void) => () => void;
|
|
22
|
+
getSnapshot: () => SessionClockState;
|
|
23
|
+
};
|
|
24
|
+
export declare function createSessionClock(now: () => number): SessionClockStore;
|
|
25
|
+
export type SessionClockOptions = {
|
|
26
|
+
/**
|
|
27
|
+
* Whether time may count (default true). False holds the clock still
|
|
28
|
+
* without pausing it: for time the session isn't on screen at all, such
|
|
29
|
+
* as its view closed or a confirmation in front of it.
|
|
30
|
+
*/
|
|
31
|
+
running?: boolean;
|
|
32
|
+
/** The elapsed milliseconds at which the session is `done`. Without it, it runs until stopped. */
|
|
33
|
+
endsAt?: number;
|
|
34
|
+
};
|
|
35
|
+
export type SessionClock = SessionClockState & Pick<SessionClockStore, 'start' | 'pause' | 'resume' | 'stop'>;
|
|
36
|
+
/**
|
|
37
|
+
* A drift-free clock with pause and resume, read from `performance.now()`
|
|
38
|
+
* each animation frame. It re-renders once per frame only while counting.
|
|
39
|
+
* A hidden tab draws no frames, but its time still counts: the first frame
|
|
40
|
+
* back catches up (and reaches `done` if it's due).
|
|
41
|
+
*/
|
|
42
|
+
export declare function useSessionClock({ running, endsAt }?: SessionClockOptions): SessionClock;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
export function createSessionClock(now) {
|
|
3
|
+
let status = 'idle';
|
|
4
|
+
let paused = false;
|
|
5
|
+
let running = true;
|
|
6
|
+
let endsAt;
|
|
7
|
+
// Time counted before the current stretch, and when that stretch began
|
|
8
|
+
// (null while time isn't counting).
|
|
9
|
+
let banked = 0;
|
|
10
|
+
let since = null;
|
|
11
|
+
let snapshot = { elapsed: 0, status, paused };
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const counting = () => status === 'running' && !paused && running;
|
|
14
|
+
const bank = () => {
|
|
15
|
+
if (since === null)
|
|
16
|
+
return;
|
|
17
|
+
banked += now() - since;
|
|
18
|
+
since = null;
|
|
19
|
+
};
|
|
20
|
+
const begin = () => {
|
|
21
|
+
if (counting() && since === null)
|
|
22
|
+
since = now();
|
|
23
|
+
};
|
|
24
|
+
const update = () => {
|
|
25
|
+
let elapsed = banked + (since === null ? 0 : now() - since);
|
|
26
|
+
if (status === 'running' && endsAt !== undefined && elapsed >= endsAt) {
|
|
27
|
+
status = 'done';
|
|
28
|
+
paused = false;
|
|
29
|
+
since = null;
|
|
30
|
+
banked = endsAt;
|
|
31
|
+
elapsed = endsAt;
|
|
32
|
+
}
|
|
33
|
+
if (elapsed === snapshot.elapsed && status === snapshot.status && paused === snapshot.paused)
|
|
34
|
+
return;
|
|
35
|
+
snapshot = { elapsed, status, paused };
|
|
36
|
+
for (const listener of listeners)
|
|
37
|
+
listener();
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
start() {
|
|
41
|
+
status = 'running';
|
|
42
|
+
paused = false;
|
|
43
|
+
banked = 0;
|
|
44
|
+
since = null;
|
|
45
|
+
begin();
|
|
46
|
+
update();
|
|
47
|
+
},
|
|
48
|
+
pause() {
|
|
49
|
+
if (status !== 'running' || paused)
|
|
50
|
+
return;
|
|
51
|
+
bank();
|
|
52
|
+
paused = true;
|
|
53
|
+
update();
|
|
54
|
+
},
|
|
55
|
+
resume() {
|
|
56
|
+
if (status !== 'running' || !paused)
|
|
57
|
+
return;
|
|
58
|
+
paused = false;
|
|
59
|
+
begin();
|
|
60
|
+
update();
|
|
61
|
+
},
|
|
62
|
+
stop() {
|
|
63
|
+
status = 'idle';
|
|
64
|
+
paused = false;
|
|
65
|
+
banked = 0;
|
|
66
|
+
since = null;
|
|
67
|
+
update();
|
|
68
|
+
},
|
|
69
|
+
setRunning(next) {
|
|
70
|
+
if (next === running)
|
|
71
|
+
return;
|
|
72
|
+
bank();
|
|
73
|
+
running = next;
|
|
74
|
+
begin();
|
|
75
|
+
update();
|
|
76
|
+
},
|
|
77
|
+
setEndsAt(next) {
|
|
78
|
+
endsAt = next;
|
|
79
|
+
update();
|
|
80
|
+
},
|
|
81
|
+
tick: update,
|
|
82
|
+
subscribe(listener) {
|
|
83
|
+
listeners.add(listener);
|
|
84
|
+
return () => listeners.delete(listener);
|
|
85
|
+
},
|
|
86
|
+
getSnapshot: () => snapshot,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A drift-free clock with pause and resume, read from `performance.now()`
|
|
91
|
+
* each animation frame. It re-renders once per frame only while counting.
|
|
92
|
+
* A hidden tab draws no frames, but its time still counts: the first frame
|
|
93
|
+
* back catches up (and reaches `done` if it's due).
|
|
94
|
+
*/
|
|
95
|
+
export function useSessionClock({ running = true, endsAt } = {}) {
|
|
96
|
+
const [clock] = useState(() => createSessionClock(() => performance.now()));
|
|
97
|
+
const state = useSyncExternalStore(clock.subscribe, clock.getSnapshot, clock.getSnapshot);
|
|
98
|
+
useEffect(() => clock.setRunning(running), [clock, running]);
|
|
99
|
+
useEffect(() => clock.setEndsAt(endsAt), [clock, endsAt]);
|
|
100
|
+
const counting = state.status === 'running' && !state.paused && running;
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!counting)
|
|
103
|
+
return;
|
|
104
|
+
let frame = requestAnimationFrame(function next() {
|
|
105
|
+
clock.tick();
|
|
106
|
+
frame = requestAnimationFrame(next);
|
|
107
|
+
});
|
|
108
|
+
return () => cancelAnimationFrame(frame);
|
|
109
|
+
}, [clock, counting]);
|
|
110
|
+
return { ...state, start: clock.start, pause: clock.pause, resume: clock.resume, stop: clock.stop };
|
|
111
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** What createWakeLock needs from the browser; `navigator.wakeLock` and `document` by default. */
|
|
2
|
+
export type WakeLockEnvironment = {
|
|
3
|
+
wakeLock: Pick<WakeLock, 'request'> | undefined;
|
|
4
|
+
document: Pick<Document, 'visibilityState' | 'addEventListener' | 'removeEventListener'>;
|
|
5
|
+
};
|
|
6
|
+
export type WakeLockController = {
|
|
7
|
+
/** Hold the lock from now on, asking again whenever the tab comes back. */
|
|
8
|
+
activate: () => void;
|
|
9
|
+
/** Let it go, including a request still waiting for the browser's answer. */
|
|
10
|
+
deactivate: () => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function createWakeLock(env: WakeLockEnvironment): WakeLockController;
|
|
13
|
+
/**
|
|
14
|
+
* Keep the screen awake while `active`. Asked for again when the tab
|
|
15
|
+
* returns, released on `active` going false or unmount. Does nothing where
|
|
16
|
+
* the browser has no wake lock, or refuses it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useWakeLock(active: boolean): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
export function createWakeLock(env) {
|
|
3
|
+
let active = false;
|
|
4
|
+
let sentinel = null;
|
|
5
|
+
// One request at a time: a second ask while the browser decides waits for
|
|
6
|
+
// that answer rather than ending up holding two locks.
|
|
7
|
+
let requesting = false;
|
|
8
|
+
const request = () => {
|
|
9
|
+
if (!active || !env.wakeLock || sentinel || requesting)
|
|
10
|
+
return;
|
|
11
|
+
if (env.document.visibilityState !== 'visible')
|
|
12
|
+
return;
|
|
13
|
+
requesting = true;
|
|
14
|
+
env.wakeLock.request('screen').then((lock) => {
|
|
15
|
+
requesting = false;
|
|
16
|
+
// Deactivated while the browser decided: let it go at once.
|
|
17
|
+
if (!active) {
|
|
18
|
+
void lock.release().catch(() => { });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
sentinel = lock;
|
|
22
|
+
lock.addEventListener('release', () => {
|
|
23
|
+
if (sentinel === lock)
|
|
24
|
+
sentinel = null;
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
// Refused (no permission, low battery, an iframe without the policy):
|
|
28
|
+
// the screen may sleep, nothing else changes.
|
|
29
|
+
() => {
|
|
30
|
+
requesting = false;
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
const onVisibility = () => {
|
|
34
|
+
if (env.document.visibilityState === 'visible')
|
|
35
|
+
request();
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
activate() {
|
|
39
|
+
if (active)
|
|
40
|
+
return;
|
|
41
|
+
active = true;
|
|
42
|
+
env.document.addEventListener('visibilitychange', onVisibility);
|
|
43
|
+
request();
|
|
44
|
+
},
|
|
45
|
+
deactivate() {
|
|
46
|
+
if (!active)
|
|
47
|
+
return;
|
|
48
|
+
active = false;
|
|
49
|
+
env.document.removeEventListener('visibilitychange', onVisibility);
|
|
50
|
+
const lock = sentinel;
|
|
51
|
+
sentinel = null;
|
|
52
|
+
if (lock)
|
|
53
|
+
void lock.release().catch(() => { });
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Keep the screen awake while `active`. Asked for again when the tab
|
|
59
|
+
* returns, released on `active` going false or unmount. Does nothing where
|
|
60
|
+
* the browser has no wake lock, or refuses it.
|
|
61
|
+
*/
|
|
62
|
+
export function useWakeLock(active) {
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!active || typeof navigator === 'undefined')
|
|
65
|
+
return;
|
|
66
|
+
const controller = createWakeLock({ wakeLock: navigator.wakeLock, document });
|
|
67
|
+
controller.activate();
|
|
68
|
+
return () => controller.deactivate();
|
|
69
|
+
}, [active]);
|
|
70
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-
|
|
|
21
21
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
22
22
|
export { PillFilter, type PillFilterOption, type PillFilterProps } from './components/app/pill-filter.js';
|
|
23
23
|
export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
|
|
24
|
+
export { SessionView, type SessionViewProps } from './components/app/session-view.js';
|
|
25
|
+
export { AllowReminders, type AllowRemindersProps } from './components/app/allow-reminders.js';
|
|
24
26
|
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
25
27
|
export { Notice, type NoticeProps, type NoticeTone } from './components/app/notice.js';
|
|
26
28
|
export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
|
|
@@ -45,4 +47,10 @@ export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
|
45
47
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
46
48
|
export { useRecordUpdate, type RecordChange, type RecordUpdateOptions, } from './hooks/use-record-update.js';
|
|
47
49
|
export { useIsMobile } from './hooks/use-mobile.js';
|
|
50
|
+
export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
51
|
+
export { useSessionClock, type SessionClock, type SessionClockOptions, type SessionClockState, type SessionStatus, } from './hooks/use-session-clock.js';
|
|
52
|
+
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
53
|
+
export { useMuted } from './hooks/use-muted.js';
|
|
54
|
+
export { createPushController, usePushPermission, type PushController, type PushControllerOptions, type PushEnvironment, type PushPermission, type PushStatus, } from './hooks/use-push-permission.js';
|
|
48
55
|
export { cn } from './lib/utils.js';
|
|
56
|
+
export { chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,8 @@ export { RichTextEditor } from './components/app/rich-text-editor.js';
|
|
|
26
26
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
27
27
|
export { PillFilter } from './components/app/pill-filter.js';
|
|
28
28
|
export { SecondaryFilters } from './components/app/secondary-filters.js';
|
|
29
|
+
export { SessionView } from './components/app/session-view.js';
|
|
30
|
+
export { AllowReminders } from './components/app/allow-reminders.js';
|
|
29
31
|
export { StageCircle, } from './components/app/stage-circle.js';
|
|
30
32
|
export { Notice } from './components/app/notice.js';
|
|
31
33
|
export { SourceCredit } from './components/app/source-credit.js';
|
|
@@ -49,5 +51,12 @@ export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
|
49
51
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
50
52
|
export { useRecordUpdate, } from './hooks/use-record-update.js';
|
|
51
53
|
export { useIsMobile } from './hooks/use-mobile.js';
|
|
54
|
+
// Timed screens (docs/foundation/timed-screens.md)
|
|
55
|
+
export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
56
|
+
export { useSessionClock, } from './hooks/use-session-clock.js';
|
|
57
|
+
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
58
|
+
export { useMuted } from './hooks/use-muted.js';
|
|
59
|
+
export { createPushController, usePushPermission, } from './hooks/use-push-permission.js';
|
|
52
60
|
// Utilities
|
|
53
61
|
export { cn } from './lib/utils.js';
|
|
62
|
+
export { chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type ChimeOptions = {
|
|
2
|
+
/** Frequency in Hz (default 660, an E5). */
|
|
3
|
+
pitch?: number;
|
|
4
|
+
/** 0 to 1 at the chime's peak (default 0.25). */
|
|
5
|
+
volume?: number;
|
|
6
|
+
/** Milliseconds from strike to silence (default 1200). */
|
|
7
|
+
duration?: number;
|
|
8
|
+
};
|
|
9
|
+
export type SequenceOptions = {
|
|
10
|
+
/** Milliseconds between one chime's strike and the next's (default 180). */
|
|
11
|
+
spacing?: number;
|
|
12
|
+
};
|
|
13
|
+
export type SayOptions = {
|
|
14
|
+
/** A BCP 47 tag; the browser's default voice when left out. */
|
|
15
|
+
lang?: string;
|
|
16
|
+
/** 0.1 to 10 (default 1). */
|
|
17
|
+
rate?: number;
|
|
18
|
+
/** 0 to 1 (default 1). */
|
|
19
|
+
volume?: number;
|
|
20
|
+
};
|
|
21
|
+
export type AudioCuesEnvironment = {
|
|
22
|
+
AudioContext?: new () => AudioContext;
|
|
23
|
+
speechSynthesis?: Pick<SpeechSynthesis, 'speak' | 'cancel'>;
|
|
24
|
+
SpeechSynthesisUtterance?: new (text?: string) => SpeechSynthesisUtterance;
|
|
25
|
+
vibrate?: (pattern: number | number[]) => boolean;
|
|
26
|
+
};
|
|
27
|
+
export type AudioCues = {
|
|
28
|
+
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
29
|
+
unlockAudio: () => void;
|
|
30
|
+
chime: (options?: ChimeOptions) => void;
|
|
31
|
+
/** Several chimes in a row (a closing pair), `spacing` apart. */
|
|
32
|
+
sequence: (chimes: ChimeOptions[], options?: SequenceOptions) => void;
|
|
33
|
+
/** Speaks `text`, cutting off any line still being spoken. */
|
|
34
|
+
say: (text: string, options?: SayOptions) => void;
|
|
35
|
+
/** Stops whatever is being spoken. */
|
|
36
|
+
hush: () => void;
|
|
37
|
+
/** A vibration pattern in milliseconds; Android only, a no-op elsewhere. */
|
|
38
|
+
vibrate: (pattern: number | number[]) => void;
|
|
39
|
+
};
|
|
40
|
+
export declare function createAudioCues(env: AudioCuesEnvironment): AudioCues;
|
|
41
|
+
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
42
|
+
export declare const unlockAudio: AudioCues['unlockAudio'];
|
|
43
|
+
/** A soft sine chime; dropped before unlockAudio() or where there's no Web Audio. */
|
|
44
|
+
export declare const chime: AudioCues['chime'];
|
|
45
|
+
/** Several chimes `spacing` ms apart (a closing pair). */
|
|
46
|
+
export declare const sequence: AudioCues['sequence'];
|
|
47
|
+
/** Speaks a line, cutting off the one before. A no-op without speech synthesis. */
|
|
48
|
+
export declare const say: AudioCues['say'];
|
|
49
|
+
/** Stops whatever is being spoken. */
|
|
50
|
+
export declare const hush: AudioCues['hush'];
|
|
51
|
+
/** A vibration pattern in ms; Android only, a no-op elsewhere. */
|
|
52
|
+
export declare const vibrate: AudioCues['vibrate'];
|