@tristan2828/ui-foundation 4.15.0 → 4.17.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/README.md +1 -1
- package/conventions/AGENTS.md +14 -0
- package/conventions/docs/reminders.md +85 -0
- package/conventions/docs/timed-screens.md +155 -0
- package/dist/api/gateway/push.d.ts +8 -0
- package/dist/api/gateway/push.js +25 -0
- package/dist/api/schema.d.ts +148 -0
- package/dist/components/app/allow-reminders.d.ts +9 -0
- package/dist/components/app/allow-reminders.js +11 -0
- package/dist/components/app/session-view.d.ts +19 -0
- package/dist/components/app/session-view.js +68 -0
- package/dist/hooks/use-muted.d.ts +15 -0
- package/dist/hooks/use-muted.js +73 -0
- package/dist/hooks/use-prefers-reduced-motion.d.ts +2 -0
- package/dist/hooks/use-prefers-reduced-motion.js +17 -0
- package/dist/hooks/use-push-permission.d.ts +72 -0
- package/dist/hooks/use-push-permission.js +154 -0
- package/dist/hooks/use-session-clock.d.ts +42 -0
- package/dist/hooks/use-session-clock.js +111 -0
- package/dist/hooks/use-wake-lock.d.ts +18 -0
- package/dist/hooks/use-wake-lock.js +70 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/lib/audio-cues.d.ts +52 -0
- package/dist/lib/audio-cues.js +138 -0
- package/dist/mocks/index.d.ts +1 -0
- package/dist/mocks/index.js +1 -0
- package/dist/mocks/push.d.ts +9 -0
- package/dist/mocks/push.js +40 -0
- package/openapi/foundation.yaml +99 -3
- package/package.json +1 -1
- package/vite/index.d.ts +27 -0
- package/vite/index.js +2 -0
- package/vite/push-sw.js +46 -0
- package/vite/pwa.d.ts +18 -0
- package/vite/pwa.js +230 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ than installing this by hand:
|
|
|
18
18
|
|
|
19
19
|
| Import | What |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
21
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `say`, `hush`, `vibrate`. For reminders (`docs/foundation/reminders.md`): `AllowReminders`, `usePushPermission` |
|
|
22
22
|
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
|
|
23
23
|
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
24
24
|
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `environmentHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
package/conventions/AGENTS.md
CHANGED
|
@@ -89,6 +89,20 @@ A pattern the foundation lacks is worth raising there: the design language
|
|
|
89
89
|
grows in the package so every app gets it, with axe contrast coverage in
|
|
90
90
|
both themes.
|
|
91
91
|
|
|
92
|
+
## Timed Screens
|
|
93
|
+
A screen that runs on its own for minutes (a timer, a player, a paced
|
|
94
|
+
routine) is built from the package's parts: `SessionView`,
|
|
95
|
+
`useSessionClock`, `useWakeLock`, the audio cues and `useMuted`. Follow
|
|
96
|
+
`docs/foundation/timed-screens.md`, including its Playwright recipe
|
|
97
|
+
(`clock.install()` before `goto`, then `fastForward`, never `runFor`).
|
|
98
|
+
|
|
99
|
+
## Reminders
|
|
100
|
+
A notification at a time the user chose, with the app closed, is Web
|
|
101
|
+
Push: `pwa()` in `vite.config.ts`, `AllowReminders` with
|
|
102
|
+
`usePushPermission()`, and the backend's `send_push` and reminders job.
|
|
103
|
+
Follow `docs/foundation/reminders.md`. Never ask for notification
|
|
104
|
+
permission on page load: only from the click on "Allow reminders".
|
|
105
|
+
|
|
92
106
|
## Scope and Stopping
|
|
93
107
|
- Do the task you were given and nothing beyond it. A task is done when
|
|
94
108
|
`npm run verify` passes — not when it feels complete.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Reminders
|
|
2
|
+
|
|
3
|
+
A notification at a time the user chose ("time for your morning routine",
|
|
4
|
+
"a short break at 3 pm", a habit, a medication) that reaches them with the
|
|
5
|
+
app closed. It's Web Push: the browser subscribes with its push service
|
|
6
|
+
(Google's, Mozilla's, Apple's, Microsoft's), the backend encrypts a message
|
|
7
|
+
to that subscription and posts it there, and the push service delivers it
|
|
8
|
+
to the device, where the app's service worker shows it.
|
|
9
|
+
|
|
10
|
+
| Part | Where | What it does |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `pwa({ name })` | `vite.config.ts`, from `@tristan2828/ui-foundation/vite` | The app installable: a manifest, icons in the theme's colours, and `/push-sw.js`, the service worker that shows pushes and opens their path |
|
|
13
|
+
| `usePushPermission()` | the package | Whether this device gets reminders, and `enable`/`disable` |
|
|
14
|
+
| `AllowReminders` | the package | The control: says what reminders are, then asks, on a click |
|
|
15
|
+
| `/push/public-key`, `/push/subscriptions` | `openapi.yaml`, the foundation's part (optional) | The VAPID key to subscribe with; this device's subscription saved or dropped |
|
|
16
|
+
| `app/push.py` | the reference backend | `send_push(session, user_id, title, body, url)`: encrypted (RFC 8291), signed (VAPID), to every device the user allowed |
|
|
17
|
+
| `app/reminders.py`, `reminders` | the reference backend | A reminder row (when, which days, the text, the path), and the `send-reminders` job that sends each one when it's due |
|
|
18
|
+
|
|
19
|
+
Off until the backend turns it on (`PUSH_ENABLED`, `docs/deploy.md`
|
|
20
|
+
"Reminders (Web Push)"). An app without reminders leaves `/push/*` out of
|
|
21
|
+
`openapi.yaml` and `pwa()` out of its config.
|
|
22
|
+
|
|
23
|
+
## Allowing reminders
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { AllowReminders, usePushPermission } from '@tristan2828/ui-foundation'
|
|
27
|
+
|
|
28
|
+
const push = usePushPermission()
|
|
29
|
+
<AllowReminders push={push} description="We'll remind you when it's time for your routine." />
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **Never ask on page load.** A prompt nobody expected gets refused, and a
|
|
33
|
+
refusal is final: only the browser's site settings undo it. The button
|
|
34
|
+
says what reminders are first; `enable()` asks then, from the click.
|
|
35
|
+
- **Put it where reminders are set**: the top of the reminders list, or a
|
|
36
|
+
settings page. One per device: each phone or browser allows its own.
|
|
37
|
+
- **Every state says what to do next.** `AllowReminders` shows them all
|
|
38
|
+
(the package's Storybook, `app/AllowReminders`):
|
|
39
|
+
|
|
40
|
+
| `status` | Means | Shows |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `off` | Can be turned on | The description and "Allow reminders" |
|
|
43
|
+
| `on` | This device gets them | "Reminders are on for this device", and "Turn off" |
|
|
44
|
+
| `needs-install` | iPhone or iPad in a browser tab | How to add the app to the Home Screen |
|
|
45
|
+
| `blocked` | The person said no | Where to allow it again |
|
|
46
|
+
| `unsupported` | No push in this browser | That it can't |
|
|
47
|
+
| `mock` | The app runs on MSW | That reminders need the real backend |
|
|
48
|
+
|
|
49
|
+
## On iPhone and iPad
|
|
50
|
+
|
|
51
|
+
Push works only in the app added to the Home Screen (Share, then Add to
|
|
52
|
+
Home Screen) and opened from there, on iOS 16.4 or later. In a Safari tab
|
|
53
|
+
there is no push at all, so `usePushPermission` says `needs-install` and
|
|
54
|
+
`AllowReminders` shows the steps. `pwa()`'s manifest (`display:
|
|
55
|
+
standalone`) is what makes the Home Screen app an app.
|
|
56
|
+
|
|
57
|
+
## A reminder is the app's own row
|
|
58
|
+
|
|
59
|
+
The reference backend has a `reminders` table and the job that sends it;
|
|
60
|
+
what a reminder means is the app's. Give it an API and screens like any
|
|
61
|
+
entity (`add-an-entity.md`): its fields are `title`, `body`, `url` (a
|
|
62
|
+
path in the app, opened when it's tapped), `timeOfDay` ("07:30"), `days`
|
|
63
|
+
(some of mon,tue,wed,thu,fri,sat,sun), `timeZone` (the person's IANA
|
|
64
|
+
zone: `Intl.DateTimeFormat().resolvedOptions().timeZone`) and `enabled`.
|
|
65
|
+
Scope it to the signed-in user, as Widgets are.
|
|
66
|
+
|
|
67
|
+
- **The job** (`send-reminders`, every minute, with `JOBS_ENABLED`)
|
|
68
|
+
sends a reminder when today is one of its days in its time zone and the
|
|
69
|
+
time has come, once a day at most. More than 15 minutes late (the
|
|
70
|
+
backend was down) and it's skipped for the day rather than sent late.
|
|
71
|
+
- **Something else to send?** Call `send_push` from the app's own code or
|
|
72
|
+
job: a message when a long task finishes, a daily summary. The `url` is
|
|
73
|
+
always the app's own path.
|
|
74
|
+
- **Time zones** come from the system's time zone database. On Windows
|
|
75
|
+
that's the `tzdata` package, which the backend doesn't install: a
|
|
76
|
+
Windows dev machine reads only `UTC` until it's added.
|
|
77
|
+
|
|
78
|
+
## Testing
|
|
79
|
+
|
|
80
|
+
Under MSW the page is held by MSW's service worker, so `usePushPermission`
|
|
81
|
+
reads `mock` and never subscribes: screens show the `mock` state, and a
|
|
82
|
+
Playwright spec checks it the same way. The rest is the backend's: its
|
|
83
|
+
tests decrypt every message as a browser would. To try it end to end, run
|
|
84
|
+
the real backend with push on, allow reminders in the browser, then
|
|
85
|
+
`backend/scripts/send_test_push.py <email>`.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Timed Screens
|
|
2
|
+
|
|
3
|
+
A screen that runs on its own for minutes: a paced timer, a step-by-step
|
|
4
|
+
player, a recipe step, anything followed from across the room. The
|
|
5
|
+
package has the parts, so a screen like this is assembled, not rebuilt:
|
|
6
|
+
|
|
7
|
+
| Part | What it does |
|
|
8
|
+
|---|---|
|
|
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` |
|
|
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 |
|
|
13
|
+
| `useMuted` | A mute toggle remembered on this device |
|
|
14
|
+
| `usePrefersReducedMotion` | Whether to swap movement for a fade |
|
|
15
|
+
|
|
16
|
+
All of them are imported from `@tristan2828/ui-foundation`. The package's
|
|
17
|
+
Storybook has the whole thing working together: `app/SessionView`, the
|
|
18
|
+
Timer story.
|
|
19
|
+
|
|
20
|
+
## The view
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
const [open, setOpen] = useState(false)
|
|
24
|
+
// The clock lives above the view: closing it holds the time, reopening
|
|
25
|
+
// carries on. Inside the view, it would start over on every open.
|
|
26
|
+
const clock = useSessionClock({ running: open, endsAt: 5 * 60_000 })
|
|
27
|
+
const primary = useRef<HTMLButtonElement>(null)
|
|
28
|
+
|
|
29
|
+
<Button onClick={() => setOpen(true)}>Start the routine</Button>
|
|
30
|
+
<SessionView
|
|
31
|
+
open={open}
|
|
32
|
+
onOpenChange={setOpen}
|
|
33
|
+
title="Morning routine"
|
|
34
|
+
keepAwake
|
|
35
|
+
fullscreenControl
|
|
36
|
+
initialFocus={primary}
|
|
37
|
+
>
|
|
38
|
+
…the screen, with its main control as `ref={primary}`
|
|
39
|
+
</SessionView>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **The title is required.** It names the dialog for screen readers and
|
|
43
|
+
heads the view. The header also holds Close and, with
|
|
44
|
+
`fullscreenControl`, a "Full screen" toggle.
|
|
45
|
+
- **Real fullscreen** asks the browser to take the whole screen too
|
|
46
|
+
(hiding its own bars). Where the browser can't (iOS Safari on a phone),
|
|
47
|
+
the control isn't shown. The view already covers the page. Closing the
|
|
48
|
+
view leaves fullscreen if the view entered it.
|
|
49
|
+
- **Safe areas.** The view pads itself by the phone's safe-area insets.
|
|
50
|
+
They are zero unless the app's `index.html` viewport meta has
|
|
51
|
+
`viewport-fit=cover`. Without it the browser keeps the page out of the
|
|
52
|
+
notch on its own. With it, check the rest of the app at phone width too.
|
|
53
|
+
- **Never build this from `DialogContent`** with its classes overridden
|
|
54
|
+
piece by piece. That fights the primitive's own classes on every
|
|
55
|
+
upgrade, and gets the details above wrong.
|
|
56
|
+
|
|
57
|
+
## The clock
|
|
58
|
+
|
|
59
|
+
`useSessionClock({ running?, endsAt? })` returns
|
|
60
|
+
`{ elapsed, status, paused, start, pause, resume, stop }`.
|
|
61
|
+
|
|
62
|
+
- `elapsed` is in milliseconds, read from `performance.now()` each
|
|
63
|
+
animation frame. It is never summed from ticks, so it doesn't drift.
|
|
64
|
+
Paused time is left out, so `resume()` carries on exactly where
|
|
65
|
+
`pause()` stopped.
|
|
66
|
+
- `status` is `idle` until `start()`, then `running`, then `done` once
|
|
67
|
+
`elapsed` reaches `endsAt`. `elapsed` stops at `endsAt`. Without
|
|
68
|
+
`endsAt` it runs until `stop()`, which goes back to `idle` at zero.
|
|
69
|
+
- `paused` is the person's own pause. `running: false` is different: it
|
|
70
|
+
holds the clock still without pausing it. Use it for time the session
|
|
71
|
+
isn't on screen at all, such as the view closed or a confirmation in
|
|
72
|
+
front of it.
|
|
73
|
+
- It re-renders once per frame, only while counting. Format what it shows
|
|
74
|
+
from `elapsed`, never by counting renders.
|
|
75
|
+
- A hidden tab draws no frames, but its time still counts. The first frame
|
|
76
|
+
back catches up, and reaches `done` if it's due.
|
|
77
|
+
|
|
78
|
+
For phases (stretch two minutes, rest thirty seconds), keep one clock for
|
|
79
|
+
the whole session and work out the phase from `elapsed`. A clock per
|
|
80
|
+
phase drifts by the gap between one ending and the next starting.
|
|
81
|
+
|
|
82
|
+
## Sound
|
|
83
|
+
|
|
84
|
+
Browsers block sound until the person has clicked or tapped. So:
|
|
85
|
+
|
|
86
|
+
- **Call `unlockAudio()` from the click that starts the session**, in the
|
|
87
|
+
click handler itself, not in an effect after it. Every cue before that
|
|
88
|
+
is dropped rather than played late. iOS also needs speech started from
|
|
89
|
+
a click, and `unlockAudio()` does that too.
|
|
90
|
+
- `chime({ pitch?, volume?, duration? })`: a soft sine tone, a short
|
|
91
|
+
attack and a slow fade. `pitch` is in Hz (660 by default). For a
|
|
92
|
+
closing pair, use `sequence([{ pitch: 660 }, { pitch: 880 }])`, 180 ms
|
|
93
|
+
apart by default.
|
|
94
|
+
- `say(text)` speaks a line ("Next: stretch, 2 minutes"), cutting off any
|
|
95
|
+
line still being spoken. `hush()` stops it. Call `hush()` when the
|
|
96
|
+
session stops or the view closes.
|
|
97
|
+
- `vibrate(pattern)` only works on Android. Elsewhere it does nothing.
|
|
98
|
+
- Every one of them is a no-op where the browser can't make the sound, so
|
|
99
|
+
none needs a check around it.
|
|
100
|
+
- **Mute** is the app's: `const [muted, setMuted] = useMuted('my-app:routine:muted')`,
|
|
101
|
+
and check `muted` before each cue. Name the key for the app and screen.
|
|
102
|
+
It's remembered on this device. Where storage is blocked it still works
|
|
103
|
+
for the page's life. Show the toggle as a pressed button
|
|
104
|
+
(`aria-pressed={muted}`) named "Mute".
|
|
105
|
+
- **Cue on a change of state** in an effect that compares the previous
|
|
106
|
+
state with the current one, so the cue plays once. Never play a cue
|
|
107
|
+
during render.
|
|
108
|
+
|
|
109
|
+
## Motion
|
|
110
|
+
|
|
111
|
+
`usePrefersReducedMotion()` is true while the person's system asks for
|
|
112
|
+
less motion. Swap a sweeping ring or a breathing circle for a fade or a
|
|
113
|
+
still state then. Don't drop the information it carried.
|
|
114
|
+
|
|
115
|
+
## Testing a timed screen in Playwright
|
|
116
|
+
|
|
117
|
+
**Install the fake clock before `goto`, then `fastForward` to each state:**
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
test('running, paused and done', async ({ page }) => {
|
|
121
|
+
await page.clock.install() // before goto, or the page keeps the real clock
|
|
122
|
+
await page.goto('/routine')
|
|
123
|
+
await page.getByRole('button', { name: 'Start' }).click()
|
|
124
|
+
|
|
125
|
+
await page.clock.fastForward('00:20')
|
|
126
|
+
await expect(page.getByRole('timer', { name: 'Time left' })).toHaveText('4:40')
|
|
127
|
+
|
|
128
|
+
await page.getByRole('button', { name: 'Pause' }).click()
|
|
129
|
+
await page.clock.fastForward('05:00')
|
|
130
|
+
await expect(page.getByRole('timer', { name: 'Time left' })).toHaveText('4:40')
|
|
131
|
+
|
|
132
|
+
await page.getByRole('button', { name: 'Resume' }).click()
|
|
133
|
+
await page.clock.fastForward('04:40')
|
|
134
|
+
await expect(page.getByRole('status')).toHaveText('Done')
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **`fastForward`, not `runFor`.** `runFor` over a long span doesn't fire
|
|
139
|
+
every animation frame. A screen that adds time up per frame comes out
|
|
140
|
+
short: a one-minute session ended about 20 seconds early in a real
|
|
141
|
+
app's tests. `useSessionClock` reads the time each frame, so one frame
|
|
142
|
+
after `fastForward` lands on the right state.
|
|
143
|
+
- Give the time on screen `role="timer"` and a name, and the state line
|
|
144
|
+
`role="status"`, so tests (and screen readers) find them by role.
|
|
145
|
+
- **The wake lock** does nothing in headless Chromium. To check it's
|
|
146
|
+
held, replace `navigator.wakeLock` in `page.addInitScript` with one that
|
|
147
|
+
records `request` and `release`. The package's own spec does this
|
|
148
|
+
(`e2e/session-view.spec.ts` in the foundation repo).
|
|
149
|
+
- **Sound in tests.** Headless browsers play to nothing, so a test never
|
|
150
|
+
needs sound off to pass. To check what's spoken, wrap
|
|
151
|
+
`speechSynthesis.speak` in `addInitScript` and record each
|
|
152
|
+
utterance's `text`. To run a headed test in silence, set the mute key
|
|
153
|
+
before the page loads:
|
|
154
|
+
`page.addInitScript(() => localStorage.setItem('my-app:routine:muted', 'true'))`.
|
|
155
|
+
- **Reduced motion:** `await page.emulateMedia({ reducedMotion: 'reduce' })`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { components } from "../schema.js";
|
|
2
|
+
export type PushSubscriptionRequest = components["schemas"]["PushSubscriptionRequest"];
|
|
3
|
+
/** The key to subscribe with. A 404 is a backend with push turned off. */
|
|
4
|
+
export declare function getPushPublicKey(): Promise<string>;
|
|
5
|
+
/** Saves the subscription; the same device again answers the same id. */
|
|
6
|
+
export declare function savePushSubscription(subscription: PushSubscriptionRequest): Promise<number>;
|
|
7
|
+
/** Already gone (404) counts as done. */
|
|
8
|
+
export declare function deletePushSubscription(id: number): Promise<void>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { safeFetch, toAppError } from "./errors.js";
|
|
2
|
+
/** The key to subscribe with. A 404 is a backend with push turned off. */
|
|
3
|
+
export async function getPushPublicKey() {
|
|
4
|
+
const res = await safeFetch("/push/public-key");
|
|
5
|
+
if (res.status !== 200)
|
|
6
|
+
throw toAppError(res.status, res.body, res.headers);
|
|
7
|
+
return res.body.publicKey;
|
|
8
|
+
}
|
|
9
|
+
/** Saves the subscription; the same device again answers the same id. */
|
|
10
|
+
export async function savePushSubscription(subscription) {
|
|
11
|
+
const res = await safeFetch("/push/subscriptions", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "content-type": "application/json" },
|
|
14
|
+
body: JSON.stringify(subscription),
|
|
15
|
+
});
|
|
16
|
+
if (res.status !== 201)
|
|
17
|
+
throw toAppError(res.status, res.body, res.headers);
|
|
18
|
+
return res.body.id;
|
|
19
|
+
}
|
|
20
|
+
/** Already gone (404) counts as done. */
|
|
21
|
+
export async function deletePushSubscription(id) {
|
|
22
|
+
const res = await safeFetch(`/push/subscriptions/${id}`, { method: "DELETE" });
|
|
23
|
+
if (res.status !== 204 && res.status !== 404)
|
|
24
|
+
throw toAppError(res.status, res.body, res.headers);
|
|
25
|
+
}
|
package/dist/api/schema.d.ts
CHANGED
|
@@ -131,6 +131,63 @@ export interface paths {
|
|
|
131
131
|
patch?: never;
|
|
132
132
|
trace?: never;
|
|
133
133
|
};
|
|
134
|
+
"/push/public-key": {
|
|
135
|
+
parameters: {
|
|
136
|
+
query?: never;
|
|
137
|
+
header?: never;
|
|
138
|
+
path?: never;
|
|
139
|
+
cookie?: never;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* The app's VAPID public key, which a browser subscribes to push with
|
|
143
|
+
* @description For usePushPermission. 404 when the backend has push turned off.
|
|
144
|
+
*/
|
|
145
|
+
get: operations["getPushPublicKey"];
|
|
146
|
+
put?: never;
|
|
147
|
+
post?: never;
|
|
148
|
+
delete?: never;
|
|
149
|
+
options?: never;
|
|
150
|
+
head?: never;
|
|
151
|
+
patch?: never;
|
|
152
|
+
trace?: never;
|
|
153
|
+
};
|
|
154
|
+
"/push/subscriptions": {
|
|
155
|
+
parameters: {
|
|
156
|
+
query?: never;
|
|
157
|
+
header?: never;
|
|
158
|
+
path?: never;
|
|
159
|
+
cookie?: never;
|
|
160
|
+
};
|
|
161
|
+
get?: never;
|
|
162
|
+
put?: never;
|
|
163
|
+
/**
|
|
164
|
+
* Save this device's push subscription for the signed-in user
|
|
165
|
+
* @description The browser's PushSubscription.toJSON(). The same endpoint again replaces its keys and owner and answers the same id. The endpoint must be a push service's (422 otherwise): the backend POSTs to it.
|
|
166
|
+
*/
|
|
167
|
+
post: operations["savePushSubscription"];
|
|
168
|
+
delete?: never;
|
|
169
|
+
options?: never;
|
|
170
|
+
head?: never;
|
|
171
|
+
patch?: never;
|
|
172
|
+
trace?: never;
|
|
173
|
+
};
|
|
174
|
+
"/push/subscriptions/{id}": {
|
|
175
|
+
parameters: {
|
|
176
|
+
query?: never;
|
|
177
|
+
header?: never;
|
|
178
|
+
path?: never;
|
|
179
|
+
cookie?: never;
|
|
180
|
+
};
|
|
181
|
+
get?: never;
|
|
182
|
+
put?: never;
|
|
183
|
+
post?: never;
|
|
184
|
+
/** Stop sending to one of the signed-in user's devices */
|
|
185
|
+
delete: operations["deletePushSubscription"];
|
|
186
|
+
options?: never;
|
|
187
|
+
head?: never;
|
|
188
|
+
patch?: never;
|
|
189
|
+
trace?: never;
|
|
190
|
+
};
|
|
134
191
|
}
|
|
135
192
|
export type webhooks = Record<string, never>;
|
|
136
193
|
export interface components {
|
|
@@ -185,6 +242,23 @@ export interface components {
|
|
|
185
242
|
/** @description The frontend build (its commit). */
|
|
186
243
|
release?: string;
|
|
187
244
|
};
|
|
245
|
+
PushPublicKey: {
|
|
246
|
+
/** @description The P-256 public key, base64url (applicationServerKey). */
|
|
247
|
+
publicKey: string;
|
|
248
|
+
};
|
|
249
|
+
PushSubscriptionRequest: {
|
|
250
|
+
/** @description The push service's URL for this device. */
|
|
251
|
+
endpoint: string;
|
|
252
|
+
/** @description Milliseconds since the epoch, sent by some browsers, ignored. */
|
|
253
|
+
expirationTime?: number | null;
|
|
254
|
+
keys: {
|
|
255
|
+
p256dh: string;
|
|
256
|
+
auth: string;
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
PushSubscriptionCreated: {
|
|
260
|
+
id: number;
|
|
261
|
+
};
|
|
188
262
|
/** @description FastAPI's default 422 shape (RequestValidationError). */
|
|
189
263
|
ValidationErrorBody: {
|
|
190
264
|
detail: {
|
|
@@ -442,4 +516,78 @@ export interface operations {
|
|
|
442
516
|
500: components["responses"]["ServerError"];
|
|
443
517
|
};
|
|
444
518
|
};
|
|
519
|
+
getPushPublicKey: {
|
|
520
|
+
parameters: {
|
|
521
|
+
query?: never;
|
|
522
|
+
header?: never;
|
|
523
|
+
path?: never;
|
|
524
|
+
cookie?: never;
|
|
525
|
+
};
|
|
526
|
+
requestBody?: never;
|
|
527
|
+
responses: {
|
|
528
|
+
/** @description OK. */
|
|
529
|
+
200: {
|
|
530
|
+
headers: {
|
|
531
|
+
[name: string]: unknown;
|
|
532
|
+
};
|
|
533
|
+
content: {
|
|
534
|
+
"application/json": components["schemas"]["PushPublicKey"];
|
|
535
|
+
};
|
|
536
|
+
};
|
|
537
|
+
401: components["responses"]["UnauthorizedError"];
|
|
538
|
+
404: components["responses"]["NotFoundError"];
|
|
539
|
+
500: components["responses"]["ServerError"];
|
|
540
|
+
};
|
|
541
|
+
};
|
|
542
|
+
savePushSubscription: {
|
|
543
|
+
parameters: {
|
|
544
|
+
query?: never;
|
|
545
|
+
header?: never;
|
|
546
|
+
path?: never;
|
|
547
|
+
cookie?: never;
|
|
548
|
+
};
|
|
549
|
+
requestBody: {
|
|
550
|
+
content: {
|
|
551
|
+
"application/json": components["schemas"]["PushSubscriptionRequest"];
|
|
552
|
+
};
|
|
553
|
+
};
|
|
554
|
+
responses: {
|
|
555
|
+
/** @description Saved. */
|
|
556
|
+
201: {
|
|
557
|
+
headers: {
|
|
558
|
+
[name: string]: unknown;
|
|
559
|
+
};
|
|
560
|
+
content: {
|
|
561
|
+
"application/json": components["schemas"]["PushSubscriptionCreated"];
|
|
562
|
+
};
|
|
563
|
+
};
|
|
564
|
+
401: components["responses"]["UnauthorizedError"];
|
|
565
|
+
404: components["responses"]["NotFoundError"];
|
|
566
|
+
422: components["responses"]["ValidationError"];
|
|
567
|
+
500: components["responses"]["ServerError"];
|
|
568
|
+
};
|
|
569
|
+
};
|
|
570
|
+
deletePushSubscription: {
|
|
571
|
+
parameters: {
|
|
572
|
+
query?: never;
|
|
573
|
+
header?: never;
|
|
574
|
+
path: {
|
|
575
|
+
id: number;
|
|
576
|
+
};
|
|
577
|
+
cookie?: never;
|
|
578
|
+
};
|
|
579
|
+
requestBody?: never;
|
|
580
|
+
responses: {
|
|
581
|
+
/** @description Deleted. */
|
|
582
|
+
204: {
|
|
583
|
+
headers: {
|
|
584
|
+
[name: string]: unknown;
|
|
585
|
+
};
|
|
586
|
+
content?: never;
|
|
587
|
+
};
|
|
588
|
+
401: components["responses"]["UnauthorizedError"];
|
|
589
|
+
404: components["responses"]["NotFoundError"];
|
|
590
|
+
500: components["responses"]["ServerError"];
|
|
591
|
+
};
|
|
592
|
+
};
|
|
445
593
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { PushPermission } from '../../hooks/use-push-permission.js';
|
|
3
|
+
export type AllowRemindersProps = {
|
|
4
|
+
push: PushPermission;
|
|
5
|
+
/** What reminders are for, in the app's words, shown before asking. */
|
|
6
|
+
description?: ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function AllowReminders({ push, description, className }: AllowRemindersProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { BellIcon, BellOffIcon } from 'lucide-react';
|
|
3
|
+
import { cn } from 'cn';
|
|
4
|
+
import { Button } from '../ui/button.js';
|
|
5
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
6
|
+
import { Notice } from './notice.js';
|
|
7
|
+
const DEFAULT_DESCRIPTION = 'Get a notification on this device at the times you set, even when the app is closed. Your browser will ask first.';
|
|
8
|
+
export function AllowReminders({ push, description = DEFAULT_DESCRIPTION, className }) {
|
|
9
|
+
const { status, pending, error } = push;
|
|
10
|
+
return (_jsxs("div", { "data-slot": "allow-reminders", className: cn('flex flex-col gap-3', className), "aria-busy": status === undefined || pending, children: [status === undefined && _jsx(Skeleton, { className: "h-9 w-full max-w-md" }), status === 'off' && (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-foreground", children: description }), _jsx("div", { children: _jsxs(Button, { onClick: push.enable, disabled: pending, children: [_jsx(BellIcon, { "aria-hidden": "true" }), pending ? 'Turning on…' : 'Allow reminders'] }) })] })), status === 'on' && (_jsx(Notice, { tone: "success", icon: BellIcon, action: _jsxs(Button, { size: "sm", variant: "outline", onClick: push.disable, disabled: pending, children: [_jsx(BellOffIcon, { "aria-hidden": "true" }), pending ? 'Turning off…' : 'Turn off'] }), children: "Reminders are on for this device." })), status === 'needs-install' && (_jsx(Notice, { title: "Add the app to your Home Screen first", children: "On iPhone and iPad, reminders work in the app from the Home Screen. Tap Share, then Add to Home Screen, open the app from there and allow reminders." })), status === 'blocked' && (_jsx(Notice, { tone: "warning", title: "Notifications are blocked", icon: BellOffIcon, children: "This browser was told not to show this app\u2019s notifications. Allow them in the site\u2019s settings (the icon beside the address), then reload." })), status === 'unsupported' && (_jsx("p", { className: "text-sm text-muted-foreground", children: "This browser can\u2019t show reminders." })), status === 'mock' && (_jsx("p", { className: "text-sm text-muted-foreground", children: "Reminders need the real backend: with mock data nothing can send them." })), error && (_jsx("p", { role: "alert", className: "text-sm text-destructive-text", children: error }))] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
|
3
|
+
export type SessionViewProps = {
|
|
4
|
+
open: boolean;
|
|
5
|
+
/** Called with false on Esc and on the Close button. */
|
|
6
|
+
onOpenChange: (open: boolean) => void;
|
|
7
|
+
/** Names the view for screen readers, and heads it on screen. */
|
|
8
|
+
title: React.ReactNode;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
/** Hold a screen wake lock while the view is open (useWakeLock). */
|
|
11
|
+
keepAwake?: boolean;
|
|
12
|
+
/** Show a "Full screen" control that asks the browser for real fullscreen too. */
|
|
13
|
+
fullscreenControl?: boolean;
|
|
14
|
+
/** What takes focus on open (default: the first focusable control, which is the header's). */
|
|
15
|
+
initialFocus?: DialogPrimitive.Popup.Props['initialFocus'];
|
|
16
|
+
/** On the area below the header, which holds `children`. */
|
|
17
|
+
className?: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function SessionView({ open, onOpenChange, title, children, keepAwake, fullscreenControl, initialFocus, className, }: SessionViewProps): React.JSX.Element;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A full-screen view for a timed screen (a paced timer, a step-by-step
|
|
3
|
+
// player, anything followed from across the room): the whole viewport over
|
|
4
|
+
// the app shell, inside the phone's safe-area insets. It's a modal dialog,
|
|
5
|
+
// so focus stays in it and Esc closes it through onOpenChange, and it's
|
|
6
|
+
// named by its title. It fades in, never zooms.
|
|
7
|
+
//
|
|
8
|
+
// Optional: a "Full screen" control that also asks the browser for real
|
|
9
|
+
// fullscreen and quietly isn't there where the browser can't (iOS Safari
|
|
10
|
+
// on a phone), and keepAwake, which holds a screen wake lock while open.
|
|
11
|
+
// Recipes for its states in Playwright: docs/foundation/timed-screens.md.
|
|
12
|
+
import * as React from 'react';
|
|
13
|
+
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
|
14
|
+
import { Maximize2Icon, Minimize2Icon, XIcon } from 'lucide-react';
|
|
15
|
+
import { cn } from 'cn';
|
|
16
|
+
import { Button } from '../ui/button.js';
|
|
17
|
+
import { Dialog, DialogClose, DialogPortal, DialogTitle } from '../ui/dialog.js';
|
|
18
|
+
import { useWakeLock } from '../../hooks/use-wake-lock.js';
|
|
19
|
+
export function SessionView({ open, onOpenChange, title, children, keepAwake = false, fullscreenControl = false, initialFocus, className, }) {
|
|
20
|
+
useWakeLock(open && keepAwake);
|
|
21
|
+
const fullscreen = useDocumentFullscreen(open);
|
|
22
|
+
return (_jsx(Dialog, { open: open, onOpenChange: (next) => onOpenChange(next), children: _jsx(DialogPortal, { children: _jsxs(DialogPrimitive.Popup, { "data-slot": "session-view", initialFocus: initialFocus, className: cn('fixed inset-0 z-50 flex flex-col bg-background text-foreground outline-none',
|
|
23
|
+
// The insets are 0 unless the page's viewport meta has viewport-fit=cover.
|
|
24
|
+
'pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]', 'duration-150 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0'), children: [_jsxs("header", { className: "flex items-center gap-1 px-4 py-2", children: [_jsx(DialogTitle, { className: "min-w-0 flex-1 truncate type-section-title", children: title }), fullscreenControl && fullscreen.supported && (_jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Full screen", "aria-pressed": fullscreen.active, onClick: fullscreen.toggle, children: fullscreen.active ? _jsx(Minimize2Icon, {}) : _jsx(Maximize2Icon, {}) })), _jsx(DialogClose, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Close" }), children: _jsx(XIcon, {}) })] }), _jsx("div", { className: cn('flex min-h-0 flex-1 flex-col overflow-auto px-4 pb-4', className), children: children })] }) }) }));
|
|
25
|
+
}
|
|
26
|
+
// The whole page in the browser's fullscreen (not the view's own element:
|
|
27
|
+
// popups inside the view portal to <body>, and would vanish outside a
|
|
28
|
+
// fullscreen element). Followed through `fullscreenchange`, since the
|
|
29
|
+
// browser's own Esc leaves it too. Left when the view closes, if the view
|
|
30
|
+
// was what entered it.
|
|
31
|
+
function useDocumentFullscreen(open) {
|
|
32
|
+
const [supported] = React.useState(() => typeof document !== 'undefined' && document.fullscreenEnabled === true && !!document.documentElement.requestFullscreen);
|
|
33
|
+
const [active, setActive] = React.useState(false);
|
|
34
|
+
const entered = React.useRef(false);
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
if (!supported)
|
|
37
|
+
return;
|
|
38
|
+
const onChange = () => {
|
|
39
|
+
const now = document.fullscreenElement !== null;
|
|
40
|
+
setActive(now);
|
|
41
|
+
if (!now)
|
|
42
|
+
entered.current = false;
|
|
43
|
+
};
|
|
44
|
+
document.addEventListener('fullscreenchange', onChange);
|
|
45
|
+
return () => document.removeEventListener('fullscreenchange', onChange);
|
|
46
|
+
}, [supported]);
|
|
47
|
+
React.useEffect(() => {
|
|
48
|
+
if (open || !entered.current)
|
|
49
|
+
return;
|
|
50
|
+
entered.current = false;
|
|
51
|
+
if (document.fullscreenElement)
|
|
52
|
+
void document.exitFullscreen().catch(() => { });
|
|
53
|
+
}, [open]);
|
|
54
|
+
React.useEffect(() => () => {
|
|
55
|
+
if (entered.current && document.fullscreenElement)
|
|
56
|
+
void document.exitFullscreen().catch(() => { });
|
|
57
|
+
}, []);
|
|
58
|
+
const toggle = () => {
|
|
59
|
+
if (document.fullscreenElement) {
|
|
60
|
+
void document.exitFullscreen().catch(() => { });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Refused (no gesture, a policy, an iframe without allowfullscreen):
|
|
64
|
+
// the view stays as it is, already covering the page.
|
|
65
|
+
document.documentElement.requestFullscreen().then(() => (entered.current = true), () => { });
|
|
66
|
+
};
|
|
67
|
+
return { supported, active, toggle };
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
|
2
|
+
export type MutedStore = {
|
|
3
|
+
get: (key: string) => boolean;
|
|
4
|
+
set: (key: string, muted: boolean) => void;
|
|
5
|
+
subscribe: (key: string, listener: () => void) => () => void;
|
|
6
|
+
/** Another tab changed `key` (a `storage` event): forget the in-memory value and re-read. */
|
|
7
|
+
changedElsewhere: (key: string) => void;
|
|
8
|
+
};
|
|
9
|
+
export declare function createMutedStore(getStorage: () => StorageLike | undefined): MutedStore;
|
|
10
|
+
/**
|
|
11
|
+
* `[muted, setMuted]`, remembered on this device under `key` (name it for
|
|
12
|
+
* the app and screen: `"my-app:timer:muted"`). Unmuted until set.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useMuted(key: string): readonly [boolean, (muted: boolean) => void];
|
|
15
|
+
export {};
|