@tristan2828/ui-foundation 4.27.0 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/conventions/AGENTS.md +3 -1
  2. package/conventions/docs/add-an-entity.md +29 -0
  3. package/conventions/docs/cell-patterns.md +5 -1
  4. package/conventions/docs/design-language.md +44 -0
  5. package/conventions/docs/timed-screens.md +114 -8
  6. package/dist/components/app/app-shell.d.ts +10 -1
  7. package/dist/components/app/app-shell.js +2 -2
  8. package/dist/components/app/foundation-providers.js +5 -1
  9. package/dist/components/app/icon-component.d.ts +12 -0
  10. package/dist/components/app/icon-component.js +1 -0
  11. package/dist/components/app/image-field.d.ts +37 -0
  12. package/dist/components/app/image-field.js +103 -0
  13. package/dist/components/app/markdown.js +1 -1
  14. package/dist/components/app/milkdown-editor.d.ts +1 -1
  15. package/dist/components/app/milkdown-editor.js +84 -5
  16. package/dist/components/app/rich-text-editor.d.ts +16 -0
  17. package/dist/components/app/rich-text-editor.js +5 -0
  18. package/dist/components/app/rich-text-image-upload.d.ts +42 -0
  19. package/dist/components/app/rich-text-image-upload.js +174 -0
  20. package/dist/components/app/rich-text-toolbar.d.ts +6 -2
  21. package/dist/components/app/rich-text-toolbar.js +36 -28
  22. package/dist/components/app/widget-card.d.ts +7 -3
  23. package/dist/error-reporting/error-reporting-provider.js +7 -1
  24. package/dist/error-reporting/reporter.d.ts +20 -1
  25. package/dist/error-reporting/reporter.js +60 -6
  26. package/dist/hooks/use-session-clock.d.ts +19 -4
  27. package/dist/hooks/use-session-clock.js +35 -6
  28. package/dist/index.d.ts +4 -1
  29. package/dist/index.js +3 -1
  30. package/dist/lib/audio-cues.d.ts +28 -6
  31. package/dist/lib/audio-cues.js +57 -8
  32. package/dist/lib/image-file.d.ts +37 -0
  33. package/dist/lib/image-file.js +82 -0
  34. package/dist/lib/markdown-merge.js +1 -1
  35. package/dist/lib/stale-chunk-reload.d.ts +14 -0
  36. package/dist/lib/stale-chunk-reload.js +64 -0
  37. package/package.json +1 -1
@@ -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
@@ -187,6 +187,35 @@ is a filled-in example while the Widgets demo is still here).
187
187
  Select is (Storybook `app/DateField` EditInPlace). The backend column
188
188
  is a SQL `date` (`datetime.date` in the model).
189
189
 
