also looking at this
Harden AnalyticsHQ release and dashboard quality
#52
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.
| @@ -375,5 +375,8 @@ jobs: | ||
| 375 | 375 | - name: Validate UI accessibility |
| 376 | 376 | run: bun run ui:validate |
| 377 | 377 | |
| 378 | - name: Validate responsive visual contracts | |
| 379 | run: bun run visual:validate | |
| 380 | ||
| 378 | 381 | - name: Verify generated files are current |
| 379 | 382 | run: git diff --exit-code |
| @@ -33,6 +33,7 @@ | ||
| 33 | 33 | "build": "./buddy build", |
| 34 | 34 | "docs:validate": "bun scripts/validate-docs.ts", |
| 35 | 35 | "ui:validate": "bun scripts/validate-ui.ts", |
| 36 | "visual:validate": "bun scripts/validate-visual.ts", | |
| 36 | 37 | "prod": "./buddy build", |
| 37 | 38 | "deploy": "./buddy deploy", |
| 38 | 39 | "export:site": "bun scripts/analytics/export-site.ts", |
| @@ -34,18 +34,18 @@ | ||
| 34 | 34 | One accent (emerald), off-black/off-white, auto light+dark, radius lock 12px. */ |
| 35 | 35 | :root { |
| 36 | 36 | --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); | |
| 38 | 38 | --sans: 'Geist', ui-sans-serif, system-ui, sans-serif; |
| 39 | 39 | --mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; |
| 40 | 40 | } |
| 41 | 41 | @media (prefers-color-scheme: dark) { |
| 42 | 42 | :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); } | |
| 44 | 44 | } |
| 45 | 45 | :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); } | |
| 47 | 47 | :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); } | |
| 49 | 49 | |
| 50 | 50 | body { background: var(--bg); color: var(--text); font-family: var(--sans); -webkit-font-smoothing: antialiased; } |
| 51 | 51 | .wordmark { font-weight: 700; letter-spacing: -0.03em; } |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | :root { |
| 27 | 27 | color-scheme: dark; |
| 28 | 28 | --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; | |
| 30 | 30 | --accent: #46d3c0; --accent-ink: #04201c; |
| 31 | 31 | --sans: 'Geist', ui-sans-serif, system-ui, sans-serif; --mono: 'Geist Mono', ui-monospace, monospace; |
| 32 | 32 | } |
| @@ -94,7 +94,7 @@ onMount(() => { | ||
| 94 | 94 | .card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; } |
| 95 | 95 | .btn { border-radius: 10px; transition: transform 0.12s ease, opacity 0.15s ease, background-color 0.15s ease; font-weight: 600; } |
| 96 | 96 | .btn:active { transform: translateY(1px); } |
| 97 | .btn-primary { background: var(--accent); color: #fff; } | |
| 97 | .btn-primary { background: var(--accent); color: var(--accent-ink); } | |
| 98 | 98 | .btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); } |
| 99 | 99 | .btn-ghost { border: 1px solid var(--border); color: var(--text); } |
| 100 | 100 | .btn-ghost:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } |
| @@ -2122,7 +2122,7 @@ onMount(() => { | ||
| 2122 | 2122 | .range-link:hover { color: var(--text); } |
| 2123 | 2123 | .range-link[aria-current="page"] { |
| 2124 | 2124 | background: var(--accent); |
| 2125 | color: #fff; | |
| 2125 | color: var(--accent-ink); | |
| 2126 | 2126 | } |
| 2127 | 2127 | .range-tool { |
| 2128 | 2128 | display: inline-flex; |
| @@ -2153,7 +2153,7 @@ summary:focus-visible { | ||
| 2153 | 2153 | outline: 2px solid var(--accent); |
| 2154 | 2154 | outline-offset: 2px; |
| 2155 | 2155 | } |
| 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); } | |
| 2157 | 2157 | .range-tool i { width: 1rem; height: 1rem; } |
| 2158 | 2158 | .date-menu { position: relative; } |
| 2159 | 2159 | .date-menu > summary { list-style: none; } |
| @@ -2527,9 +2527,9 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2527 | 2527 | role="tab" |
| 2528 | 2528 | @click="installTab.set('')" |
| 2529 | 2529 | :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' | |
| 2531 | 2531 | : '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" | |
| 2533 | 2533 | >{{ opt.label }}</button> |
| 2534 | 2534 | @endforeach |
| 2535 | 2535 | </div> |
| @@ -2626,7 +2626,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2626 | 2626 | display:none until the bundle ran. Busy state is carried by |
| 2627 | 2627 | :disabled, an ATTRIBUTE binding, which does not cloak, and by |
| 2628 | 2628 | 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> | |
| 2630 | 2630 | <span :show="inviteBusy()" style="display:none;color:var(--text-3);font:400 12px/1 var(--sans,ui-sans-serif)">Sending…</span> |
| 2631 | 2631 | </div> |
| 2632 | 2632 | <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); } | ||
| 2637 | 2637 | <div class="mt-4 px-4 py-3" style="border: 1px solid var(--border); border-radius: 10px; background: var(--bg)"> |
| 2638 | 2638 | <p class="mb-1 font-semibold text-sm">Invite your teammates with Pro</p> |
| 2639 | 2639 | <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> | |
| 2641 | 2641 | </div> |
| 2642 | 2642 | @endif |
| 2643 | 2643 | @endif |
| @@ -2695,7 +2695,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2695 | 2695 | in separate elements for the same reason: a reactive child cloaks |
| 2696 | 2696 | its parent. --}} |
| 2697 | 2697 | <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> | |
| 2699 | 2699 | </div> |
| 2700 | 2700 | <p class="mt-2 text-text-3 text-xs" :show="gaProgress()" style="display:none">{{ gaProgress() }}</p> |
| 2701 | 2701 | <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); } | ||
| 2779 | 2779 | button's own text would hide the control until hydration. --}} |
| 2780 | 2780 | <div class="flex flex-wrap gap-3 items-center mt-4"> |
| 2781 | 2781 | <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> | |
| 2783 | 2783 | </div> |
| 2784 | 2784 | |
| 2785 | 2785 | <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); } | ||
| 2808 | 2808 | <div class="mt-1 px-4 py-3" style="border: 1px solid var(--border); border-radius: 10px; background: var(--bg)"> |
| 2809 | 2809 | <p class="mb-1 font-semibold text-sm">Import your Fathom history with Pro</p> |
| 2810 | 2810 | <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> | |
| 2812 | 2812 | </div> |
| 2813 | 2813 | @endif |
| 2814 | 2814 | </div> |
| @@ -2884,7 +2884,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2884 | 2884 | <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> |
| 2885 | 2885 | <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> |
| 2886 | 2886 | </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> | |
| 2888 | 2888 | </div> |
| 2889 | 2889 | </div> |
| 2890 | 2890 | @endif |
| @@ -3312,7 +3312,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3312 | 3312 | <option value="starts_with">starts with</option> |
| 3313 | 3313 | </select> |
| 3314 | 3314 | <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> | |
| 3316 | 3316 | </form> |
| 3317 | 3317 | <div class="mt-2 text-xs" :show="goalError()" style="display:none;color:#ef4444">{{ goalError() }}</div> |
| 3318 | 3318 | @endif |
| @@ -3413,11 +3413,11 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3413 | 3413 | @if (user) |
| 3414 | 3414 | <h2 class="mb-2 font-semibold text-lg">No site to show yet</h2> |
| 3415 | 3415 | <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> | |
| 3417 | 3417 | @else |
| 3418 | 3418 | <h2 class="mb-2 font-semibold text-lg">Sign in to view analytics</h2> |
| 3419 | 3419 | <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> | |
| 3421 | 3421 | @endif |
| 3422 | 3422 | </div> |
| 3423 | 3423 | @endif |
| @@ -0,0 +1,330 @@ | ||
| 1 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { tmpdir } from 'node:os' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | ||
| 5 | interface Viewport { | |
| 6 | name: string | |
| 7 | width: number | |
| 8 | height: number | |
| 9 | mobile: boolean | |
| 10 | } | |
| 11 | ||
| 12 | interface PageAudit { | |
| 13 | contrast: string[] | |
| 14 | focusable: number | |
| 15 | imagesWithoutAlt: number | |
| 16 | overflow: number | |
| 17 | unresolved: boolean | |
| 18 | unnamedControls: string[] | |
| 19 | } | |
| 20 | ||
| 21 | interface Pending { | |
| 22 | reject: (error: Error) => void | |
| 23 | resolve: (value: any) => void | |
| 24 | timer: ReturnType<typeof setTimeout> | |
| 25 | } | |
| 26 | ||
| 27 | const DIST = resolve('dist') | |
| 28 | const SSR_OUTPUT = resolve('.output') | |
| 29 | const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages') | |
| 30 | const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public') | |
| 31 | const OUTPUT = resolve('storage/logs/visual-contract') | |
| 32 | const 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 | ] | |
| 37 | const THEMES = ['light', 'dark'] as const | |
| 38 | const ROUTE_CANDIDATES = [ | |
| 39 | '/', | |
| 40 | '/login', | |
| 41 | '/register', | |
| 42 | '/dashboard', | |
| 43 | '/dashboard/commshq', | |
| 44 | '/reports', | |
| 45 | '/projects', | |
| 46 | ] | |
| 47 | ||
| 48 | function 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 | ||
| 62 | async 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 | ||
| 73 | class 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 | ||
| 110 | async 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 | ||
| 119 | function routeName(route: string): string { | |
| 120 | return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-') | |
| 121 | } | |
| 122 | ||
| 123 | function 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 | ||
| 136 | function 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 | ||
| 142 | const 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 | ||
| 218 | async 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 | ||
| 330 | await main() | |