@tristan2828/ui-foundation 4.16.0 → 4.18.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 +2 -2
- package/conventions/AGENTS.md +7 -0
- package/conventions/docs/add-an-entity.md +18 -0
- package/conventions/docs/reminders.md +85 -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/milkdown-editor.js +4 -2
- package/dist/components/app/row-edit-cells.js +7 -4
- package/dist/hooks/use-push-permission.d.ts +72 -0
- package/dist/hooks/use-push-permission.js +154 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/link-href.d.ts +2 -0
- package/dist/lib/link-href.js +9 -1
- 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/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.js +25 -4
- 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/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ 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
24
|
export { SessionView, type SessionViewProps } from './components/app/session-view.js';
|
|
25
|
+
export { AllowReminders, type AllowRemindersProps } from './components/app/allow-reminders.js';
|
|
25
26
|
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
26
27
|
export { Notice, type NoticeProps, type NoticeTone } from './components/app/notice.js';
|
|
27
28
|
export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
|
|
@@ -50,5 +51,6 @@ export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
|
50
51
|
export { useSessionClock, type SessionClock, type SessionClockOptions, type SessionClockState, type SessionStatus, } from './hooks/use-session-clock.js';
|
|
51
52
|
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
52
53
|
export { useMuted } from './hooks/use-muted.js';
|
|
54
|
+
export { createPushController, usePushPermission, type PushController, type PushControllerOptions, type PushEnvironment, type PushPermission, type PushStatus, } from './hooks/use-push-permission.js';
|
|
53
55
|
export { cn } from './lib/utils.js';
|
|
54
56
|
export { chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ 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
29
|
export { SessionView } from './components/app/session-view.js';
|
|
30
|
+
export { AllowReminders } from './components/app/allow-reminders.js';
|
|
30
31
|
export { StageCircle, } from './components/app/stage-circle.js';
|
|
31
32
|
export { Notice } from './components/app/notice.js';
|
|
32
33
|
export { SourceCredit } from './components/app/source-credit.js';
|
|
@@ -55,6 +56,7 @@ export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
|
55
56
|
export { useSessionClock, } from './hooks/use-session-clock.js';
|
|
56
57
|
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
57
58
|
export { useMuted } from './hooks/use-muted.js';
|
|
59
|
+
export { createPushController, usePushPermission, } from './hooks/use-push-permission.js';
|
|
58
60
|
// Utilities
|
|
59
61
|
export { cn } from './lib/utils.js';
|
|
60
62
|
export { chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
|
package/dist/lib/link-href.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** The `href` to save, `''` to remove the link, or `null` when it's refused. */
|
|
2
2
|
export declare function linkHref(typed: string): string | null;
|
|
3
|
+
/** Why `linkHref` refused what was typed, for the message shown with it. */
|
|
4
|
+
export declare function linkRefusal(typed: string): 'whitespace' | 'scheme';
|
package/dist/lib/link-href.js
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
// `https://example.com` and a bare `name@example.com` a `mailto:`; a path
|
|
4
4
|
// on this site (`/x`, never `//x`) or a heading (`#x`) is kept. Any other scheme
|
|
5
5
|
// (`javascript:`, `data:`, `file:`) is refused, so a link can't run code
|
|
6
|
-
// or reach a local file.
|
|
6
|
+
// or reach a local file. An address with whitespace inside it
|
|
7
|
+
// (`example wiki`, `https://a b`) is refused too: it isn't a working link,
|
|
8
|
+
// and adding https:// to two words would save one that looks like it is.
|
|
7
9
|
/** The `href` to save, `''` to remove the link, or `null` when it's refused. */
|
|
8
10
|
export function linkHref(typed) {
|
|
9
11
|
const value = typed.trim();
|
|
10
12
|
if (value === '')
|
|
11
13
|
return '';
|
|
14
|
+
if (/\s/.test(value))
|
|
15
|
+
return null;
|
|
12
16
|
if (/^(https?:\/\/|mailto:|tel:)/i.test(value))
|
|
13
17
|
return value;
|
|
14
18
|
// A scheme of its own (`javascript:`), not a port (`example.com:8080`).
|
|
@@ -24,3 +28,7 @@ export function linkHref(typed) {
|
|
|
24
28
|
return `mailto:${value}`;
|
|
25
29
|
return `https://${value}`;
|
|
26
30
|
}
|
|
31
|
+
/** Why `linkHref` refused what was typed, for the message shown with it. */
|
|
32
|
+
export function linkRefusal(typed) {
|
|
33
|
+
return /\s/.test(typed.trim()) ? 'whitespace' : 'scheme';
|
|
34
|
+
}
|
package/dist/mocks/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/mocks/index.js
CHANGED
|
@@ -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
|
+
];
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -55,6 +55,18 @@ export type A11ySuiteOptions = {
|
|
|
55
55
|
*/
|
|
56
56
|
nav?: readonly NavEntry[];
|
|
57
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Every data view on the page has loaded: at least one shows its data (or,
|
|
60
|
+
* with `{ success: false }`, the page has its screen up), and none still
|
|
61
|
+
* shows its skeleton. A page may hold several (two `DataTable`s, an
|
|
62
|
+
* `EntityView` over a table of its sub-records), so this never waits on a
|
|
63
|
+
* locator that matches each of them: Playwright's strict mode refuses
|
|
64
|
+
* `expect(page.locator('[data-state="success"]')).toBeVisible()` as soon
|
|
65
|
+
* as two match. Use it before reading or scanning a screen.
|
|
66
|
+
*/
|
|
67
|
+
export declare function expectDataViewsLoaded(page: Page, { success }?: {
|
|
68
|
+
success?: boolean;
|
|
69
|
+
}): Promise<void>;
|
|
58
70
|
/**
|
|
59
71
|
* Accessibility of the real screens in light and dark mode: every page in
|
|
60
72
|
* the Primary nav (one test each from `nav`, or discovered in the sidebar
|
package/dist/testing/index.js
CHANGED
|
@@ -107,11 +107,31 @@ const NAV_PAGE_BUDGET_MS = 10_000;
|
|
|
107
107
|
function navEntryRoutes(nav) {
|
|
108
108
|
return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Every data view on the page has loaded: at least one shows its data (or,
|
|
112
|
+
* with `{ success: false }`, the page has its screen up), and none still
|
|
113
|
+
* shows its skeleton. A page may hold several (two `DataTable`s, an
|
|
114
|
+
* `EntityView` over a table of its sub-records), so this never waits on a
|
|
115
|
+
* locator that matches each of them: Playwright's strict mode refuses
|
|
116
|
+
* `expect(page.locator('[data-state="success"]')).toBeVisible()` as soon
|
|
117
|
+
* as two match. Use it before reading or scanning a screen.
|
|
118
|
+
*/
|
|
119
|
+
export async function expectDataViewsLoaded(page, { success = true } = {}) {
|
|
120
|
+
if (success)
|
|
121
|
+
await expect(page.locator('[data-state="success"]').first()).toBeVisible();
|
|
122
|
+
// A data view renders its skeleton from its first paint, so once the
|
|
123
|
+
// screen is up every one of them is either loading or settled.
|
|
124
|
+
else
|
|
125
|
+
await expect(page.getByRole('main').first()).toBeVisible();
|
|
126
|
+
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
127
|
+
}
|
|
110
128
|
async function expectNavPageAccessible(page, route, colorScheme) {
|
|
111
129
|
await page.goto(route);
|
|
112
130
|
await expect(page.locator('html')).toHaveClass(colorScheme);
|
|
113
|
-
// Wait for data, not the loading skeleton, so the real UI is checked.
|
|
114
|
-
|
|
131
|
+
// Wait for data, not the loading skeleton, so the real UI is checked. A
|
|
132
|
+
// nav page may have no data view, or only an empty one, so none has to
|
|
133
|
+
// show data.
|
|
134
|
+
await expectDataViewsLoaded(page, { success: false });
|
|
115
135
|
await expectNoAxeViolations(page);
|
|
116
136
|
}
|
|
117
137
|
/**
|
|
@@ -163,8 +183,9 @@ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes,
|
|
|
163
183
|
for (const route of viewRoutes) {
|
|
164
184
|
test(`${route} has zero axe violations`, async ({ page }) => {
|
|
165
185
|
await page.goto(route);
|
|
166
|
-
// EntityView's loaded state, not its skeleton or an error
|
|
167
|
-
|
|
186
|
+
// EntityView's loaded state, not its skeleton or an error, and
|
|
187
|
+
// every other data view on the page loaded too.
|
|
188
|
+
await expectDataViewsLoaded(page);
|
|
168
189
|
await expectNoAxeViolations(page);
|
|
169
190
|
});
|
|
170
191
|
}
|
package/openapi/foundation.yaml
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
3
|
+
"version": "4.18.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
package/vite/push-sw.js
ADDED
|
@@ -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
|