190
+ **An image on a record** (a photo of it) is the stored file's id on the
191
+ wire (`string`, nullable), and on the form the foundation's
192
+ `ImageField` in a `Controller` (`z.string().nullable()`): `value` and
193
+ `onChange` are the id, `thumbUrl` the record's saved photo,
194
+ `onBusyChange` holds the submit while a file uploads (Storybook
195
+ `app/ImageField`). Its `upload` is the app's own, one function in the
196
+ gateway that sends the file to the app's endpoint and resolves to
197
+ `{ id, thumbUrl, url }` (the `ImageUploadResult`), rejecting with the
198
+ `AppError`; the same function goes to a notes field's
199
+ `RichTextEditor`, so pictures in its text use the same storage:
200
+
201
+ ```ts
202
+ // src/api/gateway/files.ts
203
+ export async function uploadImage(file: File): Promise<ImageUploadResult> {
204
+ const body = new FormData()
205
+ body.append('file', file)
206
+ const response = await safeFetch('/files', { method: 'POST', body })
207
+ if (response.status !== 201) throw toAppError(response.status, response.body, response.headers)
208
+ const { id } = response.body as components['schemas']['File']
209
+ return { id, thumbUrl: `/api/files/${id}/content?size=thumb`, url: `/api/files/${id}/content` }
210
+ }
211
+ ```
212
+
213
+ The types it takes default to JPEG, PNG and WebP; never add SVG or
214
+ HTML, which run script when served from the app's own domain. The
215
+ endpoint, the storage and serving the files are the app's (the
216
+ template has none); `<Markdown>` shows an image only from a web
217
+ address or a path on the site, so `url` is one of those.
218
+
190
219
  **A `reference` inside a sub-record item** (a list whose items each
191
220
  point at another entity's record) copies Pairings, Widget's
192
221
  `pairings`: Checklist's list, with a reference in each item.
@@ -947,7 +947,11 @@ const price = editInPlace({
947
947
  `toolbar={false}` leaves out the toolbars and the slash menu where a
948
948
  value is short and the shortcuts are enough; `toolbar="floating"` keeps
949
949
  the floating one and the slash menu without the bar above (an `inline`
950
- field's look).
950
+ field's look). Images in the text (`![alt](url)`) show; give it the
951
+ app's `upload` (the same one `ImageField` takes) and an image pasted,
952
+ dropped or picked with the toolbar's Image button is uploaded and goes
953
+ in as `![](url)`, a placeholder showing meanwhile (never saved), a
954
+ refusal or failure in a toast. No `upload`, no file handling at all.
951
955
  - **The save is the single-field save** (`useRecordUpdate` with
952
956
  `optimistic: false, toastOnError: false`, the entity's
953
957
  `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
@@ -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 {};
@@ -0,0 +1,37 @@
1
+ import { type ImageUpload } from '../../lib/image-file.js';
2
+ export type ImageFieldProps = {
3
+ /** The main button's id (Add or Replace), for the field's `<FieldLabel htmlFor>`. */
4
+ id?: string;
5
+ /** The stored image's id, as the record keeps it, or null for none. */
6
+ value: string | null;
7
+ /**
8
+ * The current image's thumbnail, for a value the field didn't upload
9
+ * itself (the record's saved photo). After an upload the field shows
10
+ * the upload's own `thumbUrl`.
11
+ */
12
+ thumbUrl?: string | null;
13
+ /** Called with the new image's id once it's stored, or null when removed. */
14
+ onChange: (id: string | null) => void;
15
+ /** Stores one file in the app's storage and resolves to its id and thumbnail. */
16
+ upload: ImageUpload;
17
+ /** The image types taken. Defaults to JPEG, PNG and WebP (`DEFAULT_IMAGE_TYPES`). */
18
+ accept?: readonly string[];
19
+ /** The largest file taken, in bytes: a bigger one is refused before it's sent. */
20
+ maxSize?: number;
21
+ /** What the image is, in the buttons' words: "Add photo", "Replace photo". Defaults to `'photo'`. */
22
+ noun?: string;
23
+ /** The thumbnail's alt text. Defaults to "Current photo" (with `noun`). */
24
+ alt?: string;
25
+ /** The value was refused (the form's schema, the server): `aria-invalid` on the main button. */
26
+ invalid?: boolean;
27
+ /** Saving: the image shows, nothing can be changed. */
28
+ readOnly?: boolean;
29
+ /** Told when an upload starts and ends, so a form can hold its submit meanwhile. */
30
+ onBusyChange?: (busy: boolean) => void;
31
+ className?: string;
32
+ /** The field's name, on its group of controls: this, or `aria-labelledby` (the label's id). */
33
+ 'aria-label'?: string;
34
+ 'aria-labelledby'?: string;
35
+ 'aria-describedby'?: string;
36
+ };
37
+ export declare function ImageField({ id, value, thumbUrl, onChange, upload, accept, maxSize, noun, alt, invalid, readOnly, onBusyChange, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, }: ImageFieldProps): import("react").JSX.Element;
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // One image on a record (a photo of it): a thumbnail with Replace and
3
+ // Remove, or Add photo when there's none. A form control like DateField:
4
+ // a react-hook-form `Controller` on the form, its value what the record
5
+ // stores, the stored file's id (or null).
6
+ //
7
+ // The upload is the app's (`upload`, its own storage: the package knows no
8
+ // endpoint). A file picked uploads at once; while it does, the buttons
9
+ // wait and say so, and the value changes only once it's stored. A file
10
+ // the field won't take (its type, its size) is refused before it's sent,
11
+ // and why shows under the field, as does a failed upload's reason.
12
+ //
13
+ // Remove only empties the field; deleting the stored file, if ever, is the
14
+ // app's.
15
+ import { useEffect, useId, useRef, useState } from 'react';
16
+ import { ImageIcon, ImagePlusIcon, RefreshCwIcon, XIcon } from 'lucide-react';
17
+ import { cn } from 'cn';
18
+ import { Button } from '../ui/button.js';
19
+ import { Spinner } from '../ui/spinner.js';
20
+ import { acceptAttribute, DEFAULT_IMAGE_TYPES, imageRefusal, uploadFailure } from '../../lib/image-file.js';
21
+ export function ImageField({ id, value, thumbUrl, onChange, upload, accept = DEFAULT_IMAGE_TYPES, maxSize, noun = 'photo', alt, invalid = false, readOnly = false, onBusyChange, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, }) {
22
+ const ids = useId();
23
+ const errorId = `${ids}-error`;
24
+ const input = useRef(null);
25
+ const main = useRef(null);
26
+ const [busy, setBusy] = useState(false);
27
+ // Why the last file picked isn't the field's image.
28
+ const [problem, setProblem] = useState(null);
29
+ // What a screen reader hears of an upload: started, done.
30
+ const [status, setStatus] = useState('');
31
+ // The image this field uploaded, so it shows before the record has it.
32
+ const [uploaded, setUploaded] = useState(null);
33
+ // A thumbnail that didn't load shows as the image glyph instead.
34
+ const [broken, setBroken] = useState(null);
35
+ const mounted = useRef(true);
36
+ useEffect(() => {
37
+ mounted.current = true;
38
+ return () => {
39
+ mounted.current = false;
40
+ };
41
+ }, []);
42
+ const latestBusy = useRef(onBusyChange);
43
+ useEffect(() => {
44
+ latestBusy.current = onBusyChange;
45
+ });
46
+ const changeBusy = (next) => {
47
+ setBusy(next);
48
+ latestBusy.current?.(next);
49
+ };
50
+ const Noun = noun.charAt(0).toUpperCase() + noun.slice(1);
51
+ const src = value === null ? null : uploaded?.id === value ? uploaded.thumbUrl : (thumbUrl ?? null);
52
+ const shown = src !== null && src !== broken ? src : null;
53
+ const pick = async (file) => {
54
+ if (!file)
55
+ return;
56
+ const refusal = imageRefusal(file, { accept, maxSize });
57
+ setProblem(refusal);
58
+ if (refusal)
59
+ return;
60
+ changeBusy(true);
61
+ setStatus(`Uploading ${noun}…`);
62
+ try {
63
+ const result = await upload(file);
64
+ if (!mounted.current)
65
+ return;
66
+ setUploaded({ id: result.id, thumbUrl: result.thumbUrl });
67
+ setBroken(null);
68
+ setStatus(`${Noun} added.`);
69
+ onChange(result.id);
70
+ }
71
+ catch (error) {
72
+ if (!mounted.current)
73
+ return;
74
+ setStatus('');
75
+ setProblem(uploadFailure(error, noun));
76
+ }
77
+ finally {
78
+ if (mounted.current)
79
+ changeBusy(false);
80
+ }
81
+ };
82
+ const remove = () => {
83
+ setProblem(null);
84
+ setStatus(`${Noun} removed.`);
85
+ onChange(null);
86
+ // Remove goes with the image: focus stays in the field.
87
+ main.current?.focus();
88
+ };
89
+ const open = () => {
90
+ if (!busy && !readOnly)
91
+ input.current?.click();
92
+ };
93
+ const describedBy = [ariaDescribedBy, problem ? errorId : undefined].filter(Boolean).join(' ') || undefined;
94
+ const mainLabel = busy ? `Uploading ${noun}…` : value !== null ? `Replace ${noun}` : `Add ${noun}`;
95
+ return (_jsxs("div", { "data-slot": "image-field", role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-busy": busy || undefined, className: cn('flex flex-col gap-2', className), children: [_jsxs("div", { className: "flex items-center gap-3", children: [value !== null ? (_jsxs("div", { className: "relative size-24 shrink-0 overflow-hidden rounded-lg border border-border bg-muted", children: [shown ? (_jsx("img", { src: shown, alt: alt ?? `Current ${noun}`, className: "size-full object-cover", onError: () => setBroken(shown) })) : (_jsx("div", { role: "img", "aria-label": alt ?? `Current ${noun}`, className: "flex size-full items-center justify-center text-muted-foreground", children: _jsx(ImageIcon, { "aria-hidden": "true", className: "size-6" }) })), busy && (_jsx("div", { className: "absolute inset-0 flex items-center justify-center bg-background/70", children: _jsx(Spinner, { "aria-hidden": "true", className: "size-5 text-foreground" }) }))] })) : (_jsx("div", { "aria-hidden": "true", onClick: open, className: cn('flex size-24 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-input text-muted-foreground', !busy && !readOnly && 'cursor-pointer hover:bg-muted'), children: busy ? _jsx(Spinner, { className: "size-5" }) : _jsx(ImagePlusIcon, { className: "size-6" }) })), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { ref: main, id: id, type: "button", variant: "outline", size: "sm", "aria-label": mainLabel, "aria-describedby": describedBy, "aria-invalid": invalid || undefined, disabled: busy || readOnly,
96
+ // Keeps focus through an upload, so it's where it was after.
97
+ focusableWhenDisabled: true, onClick: open, children: [busy ? (_jsx(Spinner, { "aria-hidden": "true" })) : value !== null ? (_jsx(RefreshCwIcon, { "aria-hidden": "true" })) : (_jsx(ImagePlusIcon, { "aria-hidden": "true" })), busy ? 'Uploading…' : value !== null ? 'Replace' : `Add ${noun}`] }), value !== null && (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": `Remove ${noun}`, disabled: busy || readOnly, onClick: remove, children: [_jsx(XIcon, { "aria-hidden": "true" }), "Remove"] }))] })] }), problem && (_jsx("p", { id: errorId, role: "alert", "data-slot": "image-field-error", className: "type-caption text-destructive-text", children: problem })), _jsx("span", { role: "status", className: "sr-only", children: status }), _jsx("input", { ref: input, type: "file", accept: acceptAttribute(accept), hidden: true, tabIndex: -1, onChange: (event) => {
98
+ const file = event.target.files?.[0];
99
+ // Cleared, so picking the same file again still counts.
100
+ event.target.value = '';
101
+ void pick(file);
102
+ } })] }));
103
+ }
@@ -61,7 +61,7 @@ const COMPONENTS = {
61
61
  code: (props) => _jsx("code", { ...omitNode(props), className: "rounded bg-muted px-1 py-0.5 font-mono text-foreground" }),
62
62
  img: (props) => {
63
63
  const { alt, ...rest } = omitNode(props);
64
- return _jsx("img", { ...rest, alt: alt ?? '', loading: "lazy", className: "max-w-full rounded-md" });
64
+ return _jsx("img", { ...rest, alt: alt ?? '', loading: "lazy", className: "h-auto max-w-full rounded-md" });
65
65
  },
66
66
  // GFM tables use the package's own table, zebra stripes and density
67
67
  // tokens included, so a table in the notes reads like every other table.
@@ -1,2 +1,2 @@
1
1
  import type { RichTextEditorProps } from './rich-text-editor.js';
2
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
2
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;