@tristan2828/ui-foundation 4.28.0 → 4.29.1

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.
@@ -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 resume: `idle`, `running`, `done` |
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. The first frame
77
- back catches up, and reaches `done` if it's due.
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: _jsx("span", { className: "px-2 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: () => {
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
- /** A Lucide icon shown before the title. Decorative: the title names the card. */
8
- icon?: LucideIcon;
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({ options, send: (report) => void reportClientError(report) }));
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
- 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';
@@ -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';
@@ -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,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.28.0",
3
+ "version": "4.29.1",
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": {
@@ -94,7 +94,7 @@
94
94
  "tailwindcss": "^4.3.3",
95
95
  "typescript-eslint": "^8.70.0",
96
96
  "vite": "^8.3.4",
97
- "zod": "^4.6.5"
97
+ "zod": "^4.6.7"
98
98
  },
99
99
  "peerDependenciesMeta": {
100
100
  "@axe-core/playwright": {
@@ -147,7 +147,7 @@
147
147
  "react-markdown": "^10.1.0",
148
148
  "remark-gfm": "^4.0.1",
149
149
  "remark-parse": "^11.0.0",
150
- "shadcn": "^4.21.0",
150
+ "shadcn": "^4.22.1",
151
151
  "tw-animate-css": "^1.4.0",
152
152
  "unified": "^11.0.5"
153
153
  },
@@ -185,7 +185,7 @@
185
185
  "typescript-eslint": "^8.70.0",
186
186
  "vite": "^8.3.4",
187
187
  "vitest": "^5.0.1",
188
- "zod": "^4.6.5"
188
+ "zod": "^4.6.7"
189
189
  },
190
190
  "publishConfig": {
191
191
  "access": "public",