@tristan2828/ui-foundation 4.27.0 → 4.29.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/conventions/AGENTS.md +3 -1
- package/conventions/docs/add-an-entity.md +29 -0
- package/conventions/docs/cell-patterns.md +5 -1
- package/conventions/docs/design-language.md +44 -0
- package/conventions/docs/timed-screens.md +114 -8
- package/dist/components/app/app-shell.d.ts +10 -1
- package/dist/components/app/app-shell.js +2 -2
- package/dist/components/app/foundation-providers.js +5 -1
- package/dist/components/app/icon-component.d.ts +12 -0
- package/dist/components/app/icon-component.js +1 -0
- package/dist/components/app/image-field.d.ts +37 -0
- package/dist/components/app/image-field.js +103 -0
- package/dist/components/app/markdown.js +1 -1
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +84 -5
- package/dist/components/app/rich-text-editor.d.ts +16 -0
- package/dist/components/app/rich-text-editor.js +5 -0
- package/dist/components/app/rich-text-image-upload.d.ts +42 -0
- package/dist/components/app/rich-text-image-upload.js +174 -0
- package/dist/components/app/rich-text-toolbar.d.ts +6 -2
- package/dist/components/app/rich-text-toolbar.js +36 -28
- package/dist/components/app/widget-card.d.ts +7 -3
- package/dist/error-reporting/error-reporting-provider.js +7 -1
- package/dist/error-reporting/reporter.d.ts +20 -1
- package/dist/error-reporting/reporter.js +60 -6
- package/dist/hooks/use-session-clock.d.ts +19 -4
- package/dist/hooks/use-session-clock.js +35 -6
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/lib/audio-cues.d.ts +28 -6
- package/dist/lib/audio-cues.js +57 -8
- package/dist/lib/image-file.d.ts +37 -0
- package/dist/lib/image-file.js +82 -0
- package/dist/lib/markdown-merge.js +1 -1
- package/dist/lib/stale-chunk-reload.d.ts +14 -0
- package/dist/lib/stale-chunk-reload.js +64 -0
- package/package.json +1 -1
|
@@ -3,6 +3,11 @@ import type { ClientErrorReport } from "../api/gateway/client-errors.js";
|
|
|
3
3
|
export type ErrorReportingOptions = {
|
|
4
4
|
/** The frontend build a report names, e.g. the commit (`import.meta.env.VITE_RELEASE`). */
|
|
5
5
|
release?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Return true to drop a report: a known noise pattern the package doesn't
|
|
8
|
+
* filter yet. Sees every report, from every source, before it's sent.
|
|
9
|
+
*/
|
|
10
|
+
ignore?: (report: ClientErrorReport) => boolean;
|
|
6
11
|
};
|
|
7
12
|
export type ReportSource = ClientErrorReport["source"];
|
|
8
13
|
/** FoundationProviders' prop, as options, or null when reporting is off. */
|
|
@@ -10,6 +15,16 @@ export declare function resolveErrorReporting(option: boolean | ErrorReportingOp
|
|
|
10
15
|
export declare function scrub(text: string, limit: number): string;
|
|
11
16
|
/** `/widgets/7/edit` with `{ id: '7' }` → `/widgets/:id/edit`; a splat → `*`. */
|
|
12
17
|
export declare function routePattern(pathname: string, params: Readonly<Record<string, string | undefined>>): string;
|
|
18
|
+
/**
|
|
19
|
+
* Whether an uncaught error can't be the app's (#205): `Script error.` with
|
|
20
|
+
* nothing else, or every frame of its stack (else the event's file) outside
|
|
21
|
+
* the app's scripts. An error with no location at all is kept: a thrown
|
|
22
|
+
* string may well be the app's.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fromOutsideApp(error: unknown, event: {
|
|
25
|
+
message?: string;
|
|
26
|
+
filename?: string;
|
|
27
|
+
}, page: string): boolean;
|
|
13
28
|
export type ErrorReporter = {
|
|
14
29
|
/** Reports `error` from `source`, unless it's a duplicate or signed out. */
|
|
15
30
|
report: (source: ReportSource, error: unknown, request?: {
|
|
@@ -23,8 +38,12 @@ export type ErrorReporter = {
|
|
|
23
38
|
/** Listens on the window and the query client; returns the undo. */
|
|
24
39
|
install: (queryClient: QueryClient, target: Pick<EventTarget, "addEventListener" | "removeEventListener">) => () => void;
|
|
25
40
|
};
|
|
26
|
-
export declare function createErrorReporter({ options, send, now, }: {
|
|
41
|
+
export declare function createErrorReporter({ options, send, now, page, muted, }: {
|
|
27
42
|
options: ErrorReportingOptions;
|
|
28
43
|
send: (report: ClientErrorReport) => void;
|
|
29
44
|
now?: () => number;
|
|
45
|
+
/** The page's address, which an injected script's frames carry. */
|
|
46
|
+
page?: () => string;
|
|
47
|
+
/** True while nothing should be reported: the page reloading onto a new build. */
|
|
48
|
+
muted?: () => boolean;
|
|
30
49
|
}): ErrorReporter;
|
|
@@ -51,12 +51,59 @@ function describe(error) {
|
|
|
51
51
|
return { type: "ServerError", message: error.message };
|
|
52
52
|
return { type: "Error", message: typeof error === "string" ? error : "A value that isn't an Error was thrown." };
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
const EXTENSION_SCHEMES = /^(chrome-extension|moz-extension|safari-extension|safari-web-extension|ms-browser-extension):/i;
|
|
55
|
+
const SCRIPT_FILE = /\.(?:[cm]?js|jsx|[cm]?ts|tsx)$/i;
|
|
56
|
+
// A stack frame's location at the end of its line: `at f (url:1:2)`,
|
|
57
|
+
// `at url:1:2`, `f@url:1:2`, `global code@url:1:2`.
|
|
58
|
+
const FRAME_URL = /([a-z][a-z0-9+.-]*:\/\/[^\s()]+?):\d+(?::\d+)?\)?\s*$/i;
|
|
59
|
+
function stackUrls(stack) {
|
|
60
|
+
if (!stack)
|
|
61
|
+
return [];
|
|
62
|
+
return stack.split("\n").flatMap((line) => {
|
|
63
|
+
const match = FRAME_URL.exec(line.trim());
|
|
64
|
+
return match ? [match[1]] : [];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether `url` is somewhere the app's own code can't be: an extension, or
|
|
69
|
+
* an address on the page's origin that isn't a script file (a page, as
|
|
70
|
+
* `/timers/2`, where only an injected or inline script runs).
|
|
71
|
+
*/
|
|
72
|
+
function outsideAppScripts(url, page) {
|
|
73
|
+
if (EXTENSION_SCHEMES.test(url))
|
|
74
|
+
return true;
|
|
75
|
+
let parsed;
|
|
76
|
+
let origin;
|
|
77
|
+
try {
|
|
78
|
+
parsed = new URL(url);
|
|
79
|
+
origin = new URL(page).origin;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return parsed.origin === origin && !SCRIPT_FILE.test(parsed.pathname);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether an uncaught error can't be the app's (#205): `Script error.` with
|
|
88
|
+
* nothing else, or every frame of its stack (else the event's file) outside
|
|
89
|
+
* the app's scripts. An error with no location at all is kept: a thrown
|
|
90
|
+
* string may well be the app's.
|
|
91
|
+
*/
|
|
92
|
+
export function fromOutsideApp(error, event, page) {
|
|
93
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
94
|
+
if (!stack && error == null && /^Script error\.?$/.test(event.message ?? ""))
|
|
95
|
+
return true;
|
|
96
|
+
const urls = stackUrls(stack);
|
|
97
|
+
if (urls.length === 0 && event.filename)
|
|
98
|
+
urls.push(event.filename);
|
|
99
|
+
return urls.length > 0 && urls.every((url) => outsideAppScripts(url, page));
|
|
100
|
+
}
|
|
101
|
+
export function createErrorReporter({ options, send, now = Date.now, page = () => globalThis.location?.href ?? "", muted = () => false, }) {
|
|
55
102
|
const lastSent = new Map();
|
|
56
103
|
let route = null;
|
|
57
104
|
let signedIn = false;
|
|
58
105
|
const report = (source, error, request = {}) => {
|
|
59
|
-
if (!signedIn)
|
|
106
|
+
if (!signedIn || muted())
|
|
60
107
|
return;
|
|
61
108
|
const described = describe(error);
|
|
62
109
|
const message = scrub(described.message, MAX_MESSAGE);
|
|
@@ -67,8 +114,7 @@ export function createErrorReporter({ options, send, now = Date.now, }) {
|
|
|
67
114
|
lastSent.delete(key);
|
|
68
115
|
if (lastSent.has(signature))
|
|
69
116
|
return;
|
|
70
|
-
|
|
71
|
-
send({
|
|
117
|
+
const sent = {
|
|
72
118
|
source,
|
|
73
119
|
type: described.type.slice(0, 200),
|
|
74
120
|
message,
|
|
@@ -77,7 +123,11 @@ export function createErrorReporter({ options, send, now = Date.now, }) {
|
|
|
77
123
|
...(request.method ? { method: request.method } : {}),
|
|
78
124
|
...(request.status ? { status: request.status } : {}),
|
|
79
125
|
...(options.release ? { release: options.release.slice(0, 100) } : {}),
|
|
80
|
-
}
|
|
126
|
+
};
|
|
127
|
+
if (options.ignore?.(sent))
|
|
128
|
+
return;
|
|
129
|
+
lastSent.set(signature, at);
|
|
130
|
+
send(sent);
|
|
81
131
|
};
|
|
82
132
|
const reportAppError = (source, error, method) => {
|
|
83
133
|
if (!isAppError(error) || !REPORTED_KINDS.has(error.kind))
|
|
@@ -94,7 +144,9 @@ export function createErrorReporter({ options, send, now = Date.now, }) {
|
|
|
94
144
|
},
|
|
95
145
|
install: (queryClient, target) => {
|
|
96
146
|
const onError = (event) => {
|
|
97
|
-
const { error, message } = event;
|
|
147
|
+
const { error, message, filename } = event;
|
|
148
|
+
if (fromOutsideApp(error, { message, filename }, page()))
|
|
149
|
+
return;
|
|
98
150
|
report("window", error ?? message);
|
|
99
151
|
};
|
|
100
152
|
const onRejection = (event) => {
|
|
@@ -103,6 +155,8 @@ export function createErrorReporter({ options, send, now = Date.now, }) {
|
|
|
103
155
|
// ones that are bugs, and the rest are expected.
|
|
104
156
|
if (isAppError(reason))
|
|
105
157
|
return;
|
|
158
|
+
if (fromOutsideApp(reason, {}, page()))
|
|
159
|
+
return;
|
|
106
160
|
report("unhandledrejection", reason);
|
|
107
161
|
};
|
|
108
162
|
target.addEventListener("error", onError);
|
|
@@ -8,7 +8,14 @@ export type SessionClockState = {
|
|
|
8
8
|
paused: boolean;
|
|
9
9
|
};
|
|
10
10
|
export type SessionClockStore = {
|
|
11
|
-
|
|
11
|
+
/** Starts counting from `from` milliseconds (default 0), from any state. */
|
|
12
|
+
start: (from?: number) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Moves a running clock to `elapsed` milliseconds, paused or not, without
|
|
15
|
+
* changing either; at or past `endsAt` it's `done`. Ignored while idle or
|
|
16
|
+
* done: `start(from)` begins again from a time.
|
|
17
|
+
*/
|
|
18
|
+
seek: (elapsed: number) => void;
|
|
12
19
|
pause: () => void;
|
|
13
20
|
resume: () => void;
|
|
14
21
|
/** Back to `idle` at zero. */
|
|
@@ -32,11 +39,19 @@ export type SessionClockOptions = {
|
|
|
32
39
|
/** The elapsed milliseconds at which the session is `done`. Without it, it runs until stopped. */
|
|
33
40
|
endsAt?: number;
|
|
34
41
|
};
|
|
35
|
-
export type SessionClock = SessionClockState & Pick<SessionClockStore, '
|
|
42
|
+
export type SessionClock = SessionClockState & Pick<SessionClockStore, 'seek' | 'pause' | 'resume' | 'stop'> & {
|
|
43
|
+
/** Starts counting from zero, from any state (a click handler as is). */
|
|
44
|
+
start: () => void;
|
|
45
|
+
/** Starts counting from `elapsed` milliseconds: a run resumed from storage. */
|
|
46
|
+
startFrom: (elapsed: number) => void;
|
|
47
|
+
};
|
|
36
48
|
/**
|
|
37
49
|
* A drift-free clock with pause and resume, read from `performance.now()`
|
|
38
50
|
* each animation frame. It re-renders once per frame only while counting.
|
|
39
|
-
* A hidden tab draws no frames, but its time still counts:
|
|
40
|
-
*
|
|
51
|
+
* A hidden tab draws no frames, but its time still counts: while hidden it
|
|
52
|
+
* re-renders on a slow tick (once a second at most, for a countdown in the
|
|
53
|
+
* tab's title), and coming back catches up exactly (and reaches `done` if
|
|
54
|
+
* it's due). `startFrom(ms)` and `seek(ms)` begin from, and move to, a
|
|
55
|
+
* given elapsed time.
|
|
41
56
|
*/
|
|
42
57
|
export declare function useSessionClock({ running, endsAt }?: SessionClockOptions): SessionClock;
|
|
@@ -21,6 +21,7 @@ export function createSessionClock(now) {
|
|
|
21
21
|
if (counting() && since === null)
|
|
22
22
|
since = now();
|
|
23
23
|
};
|
|
24
|
+
const clamp = (ms) => Math.min(Math.max(Number.isFinite(ms) ? ms : 0, 0), endsAt ?? Infinity);
|
|
24
25
|
const update = () => {
|
|
25
26
|
let elapsed = banked + (since === null ? 0 : now() - since);
|
|
26
27
|
if (status === 'running' && endsAt !== undefined && elapsed >= endsAt) {
|
|
@@ -37,10 +38,18 @@ export function createSessionClock(now) {
|
|
|
37
38
|
listener();
|
|
38
39
|
};
|
|
39
40
|
return {
|
|
40
|
-
start() {
|
|
41
|
+
start(from = 0) {
|
|
41
42
|
status = 'running';
|
|
42
43
|
paused = false;
|
|
43
|
-
banked =
|
|
44
|
+
banked = clamp(from);
|
|
45
|
+
since = null;
|
|
46
|
+
begin();
|
|
47
|
+
update();
|
|
48
|
+
},
|
|
49
|
+
seek(elapsed) {
|
|
50
|
+
if (status !== 'running')
|
|
51
|
+
return;
|
|
52
|
+
banked = clamp(elapsed);
|
|
44
53
|
since = null;
|
|
45
54
|
begin();
|
|
46
55
|
update();
|
|
@@ -86,11 +95,17 @@ export function createSessionClock(now) {
|
|
|
86
95
|
getSnapshot: () => snapshot,
|
|
87
96
|
};
|
|
88
97
|
}
|
|
98
|
+
// How often a hidden tab reads the time. Browsers slow a background tab's
|
|
99
|
+
// timers (Chrome to once a minute after a while), so this is at most.
|
|
100
|
+
const HIDDEN_TICK_MS = 1000;
|
|
89
101
|
/**
|
|
90
102
|
* A drift-free clock with pause and resume, read from `performance.now()`
|
|
91
103
|
* each animation frame. It re-renders once per frame only while counting.
|
|
92
|
-
* A hidden tab draws no frames, but its time still counts:
|
|
93
|
-
*
|
|
104
|
+
* A hidden tab draws no frames, but its time still counts: while hidden it
|
|
105
|
+
* re-renders on a slow tick (once a second at most, for a countdown in the
|
|
106
|
+
* tab's title), and coming back catches up exactly (and reaches `done` if
|
|
107
|
+
* it's due). `startFrom(ms)` and `seek(ms)` begin from, and move to, a
|
|
108
|
+
* given elapsed time.
|
|
94
109
|
*/
|
|
95
110
|
export function useSessionClock({ running = true, endsAt } = {}) {
|
|
96
111
|
const [clock] = useState(() => createSessionClock(() => performance.now()));
|
|
@@ -105,7 +120,21 @@ export function useSessionClock({ running = true, endsAt } = {}) {
|
|
|
105
120
|
clock.tick();
|
|
106
121
|
frame = requestAnimationFrame(next);
|
|
107
122
|
});
|
|
108
|
-
|
|
123
|
+
let hiddenTick;
|
|
124
|
+
const onVisibility = () => {
|
|
125
|
+
clearInterval(hiddenTick);
|
|
126
|
+
hiddenTick = document.hidden ? setInterval(clock.tick, HIDDEN_TICK_MS) : undefined;
|
|
127
|
+
clock.tick();
|
|
128
|
+
};
|
|
129
|
+
if (document.hidden)
|
|
130
|
+
hiddenTick = setInterval(clock.tick, HIDDEN_TICK_MS);
|
|
131
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
132
|
+
return () => {
|
|
133
|
+
cancelAnimationFrame(frame);
|
|
134
|
+
clearInterval(hiddenTick);
|
|
135
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
136
|
+
};
|
|
109
137
|
}, [clock, counting]);
|
|
110
|
-
|
|
138
|
+
const [start] = useState(() => () => clock.start());
|
|
139
|
+
return { ...state, start, startFrom: clock.start, seek: clock.seek, pause: clock.pause, resume: clock.resume, stop: clock.stop };
|
|
111
140
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { AppShell, type AppShellProps, type AppShellVersion, type NavEntry, type NavItem, type NavSection } from './components/app/app-shell.js';
|
|
2
|
+
export type { IconComponent } from './components/app/icon-component.js';
|
|
2
3
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
3
4
|
export { DataTable, type DataTableProps, type DataTableWidth, type SortingState } from './components/app/data-table.js';
|
|
4
5
|
export { type RowGroup } from './components/app/table-groups.js';
|
|
@@ -31,6 +32,8 @@ export { ProgressRing, type ProgressRingProps } from './components/app/progress-
|
|
|
31
32
|
export { StepBar, type StepBarProps } from './components/app/step-bar.js';
|
|
32
33
|
export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
|
|
33
34
|
export { DateField, type DateFieldProps } from './components/app/date-field.js';
|
|
35
|
+
export { ImageField, type ImageFieldProps } from './components/app/image-field.js';
|
|
36
|
+
export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
|
|
34
37
|
export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
|
|
35
38
|
export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
|
|
36
39
|
export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
@@ -55,4 +58,4 @@ export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
|
55
58
|
export { useMuted } from './hooks/use-muted.js';
|
|
56
59
|
export { createPushController, usePushPermission, type PushController, type PushControllerOptions, type PushEnvironment, type PushPermission, type PushStatus, } from './hooks/use-push-permission.js';
|
|
57
60
|
export { cn } from './lib/utils.js';
|
|
58
|
-
export { canVibrate, chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
|
61
|
+
export { canVibrate, cancelCues, chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type Cue, type CueTime, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,8 @@ export { ProgressRing } from './components/app/progress-ring.js';
|
|
|
36
36
|
export { StepBar } from './components/app/step-bar.js';
|
|
37
37
|
export { RatingStar } from './components/app/rating-star.js';
|
|
38
38
|
export { DateField } from './components/app/date-field.js';
|
|
39
|
+
export { ImageField } from './components/app/image-field.js';
|
|
40
|
+
export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
|
|
39
41
|
export { formatPlainDate } from './lib/plain-date.js';
|
|
40
42
|
export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
|
|
41
43
|
export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
@@ -61,4 +63,4 @@ export { useMuted } from './hooks/use-muted.js';
|
|
|
61
63
|
export { createPushController, usePushPermission, } from './hooks/use-push-permission.js';
|
|
62
64
|
// Utilities
|
|
63
65
|
export { cn } from './lib/utils.js';
|
|
64
|
-
export { canVibrate, chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
|
|
66
|
+
export { canVibrate, cancelCues, chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
|
package/dist/lib/audio-cues.d.ts
CHANGED
|
@@ -6,10 +6,23 @@ export type ChimeOptions = {
|
|
|
6
6
|
/** Milliseconds from strike to silence (default 1200). */
|
|
7
7
|
duration?: number;
|
|
8
8
|
};
|
|
9
|
-
export type
|
|
9
|
+
export type CueTime = {
|
|
10
|
+
/**
|
|
11
|
+
* When to strike, in `Date.now()` milliseconds, booked on the audio
|
|
12
|
+
* clock so it's on time in a background tab. Left out, or already past,
|
|
13
|
+
* it strikes now.
|
|
14
|
+
*/
|
|
15
|
+
at?: number;
|
|
16
|
+
};
|
|
17
|
+
export type SequenceOptions = CueTime & {
|
|
10
18
|
/** Milliseconds between one chime's strike and the next's (default 180). */
|
|
11
19
|
spacing?: number;
|
|
12
20
|
};
|
|
21
|
+
/** A chime or sequence as booked. */
|
|
22
|
+
export type Cue = {
|
|
23
|
+
/** Takes back its strikes that haven't sounded yet; one already sounding rings out. */
|
|
24
|
+
cancel: () => void;
|
|
25
|
+
};
|
|
13
26
|
export type SayOptions = {
|
|
14
27
|
/** A BCP 47 tag; the browser's default voice when left out. */
|
|
15
28
|
lang?: string;
|
|
@@ -23,13 +36,17 @@ export type AudioCuesEnvironment = {
|
|
|
23
36
|
speechSynthesis?: Pick<SpeechSynthesis, 'speak' | 'cancel'>;
|
|
24
37
|
SpeechSynthesisUtterance?: new (text?: string) => SpeechSynthesisUtterance;
|
|
25
38
|
vibrate?: (pattern: number | number[]) => boolean;
|
|
39
|
+
/** The clock `at` is read on (default `Date.now`). */
|
|
40
|
+
now?: () => number;
|
|
26
41
|
};
|
|
27
42
|
export type AudioCues = {
|
|
28
43
|
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
29
44
|
unlockAudio: () => void;
|
|
30
|
-
chime: (options?: ChimeOptions) =>
|
|
31
|
-
/** Several chimes in a row (a closing pair), `spacing` apart
|
|
32
|
-
sequence: (chimes: ChimeOptions[], options?: SequenceOptions) =>
|
|
45
|
+
chime: (options?: ChimeOptions & CueTime) => Cue;
|
|
46
|
+
/** Several chimes in a row (a closing pair), `spacing` apart, from `at`. */
|
|
47
|
+
sequence: (chimes: ChimeOptions[], options?: SequenceOptions) => Cue;
|
|
48
|
+
/** Takes back every booked strike that hasn't sounded yet. */
|
|
49
|
+
cancelCues: () => void;
|
|
33
50
|
/** Speaks `text`, cutting off any line still being spoken. */
|
|
34
51
|
say: (text: string, options?: SayOptions) => void;
|
|
35
52
|
/** Stops whatever is being spoken. */
|
|
@@ -47,10 +64,15 @@ export declare function createAudioCues(env: AudioCuesEnvironment): AudioCues;
|
|
|
47
64
|
export declare function canVibrate(): boolean;
|
|
48
65
|
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
49
66
|
export declare const unlockAudio: AudioCues['unlockAudio'];
|
|
50
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* A soft sine chime, now or booked `at` a `Date.now()` time; dropped before
|
|
69
|
+
* unlockAudio() or where there's no Web Audio.
|
|
70
|
+
*/
|
|
51
71
|
export declare const chime: AudioCues['chime'];
|
|
52
|
-
/** Several chimes `spacing` ms apart (a closing pair)
|
|
72
|
+
/** Several chimes `spacing` ms apart (a closing pair), now or from `at`. */
|
|
53
73
|
export declare const sequence: AudioCues['sequence'];
|
|
74
|
+
/** Takes back every booked chime that hasn't sounded yet: on pause, skip, back or leaving. */
|
|
75
|
+
export declare const cancelCues: AudioCues['cancelCues'];
|
|
54
76
|
/** Speaks a line, cutting off the one before. A no-op without speech synthesis. */
|
|
55
77
|
export declare const say: AudioCues['say'];
|
|
56
78
|
/** Stops whatever is being spoken. */
|
package/dist/lib/audio-cues.js
CHANGED
|
@@ -8,20 +8,30 @@
|
|
|
8
8
|
// here is a no-op where the browser can't make the sound
|
|
9
9
|
// (docs/foundation/timed-screens.md "Sound").
|
|
10
10
|
//
|
|
11
|
+
// A chime can be booked ahead (`at`, #193): it's scheduled on the Web
|
|
12
|
+
// Audio clock, so it strikes on time in a background tab, whose timers
|
|
13
|
+
// and frames the browser slows or stops. cancelCues() takes back every
|
|
14
|
+
// booked strike that hasn't sounded yet (on pause, skip or leaving), so a
|
|
15
|
+
// screen rebooks from its new position.
|
|
16
|
+
//
|
|
11
17
|
// createAudioCues takes the browser's APIs, so the rules are unit tested
|
|
12
18
|
// against fakes (tests/audio-cues.test.ts); the functions exported below
|
|
13
19
|
// are one shared instance over the real ones.
|
|
14
20
|
const ATTACK = 0.015;
|
|
15
21
|
// An exponential ramp can't reach zero, so a chime starts and ends here.
|
|
16
22
|
const SILENT = 0.0001;
|
|
23
|
+
const NO_CUE = { cancel: () => { } };
|
|
17
24
|
export function createAudioCues(env) {
|
|
25
|
+
const now = env.now ?? Date.now;
|
|
18
26
|
let context = null;
|
|
27
|
+
// Strikes booked and not yet over, so a cancel can reach them.
|
|
28
|
+
const booked = new Set();
|
|
19
29
|
// A resume asked for from a click: the context is still "suspended" for
|
|
20
30
|
// a moment, and a chime from that same click should still play.
|
|
21
31
|
let resuming = false;
|
|
22
32
|
const playable = () => context !== null && (context.state === 'running' || resuming);
|
|
23
33
|
const strike = (ctx, { pitch = 660, volume = 0.25, duration = 1200 }, delay) => {
|
|
24
|
-
const start = ctx.currentTime + delay / 1000;
|
|
34
|
+
const start = ctx.currentTime + Math.max(delay, 0) / 1000;
|
|
25
35
|
const end = start + Math.max(duration, 50) / 1000;
|
|
26
36
|
const peak = Math.min(Math.max(volume, SILENT), 1);
|
|
27
37
|
const oscillator = ctx.createOscillator();
|
|
@@ -34,7 +44,33 @@ export function createAudioCues(env) {
|
|
|
34
44
|
oscillator.connect(gain).connect(ctx.destination);
|
|
35
45
|
oscillator.start(start);
|
|
36
46
|
oscillator.stop(end + 0.05);
|
|
47
|
+
const booking = { start, end, oscillator, gain };
|
|
48
|
+
for (const old of booked)
|
|
49
|
+
if (old.end < ctx.currentTime)
|
|
50
|
+
booked.delete(old);
|
|
51
|
+
booked.add(booking);
|
|
52
|
+
oscillator.onended = () => booked.delete(booking);
|
|
53
|
+
return booking;
|
|
54
|
+
};
|
|
55
|
+
// Silences a strike that hasn't sounded yet: one already sounding rings out.
|
|
56
|
+
const unbook = (ctx, booking) => {
|
|
57
|
+
if (!booked.has(booking))
|
|
58
|
+
return;
|
|
59
|
+
if (booking.start <= ctx.currentTime)
|
|
60
|
+
return;
|
|
61
|
+
booked.delete(booking);
|
|
62
|
+
try {
|
|
63
|
+
booking.gain.disconnect();
|
|
64
|
+
booking.oscillator.stop();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Already stopped.
|
|
68
|
+
}
|
|
37
69
|
};
|
|
70
|
+
const book = (ctx, strikes) => ({
|
|
71
|
+
cancel: () => strikes.forEach((booking) => unbook(ctx, booking)),
|
|
72
|
+
});
|
|
73
|
+
const delayUntil = (at) => (at === undefined ? 0 : at - now());
|
|
38
74
|
return {
|
|
39
75
|
unlockAudio() {
|
|
40
76
|
if (env.AudioContext) {
|
|
@@ -62,16 +98,24 @@ export function createAudioCues(env) {
|
|
|
62
98
|
}
|
|
63
99
|
}
|
|
64
100
|
},
|
|
65
|
-
chime(options = {}) {
|
|
101
|
+
chime({ at, ...options } = {}) {
|
|
66
102
|
if (!playable() || !context)
|
|
67
|
-
return;
|
|
68
|
-
strike(context, options,
|
|
103
|
+
return NO_CUE;
|
|
104
|
+
return book(context, [strike(context, options, delayUntil(at))]);
|
|
69
105
|
},
|
|
70
|
-
sequence(chimes, { spacing = 180 } = {}) {
|
|
106
|
+
sequence(chimes, { spacing = 180, at } = {}) {
|
|
71
107
|
if (!playable() || !context)
|
|
108
|
+
return NO_CUE;
|
|
109
|
+
const ctx = context;
|
|
110
|
+
const delay = Math.max(delayUntil(at), 0);
|
|
111
|
+
return book(ctx, chimes.map((options, index) => strike(ctx, options, delay + index * spacing)));
|
|
112
|
+
},
|
|
113
|
+
cancelCues() {
|
|
114
|
+
if (!context)
|
|
72
115
|
return;
|
|
73
116
|
const ctx = context;
|
|
74
|
-
|
|
117
|
+
for (const booking of [...booked])
|
|
118
|
+
unbook(ctx, booking);
|
|
75
119
|
},
|
|
76
120
|
say(text, { lang, rate, volume } = {}) {
|
|
77
121
|
if (!env.speechSynthesis || !env.SpeechSynthesisUtterance)
|
|
@@ -133,10 +177,15 @@ function browserCues() {
|
|
|
133
177
|
}
|
|
134
178
|
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
135
179
|
export const unlockAudio = () => browserCues().unlockAudio();
|
|
136
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* A soft sine chime, now or booked `at` a `Date.now()` time; dropped before
|
|
182
|
+
* unlockAudio() or where there's no Web Audio.
|
|
183
|
+
*/
|
|
137
184
|
export const chime = (options) => browserCues().chime(options);
|
|
138
|
-
/** Several chimes `spacing` ms apart (a closing pair)
|
|
185
|
+
/** Several chimes `spacing` ms apart (a closing pair), now or from `at`. */
|
|
139
186
|
export const sequence = (chimes, options) => browserCues().sequence(chimes, options);
|
|
187
|
+
/** Takes back every booked chime that hasn't sounded yet: on pause, skip, back or leaving. */
|
|
188
|
+
export const cancelCues = () => browserCues().cancelCues();
|
|
140
189
|
/** Speaks a line, cutting off the one before. A no-op without speech synthesis. */
|
|
141
190
|
export const say = (text, options) => browserCues().say(text, options);
|
|
142
191
|
/** Stops whatever is being spoken. */
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** What an app's upload resolves to: the stored file's id and its addresses. */
|
|
2
|
+
export type ImageUploadResult = {
|
|
3
|
+
/** The stored file's id, what the record keeps (ImageField's value). */
|
|
4
|
+
id: string;
|
|
5
|
+
/** A small version for a thumbnail (ImageField shows it). */
|
|
6
|
+
thumbUrl: string;
|
|
7
|
+
/**
|
|
8
|
+
* The full-size image, for text (`` in the rich-text editor).
|
|
9
|
+
* Without it the editor inserts `thumbUrl`.
|
|
10
|
+
*/
|
|
11
|
+
url?: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The app's upload: sends one file to its own storage and resolves to
|
|
15
|
+
* where it is, or rejects (an `AppError`, an `Error`: its `message` is
|
|
16
|
+
* shown).
|
|
17
|
+
*/
|
|
18
|
+
export type ImageUpload = (file: File) => Promise<ImageUploadResult>;
|
|
19
|
+
/** The image types accepted unless a field says otherwise. */
|
|
20
|
+
export declare const DEFAULT_IMAGE_TYPES: readonly string[];
|
|
21
|
+
export type ImageFileRules = {
|
|
22
|
+
/** MIME types, `image/jpeg`; `image/*` for any image. Defaults to `DEFAULT_IMAGE_TYPES`. */
|
|
23
|
+
accept?: readonly string[];
|
|
24
|
+
/** The largest file accepted, in bytes. No limit when unset. */
|
|
25
|
+
maxSize?: number;
|
|
26
|
+
};
|
|
27
|
+
/** A size as a person reads it: "820 KB", "4.2 MB". */
|
|
28
|
+
export declare function formatBytes(bytes: number): string;
|
|
29
|
+
/** The `accept` attribute for a file input: `image/jpeg,image/png,image/webp`. */
|
|
30
|
+
export declare function acceptAttribute(accept?: readonly string[]): string;
|
|
31
|
+
/**
|
|
32
|
+
* Why a file can't be uploaded, as a sentence to show under the field, or
|
|
33
|
+
* null when it can: a type not accepted, or over the size limit.
|
|
34
|
+
*/
|
|
35
|
+
export declare function imageRefusal(file: Pick<File, 'type' | 'size'>, rules?: ImageFileRules): string | null;
|
|
36
|
+
/** What a failed upload says: the rejection's own `message` (an `AppError`'s), else a generic sentence. */
|
|
37
|
+
export declare function uploadFailure(error: unknown, noun?: string): string;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Images a person picks, pastes or drops, before they're uploaded: which
|
|
2
|
+
// files are accepted, and why one isn't, in words. Shared by ImageField
|
|
3
|
+
// and the rich-text editor's upload. The upload itself is the app's (an
|
|
4
|
+
// `ImageUpload`): the package knows no endpoint and no storage.
|
|
5
|
+
/** The image types accepted unless a field says otherwise. */
|
|
6
|
+
export const DEFAULT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
|
7
|
+
// How a type is named to a person: "JPEG", not "image/jpeg".
|
|
8
|
+
const TYPE_NAMES = {
|
|
9
|
+
'image/jpeg': 'JPEG',
|
|
10
|
+
'image/png': 'PNG',
|
|
11
|
+
'image/webp': 'WebP',
|
|
12
|
+
'image/gif': 'GIF',
|
|
13
|
+
'image/avif': 'AVIF',
|
|
14
|
+
'image/heic': 'HEIC',
|
|
15
|
+
'image/heif': 'HEIF',
|
|
16
|
+
'image/svg+xml': 'SVG',
|
|
17
|
+
'image/bmp': 'BMP',
|
|
18
|
+
'image/tiff': 'TIFF',
|
|
19
|
+
'application/pdf': 'PDF',
|
|
20
|
+
};
|
|
21
|
+
function typeName(type) {
|
|
22
|
+
if (TYPE_NAMES[type])
|
|
23
|
+
return TYPE_NAMES[type];
|
|
24
|
+
if (type === 'image/*')
|
|
25
|
+
return 'image';
|
|
26
|
+
const subtype = type.split('/')[1] ?? type;
|
|
27
|
+
return subtype.replace(/^x-/, '').toUpperCase();
|
|
28
|
+
}
|
|
29
|
+
// "JPEG, PNG or WebP".
|
|
30
|
+
function listOr(names) {
|
|
31
|
+
return names.length <= 1 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} or ${names.at(-1)}`;
|
|
32
|
+
}
|
|
33
|
+
/** A size as a person reads it: "820 KB", "4.2 MB". */
|
|
34
|
+
export function formatBytes(bytes) {
|
|
35
|
+
if (bytes < 1024)
|
|
36
|
+
return `${bytes} bytes`;
|
|
37
|
+
if (bytes < 1024 * 1024)
|
|
38
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
39
|
+
const mb = bytes / (1024 * 1024);
|
|
40
|
+
return `${mb < 10 ? Math.round(mb * 10) / 10 : Math.round(mb)} MB`;
|
|
41
|
+
}
|
|
42
|
+
function accepts(accept, type) {
|
|
43
|
+
return accept.some((allowed) => allowed === type || (allowed.endsWith('/*') && type.startsWith(allowed.slice(0, -1))));
|
|
44
|
+
}
|
|
45
|
+
/** The `accept` attribute for a file input: `image/jpeg,image/png,image/webp`. */
|
|
46
|
+
export function acceptAttribute(accept = DEFAULT_IMAGE_TYPES) {
|
|
47
|
+
return accept.join(',');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Why a file can't be uploaded, as a sentence to show under the field, or
|
|
51
|
+
* null when it can: a type not accepted, or over the size limit.
|
|
52
|
+
*/
|
|
53
|
+
export function imageRefusal(file, rules = {}) {
|
|
54
|
+
const accept = rules.accept ?? DEFAULT_IMAGE_TYPES;
|
|
55
|
+
if (!accepts(accept, file.type)) {
|
|
56
|
+
const wanted = listOr(accept.map(typeName));
|
|
57
|
+
const choose = accept.length === 1 && accept[0] === 'image/*' ? 'Choose an image.' : `Choose a ${wanted} image.`;
|
|
58
|
+
// A type with a name people know is named; any other file that isn't
|
|
59
|
+
// an image is just that.
|
|
60
|
+
const named = TYPE_NAMES[file.type] ?? (file.type.startsWith('image/') ? typeName(file.type) : null);
|
|
61
|
+
return named ? `That file is ${article(named)} ${named}. ${choose}` : `That file isn't an image. ${choose}`;
|
|
62
|
+
}
|
|
63
|
+
if (rules.maxSize !== undefined && file.size > rules.maxSize) {
|
|
64
|
+
return `That image is ${formatBytes(file.size)}. The limit is ${formatBytes(rules.maxSize)}.`;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
// "a GIF", "an SVG", "an HEIC": by how the name is said, letter by letter
|
|
69
|
+
// when it's an acronym.
|
|
70
|
+
function article(name) {
|
|
71
|
+
const spelled = /^[A-Z][A-Z0-9]+$/.test(name);
|
|
72
|
+
return (spelled ? /^[AEFHILMNORSX]/ : /^[aeiou]/i).test(name) ? 'an' : 'a';
|
|
73
|
+
}
|
|
74
|
+
/** What a failed upload says: the rejection's own `message` (an `AppError`'s), else a generic sentence. */
|
|
75
|
+
export function uploadFailure(error, noun = 'image') {
|
|
76
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
77
|
+
const message = error.message;
|
|
78
|
+
if (typeof message === 'string' && message.trim())
|
|
79
|
+
return message;
|
|
80
|
+
}
|
|
81
|
+
return `The ${noun} couldn't be uploaded. Try again.`;
|
|
82
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// padded, marks it nested differently) and that `meaning` still misses is
|
|
22
22
|
// written as the editor shows it, as long as nothing in it is lost
|
|
23
23
|
// (`content`): no text, address or image. A block the editor drops part
|
|
24
|
-
// of (
|
|
24
|
+
// of (until 4.28, every image) is kept as written, and the guard refuses.
|
|
25
25
|
// - Guard: the result must mean exactly what the editor shows. If it
|
|
26
26
|
// wouldn't (two blocks fusing, say), the merge throws instead of saving
|
|
27
27
|
// something else; the draft stays on screen.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type StaleChunkReload = {
|
|
2
|
+
/** True from the reload asked for until the page goes: nothing that fails meanwhile is a bug. */
|
|
3
|
+
isReloading: () => boolean;
|
|
4
|
+
/** Listens for `vite:preloadError` on `target`; returns the undo. */
|
|
5
|
+
install: (target: Pick<EventTarget, 'addEventListener' | 'removeEventListener'>) => () => void;
|
|
6
|
+
};
|
|
7
|
+
export declare function createStaleChunkReload({ storage, reload, now, windowMs, }: {
|
|
8
|
+
storage: () => Pick<Storage, 'getItem' | 'setItem'> | undefined;
|
|
9
|
+
reload: () => void;
|
|
10
|
+
now?: () => number;
|
|
11
|
+
windowMs?: number;
|
|
12
|
+
}): StaleChunkReload;
|
|
13
|
+
/** The one instance over the real window, sessionStorage and reload. */
|
|
14
|
+
export declare function browserStaleChunkReload(): StaleChunkReload;
|