@tristan2828/ui-foundation 4.15.0 → 4.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/conventions/AGENTS.md +14 -0
- package/conventions/docs/reminders.md +85 -0
- package/conventions/docs/timed-screens.md +155 -0
- package/dist/api/gateway/push.d.ts +8 -0
- package/dist/api/gateway/push.js +25 -0
- package/dist/api/schema.d.ts +148 -0
- package/dist/components/app/allow-reminders.d.ts +9 -0
- package/dist/components/app/allow-reminders.js +11 -0
- package/dist/components/app/session-view.d.ts +19 -0
- package/dist/components/app/session-view.js +68 -0
- package/dist/hooks/use-muted.d.ts +15 -0
- package/dist/hooks/use-muted.js +73 -0
- package/dist/hooks/use-prefers-reduced-motion.d.ts +2 -0
- package/dist/hooks/use-prefers-reduced-motion.js +17 -0
- package/dist/hooks/use-push-permission.d.ts +72 -0
- package/dist/hooks/use-push-permission.js +154 -0
- package/dist/hooks/use-session-clock.d.ts +42 -0
- package/dist/hooks/use-session-clock.js +111 -0
- package/dist/hooks/use-wake-lock.d.ts +18 -0
- package/dist/hooks/use-wake-lock.js +70 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/lib/audio-cues.d.ts +52 -0
- package/dist/lib/audio-cues.js +138 -0
- package/dist/mocks/index.d.ts +1 -0
- package/dist/mocks/index.js +1 -0
- package/dist/mocks/push.d.ts +9 -0
- package/dist/mocks/push.js +40 -0
- package/openapi/foundation.yaml +99 -3
- package/package.json +1 -1
- package/vite/index.d.ts +27 -0
- package/vite/index.js +2 -0
- package/vite/push-sw.js +46 -0
- package/vite/pwa.d.ts +18 -0
- package/vite/pwa.js +230 -0
package/vite/pwa.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// `pwa()` from `@tristan2828/ui-foundation/vite`: the app installable (a web
|
|
2
|
+
// manifest and icons) and the service worker reminders arrive through
|
|
3
|
+
// (issue #167). An app's vite.config.ts:
|
|
4
|
+
//
|
|
5
|
+
// import buildInfo, { pwa } from '@tristan2828/ui-foundation/vite'
|
|
6
|
+
// plugins: [react(), tailwindcss(), buildInfo(), pwa({ name: 'Morning Routines', shortName: 'Routines' })]
|
|
7
|
+
//
|
|
8
|
+
// Serves and builds, at the site root:
|
|
9
|
+
// - /manifest.webmanifest: the name, `display: standalone` (which iOS needs
|
|
10
|
+
// before push works there), and the theme's colours.
|
|
11
|
+
// - /icons/: an SVG with the name's first letter (Chrome, Android) and
|
|
12
|
+
// plain PNGs in the theme colour (iOS's Home Screen icon must be a PNG,
|
|
13
|
+
// and drawing a letter into one needs a font rasteriser the package
|
|
14
|
+
// doesn't carry). `icon` replaces them all with the app's own PNG.
|
|
15
|
+
// - /push-sw.js: ./push-sw.js, showing pushes and opening their path.
|
|
16
|
+
// And adds the manifest, theme-color and apple-touch-icon tags to
|
|
17
|
+
// index.html. Registering the worker is usePushPermission's, on a click.
|
|
18
|
+
//
|
|
19
|
+
// Colours default to the package's tokens (styles/theme.css: --primary,
|
|
20
|
+
// --primary-foreground, --background), converted to hex as a manifest
|
|
21
|
+
// needs. An app that changed its tokens passes its own.
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import zlib from 'node:zlib'
|
|
27
|
+
|
|
28
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
const THEME_CSS = path.join(HERE, '..', 'styles', 'theme.css')
|
|
30
|
+
const WORKER_SOURCE = path.join(HERE, 'push-sw.js')
|
|
31
|
+
const HEX = /^#[0-9a-f]{6}$/i
|
|
32
|
+
|
|
33
|
+
// ------------------------------------------------------------- colours
|
|
34
|
+
|
|
35
|
+
/** The value of `--name` in theme.css's :root block, var() followed. */
|
|
36
|
+
export function themeToken(css, name, seen = new Set()) {
|
|
37
|
+
const root = css.slice(css.indexOf(':root'), css.indexOf('.dark'))
|
|
38
|
+
const match = new RegExp(`--${name}:\\s*([^;]+);`).exec(root)
|
|
39
|
+
if (!match) throw new Error(`pwa: no --${name} in the theme.`)
|
|
40
|
+
const value = match[1].trim()
|
|
41
|
+
const reference = /^var\(--([\w-]+)\)$/.exec(value)
|
|
42
|
+
if (!reference) return value
|
|
43
|
+
if (seen.has(name)) throw new Error(`pwa: --${name} refers to itself.`)
|
|
44
|
+
seen.add(name)
|
|
45
|
+
return themeToken(css, reference[1], seen)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `oklch(L C H)` → `#rrggbb` (sRGB, clipped). */
|
|
49
|
+
export function oklchToHex(value) {
|
|
50
|
+
const match = /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?\s*\)$/.exec(value.trim())
|
|
51
|
+
if (!match) throw new Error(`pwa: can't read ${value} as oklch(L C H); pass the colour as #rrggbb.`)
|
|
52
|
+
const l = Number(match[1]) / (match[2] ? 100 : 1)
|
|
53
|
+
const c = Number(match[3])
|
|
54
|
+
const h = (Number(match[4]) * Math.PI) / 180
|
|
55
|
+
const a = c * Math.cos(h)
|
|
56
|
+
const b = c * Math.sin(h)
|
|
57
|
+
// OKLab → LMS (cubed) → linear sRGB (Björn Ottosson's matrices).
|
|
58
|
+
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
|
59
|
+
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
|
60
|
+
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3
|
|
61
|
+
const linear = [
|
|
62
|
+
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
|
63
|
+
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
|
64
|
+
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
|
|
65
|
+
]
|
|
66
|
+
const toByte = (x) => {
|
|
67
|
+
const clipped = Math.min(1, Math.max(0, x))
|
|
68
|
+
const gamma = clipped <= 0.0031308 ? 12.92 * clipped : 1.055 * clipped ** (1 / 2.4) - 0.055
|
|
69
|
+
return Math.round(gamma * 255)
|
|
70
|
+
}
|
|
71
|
+
return `#${linear.map((x) => toByte(x).toString(16).padStart(2, '0')).join('')}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function colour(value, token) {
|
|
75
|
+
if (value !== undefined) {
|
|
76
|
+
if (!HEX.test(value)) throw new Error(`pwa: ${token} must be #rrggbb, not ${value}.`)
|
|
77
|
+
return value.toLowerCase()
|
|
78
|
+
}
|
|
79
|
+
return oklchToHex(themeToken(fs.readFileSync(THEME_CSS, 'utf8'), token))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- icons
|
|
83
|
+
|
|
84
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
85
|
+
let c = n
|
|
86
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
87
|
+
return c >>> 0
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
function crc32(bytes) {
|
|
91
|
+
let crc = 0xffffffff
|
|
92
|
+
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
|
93
|
+
return (crc ^ 0xffffffff) >>> 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function chunk(type, data) {
|
|
97
|
+
const length = Buffer.alloc(4)
|
|
98
|
+
length.writeUInt32BE(data.length)
|
|
99
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
|
|
100
|
+
const crc = Buffer.alloc(4)
|
|
101
|
+
crc.writeUInt32BE(crc32(body))
|
|
102
|
+
return Buffer.concat([length, body, crc])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A `size`×`size` PNG of one colour (#rrggbb). */
|
|
106
|
+
export function solidPng(size, hex) {
|
|
107
|
+
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
|
|
108
|
+
const header = Buffer.alloc(13)
|
|
109
|
+
header.writeUInt32BE(size, 0)
|
|
110
|
+
header.writeUInt32BE(size, 4)
|
|
111
|
+
header.set([8, 2, 0, 0, 0], 8) // 8-bit RGB, no interlace
|
|
112
|
+
const row = Buffer.alloc(1 + size * 3) // filter byte 0, then the pixels
|
|
113
|
+
for (let x = 0; x < size; x++) row.set(rgb, 1 + x * 3)
|
|
114
|
+
const pixels = zlib.deflateSync(Buffer.concat(Array.from({ length: size }, () => row)))
|
|
115
|
+
return Buffer.concat([
|
|
116
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
117
|
+
chunk('IHDR', header),
|
|
118
|
+
chunk('IDAT', pixels),
|
|
119
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
120
|
+
])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const escapeXml = (text) =>
|
|
124
|
+
text.replace(/[<>&'"]/g, (ch) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[ch])
|
|
125
|
+
|
|
126
|
+
/** A square SVG: the name's first letter on the theme colour. */
|
|
127
|
+
export function letterSvg(name, background, foreground) {
|
|
128
|
+
const letter = escapeXml([...name.trim()][0]?.toUpperCase() ?? '')
|
|
129
|
+
return (
|
|
130
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">` +
|
|
131
|
+
`<rect width="512" height="512" fill="${background}"/>` +
|
|
132
|
+
`<text x="256" y="256" dominant-baseline="central" text-anchor="middle" ` +
|
|
133
|
+
`font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="288" font-weight="600" ` +
|
|
134
|
+
`fill="${foreground}">${letter}</text></svg>\n`
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --------------------------------------------------------------- plugin
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Everything pwa() serves, by path from the site root, for `base`.
|
|
142
|
+
* @param {import('./index').PwaOptions} options
|
|
143
|
+
*/
|
|
144
|
+
export function pwaFiles(options, base = '/', root = process.cwd()) {
|
|
145
|
+
if (!options?.name?.trim()) throw new Error('pwa: `name` is required (the app as the Home Screen names it).')
|
|
146
|
+
const name = options.name.trim()
|
|
147
|
+
const shortName = options.shortName?.trim() || name
|
|
148
|
+
const theme = colour(options.themeColor, 'primary')
|
|
149
|
+
const onTheme = colour(options.themeForegroundColor, 'primary-foreground')
|
|
150
|
+
const background = colour(options.backgroundColor, 'background')
|
|
151
|
+
const at = (file) => `${base}${file}`
|
|
152
|
+
|
|
153
|
+
/** @type {Map<string, { type: string, source: string | Buffer }>} */
|
|
154
|
+
const files = new Map()
|
|
155
|
+
let icons
|
|
156
|
+
let touchIcon
|
|
157
|
+
if (options.icon) {
|
|
158
|
+
const own = path.resolve(root, options.icon)
|
|
159
|
+
if (!own.endsWith('.png') || !fs.existsSync(own)) throw new Error(`pwa: icon must be a PNG file; ${own} isn't one.`)
|
|
160
|
+
files.set('icons/icon.png', { type: 'image/png', source: fs.readFileSync(own) })
|
|
161
|
+
icons = [{ src: at('icons/icon.png'), sizes: '512x512', type: 'image/png', purpose: 'any' }]
|
|
162
|
+
touchIcon = 'icons/icon.png'
|
|
163
|
+
} else {
|
|
164
|
+
files.set('icons/icon.svg', { type: 'image/svg+xml', source: letterSvg(shortName, theme, onTheme) })
|
|
165
|
+
for (const size of [192, 512]) files.set(`icons/icon-${size}.png`, { type: 'image/png', source: solidPng(size, theme) })
|
|
166
|
+
files.set('icons/apple-touch-icon.png', { type: 'image/png', source: solidPng(180, theme) })
|
|
167
|
+
icons = [
|
|
168
|
+
{ src: at('icons/icon.svg'), sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
|
|
169
|
+
{ src: at('icons/icon-192.png'), sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
170
|
+
{ src: at('icons/icon-512.png'), sizes: '512x512', type: 'image/png', purpose: 'any' },
|
|
171
|
+
// One colour edge to edge: safe to crop to any mask shape.
|
|
172
|
+
{ src: at('icons/icon-512.png'), sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
173
|
+
]
|
|
174
|
+
touchIcon = 'icons/apple-touch-icon.png'
|
|
175
|
+
}
|
|
176
|
+
const manifest = {
|
|
177
|
+
name,
|
|
178
|
+
short_name: shortName,
|
|
179
|
+
...(options.description ? { description: options.description } : {}),
|
|
180
|
+
start_url: base,
|
|
181
|
+
scope: base,
|
|
182
|
+
display: 'standalone',
|
|
183
|
+
theme_color: theme,
|
|
184
|
+
background_color: background,
|
|
185
|
+
icons,
|
|
186
|
+
}
|
|
187
|
+
files.set('manifest.webmanifest', {
|
|
188
|
+
type: 'application/manifest+json',
|
|
189
|
+
source: `${JSON.stringify(manifest, null, 2)}\n`,
|
|
190
|
+
})
|
|
191
|
+
const worker = fs.readFileSync(WORKER_SOURCE, 'utf8').replace('__ICON__', options.icon ? at('icons/icon.png') : at('icons/icon-192.png'))
|
|
192
|
+
files.set('push-sw.js', { type: 'text/javascript', source: worker })
|
|
193
|
+
return {
|
|
194
|
+
files,
|
|
195
|
+
tags: [
|
|
196
|
+
{ tag: 'link', attrs: { rel: 'manifest', href: at('manifest.webmanifest') }, injectTo: 'head' },
|
|
197
|
+
{ tag: 'meta', attrs: { name: 'theme-color', content: theme }, injectTo: 'head' },
|
|
198
|
+
{ tag: 'link', attrs: { rel: 'apple-touch-icon', href: at(touchIcon) }, injectTo: 'head' },
|
|
199
|
+
],
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @param {import('./index').PwaOptions} options */
|
|
204
|
+
export function pwa(options) {
|
|
205
|
+
if (!options?.name?.trim()) throw new Error('pwa: `name` is required (the app as the Home Screen names it).')
|
|
206
|
+
/** @type {ReturnType<typeof pwaFiles>} */
|
|
207
|
+
let built
|
|
208
|
+
return {
|
|
209
|
+
name: 'ui-foundation:pwa',
|
|
210
|
+
configResolved(config) {
|
|
211
|
+
built = pwaFiles(options, config.base, config.root)
|
|
212
|
+
},
|
|
213
|
+
configureServer(server) {
|
|
214
|
+
server.middlewares.use((req, res, next) => {
|
|
215
|
+
const base = server.config.base
|
|
216
|
+
const url = (req.url ?? '').split('?')[0]
|
|
217
|
+
const file = url.startsWith(base) ? built.files.get(url.slice(base.length)) : undefined
|
|
218
|
+
if (!file) return next()
|
|
219
|
+
res.setHeader('Content-Type', file.type)
|
|
220
|
+
res.end(file.source)
|
|
221
|
+
})
|
|
222
|
+
},
|
|
223
|
+
transformIndexHtml() {
|
|
224
|
+
return built.tags
|
|
225
|
+
},
|
|
226
|
+
generateBundle() {
|
|
227
|
+
for (const [fileName, file] of built.files) this.emitFile({ type: 'asset', fileName, source: file.source })
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|