@cychien/ezdesign 0.0.0-stage → 0.9.1

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +615 -2
  3. package/dist/announcements/template-ERNKAKI5.svg +33 -0
  4. package/dist/cli.mjs +10095 -0
  5. package/dist/constants.d.ts +73 -0
  6. package/dist/events-worker.js +77 -0
  7. package/dist/extension/g/0.9.1-16d05e759b/dialog.css +1 -0
  8. package/dist/extension/g/0.9.1-16d05e759b/dialog.html +13 -0
  9. package/dist/extension/g/0.9.1-16d05e759b/dialog.js +1 -0
  10. package/dist/extension/g/0.9.1-16d05e759b/icon-128.png +0 -0
  11. package/dist/extension/g/0.9.1-16d05e759b/icon-16.png +0 -0
  12. package/dist/extension/g/0.9.1-16d05e759b/icon-32.png +0 -0
  13. package/dist/extension/g/0.9.1-16d05e759b/icon-48.png +0 -0
  14. package/dist/extension/g/0.9.1-16d05e759b/media.html +9 -0
  15. package/dist/extension/g/0.9.1-16d05e759b/media.js +1 -0
  16. package/dist/extension/g/0.9.1-16d05e759b/page.js +39 -0
  17. package/dist/extension/g/0.9.1-16d05e759b/setup.css +1 -0
  18. package/dist/extension/g/0.9.1-16d05e759b/setup.html +17 -0
  19. package/dist/extension/g/0.9.1-16d05e759b/setup.js +1 -0
  20. package/dist/extension/g/0.9.1-16d05e759b/worker.js +57 -0
  21. package/dist/extension/manifest.json +57 -0
  22. package/dist/native-host.mjs +20594 -0
  23. package/dist/overlay.css +422 -0
  24. package/dist/overlay.js +2642 -0
  25. package/dist/project-root.d.ts +10 -0
  26. package/dist/shell.css +4060 -0
  27. package/dist/shell.js +8284 -0
  28. package/dist/vite.d.ts +26 -0
  29. package/dist/vite.mjs +105 -0
  30. package/package.json +82 -4
  31. package/skills/ezdesign/SKILL.md +35 -0
  32. package/skills/ezdesign/reference/baseline.md +131 -0
  33. package/skills/ezdesign/reference/components.md +102 -0
  34. package/skills/ezdesign/reference/critique.md +129 -0
  35. package/skills/ezdesign/reference/design.md +247 -0
  36. package/skills/ezdesign/reference/inspiration.md +125 -0
  37. package/skills/ezdesign/reference/make.md +331 -0
  38. package/skills/ezdesign/reference/principles.md +130 -0
  39. package/skills/ezdesign/reference/product.md +127 -0
  40. package/skills/ezdesign/reference/stack.md +123 -0
  41. package/skills/ezdesign/reference/start.md +78 -0
  42. package/skills/ezdesign/scripts/page.d.mts +61 -0
  43. package/skills/ezdesign/scripts/page.mjs +908 -0
