@tristan2828/ui-foundation 4.27.0 → 4.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conventions/AGENTS.md +3 -1
- package/conventions/docs/add-an-entity.md +29 -0
- package/conventions/docs/cell-patterns.md +5 -1
- package/conventions/docs/design-language.md +44 -0
- package/conventions/docs/timed-screens.md +114 -8
- package/dist/components/app/app-shell.d.ts +10 -1
- package/dist/components/app/app-shell.js +2 -2
- package/dist/components/app/foundation-providers.js +5 -1
- package/dist/components/app/icon-component.d.ts +12 -0
- package/dist/components/app/icon-component.js +1 -0
- package/dist/components/app/image-field.d.ts +37 -0
- package/dist/components/app/image-field.js +103 -0
- package/dist/components/app/markdown.js +1 -1
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +84 -5
- package/dist/components/app/rich-text-editor.d.ts +16 -0
- package/dist/components/app/rich-text-editor.js +5 -0
- package/dist/components/app/rich-text-image-upload.d.ts +42 -0
- package/dist/components/app/rich-text-image-upload.js +174 -0
- package/dist/components/app/rich-text-toolbar.d.ts +6 -2
- package/dist/components/app/rich-text-toolbar.js +36 -28
- package/dist/components/app/widget-card.d.ts +7 -3
- package/dist/error-reporting/error-reporting-provider.js +7 -1
- package/dist/error-reporting/reporter.d.ts +20 -1
- package/dist/error-reporting/reporter.js +60 -6
- package/dist/hooks/use-session-clock.d.ts +19 -4
- package/dist/hooks/use-session-clock.js +35 -6
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/lib/audio-cues.d.ts +28 -6
- package/dist/lib/audio-cues.js +57 -8
- package/dist/lib/image-file.d.ts +37 -0
- package/dist/lib/image-file.js +82 -0
- package/dist/lib/markdown-merge.js +1 -1
- package/dist/lib/stale-chunk-reload.d.ts +14 -0
- package/dist/lib/stale-chunk-reload.js +64 -0
- package/package.json +1 -1
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
|
|
@@ -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 (``) 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 ``, 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
|
|
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 {};
|
|
@@ -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;
|