@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.
@@ -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);
@@ -1,5 +1,6 @@
1
1
  export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_RECOVERY_CODE, MOCK_TOTP_CODE, MOCK_TOTP_USER, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
2
2
  export { clientErrorHandlers, getMockClientErrors, resetMockClientErrors } from './client-errors.js';
3
3
  export { environmentHandlers } from './environment.js';
4
+ export { getMockPushSubscriptions, MOCK_PUSH_PUBLIC_KEY, pushHandlers, resetMockPush } from './push.js';
4
5
  export { exposeMswForE2E } from './e2e-hooks.js';
5
6
  export type { MswHandle, MswOverride } from './override.js';
@@ -4,4 +4,5 @@
4
4
  export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_RECOVERY_CODE, MOCK_TOTP_CODE, MOCK_TOTP_USER, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
5
5
  export { clientErrorHandlers, getMockClientErrors, resetMockClientErrors } from './client-errors.js';
6
6
  export { environmentHandlers } from './environment.js';
7
+ export { getMockPushSubscriptions, MOCK_PUSH_PUBLIC_KEY, pushHandlers, resetMockPush } from './push.js';
7
8
  export { exposeMswForE2E } from './e2e-hooks.js';
@@ -0,0 +1,9 @@
1
+ import type { components } from '../api/schema.js';
2
+ type PushSubscriptionRequest = components['schemas']['PushSubscriptionRequest'];
3
+ /** RFC 8291's example key: a valid P-256 point, and nobody's real one. */
4
+ export declare const MOCK_PUSH_PUBLIC_KEY = "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8";
5
+ /** What POST /push/subscriptions has saved since the last reset. */
6
+ export declare function getMockPushSubscriptions(): readonly PushSubscriptionRequest[];
7
+ export declare function resetMockPush(): void;
8
+ export declare const pushHandlers: import("msw").HttpHandler[];
9
+ export {};
@@ -0,0 +1,40 @@
1
+ // MSW handlers for /push/* (openapi/foundation.yaml), for an app whose
2
+ // contract has them. Like the reference backend they need the mock
3
+ // session, and a device subscribing again gets its id back. Nothing is
4
+ // ever sent: under MSW, usePushPermission reads 'mock' and never
5
+ // subscribes, since MSW's own worker holds the page.
6
+ import { http, HttpResponse } from 'msw';
7
+ import { isMockAuthenticated } from './auth.js';
8
+ /** RFC 8291's example key: a valid P-256 point, and nobody's real one. */
9
+ export const MOCK_PUSH_PUBLIC_KEY = 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8';
10
+ let subscriptions = new Map();
11
+ let nextId = 1;
12
+ /** What POST /push/subscriptions has saved since the last reset. */
13
+ export function getMockPushSubscriptions() {
14
+ return [...subscriptions.values()].map(({ request }) => request);
15
+ }
16
+ export function resetMockPush() {
17
+ subscriptions = new Map();
18
+ nextId = 1;
19
+ }
20
+ const unauthorized = () => HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
21
+ export const pushHandlers = [
22
+ http.get('*/api/push/public-key', () => isMockAuthenticated() ? HttpResponse.json({ publicKey: MOCK_PUSH_PUBLIC_KEY }) : unauthorized()),
23
+ http.post('*/api/push/subscriptions', async ({ request }) => {
24
+ if (!isMockAuthenticated())
25
+ return unauthorized();
26
+ const body = (await request.json());
27
+ const id = subscriptions.get(body.endpoint)?.id ?? nextId++;
28
+ subscriptions.set(body.endpoint, { id, request: body });
29
+ return HttpResponse.json({ id }, { status: 201 });
30
+ }),
31
+ http.delete('*/api/push/subscriptions/:id', ({ params }) => {
32
+ if (!isMockAuthenticated())
33
+ return unauthorized();
34
+ const entry = [...subscriptions.entries()].find(([, value]) => String(value.id) === params.id);
35
+ if (!entry)
36
+ return HttpResponse.json({ detail: 'No such subscription.' }, { status: 404 });
37
+ subscriptions.delete(entry[0]);
38
+ return new HttpResponse(null, { status: 204 });
39
+ }),
40
+ ];
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: UI Foundation — the contract every app shares
4
- version: "4.1.0"
4
+ version: "4.17.0"
5
5
  description: >
6
6
  The part of an app's API contract that the foundation package's own code
