Queue-North-Website/scripts/device-sweep.mjs

234 lines
9.7 KiB
JavaScript
Raw Normal View History

fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 07:09:32 -05:00
#!/usr/bin/env node
//
// Render every page on emulated phones and tablets and MEASURE the layout.
//
// node scripts/device-sweep.mjs # localhost:3001
// node scripts/device-sweep.mjs --url https://queuenorth.com
// node scripts/device-sweep.mjs --devices "iPhone SE,iPad Mini"
// node scripts/device-sweep.mjs --report /tmp/sweep.md --shots /tmp/sweep
//
// Exit codes: 0 nothing found. 1 findings. 2 NOTHING WAS SWEPT: playwright
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 07:09:32 -05:00
// missing, chromium unlaunchable, or no sitemap. Two is not a pass.
//
// ## Which incident motivated it
//
// #214 was "the header CTA is clipped at iPad portrait". It was fixed in v0.9.5
// by tightening the nav gaps, checked in a desktop browser window sized to 768,
// and released. The check was worthless: a desktop window at 768 has a scrollbar,
// so the layout viewport was ~753px, the md breakpoint never engaged, and the
// desktop header the fix was about was never on screen. The CTA was still 25px
// past the right edge in production, on every page, and body{overflow-x:hidden}
// sliced it off with no scrollbar to hint that anything was missing.
//
// A real device profile has no scrollbar inset, so 768 means 768. It also brings
// deviceScaleFactor, isMobile and hasTouch, which change hover media queries and
// text metrics. Those differences are the whole reason this exists alongside
// qa-browser.mjs: that script varies width, this one varies device.
//
// ## How it differs from qa-browser.mjs
//
// qa-browser a few widths, one desktop context; contrast, CLS, LCP,
// broken images, horizontal scroll
// device-sweep ten real device profiles; eight classes of layout defect
// from scripts/lib/css-audit.js, measured against each box's
// nearest CLIPPING ancestor rather than document.scrollWidth
//
// Neither replaces the other. Reach for both when a UI defect is filed.
//
// ## Why it is not wired into verify.sh
//
// Same trade as qa-browser: playwright is a global install here, not a
// dependency, and this needs a server already serving the build. A guard that
// cannot run on a clean clone is a guard that gets skipped, and verify.sh
// treating a skip as a pass is the failure mode GUARDS.md exists to prevent.
import { createRequire } from 'node:module'
import { execSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { audit } from './lib/css-audit.js'
import { parseSitemap } from './lib/html-audit.js'
const args = process.argv.slice(2)
const opt = (name, dflt) => {
const i = args.indexOf(`--${name}`)
return i === -1 ? dflt : args[i + 1]
}
const URL_BASE = opt('url', 'http://localhost:3001').replace(/\/$/, '')
const REPORT = opt('report', null)
const SHOTS = opt('shots', null)
const ONLY = opt('devices', null)?.split(',').map((s) => s.trim())
let chromium
let devices
try {
const require = createRequire(import.meta.url)
let root
try {
root = require.resolve('playwright')
} catch {
root = path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright', 'index.js')
}
;({ chromium, devices } = require(root))
} catch {
console.error('device-sweep: playwright is not available, so NOTHING was swept.')
console.error(' npm i -g playwright && npx playwright install chromium')
process.exit(2)
}
// Portrait and landscape both, because a tablet is held both ways and a phone in
// landscape is the shape that catches a menu taller than the viewport. iPad Mini
// portrait is 768 exactly, which is the md breakpoint, which is where #214 was.
// Playwright has no landscape entries for these, so the two rotated profiles keep
// the device's scale factor and touch flags and swap the viewport by hand.
const PROFILES = [
['iPhone SE', devices['iPhone SE']],
['iPhone 12', devices['iPhone 12']],
['iPhone 14 Pro Max', devices['iPhone 14 Pro Max']],
['Pixel 7', devices['Pixel 7']],
['Galaxy S9+', devices['Galaxy S9+']],
['iPhone 12 landscape', { ...devices['iPhone 12'], viewport: { width: 664, height: 390 } }],
['iPad Mini', devices['iPad Mini']],
['iPad Mini landscape', { ...devices['iPad Mini'], viewport: { width: 1024, height: 768 } }],
['iPad Pro 11', devices['iPad Pro 11']],
['iPad Pro 11 landscape', { ...devices['iPad Pro 11'], viewport: { width: 1194, height: 834 } }],
].filter(([label, profile]) => profile && (!ONLY || ONLY.includes(label)))
if (!PROFILES.length) {
console.error(`device-sweep: no device profile matched ${ONLY?.join(', ')}, so NOTHING was swept.`)
process.exit(2)
}
// Whatever the target says it serves, plus a path it does not: 404.html is a page
// users reach and it has never been in anybody's hand-typed list.
let routes
try {
const response = await fetch(`${URL_BASE}/sitemap.xml`, { signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
routes = parseSitemap(await response.text()).map((entry) => entry.path)
if (!routes.length) throw new Error('it lists no pages')
} catch (error) {
console.error(`device-sweep: could not read ${URL_BASE}/sitemap.xml (${error.message}), so NOTHING was swept.`)
process.exit(2)
}
routes.push('/no-such-page')
const browser = await chromium.launch().catch((error) => {
console.error('device-sweep: could not launch chromium, so NOTHING was swept:', error.message)
process.exit(2)
})
if (SHOTS) mkdirSync(SHOTS, { recursive: true })
const AUDIT_SRC = audit.toString()
const findings = []
let loads = 0
for (const [label, profile] of PROFILES) {
const context = await browser.newContext({ ...profile })
const page = await context.newPage()
for (const route of routes) {
let response
try {
response = await page.goto(`${URL_BASE}${route}`, { waitUntil: 'networkidle', timeout: 45000 })
} catch (error) {
findings.push({ kind: 'load_failed', severity: 'blocking', route, device: label, says: error.message.split('\n')[0], path: '', text: '' })
continue
}
loads++
if (!response || (response.status() >= 400 && route !== '/no-such-page')) {
findings.push({ kind: 'http_error', severity: 'blocking', route, device: label, says: `HTTP ${response?.status()}`, path: '', text: '' })
continue
}
// Scroll the whole page before measuring, so lazy images have loaded and
// sticky elements have been in their stuck state. Measuring at the top only
// reports a page nobody has used yet.
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += 400) {
window.scrollTo(0, y)
await new Promise((resolve) => setTimeout(resolve, 60))
}
window.scrollTo(0, 0)
})
await page.waitForTimeout(500)
for (const finding of await page.evaluate(`(${AUDIT_SRC})()`)) {
findings.push({ ...finding, route, device: label })
}
if (SHOTS) {
const name = `${label.replace(/\W+/g, '-')}${route.replace(/\//g, '_') || '_root'}.png`
await page.screenshot({ path: path.join(SHOTS, name), fullPage: true })
}
}
await context.close()
}
await browser.close()
if (!loads) {
console.error(`device-sweep: every page load failed against ${URL_BASE}, so NOTHING was measured.`)
process.exit(2)
}
// The same defect on six devices is one defect. Group on what identifies it
// (route, element, text) and keep the device list, because "only iPad Mini"
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 07:09:32 -05:00
// versus "all ten" is the difference between a breakpoint bug and a layout bug.
const groups = new Map()
for (const finding of findings) {
const key = `${finding.kind}|${finding.route}|${finding.path}|${(finding.text || '').slice(0, 40)}`
if (!groups.has(key)) groups.set(key, { ...finding, devices: new Set(), count: 0 })
groups.get(key).devices.add(finding.device)
groups.get(key).count++
}
const rows = [...groups.values()].sort(
(a, b) => (b.severity === 'blocking') - (a.severity === 'blocking') || b.devices.size - a.devices.size,
)
const bySeverity = (severity) => rows.filter((row) => row.severity === severity)
if (REPORT) {
const lines = [
`# Device sweep: ${URL_BASE}`,
'',
`${routes.length} routes x ${PROFILES.length} devices = ${loads} page loads`,
`Devices: ${PROFILES.map(([label]) => label).join(', ')}`,
'',
`**${bySeverity('blocking').length} blocking, ${bySeverity('high').length} high, ${bySeverity('info').length} informational** (grouped from ${findings.length})`,
'',
]
for (const severity of ['blocking', 'high', 'info']) {
const group = bySeverity(severity)
if (!group.length) continue
lines.push(`## ${severity}`, '')
for (const row of group) {
lines.push(
`- **${row.route}** ${row.kind}: ${row.says || ''}`,
` - \`${row.path}\`${row.text ? `, text: ${JSON.stringify(String(row.text).slice(0, 60))}` : ''}`,
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 07:09:32 -05:00
` - on ${[...row.devices].join(', ')}`,
row.detail ? ` - \`${JSON.stringify(row.detail)}\`` : '',
)
}
lines.push('')
}
mkdirSync(path.dirname(path.resolve(REPORT)), { recursive: true })
writeFileSync(REPORT, lines.filter((line) => line !== '').join('\n') + '\n')
}
console.log(`device-sweep: ${loads} page loads across ${PROFILES.length} device(s) of ${URL_BASE}`)
for (const severity of ['blocking', 'high', 'info']) {
const counts = new Map()
for (const row of bySeverity(severity)) counts.set(row.kind, (counts.get(row.kind) || 0) + 1)
for (const [kind, n] of counts) console.log(` ${severity}: ${kind} x${n}`)
}
if (REPORT) console.log(` report: ${REPORT}`)
if (!rows.length) {
console.log(' nothing wrong.')
process.exit(0)
}
for (const row of rows.slice(0, 20)) {
console.log(` ${row.severity.padEnd(8)} ${row.route} ${row.kind}: ${row.says || ''} [${row.path}]`)
}
if (rows.length > 20) console.log(` ...and ${rows.length - 20} more${REPORT ? ' in the report' : ' (pass --report to list them all)'}`)
process.exit(1)