@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.
Files changed (37) hide show
  1. package/conventions/AGENTS.md +3 -1
  2. package/conventions/docs/add-an-entity.md +29 -0
  3. package/conventions/docs/cell-patterns.md +5 -1
  4. package/conventions/docs/design-language.md +44 -0
  5. package/conventions/docs/timed-screens.md +114 -8
  6. package/dist/components/app/app-shell.d.ts +10 -1
  7. package/dist/components/app/app-shell.js +2 -2
  8. package/dist/components/app/foundation-providers.js +5 -1
  9. package/dist/components/app/icon-component.d.ts +12 -0
  10. package/dist/components/app/icon-component.js +1 -0
  11. package/dist/components/app/image-field.d.ts +37 -0
  12. package/dist/components/app/image-field.js +103 -0
  13. package/dist/components/app/markdown.js +1 -1
  14. package/dist/components/app/milkdown-editor.d.ts +1 -1
  15. package/dist/components/app/milkdown-editor.js +84 -5
  16. package/dist/components/app/rich-text-editor.d.ts +16 -0
  17. package/dist/components/app/rich-text-editor.js +5 -0
  18. package/dist/components/app/rich-text-image-upload.d.ts +42 -0
  19. package/dist/components/app/rich-text-image-upload.js +174 -0
  20. package/dist/components/app/rich-text-toolbar.d.ts +6 -2
  21. package/dist/components/app/rich-text-toolbar.js +36 -28
  22. package/dist/components/app/widget-card.d.ts +7 -3
  23. package/dist/error-reporting/error-reporting-provider.js +7 -1
  24. package/dist/error-reporting/reporter.d.ts +20 -1
  25. package/dist/error-reporting/reporter.js +60 -6
  26. package/dist/hooks/use-session-clock.d.ts +19 -4
  27. package/dist/hooks/use-session-clock.js +35 -6
  28. package/dist/index.d.ts +4 -1
  29. package/dist/index.js +3 -1
  30. package/dist/lib/audio-cues.d.ts +28 -6
  31. package/dist/lib/audio-cues.js +57 -8
  32. package/dist/lib/image-file.d.ts +37 -0
  33. package/dist/lib/image-file.js +82 -0
  34. package/dist/lib/markdown-merge.js +1 -1
  35. package/dist/lib/stale-chunk-reload.d.ts +14 -0
  36. package/dist/lib/stale-chunk-reload.js +64 -0
  37. 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
- export function createErrorReporter({ options, send, now = Date.now, }) {
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
- lastSent.set(signature, at);
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
- start: () => void;
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, 'start' | 'pause' | 'resume' | 'stop'>;
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: the first frame
40
- * back catches up (and reaches `done` if it's due).
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 = 0;
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: the first frame
93
- * back catches up (and reaches `done` if it's due).
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
- return () => cancelAnimationFrame(frame);
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
- return { ...state, start: clock.start, pause: clock.pause, resume: clock.resume, stop: clock.stop };
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';
@@ -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 SequenceOptions = {
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) => void;
31
- /** Several chimes in a row (a closing pair), `spacing` apart. */
32
- sequence: (chimes: ChimeOptions[], options?: SequenceOptions) => void;
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
- /** A soft sine chime; dropped before unlockAudio() or where there's no Web Audio. */
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. */
@@ -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, 0);
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
- chimes.forEach((options, index) => strike(ctx, options, index * spacing));
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
- /** A soft sine chime; dropped before unlockAudio() or where there's no Web Audio. */
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 (`![](url)` 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 (it doesn't show images) is kept as written, and the guard refuses.
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;