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.
| @@ -32,6 +32,7 @@ | ||
| 32 | 32 | "dev:full": "./buddy dev", |
| 33 | 33 | "build": "./buddy build", |
| 34 | 34 | "docs:validate": "bun scripts/validate-docs.ts", |
| 35 | "ui:validate": "bun scripts/validate-ui.ts", | |
| 35 | 36 | "prod": "./buddy build", |
| 36 | 37 | "deploy": "./buddy deploy", |
| 37 | 38 | "export:site": "bun scripts/analytics/export-site.ts", |
| @@ -2611,11 +2611,12 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2611 | 2611 | <input |
| 2612 | 2612 | type="email" |
| 2613 | 2613 | x-model="inviteEmail" |
| 2614 | aria-label="Teammate email" | |
| 2614 | 2615 | placeholder="teammate@example.com" |
| 2615 | 2616 | autocomplete="off" |
| 2616 | 2617 | style="flex:1 1 15rem;min-height:34px;padding:0 0.65rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)" |
| 2617 | 2618 | > |
| 2618 | <select x-model="inviteRole" style="min-height:34px;padding:0 0.5rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)"> | |
| 2619 | <select x-model="inviteRole" aria-label="Teammate role" style="min-height:34px;padding:0 0.5rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)"> | |
| 2619 | 2620 | <option value="viewer">can view</option> |
| 2620 | 2621 | <option value="admin">can manage</option> |
| 2621 | 2622 | </select> |
| @@ -3299,18 +3300,18 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3299 | 3300 | event goals or the URL PATH for pageview goals. --}} |
| 3300 | 3301 | @if (ownsActive) |
| 3301 | 3302 | <form @submit="addGoal" class="flex flex-wrap gap-2 items-center mt-4 pt-4" style="border-top:1px solid var(--border)"> |
| 3302 | <input name="name" required maxlength="255" placeholder="Goal name" style="flex:1;min-width:110px;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)"> | |
| 3303 | <select name="type" style="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)"> | |
| 3303 | <input name="name" required maxlength="255" placeholder="Goal name" aria-label="Goal name" style="flex:1;min-width:110px;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)"> | |
| 3304 | <select name="type" aria-label="Goal type" style="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)"> | |
| 3304 | 3305 | <option value="event">Event</option> |
| 3305 | 3306 | <option value="pageview">Pageview</option> |
| 3306 | 3307 | </select> |
| 3307 | <input name="pattern" required maxlength="255" placeholder="Event name or /path" style="flex:1;min-width:110px;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)"> | |
| 3308 | <select name="match" style="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)"> | |
| 3308 | <input name="pattern" required maxlength="255" placeholder="Event name or /path" aria-label="Goal event or path" style="flex:1;min-width:110px;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)"> | |
| 3309 | <select name="match" aria-label="Goal matching rule" style="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)"> | |
| 3309 | 3310 | <option value="exact">exact</option> |
| 3310 | 3311 | <option value="contains">contains</option> |
| 3311 | 3312 | <option value="starts_with">starts with</option> |
| 3312 | 3313 | </select> |
| 3313 | <input name="value" type="number" step="any" placeholder="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)"> | |
| 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)"> | |
| 3314 | 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 | 3316 | </form> |
| 3316 | 3317 | <div class="mt-2 text-xs" :show="goalError()" style="display:none;color:#ef4444">{{ goalError() }}</div> |
| @@ -93,8 +93,8 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 93 | 93 | @endif |
| 94 | 94 | |
| 95 | 95 | <div> |
| 96 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 97 | <input name="email" type="email" required autofocus autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 96 | <label for="forgot-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 97 | <input id="forgot-email" name="email" type="email" required autofocus autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 98 | 98 | </div> |
| 99 | 99 | |
| 100 | 100 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -79,15 +79,15 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 79 | 79 | @endif |
| 80 | 80 | |
| 81 | 81 | <div> |
| 82 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 83 | <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 82 | <label for="login-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 83 | <input id="login-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 84 | 84 | </div> |
| 85 | 85 | <div> |
| 86 | 86 | <div class="flex items-baseline justify-between mb-1.5"> |
| 87 | <label class="font-medium text-text-2 text-xs">Password</label> | |
| 87 | <label for="login-password" class="font-medium text-text-2 text-xs">Password</label> | |
| 88 | 88 | <StxLink to="/forgot-password" class="text-text-3 text-xs hover:text-accent">Forgot?</StxLink> |
| 89 | 89 | </div> |
| 90 | <input name="password" type="password" required autocomplete="current-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 90 | <input id="login-password" name="password" type="password" required autocomplete="current-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 91 | 91 | </div> |
| 92 | 92 | |
| 93 | 93 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -102,16 +102,16 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 102 | 102 | @endif |
| 103 | 103 | |
| 104 | 104 | <div> |
| 105 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Name</label> | |
| 106 | <input name="name" type="text" required autocomplete="name" placeholder="Ada Lovelace" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 105 | <label for="register-name" class="block mb-1.5 font-medium text-text-2 text-xs">Name</label> | |
| 106 | <input id="register-name" name="name" type="text" required autocomplete="name" placeholder="Ada Lovelace" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 107 | 107 | </div> |
| 108 | 108 | <div> |
| 109 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 110 | <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 109 | <label for="register-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 110 | <input id="register-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 111 | 111 | </div> |
| 112 | 112 | <div> |
| 113 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Password</label> | |
| 114 | <input name="password" type="password" required autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 113 | <label for="register-password" class="block mb-1.5 font-medium text-text-2 text-xs">Password</label> | |
| 114 | <input id="register-password" name="password" type="password" required autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 115 | 115 | </div> |
| 116 | 116 | |
| 117 | 117 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -128,12 +128,12 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 128 | 128 | @endif |
| 129 | 129 | |
| 130 | 130 | <div> |
| 131 | <label class="block mb-1.5 font-medium text-text-2 text-xs">New password</label> | |
| 132 | <input name="password" type="password" required autofocus autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 131 | <label for="reset-password" class="block mb-1.5 font-medium text-text-2 text-xs">New password</label> | |
| 132 | <input id="reset-password" name="password" type="password" required autofocus autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 133 | 133 | </div> |
| 134 | 134 | <div> |
| 135 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Confirm password</label> | |
| 136 | <input name="password_confirmation" type="password" required autocomplete="new-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 135 | <label for="reset-password-confirmation" class="block mb-1.5 font-medium text-text-2 text-xs">Confirm password</label> | |
| 136 | <input id="reset-password-confirmation" name="password_confirmation" type="password" required autocomplete="new-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 137 | 137 | </div> |
| 138 | 138 | |
| 139 | 139 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -93,12 +93,12 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 93 | 93 | @endif |
| 94 | 94 | |
| 95 | 95 | <div> |
| 96 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Site name</label> | |
| 97 | <input name="name" type="text" required autofocus placeholder="My Blog" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 96 | <label for="site-name" class="block mb-1.5 font-medium text-text-2 text-xs">Site name</label> | |
| 97 | <input id="site-name" name="name" type="text" required autofocus placeholder="My Blog" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 98 | 98 | </div> |
| 99 | 99 | <div> |
| 100 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Domain <span class="text-text-3">(optional)</span></label> | |
| 101 | <input name="domain" type="text" placeholder="example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 100 | <label for="site-domain" class="block mb-1.5 font-medium text-text-2 text-xs">Domain <span class="text-text-3">(optional)</span></label> | |
| 101 | <input id="site-domain" name="domain" type="text" placeholder="example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 102 | 102 | </div> |
| 103 | 103 | |
| 104 | 104 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -0,0 +1,90 @@ | ||
| 1 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const resources = resolve(root, 'resources') | |
| 6 | const issues = new Set<string>() | |
| 7 | ||
| 8 | function filesIn(directory: string): string[] { | |
| 9 | return readdirSync(directory).flatMap((name) => { | |
| 10 | const path = join(directory, name) | |
| 11 | return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : [] | |
| 12 | }) | |
| 13 | } | |
| 14 | ||
| 15 | function withoutComments(value: string): string { | |
| 16 | const preserveLines = (comment: string): string => comment.replace(/[^\n]/g, ' ') | |
| 17 | return value | |
| 18 | .replace(/<!--[^]*?-->/g, preserveLines) | |
| 19 | .replace(/\{\{--[^]*?--\}\}/g, preserveLines) | |
| 20 | .replace(/\/\*[^]*?\*\//g, preserveLines) | |
| 21 | .replace(/^\s*\/\/.*$/gm, preserveLines) | |
| 22 | } | |
| 23 | ||
| 24 | function lineAt(source: string, index: number): number { | |
| 25 | return source.slice(0, index).split('\n').length | |
| 26 | } | |
| 27 | ||
| 28 | function named(attributes: string): boolean { | |
| 29 | return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes) | |
| 30 | } | |
| 31 | ||
| 32 | function report(file: string, source: string, index: number, message: string): void { | |
| 33 | issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`) | |
| 34 | } | |
| 35 | ||
| 36 | const files = filesIn(resources) | |
| 37 | const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file)) | |
| 38 | const combined = files.map(file => readFileSync(file, 'utf8')).join('\n') | |
| 39 | ||
| 40 | for (const file of markupFiles) { | |
| 41 | const original = readFileSync(file, 'utf8') | |
| 42 | const source = withoutComments(original) | |
| 43 | ||
| 44 | for (const match of source.matchAll(/<img\b[^>]*>/gi)) { | |
| 45 | if (!/\b(?::?alt)=/.test(match[0])) | |
| 46 | report(file, source, match.index, 'image has no alt text') | |
| 47 | } | |
| 48 | ||
| 49 | for (const match of source.matchAll(/<button\b([^>]*)>([^]*?)<\/button>/gi)) { | |
| 50 | const attributes = match[1] | |
| 51 | const content = match[2] | |
| 52 | .replace(/<[^>]+>/g, '') | |
| 53 | .replace(/\{\{[^}]+\}\}/g, 'dynamic') | |
| 54 | .replace(/@[a-z]+\([^)]*\)/gi, '') | |
| 55 | .trim() | |
| 56 | if (!content && !named(attributes)) | |
| 57 | report(file, source, match.index, 'icon-only button has no accessible name') | |
| 58 | } | |
| 59 | ||
| 60 | for (const match of source.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) { | |
| 61 | const attributes = match[2] | |
| 62 | if (/\btype=["']hidden["']/i.test(attributes) || named(attributes)) | |
| 63 | continue | |
| 64 | const id = attributes.match(/\bid=["']([^"']+)["']/i)?.[1] | |
| 65 | const before = source.slice(0, match.index) | |
| 66 | const wrapped = before.lastIndexOf('<label') > before.lastIndexOf('</label>') | |
| 67 | const referenced = id ? new RegExp(`<label\\b[^>]*\\bfor=["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(source) : false | |
| 68 | if (!wrapped && !referenced) | |
| 69 | report(file, source, match.index, `${match[1].toLowerCase()} has no programmatic label`) | |
| 70 | } | |
| 71 | ||
| 72 | for (const match of source.matchAll(/\btabindex=["']([1-9]\d*)["']/gi)) | |
| 73 | report(file, source, match.index, `positive tabindex ${match[1]} overrides natural keyboard order`) | |
| 74 | ||
| 75 | for (const match of source.matchAll(/<(?:div|span)\b([^>]*\bonclick=[^>]*)>/gi)) { | |
| 76 | if (!/\brole=["'](?:button|link)["']/i.test(match[1]) || !/\btabindex=["']0["']/i.test(match[1])) | |
| 77 | report(file, source, match.index, 'clickable non-interactive element is not keyboard accessible') | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined)) | |
| 82 | issues.add('resources: no visible keyboard focus treatment found') | |
| 83 | if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined)) | |
| 84 | issues.add('resources: no responsive breakpoint treatment found') | |
| 85 | ||
| 86 | console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`) | |
| 87 | if (issues.size) { | |
| 88 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 89 | process.exit(1) | |
| 90 | } | |