7
7
  calls: session-cookie auth under /auth/*, and the two error envelopes the
@@ -13,8 +13,8 @@ info:
13
13
 
14
14
  A path marked `x-optional: true` may be left out of an app's spec (an
15
15
  app without self-service sign-up omits /auth/register, an app that
16
- doesn't render DataEnvironmentBanner omits /environment), but if
17
- present it must match.
16
+ doesn't render DataEnvironmentBanner omits /environment, an app without
17
+ reminders omits /push/*), but if present it must match.
18
18
 
19
19
  servers:
20
20
  - url: /api
@@ -161,6 +161,68 @@ paths:
161
161
  "422": { $ref: "#/components/responses/ValidationError" }
162
162
  "500": { $ref: "#/components/responses/ServerError" }
163
163
 
164
+ /push/public-key:
165
+ x-optional: true
166
+ get:
167
+ operationId: getPushPublicKey
168
+ summary: The app's VAPID public key, which a browser subscribes to push with
169
+ description: >
170
+ For usePushPermission. 404 when the backend has push turned off.
171
+ tags: [push]
172
+ responses:
173
+ "200":
174
+ description: OK.
175
+ content:
176
+ application/json:
177
+ schema: { $ref: "#/components/schemas/PushPublicKey" }
178
+ "401": { $ref: "#/components/responses/UnauthorizedError" }
179
+ "404": { $ref: "#/components/responses/NotFoundError" }
180
+ "500": { $ref: "#/components/responses/ServerError" }
181
+
182
+ /push/subscriptions:
183
+ x-optional: true
184
+ post:
185
+ operationId: savePushSubscription
186
+ summary: Save this device's push subscription for the signed-in user
187
+ description: >
188
+ The browser's PushSubscription.toJSON(). The same endpoint again
189
+ replaces its keys and owner and answers the same id. The endpoint
190
+ must be a push service's (422 otherwise): the backend POSTs to it.
191
+ tags: [push]
192
+ requestBody:
193
+ required: true
194
+ content:
195
+ application/json:
196
+ schema: { $ref: "#/components/schemas/PushSubscriptionRequest" }
197
+ responses:
198
+ "201":
199
+ description: Saved.
200
+ content:
201
+ application/json:
202
+ schema: { $ref: "#/components/schemas/PushSubscriptionCreated" }
203
+ "401": { $ref: "#/components/responses/UnauthorizedError" }
204
+ "404": { $ref: "#/components/responses/NotFoundError" }
205
+ "422": { $ref: "#/components/responses/ValidationError" }
206
+ "500": { $ref: "#/components/responses/ServerError" }
207
+
208
+ /push/subscriptions/{id}:
209
+ x-optional: true
210
+ delete:
211
+ operationId: deletePushSubscription
212
+ summary: Stop sending to one of the signed-in user's devices
213
+ tags: [push]
214
+ parameters:
215
+ - name: id
216
+ in: path
217
+ required: true
218
+ schema: { type: integer }
219
+ responses:
220
+ "204":
221
+ description: Deleted.
222
+ "401": { $ref: "#/components/responses/UnauthorizedError" }
223
+ "404": { $ref: "#/components/responses/NotFoundError" }
224
+ "500": { $ref: "#/components/responses/ServerError" }
225
+
164
226
  components:
165
227
  schemas:
166
228
  DataEnvironment:
@@ -235,6 +297,40 @@ components:
235
297
  status: { type: integer, minimum: 100, maximum: 599, description: The HTTP status of a failed request. }
236
298
  release: { type: string, maxLength: 100, description: The frontend build (its commit). }
237
299
 
300
+ PushPublicKey:
301
+ type: object
302
+ required: [publicKey]
303
+ properties:
304
+ publicKey:
305
+ type: string
306
+ minLength: 1
307
+ description: The P-256 public key, base64url (applicationServerKey).
308
+
309
+ PushSubscriptionRequest:
310
+ type: object
311
+ required: [endpoint, keys]
312
+ properties:
313
+ endpoint:
314
+ type: string
315
+ minLength: 1
316
+ maxLength: 2048
317
+ description: The push service's URL for this device.
318
+ expirationTime:
319
+ type: ["number", "null"]
320
+ description: Milliseconds since the epoch, sent by some browsers, ignored.
321
+ keys:
322
+ type: object
323
+ required: [p256dh, auth]
324
+ properties:
325
+ p256dh: { type: string, minLength: 1, maxLength: 100 }
326
+ auth: { type: string, minLength: 1, maxLength: 32 }
327
+
328
+ PushSubscriptionCreated:
329
+ type: object
330
+ required: [id]
331
+ properties:
332
+ id: { type: integer }
333
+
238
334
  ValidationErrorBody:
239
335
  type: object
240
336
  description: FastAPI's default 422 shape (RequestValidationError).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.15.0",
3
+ "version": "4.17.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": {
package/vite/index.d.ts CHANGED
@@ -24,3 +24,30 @@ export type BuildInfoOptions = {
24
24
  * `BUILD_REPO_URL`.
25
25
  */
