@tristan2828/ui-foundation 4.14.0 → 4.16.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 +7 -0
- package/conventions/docs/add-an-entity.md +3 -2
- package/conventions/docs/timed-screens.md +155 -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-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 +6 -0
- package/dist/index.js +7 -0
- package/dist/lib/audio-cues.d.ts +52 -0
- package/dist/lib/audio-cues.js +138 -0
- package/dist/testing/index.d.ts +14 -3
- package/dist/testing/index.js +43 -12
- package/package.json +1 -1
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` |
|
|
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,13 @@ 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
|
+
|
|
92
99
|
## Scope and Stopping
|
|
93
100
|
- Do the task you were given and nothing beyond it. A task is done when
|
|
94
101
|
`npm run verify` passes — not when it feels complete.
|
|
@@ -515,8 +515,9 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
515
515
|
`NAV_ENTRIES` so the shell smoke test covers the new screen, add
|
|
516
516
|
`/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
|
|
517
517
|
of a full record and of the sparse one to its `viewRoutes` (the table
|
|
518
|
-
page
|
|
519
|
-
|
|
518
|
+
page comes from `src/nav.ts`, which the suite is given as `nav`: each
|
|
519
|
+
nav page is a test of its own, so the suite's time per test stays flat
|
|
520
|
+
as the app grows; the form and the view aren't in the nav). With fields edited in place, copy `e2e/a11y.spec.ts`'s
|
|
520
521
|
"editing in place" block for them: a field open, saving and refused,
|
|
521
522
|
the rich-text editor, an open list, a list edited in place (an item
|
|
522
523
|
open, one being added, a refused change), and the leave prompt, in
|
|
@@ -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,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 {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
2
|
+
export function createMutedStore(getStorage) {
|
|
3
|
+
// Values this page set, for when storage refuses them.
|
|
4
|
+
const memory = new Map();
|
|
5
|
+
const listeners = new Map();
|
|
6
|
+
const notify = (key) => {
|
|
7
|
+
for (const listener of listeners.get(key) ?? [])
|
|
8
|
+
listener();
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
get(key) {
|
|
12
|
+
const kept = memory.get(key);
|
|
13
|
+
if (kept !== undefined)
|
|
14
|
+
return kept;
|
|
15
|
+
try {
|
|
16
|
+
const stored = getStorage()?.getItem(key);
|
|
17
|
+
if (stored === 'true')
|
|
18
|
+
return true;
|
|
19
|
+
if (stored === 'false')
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Storage blocked: unmuted.
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
},
|
|
27
|
+
set(key, muted) {
|
|
28
|
+
memory.set(key, muted);
|
|
29
|
+
try {
|
|
30
|
+
getStorage()?.setItem(key, String(muted));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Storage blocked or full: remembered in memory for this page only.
|
|
34
|
+
}
|
|
35
|
+
notify(key);
|
|
36
|
+
},
|
|
37
|
+
subscribe(key, listener) {
|
|
38
|
+
let set = listeners.get(key);
|
|
39
|
+
if (!set)
|
|
40
|
+
listeners.set(key, (set = new Set()));
|
|
41
|
+
set.add(listener);
|
|
42
|
+
return () => set.delete(listener);
|
|
43
|
+
},
|
|
44
|
+
changedElsewhere(key) {
|
|
45
|
+
memory.delete(key);
|
|
46
|
+
notify(key);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const browserStore = createMutedStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
51
|
+
let listeningForOtherTabs = false;
|
|
52
|
+
function listenForOtherTabs() {
|
|
53
|
+
if (listeningForOtherTabs || typeof window === 'undefined')
|
|
54
|
+
return;
|
|
55
|
+
listeningForOtherTabs = true;
|
|
56
|
+
window.addEventListener('storage', (event) => {
|
|
57
|
+
if (event.key !== null)
|
|
58
|
+
browserStore.changedElsewhere(event.key);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `[muted, setMuted]`, remembered on this device under `key` (name it for
|
|
63
|
+
* the app and screen: `"my-app:timer:muted"`). Unmuted until set.
|
|
64
|
+
*/
|
|
65
|
+
export function useMuted(key) {
|
|
66
|
+
const subscribe = useCallback((listener) => {
|
|
67
|
+
listenForOtherTabs();
|
|
68
|
+
return browserStore.subscribe(key, listener);
|
|
69
|
+
}, [key]);
|
|
70
|
+
const muted = useSyncExternalStore(subscribe, () => browserStore.get(key), () => false);
|
|
71
|
+
const setMuted = useCallback((next) => browserStore.set(key, next), [key]);
|
|
72
|
+
return [muted, setMuted];
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
// Whether the person asked their system for less motion, kept current as
|
|
3
|
+
// they change it. A screen swaps movement (a sweeping ring, a breathing
|
|
4
|
+
// circle) for a fade or a still state while it's true.
|
|
5
|
+
const QUERY = '(prefers-reduced-motion: reduce)';
|
|
6
|
+
function subscribe(onChange) {
|
|
7
|
+
if (typeof window === 'undefined' || !window.matchMedia)
|
|
8
|
+
return () => { };
|
|
9
|
+
const query = window.matchMedia(QUERY);
|
|
10
|
+
query.addEventListener('change', onChange);
|
|
11
|
+
return () => query.removeEventListener('change', onChange);
|
|
12
|
+
}
|
|
13
|
+
const getSnapshot = () => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(QUERY).matches;
|
|
14
|
+
/** True while the system asks for reduced motion; false where it can't be read (and on the server). */
|
|
15
|
+
export function usePrefersReducedMotion() {
|
|
16
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type SessionStatus = 'idle' | 'running' | 'done';
|
|
2
|
+
export type SessionClockState = {
|
|
3
|
+
/** Milliseconds counted so far, paused time left out; `endsAt` at most. */
|
|
4
|
+
elapsed: number;
|
|
5
|
+
/** `idle` until `start()`, `running` until `endsAt` is reached, then `done`. */
|
|
6
|
+
status: SessionStatus;
|
|
7
|
+
/** Paused by `pause()`, while running. */
|
|
8
|
+
paused: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type SessionClockStore = {
|
|
11
|
+
start: () => void;
|
|
12
|
+
pause: () => void;
|
|
13
|
+
resume: () => void;
|
|
14
|
+
/** Back to `idle` at zero. */
|
|
15
|
+
stop: () => void;
|
|
16
|
+
/** Whether time may count at all (the hook's `running`); false holds the clock still without pausing it. */
|
|
17
|
+
setRunning: (running: boolean) => void;
|
|
18
|
+
setEndsAt: (endsAt: number | undefined) => void;
|
|
19
|
+
/** Reads the time; the hook calls it each animation frame. */
|
|
20
|
+
tick: () => void;
|
|
21
|
+
subscribe: (listener: () => void) => () => void;
|
|
22
|
+
getSnapshot: () => SessionClockState;
|
|
23
|
+
};
|
|
24
|
+
export declare function createSessionClock(now: () => number): SessionClockStore;
|
|
25
|
+
export type SessionClockOptions = {
|
|
26
|
+
/**
|
|
27
|
+
* Whether time may count (default true). False holds the clock still
|
|
28
|
+
* without pausing it: for time the session isn't on screen at all, such
|
|
29
|
+
* as its view closed or a confirmation in front of it.
|
|
30
|
+
*/
|
|
31
|
+
running?: boolean;
|
|
32
|
+
/** The elapsed milliseconds at which the session is `done`. Without it, it runs until stopped. */
|
|
33
|
+
endsAt?: number;
|
|
34
|
+
};
|
|
35
|
+
export type SessionClock = SessionClockState & Pick<SessionClockStore, 'start' | 'pause' | 'resume' | 'stop'>;
|
|
36
|
+
/**
|
|
37
|
+
* A drift-free clock with pause and resume, read from `performance.now()`
|
|
38
|
+
* each animation frame. It re-renders once per frame only while counting.
|
|
39
|
+
* A hidden tab draws no frames, but its time still counts: the first frame
|
|
40
|
+
* back catches up (and reaches `done` if it's due).
|
|
41
|
+
*/
|
|
42
|
+
export declare function useSessionClock({ running, endsAt }?: SessionClockOptions): SessionClock;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
export function createSessionClock(now) {
|
|
3
|
+
let status = 'idle';
|
|
4
|
+
let paused = false;
|
|
5
|
+
let running = true;
|
|
6
|
+
let endsAt;
|
|
7
|
+
// Time counted before the current stretch, and when that stretch began
|
|
8
|
+
// (null while time isn't counting).
|
|
9
|
+
let banked = 0;
|
|
10
|
+
let since = null;
|
|
11
|
+
let snapshot = { elapsed: 0, status, paused };
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const counting = () => status === 'running' && !paused && running;
|
|
14
|
+
const bank = () => {
|
|
15
|
+
if (since === null)
|
|
16
|
+
return;
|
|
17
|
+
banked += now() - since;
|
|
18
|
+
since = null;
|
|
19
|
+
};
|
|
20
|
+
const begin = () => {
|
|
21
|
+
if (counting() && since === null)
|
|
22
|
+
since = now();
|
|
23
|
+
};
|
|
24
|
+
const update = () => {
|
|
25
|
+
let elapsed = banked + (since === null ? 0 : now() - since);
|
|
26
|
+
if (status === 'running' && endsAt !== undefined && elapsed >= endsAt) {
|
|
27
|
+
status = 'done';
|
|
28
|
+
paused = false;
|
|
29
|
+
since = null;
|
|
30
|
+
banked = endsAt;
|
|
31
|
+
elapsed = endsAt;
|
|
32
|
+
}
|
|
33
|
+
if (elapsed === snapshot.elapsed && status === snapshot.status && paused === snapshot.paused)
|
|
34
|
+
return;
|
|
35
|
+
snapshot = { elapsed, status, paused };
|
|
36
|
+
for (const listener of listeners)
|
|
37
|
+
listener();
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
start() {
|
|
41
|
+
status = 'running';
|
|
42
|
+
paused = false;
|
|
43
|
+
banked = 0;
|
|
44
|
+
since = null;
|
|
45
|
+
begin();
|
|
46
|
+
update();
|
|
47
|
+
},
|
|
48
|
+
pause() {
|
|
49
|
+
if (status !== 'running' || paused)
|
|
50
|
+
return;
|
|
51
|
+
bank();
|
|
52
|
+
paused = true;
|
|
53
|
+
update();
|
|
54
|
+
},
|
|
55
|
+
resume() {
|
|
56
|
+
if (status !== 'running' || !paused)
|
|
57
|
+
return;
|
|
58
|
+
paused = false;
|
|
59
|
+
begin();
|
|
60
|
+
update();
|
|
61
|
+
},
|
|
62
|
+
stop() {
|
|
63
|
+
status = 'idle';
|
|
64
|
+
paused = false;
|
|
65
|
+
banked = 0;
|
|
66
|
+
since = null;
|
|
67
|
+
update();
|
|
68
|
+
},
|
|
69
|
+
setRunning(next) {
|
|
70
|
+
if (next === running)
|
|
71
|
+
return;
|
|
72
|
+
bank();
|
|
73
|
+
running = next;
|
|
74
|
+
begin();
|
|
75
|
+
update();
|
|
76
|
+
},
|
|
77
|
+
setEndsAt(next) {
|
|
78
|
+
endsAt = next;
|
|
79
|
+
update();
|
|
80
|
+
},
|
|
81
|
+
tick: update,
|
|
82
|
+
subscribe(listener) {
|
|
83
|
+
listeners.add(listener);
|
|
84
|
+
return () => listeners.delete(listener);
|
|
85
|
+
},
|
|
86
|
+
getSnapshot: () => snapshot,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A drift-free clock with pause and resume, read from `performance.now()`
|
|
91
|
+
* each animation frame. It re-renders once per frame only while counting.
|
|
92
|
+
* A hidden tab draws no frames, but its time still counts: the first frame
|
|
93
|
+
* back catches up (and reaches `done` if it's due).
|
|
94
|
+
*/
|
|
95
|
+
export function useSessionClock({ running = true, endsAt } = {}) {
|
|
96
|
+
const [clock] = useState(() => createSessionClock(() => performance.now()));
|
|
97
|
+
const state = useSyncExternalStore(clock.subscribe, clock.getSnapshot, clock.getSnapshot);
|
|
98
|
+
useEffect(() => clock.setRunning(running), [clock, running]);
|
|
99
|
+
useEffect(() => clock.setEndsAt(endsAt), [clock, endsAt]);
|
|
100
|
+
const counting = state.status === 'running' && !state.paused && running;
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!counting)
|
|
103
|
+
return;
|
|
104
|
+
let frame = requestAnimationFrame(function next() {
|
|
105
|
+
clock.tick();
|
|
106
|
+
frame = requestAnimationFrame(next);
|
|
107
|
+
});
|
|
108
|
+
return () => cancelAnimationFrame(frame);
|
|
109
|
+
}, [clock, counting]);
|
|
110
|
+
return { ...state, start: clock.start, pause: clock.pause, resume: clock.resume, stop: clock.stop };
|
|
111
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** What createWakeLock needs from the browser; `navigator.wakeLock` and `document` by default. */
|
|
2
|
+
export type WakeLockEnvironment = {
|
|
3
|
+
wakeLock: Pick<WakeLock, 'request'> | undefined;
|
|
4
|
+
document: Pick<Document, 'visibilityState' | 'addEventListener' | 'removeEventListener'>;
|
|
5
|
+
};
|
|
6
|
+
export type WakeLockController = {
|
|
7
|
+
/** Hold the lock from now on, asking again whenever the tab comes back. */
|
|
8
|
+
activate: () => void;
|
|
9
|
+
/** Let it go, including a request still waiting for the browser's answer. */
|
|
10
|
+
deactivate: () => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function createWakeLock(env: WakeLockEnvironment): WakeLockController;
|
|
13
|
+
/**
|
|
14
|
+
* Keep the screen awake while `active`. Asked for again when the tab
|
|
15
|
+
* returns, released on `active` going false or unmount. Does nothing where
|
|
16
|
+
* the browser has no wake lock, or refuses it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useWakeLock(active: boolean): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
export function createWakeLock(env) {
|
|
3
|
+
let active = false;
|
|
4
|
+
let sentinel = null;
|
|
5
|
+
// One request at a time: a second ask while the browser decides waits for
|
|
6
|
+
// that answer rather than ending up holding two locks.
|
|
7
|
+
let requesting = false;
|
|
8
|
+
const request = () => {
|
|
9
|
+
if (!active || !env.wakeLock || sentinel || requesting)
|
|
10
|
+
return;
|
|
11
|
+
if (env.document.visibilityState !== 'visible')
|
|
12
|
+
return;
|
|
13
|
+
requesting = true;
|
|
14
|
+
env.wakeLock.request('screen').then((lock) => {
|
|
15
|
+
requesting = false;
|
|
16
|
+
// Deactivated while the browser decided: let it go at once.
|
|
17
|
+
if (!active) {
|
|
18
|
+
void lock.release().catch(() => { });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
sentinel = lock;
|
|
22
|
+
lock.addEventListener('release', () => {
|
|
23
|
+
if (sentinel === lock)
|
|
24
|
+
sentinel = null;
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
// Refused (no permission, low battery, an iframe without the policy):
|
|
28
|
+
// the screen may sleep, nothing else changes.
|
|
29
|
+
() => {
|
|
30
|
+
requesting = false;
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
const onVisibility = () => {
|
|
34
|
+
if (env.document.visibilityState === 'visible')
|
|
35
|
+
request();
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
activate() {
|
|
39
|
+
if (active)
|
|
40
|
+
return;
|
|
41
|
+
active = true;
|
|
42
|
+
env.document.addEventListener('visibilitychange', onVisibility);
|
|
43
|
+
request();
|
|
44
|
+
},
|
|
45
|
+
deactivate() {
|
|
46
|
+
if (!active)
|
|
47
|
+
return;
|
|
48
|
+
active = false;
|
|
49
|
+
env.document.removeEventListener('visibilitychange', onVisibility);
|
|
50
|
+
const lock = sentinel;
|
|
51
|
+
sentinel = null;
|
|
52
|
+
if (lock)
|
|
53
|
+
void lock.release().catch(() => { });
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Keep the screen awake while `active`. Asked for again when the tab
|
|
59
|
+
* returns, released on `active` going false or unmount. Does nothing where
|
|
60
|
+
* the browser has no wake lock, or refuses it.
|
|
61
|
+
*/
|
|
62
|
+
export function useWakeLock(active) {
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!active || typeof navigator === 'undefined')
|
|
65
|
+
return;
|
|
66
|
+
const controller = createWakeLock({ wakeLock: navigator.wakeLock, document });
|
|
67
|
+
controller.activate();
|
|
68
|
+
return () => controller.deactivate();
|
|
69
|
+
}, [active]);
|
|
70
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-
|
|
|
21
21
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
22
22
|
export { PillFilter, type PillFilterOption, type PillFilterProps } from './components/app/pill-filter.js';
|
|
23
23
|
export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
|
|
24
|
+
export { SessionView, type SessionViewProps } from './components/app/session-view.js';
|
|
24
25
|
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
25
26
|
export { Notice, type NoticeProps, type NoticeTone } from './components/app/notice.js';
|
|
26
27
|
export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
|
|
@@ -45,4 +46,9 @@ export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
|
45
46
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
46
47
|
export { useRecordUpdate, type RecordChange, type RecordUpdateOptions, } from './hooks/use-record-update.js';
|
|
47
48
|
export { useIsMobile } from './hooks/use-mobile.js';
|
|
49
|
+
export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
50
|
+
export { useSessionClock, type SessionClock, type SessionClockOptions, type SessionClockState, type SessionStatus, } from './hooks/use-session-clock.js';
|
|
51
|
+
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
52
|
+
export { useMuted } from './hooks/use-muted.js';
|
|
48
53
|
export { cn } from './lib/utils.js';
|
|
54
|
+
export { chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ export { RichTextEditor } from './components/app/rich-text-editor.js';
|
|
|
26
26
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
27
27
|
export { PillFilter } from './components/app/pill-filter.js';
|
|
28
28
|
export { SecondaryFilters } from './components/app/secondary-filters.js';
|
|
29
|
+
export { SessionView } from './components/app/session-view.js';
|
|
29
30
|
export { StageCircle, } from './components/app/stage-circle.js';
|
|
30
31
|
export { Notice } from './components/app/notice.js';
|
|
31
32
|
export { SourceCredit } from './components/app/source-credit.js';
|
|
@@ -49,5 +50,11 @@ export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
|
49
50
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
50
51
|
export { useRecordUpdate, } from './hooks/use-record-update.js';
|
|
51
52
|
export { useIsMobile } from './hooks/use-mobile.js';
|
|
53
|
+
// Timed screens (docs/foundation/timed-screens.md)
|
|
54
|
+
export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
55
|
+
export { useSessionClock, } from './hooks/use-session-clock.js';
|
|
56
|
+
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
57
|
+
export { useMuted } from './hooks/use-muted.js';
|
|
52
58
|
// Utilities
|
|
53
59
|
export { cn } from './lib/utils.js';
|
|
60
|
+
export { chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type ChimeOptions = {
|
|
2
|
+
/** Frequency in Hz (default 660, an E5). */
|
|
3
|
+
pitch?: number;
|
|
4
|
+
/** 0 to 1 at the chime's peak (default 0.25). */
|
|
5
|
+
volume?: number;
|
|
6
|
+
/** Milliseconds from strike to silence (default 1200). */
|
|
7
|
+
duration?: number;
|
|
8
|
+
};
|
|
9
|
+
export type SequenceOptions = {
|
|
10
|
+
/** Milliseconds between one chime's strike and the next's (default 180). */
|
|
11
|
+
spacing?: number;
|
|
12
|
+
};
|
|
13
|
+
export type SayOptions = {
|
|
14
|
+
/** A BCP 47 tag; the browser's default voice when left out. */
|
|
15
|
+
lang?: string;
|
|
16
|
+
/** 0.1 to 10 (default 1). */
|
|
17
|
+
rate?: number;
|
|
18
|
+
/** 0 to 1 (default 1). */
|
|
19
|
+
volume?: number;
|
|
20
|
+
};
|
|
21
|
+
export type AudioCuesEnvironment = {
|
|
22
|
+
AudioContext?: new () => AudioContext;
|
|
23
|
+
speechSynthesis?: Pick<SpeechSynthesis, 'speak' | 'cancel'>;
|
|
24
|
+
SpeechSynthesisUtterance?: new (text?: string) => SpeechSynthesisUtterance;
|
|
25
|
+
vibrate?: (pattern: number | number[]) => boolean;
|
|
26
|
+
};
|
|
27
|
+
export type AudioCues = {
|
|
28
|
+
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
29
|
+
unlockAudio: () => void;
|
|
30
|
+
chime: (options?: ChimeOptions) => void;
|
|
31
|
+
/** Several chimes in a row (a closing pair), `spacing` apart. */
|
|
32
|
+
sequence: (chimes: ChimeOptions[], options?: SequenceOptions) => void;
|
|
33
|
+
/** Speaks `text`, cutting off any line still being spoken. */
|
|
34
|
+
say: (text: string, options?: SayOptions) => void;
|
|
35
|
+
/** Stops whatever is being spoken. */
|
|
36
|
+
hush: () => void;
|
|
37
|
+
/** A vibration pattern in milliseconds; Android only, a no-op elsewhere. */
|
|
38
|
+
vibrate: (pattern: number | number[]) => void;
|
|
39
|
+
};
|
|
40
|
+
export declare function createAudioCues(env: AudioCuesEnvironment): AudioCues;
|
|
41
|
+
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
42
|
+
export declare const unlockAudio: AudioCues['unlockAudio'];
|
|
43
|
+
/** A soft sine chime; dropped before unlockAudio() or where there's no Web Audio. */
|
|
44
|
+
export declare const chime: AudioCues['chime'];
|
|
45
|
+
/** Several chimes `spacing` ms apart (a closing pair). */
|
|
46
|
+
export declare const sequence: AudioCues['sequence'];
|
|
47
|
+
/** Speaks a line, cutting off the one before. A no-op without speech synthesis. */
|
|
48
|
+
export declare const say: AudioCues['say'];
|
|
49
|
+
/** Stops whatever is being spoken. */
|
|
50
|
+
export declare const hush: AudioCues['hush'];
|
|
51
|
+
/** A vibration pattern in ms; Android only, a no-op elsewhere. */
|
|
52
|
+
export declare const vibrate: AudioCues['vibrate'];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Sound cues for timed screens, with no audio files: a sine-tone chime
|
|
2
|
+
// (a short attack, a slow fade) from Web Audio, spoken lines from the
|
|
3
|
+
// browser's speech synthesis, and a vibration on Android.
|
|
4
|
+
//
|
|
5
|
+
// Browsers block sound until the person has clicked or tapped, so a
|
|
6
|
+
// session's start button calls unlockAudio() from its click; every cue
|
|
7
|
+
// before that is silently dropped rather than played late. Everything
|
|
8
|
+
// here is a no-op where the browser can't make the sound
|
|
9
|
+
// (docs/foundation/timed-screens.md "Sound").
|
|
10
|
+
//
|
|
11
|
+
// createAudioCues takes the browser's APIs, so the rules are unit tested
|
|
12
|
+
// against fakes (tests/audio-cues.test.ts); the functions exported below
|
|
13
|
+
// are one shared instance over the real ones.
|
|
14
|
+
const ATTACK = 0.015;
|
|
15
|
+
// An exponential ramp can't reach zero, so a chime starts and ends here.
|
|
16
|
+
const SILENT = 0.0001;
|
|
17
|
+
export function createAudioCues(env) {
|
|
18
|
+
let context = null;
|
|
19
|
+
// A resume asked for from a click: the context is still "suspended" for
|
|
20
|
+
// a moment, and a chime from that same click should still play.
|
|
21
|
+
let resuming = false;
|
|
22
|
+
const playable = () => context !== null && (context.state === 'running' || resuming);
|
|
23
|
+
const strike = (ctx, { pitch = 660, volume = 0.25, duration = 1200 }, delay) => {
|
|
24
|
+
const start = ctx.currentTime + delay / 1000;
|
|
25
|
+
const end = start + Math.max(duration, 50) / 1000;
|
|
26
|
+
const peak = Math.min(Math.max(volume, SILENT), 1);
|
|
27
|
+
const oscillator = ctx.createOscillator();
|
|
28
|
+
oscillator.type = 'sine';
|
|
29
|
+
oscillator.frequency.setValueAtTime(pitch, start);
|
|
30
|
+
const gain = ctx.createGain();
|
|
31
|
+
gain.gain.setValueAtTime(SILENT, start);
|
|
32
|
+
gain.gain.exponentialRampToValueAtTime(peak, start + ATTACK);
|
|
33
|
+
gain.gain.exponentialRampToValueAtTime(SILENT, end);
|
|
34
|
+
oscillator.connect(gain).connect(ctx.destination);
|
|
35
|
+
oscillator.start(start);
|
|
36
|
+
oscillator.stop(end + 0.05);
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
unlockAudio() {
|
|
40
|
+
if (env.AudioContext) {
|
|
41
|
+
try {
|
|
42
|
+
context ??= new env.AudioContext();
|
|
43
|
+
if (context.state !== 'running' && context.state !== 'closed') {
|
|
44
|
+
resuming = true;
|
|
45
|
+
context.resume().then(() => (resuming = false), () => (resuming = false));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
context = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// iOS speaks only once speech has started from a gesture: a silent,
|
|
53
|
+
// empty line here lets later cues through.
|
|
54
|
+
if (env.speechSynthesis && env.SpeechSynthesisUtterance) {
|
|
55
|
+
try {
|
|
56
|
+
const primer = new env.SpeechSynthesisUtterance('');
|
|
57
|
+
primer.volume = 0;
|
|
58
|
+
env.speechSynthesis.speak(primer);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// No speech: say() stays a no-op.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
chime(options = {}) {
|
|
66
|
+
if (!playable() || !context)
|
|
67
|
+
return;
|
|
68
|
+
strike(context, options, 0);
|
|
69
|
+
},
|
|
70
|
+
sequence(chimes, { spacing = 180 } = {}) {
|
|
71
|
+
if (!playable() || !context)
|
|
72
|
+
return;
|
|
73
|
+
const ctx = context;
|
|
74
|
+
chimes.forEach((options, index) => strike(ctx, options, index * spacing));
|
|
75
|
+
},
|
|
76
|
+
say(text, { lang, rate, volume } = {}) {
|
|
77
|
+
if (!env.speechSynthesis || !env.SpeechSynthesisUtterance)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
env.speechSynthesis.cancel();
|
|
81
|
+
const line = new env.SpeechSynthesisUtterance(text);
|
|
82
|
+
if (lang)
|
|
83
|
+
line.lang = lang;
|
|
84
|
+
if (rate !== undefined)
|
|
85
|
+
line.rate = rate;
|
|
86
|
+
if (volume !== undefined)
|
|
87
|
+
line.volume = volume;
|
|
88
|
+
env.speechSynthesis.speak(line);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Speech unavailable: the cue is dropped.
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
hush() {
|
|
95
|
+
try {
|
|
96
|
+
env.speechSynthesis?.cancel();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Nothing to stop.
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
vibrate(pattern) {
|
|
103
|
+
try {
|
|
104
|
+
env.vibrate?.(pattern);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// No vibration motor, or not allowed here.
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
let shared = null;
|
|
113
|
+
function browserCues() {
|
|
114
|
+
if (shared)
|
|
115
|
+
return shared;
|
|
116
|
+
const win = typeof window === 'undefined' ? undefined : window;
|
|
117
|
+
shared = createAudioCues({
|
|
118
|
+
AudioContext: win?.AudioContext ?? win?.webkitAudioContext,
|
|
119
|
+
speechSynthesis: win?.speechSynthesis,
|
|
120
|
+
SpeechSynthesisUtterance: win?.SpeechSynthesisUtterance,
|
|
121
|
+
vibrate: typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'
|
|
122
|
+
? (pattern) => navigator.vibrate(pattern)
|
|
123
|
+
: undefined,
|
|
124
|
+
});
|
|
125
|
+
return shared;
|
|
126
|
+
}
|
|
127
|
+
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
128
|
+
export const unlockAudio = () => browserCues().unlockAudio();
|
|
129
|
+
/** A soft sine chime; dropped before unlockAudio() or where there's no Web Audio. */
|
|
130
|
+
export const chime = (options) => browserCues().chime(options);
|
|
131
|
+
/** Several chimes `spacing` ms apart (a closing pair). */
|
|
132
|
+
export const sequence = (chimes, options) => browserCues().sequence(chimes, options);
|
|
133
|
+
/** Speaks a line, cutting off the one before. A no-op without speech synthesis. */
|
|
134
|
+
export const say = (text, options) => browserCues().say(text, options);
|
|
135
|
+
/** Stops whatever is being spoken. */
|
|
136
|
+
export const hush = () => browserCues().hush();
|
|
137
|
+
/** A vibration pattern in ms; Android only, a no-op elsewhere. */
|
|
138
|
+
export const vibrate = (pattern) => browserCues().vibrate(pattern);
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Page } from '@playwright/test';
|
|
2
|
+
import { type NavEntry } from '../components/app/app-shell-nav.js';
|
|
2
3
|
import type { MswOverride } from '../mocks/override.js';
|
|
3
4
|
export type { MswHandle, MswOverride } from '../mocks/override.js';
|
|
4
5
|
/**
|
|
@@ -44,16 +45,26 @@ export type A11ySuiteOptions = {
|
|
|
44
45
|
viewRoutes?: readonly string[];
|
|
45
46
|
/** Screens outside the app shell, e.g. '/login' (and '/register' if the app has it). */
|
|
46
47
|
loggedOutRoutes: readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* The app's nav, the same `NavEntry[]` passed to `AppShell` (`src/nav.ts`).
|
|
50
|
+
* Given, each page in it is a test of its own, so the suite's time per
|
|
51
|
+
* test stays flat as the app grows, and one more test checks the
|
|
52
|
+
* rendered sidebar links exactly those pages. Left out, the pages are
|
|
53
|
+
* found in the sidebar at run time, in one test whose timeout grows with
|
|
54
|
+
* their number.
|
|
55
|
+
*/
|
|
56
|
+
nav?: readonly NavEntry[];
|
|
47
57
|
};
|
|
48
58
|
/**
|
|
49
59
|
* Accessibility of the real screens in light and dark mode: every page in
|
|
50
|
-
* the Primary nav (
|
|
51
|
-
*
|
|
60
|
+
* the Primary nav (one test each from `nav`, or discovered in the sidebar
|
|
61
|
+
* when `nav` is left out), the given form routes, and the logged-out
|
|
62
|
+
* screens. axe's
|
|
52
63
|
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
53
64
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
54
65
|
* emulateMedia switches it.
|
|
55
66
|
*/
|
|
56
|
-
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
|
|
67
|
+
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav }: A11ySuiteOptions): void;
|
|
57
68
|
export type MockModeBannerSuiteOptions = {
|
|
58
69
|
/** Screens to check, logged-out ones included (e.g. '/login'). */
|
|
59
70
|
routes: readonly string[];
|
package/dist/testing/index.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// are optional peer dependencies, needed only by apps that import this.
|
|
14
14
|
import AxeBuilder from '@axe-core/playwright';
|
|
15
15
|
import { expect, test } from '@playwright/test';
|
|
16
|
+
import { isNavSection } from '../components/app/app-shell-nav.js';
|
|
16
17
|
/**
|
|
17
18
|
* Forces a response for the page's *first* load: set before navigation, so
|
|
18
19
|
* it wins the race against the app's own first fetch. For states forced
|
|
@@ -92,36 +93,66 @@ async function navRoutes(page, isMobile) {
|
|
|
92
93
|
? (await page.getByRole('button', { name: 'Toggle Sidebar' }).first().click(), page.getByRole('dialog'))
|
|
93
94
|
: page.getByRole('navigation', { name: 'Primary' });
|
|
94
95
|
await expect(nav.getByRole('link').first()).toBeVisible();
|
|
96
|
+
// By element, not role: a link inside a folded sidebar section is hidden
|
|
97
|
+
// (AppShell's collapsible sections) and role queries skip it.
|
|
95
98
|
const hrefs = await nav
|
|
96
|
-
.
|
|
99
|
+
.locator('a[href]')
|
|
97
100
|
.evaluateAll((links) => links.map((link) => link.href));
|
|
98
101
|
// Only this app's pages: a sidebarExtra group may link to other sites.
|
|
99
102
|
const origin = new URL(page.url()).origin;
|
|
100
103
|
return [...new Set(hrefs.filter((href) => new URL(href).origin === origin).map((href) => new URL(href).pathname))];
|
|
101
104
|
}
|
|
105
|
+
// A page's share of the one discovering test's time: load, data, axe.
|
|
106
|
+
const NAV_PAGE_BUDGET_MS = 10_000;
|
|
107
|
+
function navEntryRoutes(nav) {
|
|
108
|
+
return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
|
|
109
|
+
}
|
|
110
|
+
async function expectNavPageAccessible(page, route, colorScheme) {
|
|
111
|
+
await page.goto(route);
|
|
112
|
+
await expect(page.locator('html')).toHaveClass(colorScheme);
|
|
113
|
+
// Wait for data, not the loading skeleton, so the real UI is checked.
|
|
114
|
+
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
115
|
+
await expectNoAxeViolations(page);
|
|
116
|
+
}
|
|
102
117
|
/**
|
|
103
118
|
* Accessibility of the real screens in light and dark mode: every page in
|
|
104
|
-
* the Primary nav (
|
|
105
|
-
*
|
|
119
|
+
* the Primary nav (one test each from `nav`, or discovered in the sidebar
|
|
120
|
+
* when `nav` is left out), the given form routes, and the logged-out
|
|
121
|
+
* screens. axe's
|
|
106
122
|
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
107
123
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
108
124
|
* emulateMedia switches it.
|
|
109
125
|
*/
|
|
110
|
-
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes }) {
|
|
126
|
+
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes, nav }) {
|
|
127
|
+
const listed = nav && navEntryRoutes(nav);
|
|
128
|
+
if (listed) {
|
|
129
|
+
test('the sidebar links exactly the pages in nav', async ({ page, isMobile }) => {
|
|
130
|
+
const rendered = await navRoutes(page, isMobile);
|
|
131
|
+
expect([...rendered].sort(), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual([...listed].sort());
|
|
132
|
+
});
|
|
133
|
+
}
|
|
111
134
|
for (const colorScheme of ['light', 'dark']) {
|
|
112
135
|
test.describe(`accessibility (${colorScheme})`, () => {
|
|
113
136
|
test.beforeEach(async ({ page }) => {
|
|
114
137
|
await page.emulateMedia({ colorScheme });
|
|
115
138
|
});
|
|
116
|
-
|
|
117
|
-
for (const route of
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
122
|
-
await test.step(route, () => expectNoAxeViolations(page));
|
|
139
|
+
if (listed) {
|
|
140
|
+
for (const route of listed) {
|
|
141
|
+
test(`nav page ${route} has zero axe violations`, async ({ page }) => {
|
|
142
|
+
await expectNavPageAccessible(page, route, colorScheme);
|
|
143
|
+
});
|
|
123
144
|
}
|
|
124
|
-
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
test('every nav page has zero axe violations', async ({ page, isMobile }, testInfo) => {
|
|
148
|
+
const routes = await navRoutes(page, isMobile);
|
|
149
|
+
// One test for every page: its time grows with the app.
|
|
150
|
+
test.setTimeout(testInfo.timeout + routes.length * NAV_PAGE_BUDGET_MS);
|
|
151
|
+
for (const route of routes) {
|
|
152
|
+
await test.step(route, () => expectNavPageAccessible(page, route, colorScheme));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
125
156
|
for (const route of [...formRoutes, ...loggedOutRoutes]) {
|
|
126
157
|
test(`${route} has zero axe violations`, async ({ page }) => {
|
|
127
158
|
await page.goto(route);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.16.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|