@tristan2828/ui-foundation 4.28.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/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/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 +2 -1
- package/dist/index.js +1 -1
- package/dist/lib/audio-cues.d.ts +28 -6
- package/dist/lib/audio-cues.js +57 -8
- package/dist/lib/stale-chunk-reload.d.ts +14 -0
- package/dist/lib/stale-chunk-reload.js +64 -0
- package/package.json +1 -1
package/conventions/AGENTS.md
CHANGED
|
@@ -47,7 +47,9 @@ is right. Do not disable, skip, or work around it.
|
|
|
47
47
|
- NEVER use a raw hex value or a Tailwind palette color (`bg-blue-500`).
|
|
48
48
|
Semantic tokens only: `bg-primary`, `text-muted-foreground`. (Enforced:
|
|
49
49
|
the foundation's ESLint token rule; axe contrast in light and dark mode,
|
|
50
|
-
`e2e/a11y.spec.ts`.)
|
|
50
|
+
`e2e/a11y.spec.ts`.) The one exception is an app's `--brand-<name>`
|
|
51
|
+
tokens, for logos and brand marks only (`docs/foundation/design-language.md`
|
|
52
|
+
"Logos and brand marks").
|
|
51
53
|
- NEVER fetch in `useEffect`. All server state goes through TanStack Query.
|
|
52
54
|
(Enforced: eslint-plugin-query + the no-bare-fetch rule.)
|
|
53
55
|
- NEVER read auth state outside `useAuth()`. The package exports nothing
|
|
@@ -101,6 +101,50 @@ doesn't (2.7:1). A deliberately faint icon that carries nothing on its
|
|
|
101
101
|
own (an "empty" star beside a rating's filled ones, pattern 9) may go
|
|
102
102
|
lower; say so in a comment.
|
|
103
103
|
|
|
104
|
+
## Logos and brand marks
|
|
105
|
+
|
|
106
|
+
An app's own logo (`AppShell`'s `logo`) and the marks of the services it
|
|
107
|
+
links to (a video site's mark on "Open on …", on a `WidgetCard` about it)
|
|
108
|
+
are **icon components** (`IconComponent`): sized by a `size-*` class,
|
|
109
|
+
`aria-hidden` passed through to the `<svg>`, with the same props as a
|
|
110
|
+
Lucide icon, so they go wherever an icon goes. Lucide has no brand logos,
|
|
111
|
+
and the package ships none: an app keeps the few it needs as its own
|
|
112
|
+
components (Simple Icons' paths are CC0), with no icon pack as a
|
|
113
|
+
dependency.
|
|
114
|
+
|
|
115
|
+
Colour them in one of two ways:
|
|
116
|
+
|
|
117
|
+
- **`currentColor`** for a one-colour mark that isn't a colour of its own
|
|
118
|
+
(a black or white logo): it takes the text colour around it, so it
|
|
119
|
+
turns over with the theme. The default.
|
|
120
|
+
- **A brand token** for a mark whose colour *is* the brand (a red play
|
|
121
|
+
button). Define it in the app's `src/index.css`, the same value in both
|
|
122
|
+
themes, since a logo keeps its colours, and register it so it's a
|
|
123
|
+
class:
|
|
124
|
+
|
|
125
|
+
```css
|
|
126
|
+
:root {
|
|
127
|
+
--brand-videosite: #ff0033;
|
|
128
|
+
--brand-videosite-foreground: #ffffff;
|
|
129
|
+
}
|
|
130
|
+
@theme inline {
|
|
131
|
+
--color-brand-videosite: var(--brand-videosite);
|
|
132
|
+
--color-brand-videosite-foreground: var(--brand-videosite-foreground);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then `fill-brand-videosite` on the mark's shape. This is the one place
|
|
137
|
+
a raw colour value is right, and it doesn't break "semantic tokens
|
|
138
|
+
only": the token's name says what it's for. **Brand tokens paint
|
|
139
|
+
marks only**: never text, a surface, a border, a focus ring or a tone.
|
|
140
|
+
A mark is decorative (whatever it sits beside names it), so it isn't
|
|
141
|
+
held to the 3:1 above, but check it against the page in both themes;
|
|
142
|
+
where it vanishes in one, give it a `currentColor` outline or use the
|
|
143
|
+
one-colour mark there.
|
|
144
|
+
|
|
145
|
+
The package's own stories show the slots with made-up marks in
|
|
146
|
+
`currentColor` (`app/AppShell` With logo, `app/WidgetCard` Brand mark).
|
|
147
|
+
|
|
104
148
|
## Categorical colour
|
|
105
149
|
|
|
106
150
|
Tones say good, bad or *notice this*. A value that is merely *different* from its
|
|
@@ -7,9 +7,9 @@ package has the parts, so a screen like this is assembled, not rebuilt:
|
|
|
7
7
|
| Part | What it does |
|
|
8
8
|
|---|---|
|
|
9
9
|
| `SessionView` | The whole viewport over the app shell: a modal named by its title, focus kept inside, Esc closes it. Optional real fullscreen and a held wake lock |
|
|
10
|
-
| `useSessionClock` | A clock that doesn't drift, with pause and
|
|
10
|
+
| `useSessionClock` | A clock that doesn't drift, with pause, resume and seek: `idle`, `running`, `done` |
|
|
11
11
|
| `useWakeLock` | Keeps the screen on while a session runs (`SessionView`'s `keepAwake` uses it) |
|
|
12
|
-
| `unlockAudio`, `chime`, `sequence`, `say`, `hush`, `vibrate` | Sound cues with no audio files |
|
|
12
|
+
| `unlockAudio`, `chime`, `sequence`, `cancelCues`, `say`, `hush`, `vibrate` | Sound cues with no audio files, booked ahead where they must be on time |
|
|
13
13
|
| `canVibrate` | Whether this browser can vibrate, to offer a Vibrate setting only where it works |
|
|
14
14
|
| `useMuted` | A mute toggle remembered on this device |
|
|
15
15
|
| `usePrefersReducedMotion` | Whether to swap movement for a fade |
|
|
@@ -58,7 +58,7 @@ const primary = useRef<HTMLButtonElement>(null)
|
|
|
58
58
|
## The clock
|
|
59
59
|
|
|
60
60
|
`useSessionClock({ running?, endsAt? })` returns
|
|
61
|
-
`{ elapsed, status, paused, start, pause, resume, stop }`.
|
|
61
|
+
`{ elapsed, status, paused, start, startFrom, seek, pause, resume, stop }`.
|
|
62
62
|
|
|
63
63
|
- `elapsed` is in milliseconds, read from `performance.now()` each
|
|
64
64
|
animation frame. It is never summed from ticks, so it doesn't drift.
|
|
@@ -73,8 +73,15 @@ const primary = useRef<HTMLButtonElement>(null)
|
|
|
73
73
|
front of it.
|
|
74
74
|
- It re-renders once per frame, only while counting. Format what it shows
|
|
75
75
|
from `elapsed`, never by counting renders.
|
|
76
|
-
- A hidden tab draws no frames, but its time still counts.
|
|
77
|
-
|
|
76
|
+
- A hidden tab draws no frames, but its time still counts. While hidden,
|
|
77
|
+
the clock reads the time on a slow tick instead (once a second at most),
|
|
78
|
+
enough for a countdown in the tab's title, and coming back catches up
|
|
79
|
+
exactly, reaching `done` if it's due. See "Background tabs" below.
|
|
80
|
+
- `startFrom(ms)` starts a run at a given elapsed time: one resumed from
|
|
81
|
+
storage after a reload. `seek(ms)` moves a running run there, paused or
|
|
82
|
+
not, for Skip and Back; at or past `endsAt` it's `done`. A seek while
|
|
83
|
+
idle or done does nothing: use `startFrom`. Pause and resume stay exact
|
|
84
|
+
after either. Don't keep a `Date.now()` clock of your own beside it.
|
|
78
85
|
|
|
79
86
|
For phases (stretch two minutes, rest thirty seconds), keep one clock for
|
|
80
87
|
the whole session and work out the phase from `elapsed`. A clock per
|
|
@@ -88,10 +95,13 @@ Browsers block sound until the person has clicked or tapped. So:
|
|
|
88
95
|
click handler itself, not in an effect after it. Every cue before that
|
|
89
96
|
is dropped rather than played late. iOS also needs speech started from
|
|
90
97
|
a click, and `unlockAudio()` does that too.
|
|
91
|
-
- `chime({ pitch?, volume?, duration? })`: a soft sine tone, a short
|
|
98
|
+
- `chime({ pitch?, volume?, duration?, at? })`: a soft sine tone, a short
|
|
92
99
|
attack and a slow fade. `pitch` is in Hz (660 by default). For a
|
|
93
100
|
closing pair, use `sequence([{ pitch: 660 }, { pitch: 880 }])`, 180 ms
|
|
94
|
-
apart by default.
|
|
101
|
+
apart by default. `at` (a `Date.now()` time; `sequence` takes it in its
|
|
102
|
+
options) books it ahead: see "Background tabs". Each returns a handle
|
|
103
|
+
whose `cancel()` takes back what hasn't sounded yet, and `cancelCues()`
|
|
104
|
+
takes back every booked chime.
|
|
95
105
|
- `say(text)` speaks a line ("Next: stretch, 2 minutes"), cutting off any
|
|
96
106
|
line still being spoken. `hush()` stops it. Call `hush()` when the
|
|
97
107
|
session stops or the view closes.
|
|
@@ -112,7 +122,42 @@ Browsers block sound until the person has clicked or tapped. So:
|
|
|
112
122
|
(`aria-pressed={muted}`) named "Mute".
|
|
113
123
|
- **Cue on a change of state** in an effect that compares the previous
|
|
114
124
|
state with the current one, so the cue plays once. Never play a cue
|
|
115
|
-
during render.
|
|
125
|
+
during render. A cue that must be on time while the tab is in the
|
|
126
|
+
background (a phase ending) is booked ahead instead, below.
|
|
127
|
+
|
|
128
|
+
## Background tabs
|
|
129
|
+
|
|
130
|
+
A timer is often left running in a tab behind another. The browser draws
|
|
131
|
+
no frames there and slows its timers (Chrome to about once a minute after
|
|
132
|
+
a few minutes), so anything driven by them runs late: a chime from an
|
|
133
|
+
effect on a phase change sounds when the person comes back, not when the
|
|
134
|
+
phase ends. Web Audio's own clock keeps time, so:
|
|
135
|
+
|
|
136
|
+
- **Book the cues ahead from the run's plan.** When counting starts
|
|
137
|
+
(start, resume, the view reopened, unmuted), work out when each coming
|
|
138
|
+
cue is due from the plan and the clock's position, and book it:
|
|
139
|
+
`sequence(closingPair, { at: Date.now() + endsAt - clock.elapsed })`,
|
|
140
|
+
a `chime({ at })` for each phase boundary. They strike on time whatever
|
|
141
|
+
the page's timers do.
|
|
142
|
+
- **Take them back whenever counting stops, and rebook when the run
|
|
143
|
+
moves:** `cancelCues()` on pause, on closing the view, on mute and on
|
|
144
|
+
leaving the screen (an effect's cleanup); on Skip and Back, `seek`,
|
|
145
|
+
then `cancelCues()` and book again from the new position. A chime
|
|
146
|
+
already sounding rings out. Reaching `done` takes nothing back: the
|
|
147
|
+
closing cue is sounding then.
|
|
148
|
+
- **Do it in the handlers**, where you know why counting stopped, not in
|
|
149
|
+
an effect on `paused`: an effect's cleanup can't tell pause from done.
|
|
150
|
+
The Timer story (`app/SessionView`) does exactly this.
|
|
151
|
+
- **Speech can't be booked.** `say()` still comes when the clock's slow
|
|
152
|
+
tick reaches the moment, so in a background tab it may be late. Keep
|
|
153
|
+
what must be on time to chimes.
|
|
154
|
+
- **The tab's title** (`Focus 18:42`) can count down from `elapsed`: the
|
|
155
|
+
clock's slow tick re-renders while hidden. Set it in an effect, and put
|
|
156
|
+
the plain title back when the run ends. A tick a browser has slowed to
|
|
157
|
+
once a minute shows a coarser countdown; the time itself is never off.
|
|
158
|
+
- Booked cues need `unlockAudio()` from a click, like any other. iOS
|
|
159
|
+
Safari may suspend audio in a background tab altogether; nothing in a
|
|
160
|
+
web page gets round that.
|
|
116
161
|
|
|
117
162
|
## Motion
|
|
118
163
|
|
|
@@ -162,6 +207,67 @@ test('running, paused and done', async ({ page }) => {
|
|
|
162
207
|
`page.addInitScript(() => localStorage.setItem('my-app:routine:muted', 'true'))`.
|
|
163
208
|
- **Reduced motion:** `await page.emulateMedia({ reducedMotion: 'reduce' })`.
|
|
164
209
|
|
|
210
|
+
**A background tab:** set the page's visibility yourself, stop its
|
|
211
|
+
animation frames, and record what's booked on a stand-in `AudioContext`:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
test.beforeEach(async ({ page }) => {
|
|
215
|
+
await page.clock.install()
|
|
216
|
+
await page.addInitScript(() => {
|
|
217
|
+
let visibility: DocumentVisibilityState = 'visible'
|
|
218
|
+
Object.defineProperty(document, 'visibilityState', { get: () => visibility })
|
|
219
|
+
Object.defineProperty(document, 'hidden', { get: () => visibility === 'hidden' })
|
|
220
|
+
const booked: { pitch: number; at: number; cancelled: boolean }[] = []
|
|
221
|
+
class RecordingAudioContext {
|
|
222
|
+
state = 'running'; currentTime = 0; destination = {}
|
|
223
|
+
resume = async () => {}
|
|
224
|
+
last = { pitch: 0, at: 0, cancelled: false }
|
|
225
|
+
createOscillator() {
|
|
226
|
+
const cue = (this.last = { pitch: 0, at: 0, cancelled: false })
|
|
227
|
+
return {
|
|
228
|
+
frequency: { setValueAtTime: (hz: number) => (cue.pitch = hz) },
|
|
229
|
+
connect: (node: unknown) => node,
|
|
230
|
+
start: (at: number) => booked.push(Object.assign(cue, { at })),
|
|
231
|
+
stop: () => {},
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
createGain() {
|
|
235
|
+
const cue = this.last
|
|
236
|
+
return {
|
|
237
|
+
gain: { setValueAtTime() {}, exponentialRampToValueAtTime() {} },
|
|
238
|
+
connect: (node: unknown) => node,
|
|
239
|
+
disconnect: () => (cue.cancelled = true),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
Object.assign(window, {
|
|
244
|
+
AudioContext: RecordingAudioContext,
|
|
245
|
+
__booked: booked,
|
|
246
|
+
__hide() {
|
|
247
|
+
visibility = 'hidden'
|
|
248
|
+
window.requestAnimationFrame = () => 0 // a hidden tab draws no frames
|
|
249
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('the end is booked ahead, and the title counts down while hidden', async ({ page }) => {
|
|
256
|
+
await page.goto('/routine')
|
|
257
|
+
await page.getByRole('button', { name: 'Start' }).click()
|
|
258
|
+
// Seconds ahead on the audio clock: the closing pair five minutes on.
|
|
259
|
+
const booked = () => page.evaluate(() => (window as any).__booked.map((c: any) => [c.pitch, Math.round(c.at), c.cancelled]))
|
|
260
|
+
await expect.poll(booked).toContainEqual([660, 300, false])
|
|
261
|
+
|
|
262
|
+
await page.evaluate(() => (window as any).__hide())
|
|
263
|
+
await page.clock.fastForward('00:10')
|
|
264
|
+
await expect(page).toHaveTitle(/^4:50/)
|
|
265
|
+
})
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Pause, Skip and Back are asserted the same way: the earlier booking's
|
|
269
|
+
`cancelled` turns true and a new one appears at the new time.
|
|
270
|
+
|
|
165
271
|
**An open-ended count-up: pause the fake clock.** After
|
|
166
272
|
`clock.install()` the fake clock still moves in real time between
|
|
167
273
|
steps, so every reading picks up however long the machine took since the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type NavEntry } from './app-shell-nav.js';
|
|
3
3
|
import { type AppShellVersion } from './app-shell-version.js';
|
|
4
|
+
import type { IconComponent } from './icon-component.js';
|
|
4
5
|
export type { NavEntry, NavItem, NavSection } from './app-shell-nav.js';
|
|
5
6
|
export type { AppShellVersion } from './app-shell-version.js';
|
|
6
7
|
export type AppShellProps = {
|
|
@@ -12,6 +13,14 @@ export type AppShellProps = {
|
|
|
12
13
|
* does, with its record's title).
|
|
13
14
|
*/
|
|
14
15
|
title: string;
|
|
16
|
+
/**
|
|
17
|
+
* The app's mark, shown before `title` at the top of the sidebar, and
|
|
18
|
+
* alone, centred, at the top of the collapsed icon rail. Drawn like an
|
|
19
|
+
* icon (`IconComponent`: sized by the shell, `aria-hidden` passed
|
|
20
|
+
* through) and decorative, since `title` names the app. Its own colours,
|
|
21
|
+
* or `currentColor` for the sidebar's text colour.
|
|
22
|
+
*/
|
|
23
|
+
logo?: IconComponent;
|
|
15
24
|
/**
|
|
16
25
|
* The app's primary navigation, in order: pages, and labelled sections of
|
|
17
26
|
* pages (`{ label, items }`, one per module, say). A nav with no sections
|
|
@@ -44,4 +53,4 @@ export type AppShellProps = {
|
|
|
44
53
|
*/
|
|
45
54
|
version?: AppShellVersion;
|
|
46
55
|
};
|
|
47
|
-
export declare function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen, version }: AppShellProps): import("react").JSX.Element;
|
|
56
|
+
export declare function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarOpen, version }: AppShellProps): import("react").JSX.Element;
|
|
@@ -110,7 +110,7 @@ function CollapsibleNavGroup({ group, activeTo }) {
|
|
|
110
110
|
function AppShellSkeleton() {
|
|
111
111
|
return (_jsxs("div", { className: "flex h-screen w-full gap-4 p-4", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-full w-56 shrink-0" }), _jsxs("div", { className: "flex-1 space-y-4 pt-2", children: [_jsx(Skeleton, { className: "h-8 w-64" }), _jsx(Skeleton, { className: "h-40 w-full" })] })] }));
|
|
112
112
|
}
|
|
113
|
-
export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true, version }) {
|
|
113
|
+
export function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarOpen = true, version }) {
|
|
114
114
|
const { user, status, error, retry, logout } = useAuth();
|
|
115
115
|
const location = useLocation();
|
|
116
116
|
const reporter = useErrorReporter();
|
|
@@ -140,7 +140,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true,
|
|
|
140
140
|
return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
|
|
141
141
|
}
|
|
142
142
|
const activeTo = activeNavTo(nav, location.pathname);
|
|
143
|
-
return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children:
|
|
143
|
+
return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsxs("div", { className: "flex items-center gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [Logo && (_jsx(Logo, { "aria-hidden": "true", className: "size-5 shrink-0 text-sidebar-foreground group-data-[collapsible=icon]:size-4" })), _jsx("span", { className: "truncate text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title })] }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => group.collapsible && group.label !== undefined ? (_jsx(CollapsibleNavGroup, { group: { ...group, label: group.label }, activeTo: activeTo }, `${groupIndex}:${group.label}`)) : (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsxs(SidebarFooter, { children: [version && _jsx(VersionEntry, { version: version }), _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
|
|
144
144
|
setIsLoggingOut(true);
|
|
145
145
|
// A failed request still ends the session on screen
|
|
146
146
|
// (AuthProvider), so this stays a deliberate logout: a
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
4
|
import { createQueryClient } from '../../api/query-client.js';
|
|
5
5
|
import { AuthProvider } from '../../auth/use-auth.js';
|
|
6
6
|
import { MockModeBanner } from './mock-mode-banner.js';
|
|
7
7
|
import { ThemeProvider } from '../theme-provider.js';
|
|
8
8
|
import { ErrorReportingProvider } from '../../error-reporting/error-reporting-provider.js';
|
|
9
9
|
import { resolveErrorReporting } from '../../error-reporting/reporter.js';
|
|
10
|
+
import { browserStaleChunkReload } from '../../lib/stale-chunk-reload.js';
|
|
10
11
|
// Everything an app's root needs around its router, in the order that
|
|
11
12
|
// works: the theme outermost, the mock-mode banner above the router so it
|
|
12
13
|
// shows on every route (/login included), and AuthProvider inside
|
|
13
14
|
// QueryClientProvider because it queries /auth/me through TanStack Query.
|
|
14
15
|
// An app's DataEnvironmentBanner goes first among the children, before its
|
|
15
16
|
// router: it queries too, and shows on every route like the mock banner.
|
|
17
|
+
// It also reloads the page once when a lazy chunk is gone after a deploy
|
|
18
|
+
// (lib/stale-chunk-reload.ts), so no app adds its own listener.
|
|
16
19
|
export function FoundationProviders({ mockMode, queryClient, errorReporting, children, }) {
|
|
17
20
|
const [client] = useState(() => queryClient ?? createQueryClient());
|
|
18
21
|
const reporting = resolveErrorReporting(errorReporting);
|
|
22
|
+
useEffect(() => browserStaleChunkReload().install(window), []);
|
|
19
23
|
return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: reporting ? _jsx(ErrorReportingProvider, { options: reporting, children: children }) : children }) })] }));
|
|
20
24
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Anything drawn like a Lucide icon: sized by a `size-*` class, coloured
|
|
4
|
+
* through `currentColor` or its own fills, and hidden from screen readers
|
|
5
|
+
* by `aria-hidden` (which it should pass to its `<svg>`). Every
|
|
6
|
+
* `LucideIcon` is one, and so is an app's own mark (a brand logo from
|
|
7
|
+
* Simple Icons' paths, say), so a slot typed with it takes either.
|
|
8
|
+
*/
|
|
9
|
+
export type IconComponent = ComponentType<{
|
|
10
|
+
className?: string;
|
|
11
|
+
'aria-hidden'?: boolean | 'true' | 'false';
|
|
12
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type To } from 'react-router';
|
|
3
|
-
import { type LucideIcon } from 'lucide-react';
|
|
4
3
|
import type { AppError } from '../../api/contracts.js';
|
|
4
|
+
import type { IconComponent } from './icon-component.js';
|
|
5
5
|
export interface WidgetCardProps {
|
|
6
6
|
title: string;
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* An icon shown before the title: a Lucide icon, or an app's own mark (an
|
|
9
|
+
* outside service's logo on a card about that service). Decorative: the
|
|
10
|
+
* title names the card.
|
|
11
|
+
*/
|
|
12
|
+
icon?: IconComponent;
|
|
9
13
|
/** The title's heading level: 2 under the page's h1 (the default), deeper under a section. */
|
|
10
14
|
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
11
15
|
/**
|
|
@@ -6,12 +6,18 @@ import { useQueryClient } from '@tanstack/react-query';
|
|
|
6
6
|
import { useEffect, useState } from 'react';
|
|
7
7
|
import { reportClientError } from '../api/gateway/client-errors.js';
|
|
8
8
|
import { useAuth } from '../auth/use-auth.js';
|
|
9
|
+
import { browserStaleChunkReload } from '../lib/stale-chunk-reload.js';
|
|
9
10
|
import { ErrorReportingContext, useRoutePattern } from './error-reporting-context.js';
|
|
10
11
|
import { createErrorReporter } from './reporter.js';
|
|
11
12
|
export function ErrorReportingProvider({ options, children }) {
|
|
12
13
|
const queryClient = useQueryClient();
|
|
13
14
|
const { status } = useAuth();
|
|
14
|
-
const [reporter] = useState(() => createErrorReporter({
|
|
15
|
+
const [reporter] = useState(() => createErrorReporter({
|
|
16
|
+
options,
|
|
17
|
+
send: (report) => void reportClientError(report),
|
|
18
|
+
// A failure that sends the page to a new build is the old build going, not a bug.
|
|
19
|
+
muted: browserStaleChunkReload().isReloading,
|
|
20
|
+
}));
|
|
15
21
|
useEffect(() => reporter.setSignedIn(status === 'authenticated'), [reporter, status]);
|
|
16
22
|
useEffect(() => reporter.install(queryClient, window), [reporter, queryClient]);
|
|
17
23
|
return _jsx(ErrorReportingContext.Provider, { value: reporter, children: children });
|
|
@@ -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';
|
|
@@ -57,4 +58,4 @@ export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
|
57
58
|
export { useMuted } from './hooks/use-muted.js';
|
|
58
59
|
export { createPushController, usePushPermission, type PushController, type PushControllerOptions, type PushEnvironment, type PushPermission, type PushStatus, } from './hooks/use-push-permission.js';
|
|
59
60
|
export { cn } from './lib/utils.js';
|
|
60
|
-
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
|
@@ -63,4 +63,4 @@ export { useMuted } from './hooks/use-muted.js';
|
|
|
63
63
|
export { createPushController, usePushPermission, } from './hooks/use-push-permission.js';
|
|
64
64
|
// Utilities
|
|
65
65
|
export { cn } from './lib/utils.js';
|
|
66
|
-
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,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;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// After a deploy, a tab opened on the build before it still runs that
|
|
2
|
+
// build, and its content-hashed chunks are gone from the server: the next
|
|
3
|
+
// lazy import (the rich-text editor's, say) fails with "Failed to fetch
|
|
4
|
+
// dynamically imported module" (#206). Vite's preload helper announces it
|
|
5
|
+
// first as a cancelable `vite:preloadError` on `window`, for the package's
|
|
6
|
+
// own lazy chunks too once the app's build bundles them. On it, the page
|
|
7
|
+
// reloads once onto the new build, and the failure is not reported.
|
|
8
|
+
//
|
|
9
|
+
// Once, because a file missing from the new build too would reload
|
|
10
|
+
// forever: the reload is remembered in sessionStorage, and a second
|
|
11
|
+
// failure within `windowMs` of it is let through to the error boundary
|
|
12
|
+
// (and reported, since that one is a real missing file). Where nothing
|
|
13
|
+
// can be remembered, nothing reloads.
|
|
14
|
+
//
|
|
15
|
+
// createStaleChunkReload takes its storage, reload and clock, so the rules
|
|
16
|
+
// are unit tested in node (tests/stale-chunk-reload.test.ts);
|
|
17
|
+
// FoundationProviders installs the shared instance over the real ones.
|
|
18
|
+
const KEY = 'ui-foundation:stale-chunk-reload';
|
|
19
|
+
const WINDOW_MS = 60_000;
|
|
20
|
+
export function createStaleChunkReload({ storage, reload, now = Date.now, windowMs = WINDOW_MS, }) {
|
|
21
|
+
let reloading = false;
|
|
22
|
+
const remember = () => {
|
|
23
|
+
try {
|
|
24
|
+
const store = storage();
|
|
25
|
+
if (!store)
|
|
26
|
+
return false;
|
|
27
|
+
const last = Number(store.getItem(KEY));
|
|
28
|
+
if (Number.isFinite(last) && last > 0 && now() - last < windowMs)
|
|
29
|
+
return false;
|
|
30
|
+
store.setItem(KEY, String(now()));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const onPreloadError = (event) => {
|
|
38
|
+
if (reloading) {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!remember())
|
|
43
|
+
return;
|
|
44
|
+
reloading = true;
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
reload();
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
isReloading: () => reloading,
|
|
50
|
+
install(target) {
|
|
51
|
+
target.addEventListener('vite:preloadError', onPreloadError);
|
|
52
|
+
return () => target.removeEventListener('vite:preloadError', onPreloadError);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
let shared = null;
|
|
57
|
+
/** The one instance over the real window, sessionStorage and reload. */
|
|
58
|
+
export function browserStaleChunkReload() {
|
|
59
|
+
shared ??= createStaleChunkReload({
|
|
60
|
+
storage: () => (typeof sessionStorage === 'undefined' ? undefined : sessionStorage),
|
|
61
|
+
reload: () => window.location.reload(),
|
|
62
|
+
});
|
|
63
|
+
return shared;
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.29.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|