ReviewOS

also looking at this

loghqorg/loghq

Add BunPress guide and pre-E2E quality gates

#4
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
246 files +1744 -64381

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
@@ -200,5 +200,8 @@ jobs:
200200 - name: Validate UI accessibility
201201 run: bun run ui:validate
202202
203 - name: Validate responsive visual contracts
204 run: bun run visual:validate
205
203206 - name: Verify generated files are current
204207 run: git diff --exit-code
.gitignoremodified+1-0
Changes to .gitignore
@@ -44,6 +44,7 @@ database/*.sqlite-shm
4444
4545# Build files
4646.cache
47/.output/
4748temp
4849.temp
4950data.ms
package.jsonmodified+3-2
Changes to package.json
@@ -30,10 +30,11 @@
3030 "development": "bun scripts/dev.ts",
3131 "dev:api": "./buddy dev:api",
3232 "dev:full": "STACKS_FULL_DEV=1 ./buddy dev",
33 "build": "./buddy build",
33 "build": "bun scripts/build-app.ts",
3434 "docs:validate": "bun scripts/validate-docs.ts",
3535 "ui:validate": "bun scripts/validate-ui.ts",
36 "prod": "./buddy build",
36 "visual:validate": "bun scripts/validate-visual.ts",
37 "prod": "bun scripts/build-app.ts",
3738 "deploy": "./buddy deploy",
3839 "lint": "./buddy lint",
3940 "lint:fix": "./buddy lint:fix",
scripts/build-app.tsadded+44-0
Changes to scripts/build-app.ts
@@ -0,0 +1,44 @@
1import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs'
2import { resolve } from 'node:path'
3
4const projectRoot = resolve(import.meta.dir, '..')
5const compatibilityPath = resolve(projectRoot, 'pages')
6const viewsPath = resolve(projectRoot, 'resources/views')
7const relativeViewsPath = 'resources/views'
8
9let createdCompatibilityLink = false
10
11if (existsSync(compatibilityPath)) {
12 const stat = lstatSync(compatibilityPath)
13 const existingTarget = stat.isSymbolicLink()
14 ? resolve(projectRoot, readlinkSync(compatibilityPath))
15 : null
16
17 if (existingTarget !== viewsPath) {
18 throw new Error('Cannot build: the root pages path already exists and is not the expected resources/views link.')
19 }
20}
21else {
22 // The current SSR builder discovers only a root pages directory and does not
23 // honor ui.pagesDir. Keep the source layout canonical and expose it only while
24 // the framework compiles the application.
25 symlinkSync(relativeViewsPath, compatibilityPath, 'dir')
26 createdCompatibilityLink = true
27}
28
29try {
30 const build = Bun.spawn(['./buddy', 'build'], {
31 cwd: projectRoot,
32 stdin: 'inherit',
33 stdout: 'inherit',
34 stderr: 'inherit',
35 })
36 const exitCode = await build.exited
37
38 if (exitCode !== 0)
39 process.exitCode = exitCode
40}
41finally {
42 if (createdCompatibilityLink && existsSync(compatibilityPath))
43 unlinkSync(compatibilityPath)
44}
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()