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
@@ -372,5 +372,8 @@ jobs:
372372 - name: Validate documentation
373373 run: bun run docs:validate
374374
375 - name: Validate UI accessibility
376 run: bun run ui:validate
377
375378 - name: Verify generated files are current
376379 run: git diff --exit-code
package.jsonmodified+1-0
Changes to package.json
@@ -32,6 +32,7 @@
3232 "dev:full": "./buddy dev",
3333 "build": "./buddy build",
3434 "docs:validate": "bun scripts/validate-docs.ts",
35 "ui:validate": "bun scripts/validate-ui.ts",
3536 "prod": "./buddy build",
3637 "deploy": "./buddy deploy",
3738 "export:site": "bun scripts/analytics/export-site.ts",
resources/views/dashboard.stxmodified+7-6
Changes to resources/views/dashboard.stx
@@ -2611,11 +2611,12 @@ select.panel:hover { border-color: var(--accent); }
26112611 <input
26122612 type="email"
26132613 x-model="inviteEmail"
2614 aria-label="Teammate email"
26142615 placeholder="teammate@example.com"
26152616 autocomplete="off"
26162617 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)"
26172618 >
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)">
26192620 <option value="viewer">can view</option>
26202621 <option value="admin">can manage</option>
26212622 </select>
@@ -3299,18 +3300,18 @@ select.panel:hover { border-color: var(--accent); }
32993300 event goals or the URL PATH for pageview goals. --}}
33003301 @if (ownsActive)
33013302 <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)">
33043305 <option value="event">Event</option>
33053306 <option value="pageview">Pageview</option>
33063307 </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)">
33093310 <option value="exact">exact</option>
33103311 <option value="contains">contains</option>
33113312 <option value="starts_with">starts with</option>
33123313 </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)">
33143315 <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>
33153316 </form>
33163317 <div class="mt-2 text-xs" :show="goalError()" style="display:none;color:#ef4444">{{ goalError() }}</div>
resources/views/forgot-password.stxmodified+2-2
Changes to resources/views/forgot-password.stx
@@ -93,8 +93,8 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
9393 @endif
9494
9595 <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">
9898 </div>
9999
100100 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
resources/views/login.stxmodified+4-4
Changes to resources/views/login.stx
@@ -79,15 +79,15 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
7979 @endif
8080
8181 <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">
8484 </div>
8585 <div>
8686 <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>
8888 <StxLink to="/forgot-password" class="text-text-3 text-xs hover:text-accent">Forgot?</StxLink>
8989 </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">
9191 </div>
9292
9393 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
resources/views/register.stxmodified+6-6
Changes to resources/views/register.stx
@@ -102,16 +102,16 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
102102 @endif
103103
104104 <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">
107107 </div>
108108 <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">
111111 </div>
112112 <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">
115115 </div>
116116
117117 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
resources/views/reset-password.stxmodified+4-4
Changes to resources/views/reset-password.stx
@@ -128,12 +128,12 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
128128 @endif
129129
130130 <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">
133133 </div>
134134 <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">
137137 </div>
138138
139139 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
resources/views/sites/new.stxmodified+4-4
Changes to resources/views/sites/new.stx
@@ -93,12 +93,12 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
9393 @endif
9494
9595 <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">
9898 </div>
9999 <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">
102102 </div>
103103
104104 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
scripts/validate-ui.tsadded+90-0
Changes to scripts/validate-ui.ts
@@ -0,0 +1,90 @@
1import { readdirSync, readFileSync, statSync } from 'node:fs'
2import { join, relative, resolve } from 'node:path'
3
4const root = process.cwd()
5const resources = resolve(root, 'resources')
6const issues = new Set<string>()
7
8function 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
15function 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
24function lineAt(source: string, index: number): number {
25 return source.slice(0, index).split('\n').length
26}
27
28function named(attributes: string): boolean {
29 return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes)
30}
31
32function report(file: string, source: string, index: number, message: string): void {
33 issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`)
34}
35
36const files = filesIn(resources)
37const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file))
38const combined = files.map(file => readFileSync(file, 'utf8')).join('\n')
39
40for (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
81if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined))
82 issues.add('resources: no visible keyboard focus treatment found')
83if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined))
84 issues.add('resources: no responsive breakpoint treatment found')
85
86console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`)
87if (issues.size) {
88 for (const issue of [...issues].sort()) console.error(`- ${issue}`)
89 process.exit(1)
90}