ReviewOS

also looking at this

loghqorg/loghq

Deploy: uniform auth session model (main → production)

#7
Merged glennmichael123 wants to merge main into production
282 files +3028 -64856

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
@@ -197,5 +197,8 @@ jobs:
197197 - name: Validate documentation
198198 run: bun run docs:validate
199199
200 - name: Validate UI accessibility
201 run: bun run ui:validate
202
200203 - name: Verify generated files are current
201204 run: git diff --exit-code
package.jsonmodified+1-0
Changes to package.json
@@ -32,6 +32,7 @@
3232 "dev:full": "STACKS_FULL_DEV=1 ./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 "lint": "./buddy lint",
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}