26
26
  export default function buildInfo(options?: BuildInfoOptions): Plugin
27
+
28
+ export type PwaOptions = {
29
+ /** The app's name, as the Home Screen and the install prompt show it. */
30
+ name: string
31
+ /** A shorter one for under the icon. Defaults to `name`; its first letter is the SVG icon's. */
32
+ shortName?: string
33
+ description?: string
34
+ /** `#rrggbb`. Defaults to the package's `--primary` token. */
35
+ themeColor?: string
36
+ /** The letter's colour on the SVG icon, `#rrggbb`. Defaults to `--primary-foreground`. */
37
+ themeForegroundColor?: string
38
+ /** The splash screen's, `#rrggbb`. Defaults to `--background`. */
39
+ backgroundColor?: string
40
+ /**
41
+ * The app's own icon, a square PNG of at least 512×512 (path from the Vite
42
+ * root). Replaces the generated ones everywhere.
43
+ */
44
+ icon?: string
45
+ }
46
+
47
+ /**
48
+ * Makes the app installable and receives reminders: serves and builds
49
+ * `/manifest.webmanifest`, `/icons/*` and `/push-sw.js` (the service worker
50
+ * usePushPermission registers), and adds the manifest, theme-color and
51
+ * apple-touch-icon tags to index.html.
52
+ */
53
+ export function pwa(options: PwaOptions): Plugin
package/vite/index.js CHANGED
@@ -26,6 +26,8 @@
26
26
  import fs from 'node:fs'
27
27
  import path from 'node:path'
28
28
 
29
+ export { pwa } from './pwa.js'
30
+
29
31
  const OWN_FIELDS = ['api', 'commit', 'pr', 'builtAt', 'schema']
30
32
 
31
33
  /** @param {string | undefined} value */
@@ -0,0 +1,46 @@
1
+ // The app's service worker, written by pwa() (./index.js) as /push-sw.js.
2
+ // Push and notification clicks only: no fetch handler, so it never caches
3
+ // the app or stands between it and Vite's hashed files. Registered by
4
+ // usePushPermission's enable(), not on load. ICON is set by pwa().
5
+ /* global self, clients */
6
+ const ICON = '__ICON__'
7
+
8
+ self.addEventListener('install', () => self.skipWaiting())
9
+ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
10
+
11
+ // app/push.py sends {title, body, url, tag}. Anything else still shows.
12
+ self.addEventListener('push', (event) => {
13
+ let data = {}
14
+ try {
15
+ data = event.data ? event.data.json() : {}
16
+ } catch {
17
+ data = { body: event.data ? event.data.text() : '' }
18
+ }
19
+ const options = { body: data.body || '', icon: ICON, data: { url: data.url || '/' } }
20
+ if (data.tag) options.tag = data.tag
21
+ event.waitUntil(self.registration.showNotification(data.title || 'Reminder', options))
22
+ })
23
+
24
+ // Opens the message's path in the app: in a window already open on the
25
+ // app if there is one, else a new one. Never another site.
26
+ self.addEventListener('notificationclick', (event) => {
27
+ event.notification.close()
28
+ const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin)
29
+ if (target.origin !== self.location.origin) return
30
+ event.waitUntil(
31
+ (async () => {
32
+ const windows = await clients.matchAll({ type: 'window', includeUncontrolled: true })
33
+ const open = windows.find((client) => new URL(client.url).origin === target.origin)
34
+ if (open) {
35
+ await open.focus()
36
+ try {
37
+ await open.navigate(target.href)
38
+ return
39
+ } catch {
40
+ // An uncontrolled window can't be navigated from here.
41
+ }
42
+ }
43
+ await clients.openWindow(target.href)
44
+ })(),
45
+ )
46
+ })
package/vite/pwa.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ // The helpers behind pwa(), typed for the package's own tests. Apps use
2
+ // `pwa` from `@tristan2828/ui-foundation/vite` (./index.d.ts).
3
+ import type { HtmlTagDescriptor, Plugin } from 'vite'
4
+ import type { PwaOptions } from './index'
5
+
6
+ export function themeToken(css: string, name: string): string
7
+ export function oklchToHex(value: string): string
8
+ export function solidPng(size: number, hex: string): Buffer
9
+ export function letterSvg(name: string, background: string, foreground: string): string
10
+ export function pwaFiles(
11
+ options: PwaOptions,
12
+ base?: string,
13
+ root?: string,
14
+ ): {
15
+ files: Map<string, { type: string; source: string | Buffer }>
16
+ tags: (HtmlTagDescriptor & { attrs: Record<string, string> })[]
17
+ }
18
+ export function pwa(options: PwaOptions): Plugin