ReviewOS

also looking at this

stacks/analyticshq

Harden AnalyticsHQ release and dashboard quality

#52
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
34 files +1236 -340

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/ci.ymlmodified+3-0
Changes to .github/workflows/ci.yml
@@ -375,5 +375,8 @@ jobs:
375375 - name: Validate UI accessibility
376376 run: bun run ui:validate
377377
378 - name: Validate responsive visual contracts
379 run: bun run visual:validate
380
378381 - name: Verify generated files are current
379382 run: git diff --exit-code
package.jsonmodified+1-0
Changes to package.json
@@ -33,6 +33,7 @@
3333 "build": "./buddy build",
3434 "docs:validate": "bun scripts/validate-docs.ts",
3535 "ui:validate": "bun scripts/validate-ui.ts",
36 "visual:validate": "bun scripts/validate-visual.ts",
3637 "prod": "./buddy build",
3738 "deploy": "./buddy deploy",
3839 "export:site": "bun scripts/analytics/export-site.ts",
public/marketing.cssmodified+1-1
Changes to public/marketing.css
@@ -8,7 +8,7 @@
88 --surface-2: #15191e;
99 --ink: #eef2f3;
1010 --muted: #9aa4a9;
11 --faint: #6d777c;
11 --faint: #7b878c;
1212 --line: rgba(233, 238, 240, 0.09);
1313 --line-strong: rgba(233, 238, 240, 0.16);
1414 --accent: #46d3c0;
resources/layouts/app.stxmodified+4-4
Changes to resources/layouts/app.stx
@@ -34,18 +34,18 @@
3434 One accent (emerald), off-black/off-white, auto light+dark, radius lock 12px. */
3535:root {
3636 --bg: #fbfbfc; --panel: #ffffff; --border: rgba(15,23,42,0.08);
37 --text: #0b1220; --text-2: #4b5563; --text-3: #98a2b3; --accent: #059669; --bar: rgba(5,150,105,0.16);
37 --text: #0b1220; --text-2: #4b5563; --text-3: #667085; --accent: #059669; --accent-ink: #04120d; --bar: rgba(5,150,105,0.16);
3838 --sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
3939 --mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
4040}
4141@media (prefers-color-scheme: dark) {
4242 :root { --bg: #080b12; --panel: #0f131c; --border: rgba(148,163,184,0.12);
43 --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #667085; --accent: #34d399; --bar: rgba(52,211,153,0.18); }
43 --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #7b879a; --accent: #34d399; --accent-ink: #04120d; --bar: rgba(52,211,153,0.18); }
4444}
4545:root[data-theme="dark"] { --bg: #080b12; --panel: #0f131c; --border: rgba(148,163,184,0.12);
46 --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #667085; --accent: #34d399; --bar: rgba(52,211,153,0.18); }
46 --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #7b879a; --accent: #34d399; --accent-ink: #04120d; --bar: rgba(52,211,153,0.18); }
4747:root[data-theme="light"] { --bg: #fbfbfc; --panel: #ffffff; --border: rgba(15,23,42,0.08);
48 --text: #0b1220; --text-2: #4b5563; --text-3: #98a2b3; --accent: #059669; --bar: rgba(5,150,105,0.16); }
48 --text: #0b1220; --text-2: #4b5563; --text-3: #667085; --accent: #059669; --accent-ink: #04120d; --bar: rgba(5,150,105,0.16); }
4949
5050body { background: var(--bg); color: var(--text); font-family: var(--sans); -webkit-font-smoothing: antialiased; }
5151.wordmark { font-weight: 700; letter-spacing: -0.03em; }
resources/layouts/auth.stxmodified+1-1
Changes to resources/layouts/auth.stx
@@ -26,7 +26,7 @@
2626:root {
2727 color-scheme: dark;
2828 --bg: #0a0c0e; --panel: #101317; --border: rgba(233,238,240,0.12);
29 --text: #eef2f3; --text-2: #9aa4a9; --text-3: #6d777c;
29 --text: #eef2f3; --text-2: #9aa4a9; --text-3: #7b878c;
3030 --accent: #46d3c0; --accent-ink: #04201c;
3131 --sans: 'Geist', ui-sans-serif, system-ui, sans-serif; --mono: 'Geist Mono', ui-monospace, monospace;
3232}
resources/views/account.stxmodified+1-1
Changes to resources/views/account.stx
@@ -94,7 +94,7 @@ onMount(() => {
9494.card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; }
9595.btn { border-radius: 10px; transition: transform 0.12s ease, opacity 0.15s ease, background-color 0.15s ease; font-weight: 600; }
9696.btn:active { transform: translateY(1px); }
97.btn-primary { background: var(--accent); color: #fff; }
97.btn-primary { background: var(--accent); color: var(--accent-ink); }
9898.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); }
9999.btn-ghost { border: 1px solid var(--border); color: var(--text); }
100100.btn-ghost:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
resources/views/dashboard.stxmodified+13-13
Changes to resources/views/dashboard.stx
@@ -2122,7 +2122,7 @@ onMount(() => {
21222122.range-link:hover { color: var(--text); }
21232123.range-link[aria-current="page"] {
21242124 background: var(--accent);
2125 color: #fff;
2125 color: var(--accent-ink);
21262126}
21272127.range-tool {
21282128 display: inline-flex;
@@ -2153,7 +2153,7 @@ summary:focus-visible {
21532153 outline: 2px solid var(--accent);
21542154 outline-offset: 2px;
21552155}
2156.range-tool.is-active { color: #fff; background: var(--accent); border-color: var(--accent); }
2156.range-tool.is-active { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
21572157.range-tool i { width: 1rem; height: 1rem; }
21582158.date-menu { position: relative; }
21592159.date-menu > summary { list-style: none; }
@@ -2527,9 +2527,9 @@ select.panel:hover { border-color: var(--accent); }
25272527 role="tab"
25282528 @click="installTab.set('')"
25292529 :style="installTab() === ''
2530 ? 'padding:5px 11px;border-radius:999px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer'
2530 ? 'padding:5px 11px;border-radius:999px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer'
25312531 : 'padding:5px 11px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer'"
2532 style="padding:5px 11px;border-radius:999px;border:1px solid ;background:;color:;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer"
2532 style="padding:5px 11px;border-radius:999px;border:1px solid ;background:;color:;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer"
25332533 >{{ opt.label }}</button>
25342534 @endforeach
25352535 </div>
@@ -2626,7 +2626,7 @@ select.panel:hover { border-color: var(--accent); }
26262626 display:none until the bundle ran. Busy state is carried by
26272627 :disabled, an ATTRIBUTE binding, which does not cloak, and by
26282628 the sibling below, which is supposed to start hidden. --}}
2629 <button type="button" @click="sendInvite()" :disabled="inviteBusy()" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Send invitation</button>
2629 <button type="button" @click="sendInvite()" :disabled="inviteBusy()" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Send invitation</button>
26302630 <span :show="inviteBusy()" style="display:none;color:var(--text-3);font:400 12px/1 var(--sans,ui-sans-serif)">Sending…</span>
26312631 </div>
26322632 <p class="mt-2 text-text-3 text-xs">They get an emailed link that works once, for that address only, and expires in 14 days.</p>
@@ -2637,7 +2637,7 @@ select.panel:hover { border-color: var(--accent); }
26372637 <div class="mt-4 px-4 py-3" style="border: 1px solid var(--border); border-radius: 10px; background: var(--bg)">
26382638 <p class="mb-1 font-semibold text-sm">Invite your teammates with Pro</p>
26392639 <p class="mb-3 text-text-3 text-xs">Share this site with as many people as you like, each with their own login and their own level of access. Included in Pro at no extra cost per person.</p>
2640 <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink>
2640 <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink>
26412641 </div>
26422642 @endif
26432643 @endif
@@ -2695,7 +2695,7 @@ select.panel:hover { border-color: var(--accent); }
26952695 in separate elements for the same reason: a reactive child cloaks
26962696 its parent. --}}
26972697 <div class="flex flex-wrap gap-3 items-center mt-4">
2698 <button type="button" @click="importFromGa" :disabled="gaBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button>
2698 <button type="button" @click="importFromGa" :disabled="gaBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button>
26992699 </div>
27002700 <p class="mt-2 text-text-3 text-xs" :show="gaProgress()" style="display:none">{{ gaProgress() }}</p>
27012701 <p class="mt-3 text-sm text-text-2" :show="gaResult()" style="display:none">{{ gaResult() }}</p>
@@ -2779,7 +2779,7 @@ select.panel:hover { border-color: var(--accent); }
27792779 button's own text would hide the control until hydration. --}}
27802780 <div class="flex flex-wrap gap-3 items-center mt-4">
27812781 <button type="button" @click="checkFathomExport" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--text-2);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Check this export</button>
2782 <button type="button" @click="importFromFathom" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button>
2782 <button type="button" @click="importFromFathom" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button>
27832783 </div>
27842784
27852785 <p class="mt-2 text-text-3 text-xs" :show="fathomProgress()" style="display:none">{{ fathomProgress() }}</p>
@@ -2808,7 +2808,7 @@ select.panel:hover { border-color: var(--accent); }
28082808 <div class="mt-1 px-4 py-3" style="border: 1px solid var(--border); border-radius: 10px; background: var(--bg)">
28092809 <p class="mb-1 font-semibold text-sm">Import your Fathom history with Pro</p>
28102810 <p class="mb-3 text-text-3 text-xs">Upload your Fathom export and keep every number you already have, sitting alongside the traffic we start tracking today. Included in Pro at no extra cost.</p>
2811 <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink>
2811 <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink>
28122812 </div>
28132813 @endif
28142814 </div>
@@ -2884,7 +2884,7 @@ select.panel:hover { border-color: var(--accent); }
28842884 <button type="button" @click="copyShare" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareCopied() ? 'Copied' : 'Copy' }}</button>
28852885 <button type="button" @click="revokeShare" :disabled="shareBusy()" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-3);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">Revoke</button>
28862886 </div>
2887 <button type="button" @click="createShare" :disabled="shareBusy()" :show="!shareUrl()" style="display:none;padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareBusy() ? 'Creating…' : 'Create share link' }}</button>
2887 <button type="button" @click="createShare" :disabled="shareBusy()" :show="!shareUrl()" style="display:none;padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareBusy() ? 'Creating…' : 'Create share link' }}</button>
28882888 </div>
28892889 </div>
28902890 @endif
@@ -3312,7 +3312,7 @@ select.panel:hover { border-color: var(--accent); }
33123312 <option value="starts_with">starts with</option>
33133313 </select>
33143314 <input name="value" type="number" step="any" placeholder="Value" aria-label="Goal value" style="width:80px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)">
3315 <button type="submit" :disabled="goalBusy()" style="padding:7px 13px;border-radius:8px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ goalBusy() ? 'Adding…' : 'Add goal' }}</button>
3315 <button type="submit" :disabled="goalBusy()" style="padding:7px 13px;border-radius:8px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ goalBusy() ? 'Adding…' : 'Add goal' }}</button>
33163316 </form>
33173317 <div class="mt-2 text-xs" :show="goalError()" style="display:none;color:#ef4444">{{ goalError() }}</div>
33183318 @endif
@@ -3413,11 +3413,11 @@ select.panel:hover { border-color: var(--accent); }
34133413 @if (user)
34143414 <h2 class="mb-2 font-semibold text-lg">No site to show yet</h2>
34153415 <p class="mb-5 text-sm text-text-2">Add a site to get a tracking snippet and start seeing analytics here.</p>
3416 <StxLink to="/sites/new" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:#fff;text-decoration:none">Add a site</StxLink>
3416 <StxLink to="/sites/new" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:var(--accent-ink);text-decoration:none">Add a site</StxLink>
34173417 @else
34183418 <h2 class="mb-2 font-semibold text-lg">Sign in to view analytics</h2>
34193419 <p class="mb-5 text-sm text-text-2">This dashboard is private to the site owner.</p>
3420 <StxLink to="/login" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:#fff;text-decoration:none">Sign in</StxLink>
3420 <StxLink to="/login" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:var(--accent-ink);text-decoration:none">Sign in</StxLink>
34213421 @endif
34223422 </div>
34233423 @endif
scripts/validate-visual.tsadded+330-0
Changes to scripts/validate-visual.ts
@@ -0,0 +1,330 @@
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { tmpdir } from 'node:os'
3import { join, resolve } from 'node:path'
4
5interface Viewport {
6 name: string
7 width: number
8 height: number
9 mobile: boolean
10}
11
12interface PageAudit {
13 contrast: string[]
14 focusable: number
15 imagesWithoutAlt: number
16 overflow: number
17 unresolved: boolean
18 unnamedControls: string[]
19}
20
21interface Pending {
22 reject: (error: Error) => void
23 resolve: (value: any) => void
24 timer: ReturnType<typeof setTimeout>
25}
26
27const DIST = resolve('dist')
28const SSR_OUTPUT = resolve('.output')
29const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages')
30const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public')
31const OUTPUT = resolve('storage/logs/visual-contract')
32const VIEWPORTS: Viewport[] = [
33 { name: 'desktop', width: 1440, height: 1000, mobile: false },
34 { name: 'tablet', width: 768, height: 1024, mobile: false },
35 { name: 'mobile', width: 390, height: 844, mobile: true },
36]
37const THEMES = ['light', 'dark'] as const
38const ROUTE_CANDIDATES = [
39 '/',
40 '/login',
41 '/register',
42 '/dashboard',
43 '/dashboard/commshq',
44 '/reports',
45 '/projects',
46]
47
48function chromePath(): string {
49 const candidates = [
50 process.env.CHROME_PATH,
51 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
52 '/usr/bin/google-chrome',
53 '/usr/bin/google-chrome-stable',
54 '/usr/bin/chromium',
55 '/usr/bin/chromium-browser',
56 ]
57 const found = candidates.find(candidate => candidate && existsSync(candidate))
58 if (!found) throw new Error('Chrome was not found. Set CHROME_PATH to a Chrome or Chromium executable.')
59 return found
60}
61
62async function waitFor<T>(read: () => Promise<T> | T, timeout = 12_000): Promise<T> {
63 const started = Date.now()
64 let value = await read()
65 while (!value && Date.now() - started < timeout) {
66 await Bun.sleep(100)
67 value = await read()
68 }
69 if (!value) throw new Error(`Timed out after ${timeout}ms`)
70 return value
71}
72
73class Cdp {
74 private id = 0
75 private pending = new Map<number, Pending>()
76
77 constructor(private socket: WebSocket) {
78 socket.addEventListener('message', (event) => {
79 const message = JSON.parse(String(event.data))
80 if (!message.id) return
81 const pending = this.pending.get(message.id)
82 if (!pending) return
83 this.pending.delete(message.id)
84 clearTimeout(pending.timer)
85 if (message.error) pending.reject(new Error(message.error.message))
86 else pending.resolve(message.result)
87 })
88 }
89
90 send(method: string, params: Record<string, any> = {}): Promise<any> {
91 const id = ++this.id
92 return new Promise((resolve, reject) => {
93 const timer = setTimeout(() => {
94 this.pending.delete(id)
95 reject(new Error(`Chrome command timed out: ${method}`))
96 }, 15_000)
97 this.pending.set(id, { reject, resolve, timer })
98 this.socket.send(JSON.stringify({ id, method, params }))
99 })
100 }
101
102 async evaluate<T>(expression: string): Promise<T> {
103 const result = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
104 if (result.exceptionDetails)
105 throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
106 return result.result?.value as T
107 }
108}
109
110async function connect(url: string): Promise<Cdp> {
111 const socket = new WebSocket(url)
112 await new Promise<void>((resolve, reject) => {
113 socket.addEventListener('open', () => resolve(), { once: true })
114 socket.addEventListener('error', () => reject(new Error('Could not connect to Chrome')), { once: true })
115 })
116 return new Cdp(socket)
117}
118
119function routeName(route: string): string {
120 return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-')
121}
122
123function routeFile(route: string): { kind: 'html' | 'ssr', path: string } | undefined {
124 if (existsSync(DIST)) {
125 const base = route === '/' ? join(DIST, 'index.html') : join(DIST, `${route.slice(1)}.html`)
126 if (existsSync(base)) return { kind: 'html', path: base }
127 const index = join(DIST, route.slice(1), 'index.html')
128 if (existsSync(index)) return { kind: 'html', path: index }
129 }
130
131 const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-')
132 const compiled = join(SSR_PAGES, `${name}.compiled.json`)
133 return existsSync(compiled) ? { kind: 'ssr', path: compiled } : undefined
134}
135
136function pageResponse(page: { kind: 'html' | 'ssr', path: string }): Response {
137 if (page.kind === 'html') return new Response(Bun.file(page.path))
138 const compiled = JSON.parse(readFileSync(page.path, 'utf8')) as { html?: string }
139 return new Response(compiled.html || '', { headers: { 'content-type': 'text/html; charset=utf-8' } })
140}
141
142const auditExpression = `(() => {
143 const visible = element => {
144 const style = getComputedStyle(element)
145 const rect = element.getBoundingClientRect()
146 if (element.closest('details:not([open])') && element.tagName !== 'SUMMARY') return false
147 return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0
148 && rect.width > 0 && rect.height > 0
149 }
150 const label = element => {
151 if (element.getAttribute('aria-label')) return element.getAttribute('aria-label')
152 const labelledBy = element.getAttribute('aria-labelledby')
153 if (labelledBy && labelledBy.split(/\\s+/).some(id => document.getElementById(id)?.textContent?.trim())) return labelledBy
154 if (element.id && document.querySelector('label[for="' + CSS.escape(element.id) + '"]')?.textContent?.trim()) return element.id
155 if (element.closest('label')?.textContent?.trim()) return 'wrapped'
156 if (element.tagName === 'BUTTON' && element.textContent?.trim()) return 'text'
157 if (element.tagName === 'A' && element.textContent?.trim()) return 'text'
158 if (element.getAttribute('title')) return element.getAttribute('title')
159 return ''
160 }
161 const parseRgb = value => {
162 const match = value.match(/rgba?\\(([^)]+)\\)/)
163 if (!match) return null
164 const parts = match[1].split(/[ ,/]+/).filter(Boolean).map(Number)
165 if (parts.length < 3 || parts.some(Number.isNaN)) return null
166 return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 }
167 }
168 const luminance = color => {
169 const channel = value => {
170 const normalized = value / 255
171 return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
172 }
173 return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b)
174 }
175 const background = element => {
176 let current = element
177 while (current) {
178 const color = parseRgb(getComputedStyle(current).backgroundColor)
179 if (color && color.a >= 0.98) return color
180 current = current.parentElement
181 }
182 return { r: 255, g: 255, b: 255, a: 1 }
183 }
184 const controls = [...document.querySelectorAll('a[href], button, input:not([type="hidden"]), select, textarea')].filter(visible)
185 const unnamedControls = controls.filter(element => !label(element)).map(element => {
186 const identity = element.id ? '#' + element.id : element.getAttribute('name') ? '[name="' + element.getAttribute('name') + '"]' : ''
187 return element.tagName.toLowerCase() + identity
188 })
189 const contrast = []
190 const textElements = [...document.querySelectorAll('h1, h2, h3, h4, p, label, button, a, summary, span')]
191 for (const element of textElements) {
192 if (!visible(element) || !element.textContent?.trim()) continue
193 if (element.children.length && !['A', 'BUTTON', 'SUMMARY'].includes(element.tagName)) continue
194 const style = getComputedStyle(element)
195 const foreground = parseRgb(style.color)
196 const behind = background(element)
197 if (!foreground || foreground.a < 0.98) continue
198 const lighter = Math.max(luminance(foreground), luminance(behind))
199 const darker = Math.min(luminance(foreground), luminance(behind))
200 const ratio = (lighter + 0.05) / (darker + 0.05)
201 const large = Number.parseFloat(style.fontSize) >= 24 || (Number.parseFloat(style.fontSize) >= 18.66 && Number(style.fontWeight) >= 700)
202 if (ratio + 0.01 < (large ? 3 : 4.5)) {
203 const sample = element.textContent.trim().replace(/\\s+/g, ' ').slice(0, 42)
204 contrast.push(sample + ' (' + ratio.toFixed(2) + ':1)')
205 }
206 if (contrast.length >= 10) break
207 }
208 return {
209 contrast,
210 focusable: controls.length,
211 imagesWithoutAlt: [...document.images].filter(image => !image.hasAttribute('alt')).length,
212 overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
213 unresolved: document.body.innerText.includes('{{') || document.body.innerText.includes('@if'),
214 unnamedControls,
215 }
216})()`
217
218async function main(): Promise<void> {
219 if (!existsSync(DIST) && !existsSync(SSR_PAGES))
220 throw new Error(`Built site not found at ${DIST} or ${SSR_OUTPUT}. Run bun run build first.`)
221 const routes = ROUTE_CANDIDATES.filter(route => routeFile(route))
222 if (routes.length < 3) throw new Error(`Expected at least three visual routes, found ${routes.join(', ') || 'none'}.`)
223
224 mkdirSync(OUTPUT, { recursive: true })
225 const server = Bun.serve({
226 hostname: '127.0.0.1',
227 port: 0,
228 fetch(request) {
229 const url = new URL(request.url)
230 const route = decodeURIComponent(url.pathname)
231 const page = routeFile(route)
232 if (page) return pageResponse(page)
233 const publicRoot = existsSync(DIST) ? DIST : SSR_PUBLIC
234 const asset = resolve(publicRoot, `.${route}`)
235 const file = asset.startsWith(`${publicRoot}/`) && existsSync(asset) ? asset : undefined
236 return file ? new Response(Bun.file(file)) : new Response('Not found', { status: 404 })
237 },
238 })
239 const profile = mkdtempSync(join(tmpdir(), 'hq-visual-'))
240 const chrome = Bun.spawn([
241 chromePath(),
242 '--headless=new',
243 '--remote-debugging-port=0',
244 `--user-data-dir=${profile}`,
245 '--no-first-run',
246 '--no-default-browser-check',
247 '--disable-background-networking',
248 '--disable-component-update',
249 '--disable-extensions',
250 '--hide-scrollbars',
251 '--no-sandbox',
252 'about:blank',
253 ], { stdout: 'ignore', stderr: 'ignore' })
254
255 const failures: string[] = []
256 let screenshots = 0
257 try {
258 const portFile = join(profile, 'DevToolsActivePort')
259 await waitFor(() => existsSync(portFile), 10_000)
260 const port = Number(readFileSync(portFile, 'utf8').split('\n')[0])
261 const targets = await waitFor(async () => {
262 const items = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()).catch(() => [])
263 return Array.isArray(items) && items.length ? items : null
264 }) as Array<{ type: string, url: string, webSocketDebuggerUrl: string }>
265 const target = targets.find(item => item.type === 'page' && item.url === 'about:blank') || targets.find(item => item.type === 'page')
266 if (!target) throw new Error('Chrome did not expose a page target.')
267
268 const cdp = await connect(target.webSocketDebuggerUrl)
269 await cdp.send('Page.enable')
270 await cdp.send('Runtime.enable')
271 await cdp.send('Emulation.setScriptExecutionDisabled', { value: true })
272
273 for (const route of routes) {
274 for (const viewport of VIEWPORTS) {
275 await cdp.send('Emulation.setDeviceMetricsOverride', {
276 width: viewport.width,
277 height: viewport.height,
278 deviceScaleFactor: 1,
279 mobile: viewport.mobile,
280 })
281 await cdp.send('Page.navigate', { url: `http://127.0.0.1:${server.port}${route}` })
282 await waitFor(() => cdp.evaluate("document.readyState === 'complete'"))
283 await Bun.sleep(100)
284 for (const theme of THEMES) {
285 await cdp.evaluate(`(() => {
286 const theme = ${JSON.stringify(theme)}
287 document.documentElement.classList.toggle('dark', theme === 'dark')
288 document.documentElement.setAttribute('data-theme', theme)
289 document.documentElement.setAttribute('data-color-mode', theme)
290 document.documentElement.setAttribute('color-mode', theme)
291 })()`)
292 await Bun.sleep(500)
293 const audit = await cdp.evaluate<PageAudit>(auditExpression)
294 const key = `${route} at ${viewport.name}/${theme}`
295 if (audit.overflow > 1) failures.push(`${key}: ${audit.overflow}px horizontal overflow`)
296 if (audit.unresolved) failures.push(`${key}: unresolved template expression is visible`)
297 if (audit.imagesWithoutAlt) failures.push(`${key}: ${audit.imagesWithoutAlt} image(s) have no alt attribute`)
298 if (audit.unnamedControls.length) failures.push(`${key}: unnamed controls ${audit.unnamedControls.join(', ')}`)
299 for (const issue of audit.contrast) failures.push(`${key}: low text contrast ${issue}`)
300
301 if (audit.focusable) {
302 await cdp.evaluate("document.body.setAttribute('tabindex', '-1'); document.body.focus()")
303 await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 })
304 await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 })
305 const focused = await cdp.evaluate("document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement")
306 if (!focused) failures.push(`${key}: Tab did not move focus to a control`)
307 }
308
309 const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, fromSurface: true })
310 writeFileSync(join(OUTPUT, `${routeName(route)}-${theme}-${viewport.name}.png`), Buffer.from(shot.data, 'base64'))
311 screenshots += 1
312 }
313 }
314 }
315 }
316 finally {
317 chrome.kill()
318 await chrome.exited
319 server.stop(true)
320 rmSync(profile, { force: true, recursive: true })
321 }
322
323 console.log(`Rendered ${routes.length} routes in ${THEMES.length} themes at ${VIEWPORTS.length} viewports and wrote ${screenshots} screenshots.`)
324 if (failures.length) {
325 for (const failure of failures) console.error(`- ${failure}`)
326 process.exit(1)
327 }
328}
329
330await main()