@@ -0,0 +1,908 @@
1
+ #!/usr/bin/env node
2
+ // Measures a rendered page, screenshots it, and compares two sets of screenshots, so the design,
3
+ // components and making steps run the same commands every time instead of improvising them.
4
+ //
5
+ // node page.mjs measure <url> what the page renders, tallied, as JSON
6
+ // node page.mjs shot <url> <file> [--viewport WxH[,WxH...]] [--around <css>] [--page]
7
+ // [--hover [css]] [--focus [css]] [--active [css]] [--states]
8
+ // the element at 2x, its states, and the page
9
+ // node page.mjs shots <url> <dir> [path ...] full-page PNGs at desktop and mobile per path
10
+ // node page.mjs compare <before-dir> <after-dir> which screenshots changed; exit 1 when any did
11
+ //
12
+ // It drives a headless Chrome of its own over the DevTools protocol, one browser per command. In an
13
+ // ezdesign session that browser is signed in the way the user's is: the daemon hands over the page's
14
+ // cookies and storage, in Playwright's storageState shape, through EZDESIGN_PAGE_STATE.
15
+
16
+ import { spawn } from 'node:child_process'
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ readFileSync,
22
+ readdirSync,
23
+ realpathSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ } from 'node:fs'
27
+ import { tmpdir } from 'node:os'
28
+ import { basename, dirname, extname, join, resolve } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { inflateSync } from 'node:zlib'
31
+
32
+ export const DESKTOP = { width: 1440, height: 900 }
33
+ export const MOBILE = { width: 390, height: 844 }
34
+ const SETTLE_MS = 400
35
+ const LOAD_TIMEOUT_MS = 30_000
36
+ const STATE_TIMEOUT_MS = 2000
37
+ const MARGIN = 48
38
+ // Twice the pixels, so a detail is judged on what it is rather than guessed at.
39
+ const PIECE_SCALE = 2
40
+ export const STATES = ['hover', 'focus', 'active']
41
+
42
+ // ---------------------------------------------------------------------------- the browser
43
+
44
+ const CHROMES = {
45
+ darwin: [
46
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
47
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
48
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
49
+ ],
50
+ linux: [
51
+ '/usr/bin/google-chrome',
52
+ '/usr/bin/google-chrome-stable',
53
+ '/usr/bin/chromium',
54
+ '/usr/bin/chromium-browser',
55
+ '/snap/bin/chromium',
56
+ ],
57
+ win32: [
58
+ `${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
59
+ `${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
60
+ `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
61
+ ],
62
+ }
63
+
64
+ export function findChrome(env = process.env, platform = process.platform) {
65
+ if (env.CHROME_PATH) return env.CHROME_PATH
66
+ const found = (CHROMES[platform] ?? []).find((path) => existsSync(path))
67
+ if (!found) throw new Error('no Chrome found; set CHROME_PATH to its executable')
68
+ return found
69
+ }
70
+
71
+ /** The DevTools protocol over the pipe Chrome opens on fds 3 and 4: JSON messages ended by NUL. */
72
+ async function launch() {
73
+ const profile = mkdtempSync(join(tmpdir(), 'ezdesign-page-'))
74
+ const chrome = spawn(
75
+ findChrome(),
76
+ [
77
+ '--headless=new',
78
+ '--remote-debugging-pipe',
79
+ `--user-data-dir=${profile}`,
80
+ '--no-first-run',
81
+ '--no-default-browser-check',
82
+ '--hide-scrollbars',
83
+ '--mute-audio',
84
+ '--disable-extensions',
85
+ '--use-mock-keychain',
86
+ '--password-store=basic',
87
+ 'about:blank',
88
+ ],
89
+ { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] },
90
+ )
91
+ const toChrome = chrome.stdio[3]
92
+ const fromChrome = chrome.stdio[4]
93
+ let id = 0
94
+ const pending = new Map()
95
+ const listeners = new Set()
96
+ let buffered = ''
97
+ fromChrome.setEncoding('utf8')
98
+ fromChrome.on('data', (chunk) => {
99
+ buffered += chunk
100
+ let end
101
+ while ((end = buffered.indexOf('\0')) >= 0) {
102
+ const message = JSON.parse(buffered.slice(0, end))
103
+ buffered = buffered.slice(end + 1)
104
+ if (message.id !== undefined && pending.has(message.id)) {
105
+ const { ok, fail } = pending.get(message.id)
106
+ pending.delete(message.id)
107
+ if (message.error)
108
+ fail(
109
+ new Error(
110
+ `${message.error.message}${message.error.data ? `: ${message.error.data}` : ''}`,
111
+ ),
112
+ )
113
+ else ok(message.result)
114
+ } else for (const listen of listeners) listen(message)
115
+ }
116
+ })
117
+ const died = new Promise((_, fail) => {
118
+ chrome.on('exit', (code) => fail(new Error(`Chrome exited (${code}) before the page was done`)))
119
+ chrome.on('error', fail)
120
+ })
121
+ died.catch(() => {})
122
+ const send = (method, params = {}, sessionId) =>
123
+ Promise.race([
124
+ new Promise((ok, fail) => {
125
+ const message = { id: ++id, method, params, ...(sessionId ? { sessionId } : {}) }
126
+ pending.set(message.id, { ok, fail })
127
+ toChrome.write(`${JSON.stringify(message)}\0`)
128
+ }),
129
+ died,
130
+ ])
131
+ const next = (sessionId, method, timeout) =>
132
+ new Promise((ok) => {
133
+ const timer = setTimeout(() => {
134
+ listeners.delete(listen)
135
+ ok(null)
136
+ }, timeout)
137
+ const listen = (m) => {
138
+ if (m.sessionId !== sessionId || m.method !== method) return
139
+ clearTimeout(timer)
140
+ listeners.delete(listen)
141
+ ok(m.params)
142
+ }
143
+ listeners.add(listen)
144
+ })
145
+
146
+ const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
147
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
148
+ const page = (method, params) => send(method, params, sessionId)
149
+ await page('Page.enable')
150
+
151
+ return {
152
+ async viewport({ width, height }, scale = 1) {
153
+ const mobile = isPhone({ width })
154
+ await page('Emulation.setDeviceMetricsOverride', {
155
+ width,
156
+ height,
157
+ deviceScaleFactor: scale,
158
+ mobile,
159
+ })
160
+ await page('Emulation.setTouchEmulationEnabled', { enabled: mobile })
161
+ },
162
+ async navigate(url) {
163
+ const loaded = next(sessionId, 'Page.loadEventFired', LOAD_TIMEOUT_MS)
164
+ const { errorText } = await page('Page.navigate', { url })
165
+ if (errorText) throw new Error(`could not open ${url}: ${errorText}`)
166
+ await loaded
167
+ },
168
+ /** Where the page ended up: a sign-in it bounced to is somewhere else. */
169
+ async open(url) {
170
+ await this.navigate(url)
171
+ return this.evaluate(
172
+ `async () => { await document.fonts.ready; await new Promise((r) => setTimeout(r, ${SETTLE_MS})); return location.href }`,
173
+ )
174
+ },
175
+ /** Cookies go in directly; storage is written from a blank page of the origin, served here so
176
+ * none of the app runs before it is in place. */
177
+ async signIn(origin, { cookies, storage }) {
178
+ if (cookies.length) {
179
+ await page('Network.setCookies', {
180
+ cookies: cookies.map(({ name, value, path }) => ({ name, value, path, url: origin })),
181
+ })
182
+ }
183
+ if (!storage) return
184
+ const serve = (m) => {
185
+ if (m.sessionId !== sessionId || m.method !== 'Fetch.requestPaused') return
186
+ page('Fetch.fulfillRequest', {
187
+ requestId: m.params.requestId,
188
+ responseCode: 200,
189
+ responseHeaders: [{ name: 'content-type', value: 'text/html' }],
190
+ body: Buffer.from('<!doctype html><title></title>').toString('base64'),
191
+ }).catch(() => {})
192
+ }
193
+ listeners.add(serve)
194
+ try {
195
+ await page('Fetch.enable', { patterns: [{ urlPattern: `${origin}/*` }] })
196
+ await this.navigate(`${origin}/`)
197
+ await this.evaluate(`() => (${seedStorage})(${JSON.stringify(storage)})`)
198
+ } finally {
199
+ listeners.delete(serve)
200
+ await page('Fetch.disable').catch(() => {})
201
+ }
202
+ },
203
+ async evaluate(fn) {
204
+ const { result, exceptionDetails } = await page('Runtime.evaluate', {
205
+ expression: `(${fn})()`,
206
+ awaitPromise: true,
207
+ returnByValue: true,
208
+ })
209
+ if (exceptionDetails) {
210
+ throw new Error(
211
+ exceptionDetails.exception?.description?.split('\n')[0] ?? exceptionDetails.text,
212
+ )
213
+ }
214
+ return result.value
215
+ },
216
+ /** The whole page when `clip` is left out, or that rect of it, past the viewport either way.
217
+ * `zoom` scales the image against the viewport's own pixel ratio. */
218
+ async screenshot(clip, zoom = 1) {
219
+ const { cssContentSize } = await page('Page.getLayoutMetrics')
220
+ const area = clip ?? {
221
+ x: 0,
222
+ y: 0,
223
+ width: cssContentSize.width,
224
+ height: cssContentSize.height,
225
+ }
226
+ const { data } = await page('Page.captureScreenshot', {
227
+ format: 'png',
228
+ captureBeyondViewport: true,
229
+ clip: { ...area, scale: zoom },
230
+ })
231
+ return Buffer.from(data, 'base64')
232
+ },
233
+ async mouse(type, x, y, buttons = 0) {
234
+ await page('Input.dispatchMouseEvent', {
235
+ type,
236
+ x,
237
+ y,
238
+ button: type === 'mouseMoved' ? 'none' : 'left',
239
+ buttons,
240
+ clickCount: type === 'mouseMoved' ? 0 : 1,
241
+ })
242
+ },
243
+ async key(key) {
244
+ for (const type of ['keyDown', 'keyUp']) await page('Input.dispatchKeyEvent', { type, key })
245
+ },
246
+ // Chrome writes to its profile until it has exited, so the folder goes only after that.
247
+ async close() {
248
+ const exited = new Promise((ok) => chrome.once('exit', ok))
249
+ await send('Browser.close').catch(() => {})
250
+ let timer
251
+ await Promise.race([exited, new Promise((ok) => (timer = setTimeout(ok, 2000)))])
252
+ clearTimeout(timer)
253
+ if (chrome.exitCode === null && chrome.signalCode === null) chrome.kill()
254
+ rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
255
+ },
256
+ }
257
+ }
258
+
259
+ async function withBrowser(url, run) {
260
+ const browser = await launch()
261
+ try {
262
+ const origin = new URL(url).origin
263
+ const state = signInFor(origin, await fetchPageState())
264
+ if (state) await browser.signIn(origin, state)
265
+ return await run(browser)
266
+ } finally {
267
+ await browser.close()
268
+ }
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------- signing in
272
+
273
+ async function fetchPageState(env = process.env) {
274
+ if (!env.EZDESIGN_PAGE_STATE || !env.EZDESIGN_PAGE_STATE_TOKEN) return null
275
+ try {
276
+ const res = await fetch(env.EZDESIGN_PAGE_STATE, {
277
+ headers: { authorization: `Bearer ${env.EZDESIGN_PAGE_STATE_TOKEN}` },
278
+ signal: AbortSignal.timeout(STATE_TIMEOUT_MS),
279
+ })
280
+ return res.ok ? await res.json() : null
281
+ } catch {
282
+ return null
283
+ }
284
+ }
285
+
286
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
287
+
288
+ /** A dev server answers on every loopback name, and the user's browser may know it by another. */
289
+ export function sameOrigin(a, b) {
290
+ const x = new URL(a)
291
+ const y = new URL(b)
292
+ if (x.protocol !== y.protocol || x.port !== y.port) return false
293
+ return x.hostname === y.hostname || (LOOPBACK.has(x.hostname) && LOOPBACK.has(y.hostname))
294
+ }
295
+
296
+ /** Whether a cookie for `domain` is sent to `host`, loopback names counting as one. */
297
+ function cookieReaches(domain, host) {
298
+ const bare = domain.replace(/^\./, '')
299
+ return bare === host || host.endsWith(`.${bare}`) || (LOOPBACK.has(bare) && LOOPBACK.has(host))
300
+ }
301
+
302
+ /** What of a storageState applies to the page at `origin`, or null when none of it does. */
303
+ export function signInFor(origin, state) {
304
+ if (!state) return null
305
+ const host = new URL(origin).hostname
306
+ const cookies = (state.cookies ?? []).filter((c) => cookieReaches(c.domain, host))
307
+ const entry = state.origins?.find((o) => sameOrigin(o.origin, origin)) ?? null
308
+ const storage = entry && {
309
+ localStorage: entry.localStorage ?? [],
310
+ sessionStorage: entry.sessionStorage ?? [],
311
+ indexedDB: entry.indexedDB ?? [],
312
+ }
313
+ return cookies.length || storage ? { cookies, storage } : null
314
+ }
315
+
316
+ /** Runs in the page. Writes the user's storage into this browser's copy of the origin. */
317
+ async function seedStorage({ localStorage: local, sessionStorage: session, indexedDB: dbs }) {
318
+ for (const { name, value } of local) localStorage.setItem(name, value)
319
+ for (const { name, value } of session) sessionStorage.setItem(name, value)
320
+ const path = (o) => o.keyPathArray ?? o.keyPath
321
+ for (const db of dbs) {
322
+ await new Promise((ok) => {
323
+ const request = indexedDB.open(db.name, db.version)
324
+ request.onupgradeneeded = () => {
325
+ for (const s of db.stores) {
326
+ const store = request.result.createObjectStore(s.name, {
327
+ ...(path(s) !== undefined ? { keyPath: path(s) } : {}),
328
+ autoIncrement: s.autoIncrement,
329
+ })
330
+ for (const i of s.indexes)
331
+ store.createIndex(i.name, path(i), { multiEntry: i.multiEntry, unique: i.unique })
332
+ for (const r of s.records) {
333
+ if ('key' in r) store.put(r.value, r.key)
334
+ else store.put(r.value)
335
+ }
336
+ }
337
+ }
338
+ request.onsuccess = () => {
339
+ request.result.close()
340
+ ok()
341
+ }
342
+ request.onerror = () => ok()
343
+ })
344
+ }
345
+ }
346
+
347
+ /** The page went somewhere other than where it was sent: a sign-in, most of the time. */
348
+ export function landedElsewhere(requested, landed) {
349
+ const trim = (p) => p.replace(/\/+$/, '') || '/'
350
+ const a = new URL(requested)
351
+ const b = new URL(landed)
352
+ return !sameOrigin(a.href, b.href) || trim(a.pathname) !== trim(b.pathname)
353
+ }
354
+
355
+ const redirectLine = (requested, landed) => `redirected: ${requested} ended at ${landed}`
356
+
357
+ // Phones get touch and a mobile viewport, so a layout that keys on either renders as it would there.
358
+ const isPhone = ({ width }) => width < 600
359
+
360
+ // ---------------------------------------------------------------------------- measure
361
+
362
+ /** Runs in the page. Counts what the visible elements render, so frequency says what dominates. */
363
+ function tally() {
364
+ const TOP = 12
365
+ const rows = (map) =>
366
+ [...map.entries()]
367
+ .sort((a, b) => b[1] - a[1])
368
+ .slice(0, TOP)
369
+ .map(([value, count]) => ({ value, count }))
370
+ const add = (map, key, by = 1) => {
371
+ if (key) map.set(key, (map.get(key) ?? 0) + by)
372
+ }
373
+ const transparent = (c) => !c || c === 'transparent' || /rgba?\([^)]*,\s*0\)$/.test(c)
374
+ const px = (v) => (v && v !== '0px' && v !== 'normal' && v !== 'none' ? v : null)
375
+
376
+ const m = {
377
+ text: new Map(),
378
+ background: new Map(),
379
+ border: new Map(),
380
+ family: new Map(),
381
+ size: new Map(),
382
+ weight: new Map(),
383
+ lineHeight: new Map(),
384
+ letterSpacing: new Map(),
385
+ radius: new Map(),
386
+ shadow: new Map(),
387
+ padding: new Map(),
388
+ gap: new Map(),
389
+ }
390
+ const area = new Map()
391
+ const screen = innerWidth * Math.max(innerHeight, document.documentElement.scrollHeight)
392
+ let elements = 0
393
+ for (const el of document.body.querySelectorAll('*')) {
394
+ const r = el.getBoundingClientRect()
395
+ if (r.width < 1 || r.height < 1) continue
396
+ const s = getComputedStyle(el)
397
+ if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) === 0) continue
398
+ elements += 1
399
+ const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())
400
+ if (ownText) {
401
+ add(m.text, s.color)
402
+ add(m.family, s.fontFamily.split(',')[0].replace(/["']/g, '').trim())
403
+ add(m.size, s.fontSize)
404
+ add(m.weight, s.fontWeight)
405
+ add(m.lineHeight, s.lineHeight)
406
+ add(m.letterSpacing, px(s.letterSpacing))
407
+ }
408
+ if (!transparent(s.backgroundColor)) {
409
+ add(m.background, s.backgroundColor)
410
+ add(area, s.backgroundColor, r.width * r.height)
411
+ }
412
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
413
+ if (parseFloat(s[`border${side}Width`]) > 0 && s[`border${side}Style`] !== 'none') {
414
+ add(m.border, `${s[`border${side}Width`]} ${s[`border${side}Color`]}`)
415
+ break
416
+ }
417
+ }
418
+ add(m.radius, px(s.borderRadius))
419
+ add(m.shadow, px(s.boxShadow))
420
+ for (const v of [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft])
421
+ add(m.padding, px(v))
422
+ add(m.gap, px(s.rowGap))
423
+ add(m.gap, s.columnGap !== s.rowGap ? px(s.columnGap) : null)
424
+ }
425
+
426
+ // A stock palette defines hundreds of tokens no page uses; the project's own are what matter.
427
+ const STOCK =
428
+ /^--(tw-|color-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\d)/
429
+ const tokens = {}
430
+ const root = getComputedStyle(document.documentElement)
431
+ let tokenCount = 0
432
+ for (const sheet of document.styleSheets) {
433
+ let rules
434
+ try {
435
+ rules = sheet.cssRules
436
+ } catch {
437
+ continue
438
+ }
439
+ const walk = (list) => {
440
+ for (const rule of list) {
441
+ if (rule.cssRules && !rule.selectorText) walk(rule.cssRules)
442
+ if (!rule.selectorText || !/(^|,)\s*(:root|html)\s*(,|$)/.test(rule.selectorText)) continue
443
+ for (const name of rule.style) {
444
+ if (!name.startsWith('--') || name in tokens) continue
445
+ tokenCount += 1
446
+ if (!STOCK.test(name)) tokens[name] = root.getPropertyValue(name).trim()
447
+ }
448
+ }
449
+ }
450
+ walk(rules)
451
+ }
452
+
453
+ const widths = new Map()
454
+ for (const el of document.querySelectorAll(
455
+ 'main, section, header, footer, [class*="container"], [class*="shell"]',
456
+ )) {
457
+ const w = Math.round(el.getBoundingClientRect().width)
458
+ if (w > 0 && w < innerWidth) add(widths, `${w}px`)
459
+ }
460
+
461
+ return {
462
+ viewport: {
463
+ width: innerWidth,
464
+ height: innerHeight,
465
+ scrollHeight: document.documentElement.scrollHeight,
466
+ },
467
+ overflowsSideways: document.documentElement.scrollWidth > innerWidth,
468
+ elements,
469
+ colors: { text: rows(m.text), background: rows(m.background), border: rows(m.border) },
470
+ backgroundShare: rows(area).map(({ value, count }) => ({
471
+ value,
472
+ percent: Math.round((count / screen) * 1000) / 10,
473
+ })),
474
+ type: {
475
+ family: rows(m.family),
476
+ size: rows(m.size),
477
+ weight: rows(m.weight),
478
+ lineHeight: rows(m.lineHeight),
479
+ letterSpacing: rows(m.letterSpacing),
480
+ },
481
+ radius: rows(m.radius),
482
+ shadow: rows(m.shadow),
483
+ spacing: { padding: rows(m.padding), gap: rows(m.gap) },
484
+ containerWidths: rows(widths),
485
+ tokens: { declared: tokenCount, project: tokens },
486
+ }
487
+ }
488
+
489
+ function measure(url) {
490
+ return withBrowser(url, async (browser) => {
491
+ await browser.viewport(DESKTOP)
492
+ const landed = await browser.open(url)
493
+ const desktop = await browser.evaluate(tally)
494
+ await browser.viewport(MOBILE)
495
+ await browser.open(url)
496
+ const { tokens, ...mobile } = await browser.evaluate(tally)
497
+ return {
498
+ url,
499
+ ...(landedElsewhere(url, landed) ? { redirected: landed } : {}),
500
+ desktop,
501
+ mobile,
502
+ }
503
+ })
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------- shot
507
+
508
+ /** Runs in the page. Where the element sits on the whole page, and what of it no screenshot shows. */
509
+ function locate(selector) {
510
+ const all = document.querySelectorAll(selector)
511
+ if (!all.length) return { found: 0 }
512
+ const el = all[0]
513
+ const r = el.getBoundingClientRect()
514
+ const name = (e) => {
515
+ const cls = typeof e.className === 'string' ? e.className.trim().split(/\s+/).slice(0, 2) : []
516
+ return (
517
+ e.tagName.toLowerCase() +
518
+ (e.id ? `#${e.id}` : '') +
519
+ cls.map((c) => (c ? `.${c}` : '')).join('')
520
+ )
521
+ }
522
+ const clipped = []
523
+ for (const e of [el, ...el.querySelectorAll('*')]) {
524
+ const st = getComputedStyle(e)
525
+ const below = st.overflowY !== 'visible' ? e.scrollHeight - e.clientHeight : 0
526
+ const side = st.overflowX !== 'visible' ? e.scrollWidth - e.clientWidth : 0
527
+ if (below > 1) clipped.push(`${name(e)} scrolls ${below}px more below`)
528
+ if (side > 1) clipped.push(`${name(e)} scrolls ${side}px more to the side`)
529
+ if (clipped.length >= 5) break
530
+ }
531
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
532
+ const st = getComputedStyle(a)
533
+ if (st.overflowX === 'visible' && st.overflowY === 'visible') continue
534
+ const ar = a.getBoundingClientRect()
535
+ if (
536
+ r.top < ar.top - 1 ||
537
+ r.bottom > ar.bottom + 1 ||
538
+ r.left < ar.left - 1 ||
539
+ r.right > ar.right + 1
540
+ ) {
541
+ clipped.push(`${name(a)} cuts part of it off`)
542
+ break
543
+ }
544
+ }
545
+ return {
546
+ found: all.length,
547
+ rect: { x: r.left + scrollX, y: r.top + scrollY, width: r.width, height: r.height },
548
+ clipped,
549
+ }
550
+ }
551
+
552
+ export function parseViewport(raw) {
553
+ const match = /^(\d+)x(\d+)$/.exec(raw ?? '')
554
+ if (!match) throw new Error(`--viewport takes WIDTHxHEIGHT, like 1168x861, not ${raw}`)
555
+ return { width: Number(match[1]), height: Number(match[2]) }
556
+ }
557
+
558
+ /** `card.png` at one viewport, `card-1168x861.png` and the rest when there are several; a state
559
+ * or the page adds its name, as in `card-hover.png`. */
560
+ export function shotFiles(file, sizes, page, states = []) {
561
+ const stem = join(dirname(file), basename(file, extname(file)))
562
+ const name = (tag, size) =>
563
+ sizes.length === 1 && !tag
564
+ ? file
565
+ : `${stem}${tag}${sizes.length > 1 ? `-${size.width}x${size.height}` : ''}.png`
566
+ return sizes.flatMap((size) => [
567
+ { size, file: name('', size), kind: 'piece' },
568
+ ...(page ? [{ size, file: name('-page', size), kind: 'page' }] : []),
569
+ ...states.map(({ state, target }) => ({
570
+ size,
571
+ file: name(`-${state}`, size),
572
+ kind: state,
573
+ target,
574
+ })),
575
+ ])
576
+ }
577
+
578
+ /** The states asked for, each on its own element or on the piece. */
579
+ export function statesFrom(named, around) {
580
+ const wanted = named.states === true ? STATES : STATES.filter((s) => named[s] !== undefined)
581
+ return wanted.map((state) => {
582
+ const target = typeof named[state] === 'string' ? named[state] : around
583
+ if (!target) throw new Error(`--${state} needs a selector, or --around to take it from`)
584
+ return { state, target }
585
+ })
586
+ }
587
+
588
+ /** Runs in the page. Brings the element into view and says where its middle is on screen. */
589
+ function reach(selector) {
590
+ const el = document.querySelector(selector)
591
+ if (!el) return null
592
+ el.scrollIntoView({ block: 'center', inline: 'center' })
593
+ const r = el.getBoundingClientRect()
594
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
595
+ }
596
+
597
+ /** Runs in the page. Waits out the transitions a state started, never one that loops. */
598
+ async function settle() {
599
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
600
+ const finite = document
601
+ .getAnimations()
602
+ .filter((a) => a.effect?.getComputedTiming().endTime !== Infinity)
603
+ await Promise.race([
604
+ Promise.all(finite.map((a) => a.finished.catch(() => {}))),
605
+ new Promise((r) => setTimeout(r, 1000)),
606
+ ])
607
+ }
608
+
609
+ /** Puts the element in a state the way a person would: the pointer on it, pressed on it, or
610
+ * focus reached from the keyboard, so `:focus-visible` holds too. */
611
+ async function enterState(browser, state, target) {
612
+ const at = await browser.evaluate(`() => (${reach})(${JSON.stringify(target)})`)
613
+ if (!at) throw new Error(`nothing matches ${target} to ${state}`)
614
+ if (state === 'focus') {
615
+ await browser.key('Shift')
616
+ const focused = await browser.evaluate(
617
+ `() => { const el = document.querySelector(${JSON.stringify(target)}); el.focus(); return document.activeElement === el }`,
618
+ )
619
+ if (!focused) throw new Error(`${target} cannot take focus`)
620
+ } else {
621
+ await browser.mouse('mouseMoved', at.x, at.y)
622
+ if (state === 'active') await browser.mouse('mousePressed', at.x, at.y, 1)
623
+ }
624
+ await browser.evaluate(settle)
625
+ }
626
+
627
+ /** Lets go without a click: the press is released away from the element. */
628
+ async function leaveState(browser, state) {
629
+ if (state === 'active') {
630
+ await browser.mouse('mouseMoved', 0, 0, 1)
631
+ await browser.mouse('mouseReleased', 0, 0)
632
+ }
633
+ await browser.mouse('mouseMoved', 0, 0)
634
+ await browser.evaluate('() => { document.activeElement?.blur(); window.scrollTo(0, 0) }')
635
+ await browser.evaluate(settle)
636
+ }
637
+
638
+ async function shot(
639
+ url,
640
+ file,
641
+ {
642
+ sizes = [DESKTOP],
643
+ around = null,
644
+ page = false,
645
+ margin = MARGIN,
646
+ scale = PIECE_SCALE,
647
+ states = [],
648
+ } = {},
649
+ ) {
650
+ mkdirSync(dirname(file), { recursive: true })
651
+ return withBrowser(url, async (browser) => {
652
+ const lines = []
653
+ let loaded = null
654
+ let landed = url
655
+ for (const { size, file: out, kind, target } of shotFiles(file, sizes, page, states)) {
656
+ // The piece, the page and the states at one viewport come out of one load.
657
+ if (loaded !== size) {
658
+ await browser.viewport(size, scale)
659
+ landed = await browser.open(url)
660
+ if (loaded === null && landedElsewhere(url, landed)) lines.push(redirectLine(url, landed))
661
+ loaded = size
662
+ }
663
+ const crop = kind === 'piece' || kind === 'page' ? around : (around ?? target)
664
+ // The page is for rhythm and proportion, which read at 1x; the detail is in the crop.
665
+ if (!crop || kind === 'page') {
666
+ writeFileSync(out, await browser.screenshot(undefined, 1 / scale))
667
+ lines.push(out)
668
+ continue
669
+ }
670
+ if (kind === 'hover' && isPhone(size)) {
671
+ lines.push(`${kind}: none at ${size.width}x${size.height}, a phone has no hover`)
672
+ continue
673
+ }
674
+ if (kind !== 'piece') await enterState(browser, kind, target)
675
+ const found = await browser.evaluate(`() => (${locate})(${JSON.stringify(crop)})`)
676
+ if (!found.found) {
677
+ const where = landedElsewhere(url, landed) ? `; it was ${redirectLine(url, landed)}` : ''
678
+ throw new Error(`nothing on ${url} matches ${crop} at ${size.width}x${size.height}${where}`)
679
+ }
680
+ const doc = await browser.evaluate(
681
+ '() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })',
682
+ )
683
+ const left = Math.max(0, Math.floor(found.rect.x - margin))
684
+ const top = Math.max(0, Math.floor(found.rect.y - margin))
685
+ const right = Math.min(doc.width, Math.ceil(found.rect.x + found.rect.width + margin))
686
+ const bottom = Math.min(doc.height, Math.ceil(found.rect.y + found.rect.height + margin))
687
+ writeFileSync(
688
+ out,
689
+ await browser.screenshot({ x: left, y: top, width: right - left, height: bottom - top }),
690
+ )
691
+ lines.push(out)
692
+ if (kind !== 'piece') {
693
+ await leaveState(browser, kind)
694
+ continue
695
+ }
696
+ if (found.found > 1) lines.push(`${found.found} elements match ${crop}; this is the first`)
697
+ for (const line of found.clipped) lines.push(`clipped: ${line}`)
698
+ }
699
+ return lines
700
+ })
701
+ }
702
+
703
+ // ---------------------------------------------------------------------------- shots
704
+
705
+ export function shotName(path) {
706
+ const slug = path
707
+ .replace(/^\/+|\/+$/g, '')
708
+ .replace(/\.html?$/i, '')
709
+ .replace(/[^a-z0-9]+/gi, '-')
710
+ .toLowerCase()
711
+ return slug || 'index'
712
+ }
713
+
714
+ function shots(url, dir, paths) {
715
+ mkdirSync(dir, { recursive: true })
716
+ return withBrowser(url, async (browser) => {
717
+ const written = []
718
+ for (const [name, size] of [
719
+ ['desktop', DESKTOP],
720
+ ['mobile', MOBILE],
721
+ ]) {
722
+ await browser.viewport(size)
723
+ for (const path of paths) {
724
+ const target = new URL(path, url).href
725
+ const landed = await browser.open(target)
726
+ const file = join(dir, `${shotName(path)}-${name}.png`)
727
+ writeFileSync(file, await browser.screenshot())
728
+ written.push(file)
729
+ if (name === 'desktop' && landedElsewhere(target, landed))
730
+ written.push(redirectLine(target, landed))
731
+ }
732
+ }
733
+ return written
734
+ })
735
+ }
736
+
737
+ // ---------------------------------------------------------------------------- compare
738
+
739
+ /** 8-bit truecolour PNGs, with or without alpha: what a browser screenshot is. */
740
+ export function decodePng(buffer) {
741
+ if (buffer.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG')
742
+ let at = 8
743
+ let width = 0
744
+ let height = 0
745
+ let channels = 0
746
+ const data = []
747
+ while (at < buffer.length) {
748
+ const length = buffer.readUInt32BE(at)
749
+ const type = buffer.toString('ascii', at + 4, at + 8)
750
+ const body = buffer.subarray(at + 8, at + 8 + length)
751
+ if (type === 'IHDR') {
752
+ width = body.readUInt32BE(0)
753
+ height = body.readUInt32BE(4)
754
+ const depth = body[8]
755
+ const colour = body[9]
756
+ if (depth !== 8 || (colour !== 2 && colour !== 6) || body[12] !== 0) {
757
+ throw new Error(
758
+ `unsupported PNG: bit depth ${depth}, colour type ${colour}, interlace ${body[12]}`,
759
+ )
760
+ }
761
+ channels = colour === 6 ? 4 : 3
762
+ } else if (type === 'IDAT') data.push(body)
763
+ else if (type === 'IEND') break
764
+ at += 12 + length
765
+ }
766
+ const raw = inflateSync(Buffer.concat(data))
767
+ const stride = width * channels
768
+ const pixels = Buffer.alloc(stride * height)
769
+ for (let y = 0; y < height; y++) {
770
+ const filter = raw[y * (stride + 1)]
771
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
772
+ const out = pixels.subarray(y * stride, (y + 1) * stride)
773
+ const up = y > 0 ? pixels.subarray((y - 1) * stride, y * stride) : null
774
+ for (let x = 0; x < stride; x++) {
775
+ const a = x >= channels ? out[x - channels] : 0
776
+ const b = up ? up[x] : 0
777
+ const c = up && x >= channels ? up[x - channels] : 0
778
+ let v = line[x]
779
+ if (filter === 1) v += a
780
+ else if (filter === 2) v += b
781
+ else if (filter === 3) v += (a + b) >> 1
782
+ else if (filter === 4) {
783
+ const p = a + b - c
784
+ const pa = Math.abs(p - a)
785
+ const pb = Math.abs(p - b)
786
+ const pc = Math.abs(p - c)
787
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
788
+ }
789
+ out[x] = v & 0xff
790
+ }
791
+ }
792
+ return { width, height, channels, pixels }
793
+ }
794
+
795
+ export function diffPngs(before, after) {
796
+ if (before.equals(after)) return { same: true }
797
+ const a = decodePng(before)
798
+ const b = decodePng(after)
799
+ if (a.width !== b.width || a.height !== b.height) {
800
+ return {
801
+ same: false,
802
+ size: { before: `${a.width}x${a.height}`, after: `${b.width}x${b.height}` },
803
+ }
804
+ }
805
+ let changed = 0
806
+ let box = null
807
+ for (let y = 0; y < a.height; y++) {
808
+ for (let x = 0; x < a.width; x++) {
809
+ const i = (y * a.width + x) * a.channels
810
+ const j = (y * b.width + x) * b.channels
811
+ if (
812
+ a.pixels[i] === b.pixels[j] &&
813
+ a.pixels[i + 1] === b.pixels[j + 1] &&
814
+ a.pixels[i + 2] === b.pixels[j + 2]
815
+ )
816
+ continue
817
+ changed += 1
818
+ box = box
819
+ ? {
820
+ left: Math.min(box.left, x),
821
+ top: Math.min(box.top, y),
822
+ right: Math.max(box.right, x),
823
+ bottom: Math.max(box.bottom, y),
824
+ }
825
+ : { left: x, top: y, right: x, bottom: y }
826
+ }
827
+ }
828
+ return changed ? { same: false, changed, box } : { same: true }
829
+ }
830
+
831
+ export function compare(beforeDir, afterDir) {
832
+ const names = new Set(
833
+ [...readdirSync(beforeDir), ...readdirSync(afterDir)].filter((n) => n.endsWith('.png')),
834
+ )
835
+ return [...names].sort().map((name) => {
836
+ const a = join(beforeDir, name)
837
+ const b = join(afterDir, name)
838
+ if (!existsSync(a) || !existsSync(b))
839
+ return { name, same: false, missing: existsSync(a) ? 'after' : 'before' }
840
+ return { name, ...diffPngs(readFileSync(a), readFileSync(b)) }
841
+ })
842
+ }
843
+
844
+ export function describe(result) {
845
+ if (result.same) return `same ${result.name}`
846
+ if (result.missing) return `missing ${result.name} (no ${result.missing})`
847
+ if (result.size)
848
+ return `differs ${result.name}: size ${result.size.before} -> ${result.size.after}`
849
+ const { left, top, right, bottom } = result.box
850
+ return `differs ${result.name}: ${result.changed} pixels, x ${left}-${right}, y ${top}-${bottom}`
851
+ }
852
+
853
+ // ---------------------------------------------------------------------------- the CLI
854
+
855
+ const USAGE = `usage:
856
+ node page.mjs measure <url>
857
+ node page.mjs shot <url> <file> [--viewport WxH[,WxH...]] [--around <css>] [--page] [--margin px] [--scale n]
858
+ [--hover [css]] [--focus [css]] [--active [css]] [--states]
859
+ node page.mjs shots <url> <dir> [path ...]
860
+ node page.mjs compare <before-dir> <after-dir>`
861
+
862
+ /** `--name value` pairs out of the arguments, the rest kept in order; a flag with no value is true. */
863
+ export function flags(args) {
864
+ const named = {}
865
+ const rest = []
866
+ for (let at = 0; at < args.length; at++) {
867
+ if (!args[at].startsWith('--')) rest.push(args[at])
868
+ else if (at + 1 < args.length && !args[at + 1].startsWith('--'))
869
+ named[args[at].slice(2)] = args[++at]
870
+ else named[args[at].slice(2)] = true
871
+ }
872
+ return { named, rest }
873
+ }
874
+
875
+ async function main([command, ...args]) {
876
+ if (command === 'measure' && args.length === 1) {
877
+ console.log(JSON.stringify(await measure(args[0]), null, 1))
878
+ } else if (command === 'shot' && flags(args).rest.length === 2) {
879
+ const { named, rest } = flags(args)
880
+ const lines = await shot(rest[0], resolve(rest[1]), {
881
+ sizes: named.viewport ? named.viewport.split(',').map(parseViewport) : [DESKTOP],
882
+ around: named.around ?? null,
883
+ page: named.page === true,
884
+ margin: named.margin !== undefined ? Number(named.margin) : MARGIN,
885
+ scale: named.scale !== undefined ? Number(named.scale) : PIECE_SCALE,
886
+ states: statesFrom(named, named.around ?? null),
887
+ })
888
+ for (const line of lines) console.log(line)
889
+ } else if (command === 'shots' && args.length >= 2) {
890
+ const [url, dir, ...paths] = args
891
+ for (const file of await shots(url, resolve(dir), paths.length ? paths : ['/']))
892
+ console.log(file)
893
+ } else if (command === 'compare' && args.length === 2) {
894
+ const results = compare(resolve(args[0]), resolve(args[1]))
895
+ for (const result of results) console.log(describe(result))
896
+ if (results.some((r) => !r.same)) process.exitCode = 1
897
+ } else {
898
+ console.error(USAGE)
899
+ process.exitCode = 2
900
+ }
901
+ }
902
+
903
+ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
904
+ main(process.argv.slice(2)).catch((error) => {
905
+ console.error(error instanceof Error ? error.message.split('\n')[0] : String(error))
906
+ process.exitCode = 1
907
+ })
908
+ }