diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3f99424..75860e8 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -168,10 +168,10 @@ curl -s https://qn.isnull.dev/api/health # same container, other ingress If those two disagree, the container is fine and the problem is in front of it. `docs/OPERATIONS.md` has the topology. -## Two checks that are run by hand +## Three checks that are run by hand -Neither is adopted into `scripts/`, so neither runs in `verify.sh`. Both are -worth running when the documents change a lot. +None of them runs in `verify.sh`. The first is not adopted into `scripts/` at +all; the other two are, and the reason they still do not gate is below. **`doc-claims.sh` — every path a document names must exist.** Run from the template, and **exclude `docs/history/`**: @@ -209,6 +209,32 @@ node scripts/qa-browser.mjs --paths /contact --viewports 320 Exit 2 means playwright was missing or the site was unreachable — nothing was checked, which is not a pass. +**`device-sweep.mjs` renders every page on ten emulated phones and tablets.** +Same playwright trade as above, plus it needs something already serving the +build, so it is a tool you reach for rather than a gate. + +```bash +npm run preview & # serves dist/ on 3001 +node scripts/device-sweep.mjs # all ten devices +node scripts/device-sweep.mjs --devices "iPad Mini" # one, while iterating +node scripts/device-sweep.mjs --url https://queuenorth.com --report /tmp/sweep.md +``` + +It measures each box against its nearest **clipping** ancestor rather than +`document.scrollWidth`, and that distinction is the point: this site's `body` +carries `overflow-x: hidden`, so a page can slice content off its right edge and +still report a scrollWidth equal to the viewport. Eight kinds of finding, of +which `clipped`, `past_viewport` and `document_scrolls` are blocking. Exit 0 +clean, 1 findings, 2 nothing swept. + +It exists because #214 — the header CTA clipped at iPad portrait — was fixed, +checked in a desktop window sized to 768, and released still broken. A desktop +window at 768 has a scrollbar, so the layout viewport was ~753px and the `md` +breakpoint the fix was about never engaged. A device profile has no scrollbar +inset, so 768 means 768. The engine is a copy of the Privacy LLC site's +`scripts/css-qc.mjs`; its provenance and the ways this driver differs are in the +header of `scripts/lib/css-audit.js`. + **`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and requires the guard to go red. This project has four guards, all shell scripts that fail visibly, so §1 of `architecture/GUARDS.md` is performed by hand diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7f6c769..4865cd5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -205,7 +205,7 @@ each row says what it does *here*. | `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, refusing malformed ones before they are filed | | `scripts/audit-html.js` | what the site actually serves: over `dist/` as guard `15-built-html`, and with `--url` against a live origin once per crawler user agent. The URL run is a check to make after a deploy, not a gate | | `scripts/validate-content.js` | the content check on its own, for proving it fails and for a fast answer while writing copy. `npm run build` runs the same check inside the prerenderer, so a clean run here is not a substitute for a build | -| `scripts/lib/` | shared, side-effect-free modules: `routes.js` (the one route list, and the drift check against the router's own table) and `content.js` (what must be true of `src/data/**` before a page is built from it) | +| `scripts/lib/` | shared, side-effect-free modules: `routes.js` (the one route list, and the drift check against the router's own table), `content.js` (what must be true of `src/data/**` before a page is built from it), `html-audit.js` (what a served page must say) and `css-audit.js` (what a rendered page must measure, serialised into the browser by `device-sweep.mjs`) | | `scripts/status.sh` | what is running on **nebula** as `qn-website-dev`, its version and its restart count. Read-only | | `scripts/healthcheck.sh` | a liveness tick against `queuenorth.com`, asserting HTTP 200 **and** `"status":"ok"` **and** `"db":"ok"` — a 503 with a JSON body is a real answer, not an outage. `HEALTHCHECK_BASE_URL` points it at the other front door | | `scripts/preflight.sh` | headers and TLS against the live origin. No `--auth` checks: there are no accounts | @@ -216,6 +216,7 @@ each row says what it does *here*. | `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template | | `scripts/qa-browser.mjs` | renders the site in real Chromium at real viewports and **measures** it — horizontal scroll, broken images, elements past the right edge, CLS and LCP. Exit 2 if playwright is missing, because "could not check" is not a pass. Not in `verify.sh`: playwright is global here, not a project dependency | +| `scripts/device-sweep.mjs` | renders every page on ten emulated phones and tablets and measures the layout: clipping, boxes past the viewport, media wider than its container, occluded sticky headers, tiny text, touch targets. Compares each box against its nearest **clipping** ancestor, because `body{overflow-x:hidden}` makes `document.scrollWidth` agree with the viewport while content is being sliced off. Exit 2 if nothing was swept. Not in `verify.sh`: playwright is global here, and it needs a server already serving the build | | `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template | **Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and diff --git a/docs/design/OVERHAUL_PLAN.md b/docs/design/OVERHAUL_PLAN.md index 9f98229..ac1b44b 100644 --- a/docs/design/OVERHAUL_PLAN.md +++ b/docs/design/OVERHAUL_PLAN.md @@ -826,6 +826,46 @@ The build refuses copy that breaks any of this: see `scripts/lib/content.js`. - Mobile section padding: `py-16` (64px) - Desktop section padding: `py-24` (96px) - Card padding: `p-4 md:p-6` +- **Standalone link lists: `space-y-4`, not `space-y-2`** — see the tap-target + rule below. This is a change from what the rest of this file describes, made + 2026-09-10; it affects the footer columns, the privacy contents and the + related-links lists. + +**Tap targets (added 2026-09-10)** + +A standalone text link renders 17 to 20px tall, and a finger needs about 32. +`.tap-target` in `src/index.css` grows the hit box by 8px above and below and +takes those 8px back out of the layout, so the line the link sits on does not +move. **It only works if the list leaves 16px between rows**: the negative +margin does not shrink the box, only its effect on layout, so at `space-y-2` +each link's box reached 8px into a gap its neighbour was already reaching 8px +into. They overlapped, and `getBoundingClientRect` still read 33px — a target +that measured right and was not there. + +Three rules follow from that: + +- A link in a **vertical list** gets `.tap-target`, and the list gets + `space-y-4` or `gap-y-4`. +- A link that fills a **column** gets `block` as well, so the whole row is the + target rather than the width of the word. That is also what keeps it passing: + a 39x36 word is judged as a compact target and wanted 44px of height, where a + 200x36 row is judged as a row and wants 32. +- A link **inside a sentence** gets nothing. WCAG 2.5.8 exempts it, and padding + would reach into the lines above and below. `LINK_CLASS` in `ContentBlocks` + is therefore padding-free by design: it is used both ways, and the callers + that need a target add `.tap-target` beside it. + +**Breakpoint for the desktop header: `lg`, not `md` (changed 2026-09-10)** + +768 to 1023 gets the `Sheet` menu. The desktop row cannot fit 768: brand, six +nav links and the CTA want 787px of natural width against 736px of container, +so flex shrank the CTA and wrapped its label and it *still* overflowed. The +menu is the better tablet experience regardless — 44px rows instead of 17px +ones, and the Services and Industries submenus are reachable, where the desktop +dropdowns open on hover and a touch device has no hover. + +The wordmark holds at `text-xl` until `xl`. At exactly 1024 the desktop row had +four pixels of room; at `text-xl` it has sixty-nine. ### Asset / Image Treatment @@ -856,6 +896,12 @@ The build refuses copy that breaks any of this: see `scripts/lib/content.js`. ❌ Gradient overlays on every section ❌ Multiple competing typefaces ❌ Large font sizes without line-height spacing +❌ A partner logo scaled up inside `overflow-hidden` to fill its tile. The Cisco + mark sat in a 700x700 canvas it filled 66% of, so `object-contain` rendered + it small beside 8x8's; `scale-[1.5]` and `scale-[2]` made it match and cut + 13px off the trademark on `/` and 24px on `/about`. Crop the asset's own + `viewBox` to the artwork instead and let the tile's padding be the clear + space, so nothing is clipped and nothing is distorted --- diff --git a/docs/history/DEVELOPMENT_LOG.md b/docs/history/DEVELOPMENT_LOG.md index cf6903d..6ee2bd6 100644 --- a/docs/history/DEVELOPMENT_LOG.md +++ b/docs/history/DEVELOPMENT_LOG.md @@ -46,6 +46,84 @@ sequence would have implied more. ## Entries +### 2026-09-10 — A device sweep, and a fix that was released without ever being exercised + +Every page rendered on ten emulated phones and tablets — real Playwright device +profiles rather than a resized window — and measured. **190 page loads, 21 +blocking and 530 high findings** on pages that had already passed Batch 11 and +Batch 16. Three defects, all live in production. Filed as #238 to #241 under +Batch 19. + +**#238 is a regression of a fix that could not have worked.** #214 was "the +header CTA is clipped at iPad portrait". v0.9.5 tightened the nav gaps at `md`, +checked it in a desktop browser window sized to 768, and released. A desktop +window at 768 has a scrollbar, so the layout viewport was about 753px, the `md` +breakpoint never engaged, and the desktop header the fix was about was never on +screen. On a real 768px device nothing had changed: `Request Consultation` sat +25px past the right edge on all 19 routes, and `body{overflow-x:hidden}` sliced +it off with no scrollbar to hint at it. + +The arithmetic says the approach was never available. At a true 768 the brand, +six nav links and the CTA want 787px of natural width against 736px of +container; the CTA had already been squeezed from 176px to 114px and its label +wrapped, and it still did not fit. No gap tightening closes 51px. So 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 the +Services and Industries submenus are reachable, where the desktop dropdowns open +on hover and a touch device has no hover. The CTA gained `shrink-0 +whitespace-nowrap`, so the next row that does not fit overflows visibly instead +of being quietly squeezed past the edge. + +**#239, the Cisco mark.** It ships in a 700x700 canvas it fills 66% of, so +`object-contain` rendered it small beside 8x8's and both pages compensated with +`scale-[1.5]` and `scale-[2]` inside `overflow-hidden` — cutting 13px off the +trademark on `/` and 24px on `/about`. Cropping the asset's own `viewBox` to the +artwork and dropping the scale renders it at 62x46 on `/`, the same size as the +8x8 logo beside it, with nothing clipped. + +**#240, tap targets, cost a round to get right.** Padding an `inline` link is +painted and hit-tested but never enters the line box, so it reports a taller +rect while overlapping its neighbours. And an `inline-block` with negative +margins overlaps too at `space-y-2`: each link reached 8px into a gap the +neighbour was already reaching 8px into, and hit-testing gave the whole gap to +whichever painted last. Both versions measure 33px and neither is 33px. **A +target that measures right and is not there is worse than one that measures +wrong.** The rule that survived is in `design/OVERHAUL_PLAN.md`: `.tap-target` +is `inline-block`, and every list that uses it moves to `space-y-4`. Then a +second surprise — a 39x17 link grown to 39x36 stops being row-shaped and starts +being judged as a compact target wanting 44px — so footer links also take +`block` and the whole row becomes the target. + +**#241 is the instrument.** `scripts/device-sweep.mjs` plus +`scripts/lib/css-audit.js`, the engine copied from the Privacy LLC site's +`css-qc.mjs`. It compares every box against its nearest **clipping** ancestor +rather than `document.scrollWidth`, which this site's `body` makes agree with +the viewport while content is sliced off the right edge. Not wired into +`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. Worth +recording that the original in the other repository declares device profiles and +then only calls `setViewportSize`, so its `isMobile` and `deviceScaleFactor` +never take effect — it is a width sweep wearing a phone's clothes, which is the +same blind spot in a different form. + +**Proven, not assumed.** After the fixes the sweep reports zero blocking and +zero high across all ten devices. Each defect was then re-introduced and the +sweep reported it again: `clipped x1` and `media_overflow x1` for the logo, +`past_viewport x19` for the header, `touch_target x342` for the footer. The +first attempt at the header proof reverted only the nav's breakpoint and left +the CTA at `lg`, so the element that overflows was not on screen and the sweep +correctly reported nothing — a wrong mutation, not a blind checker, and worth +writing down because it looks identical to a checker that has stopped working. +Exit codes were proven separately: 2 for an unreachable origin and for an +unknown `--devices` name, because "nothing was swept" must never read as +"nothing was wrong". + +**Next action:** deploy v0.9.7, which carries both the "25+ years of industry +experience" wording change and this sweep. Production is still on v0.9.6. + +**Blockers:** #216 needs Levi's four vendor screenshots. #217 needs Google +Search Console and Bing Webmaster Tools access to submit the two service URLs. + ### 2026-09-10 — Levi's approved pages shipped, and the guards that should have caught what was found on the way Levi Halford approved two copy sheets on 2026-08-28 and emailed them the same diff --git a/docs/qa/ClaudeQAPlan.md b/docs/qa/ClaudeQAPlan.md index ccbb5e0..08237f3 100644 --- a/docs/qa/ClaudeQAPlan.md +++ b/docs/qa/ClaudeQAPlan.md @@ -84,6 +84,7 @@ So, before filing a UI defect and before acting on one: ```bash node scripts/qa-browser.mjs # production, every page in its sitemap, four widths node scripts/qa-browser.mjs --url http://localhost:3099 --viewports 320,768 +node scripts/device-sweep.mjs # every page, ten emulated phones and tablets ``` Contrast is arithmetic — composite the colour over its background and compute @@ -91,6 +92,30 @@ the ratio, do not judge it by eye. Overlap is two rectangles. "Does it clip" is a computed style you can read off the ancestors. A filed defect's numbers are a claim to check, not a measurement. +### A resized desktop window is not a device (added 2026-09-10) + +Two defect classes reached production through a QA round that looked thorough, +and both were invisible to the instrument being used. + +**A window sized to 768 is not 768.** It has a scrollbar, so the layout viewport +is about 753, so the `md` breakpoint never engages and the desktop layout the +check is about is never on screen. That is how #214 — the header CTA clipped at +iPad portrait — was fixed, checked at "768", released, and was still 25px past +the right edge on every page. A device profile has no scrollbar inset. Check a +breakpoint on a device, or on an emulated one; never on a window you dragged. + +**`document.scrollWidth` is not evidence of fitting.** This site's `body` +carries `overflow-x: hidden`, so content sliced off the right edge leaves +`scrollWidth === innerWidth` and no scrollbar to hint at it. Compare a box +against its nearest **clipping** ancestor, which is what +`scripts/lib/css-audit.js` does. The first sweep found 21 blocking and 530 high +findings on pages that had passed every earlier round. + +Touch targets are the other class that got through, for a related reason: a +17px-tall footer link is fine to click and fiddly to tap, and nothing in a +desktop pass distinguishes them. The rule that came out of it is in +`design/OVERHAUL_PLAN.md` under Tap targets. + ## What counts as a finding A finding needs: what was done, what happened, what should have happened, and diff --git a/public/assets/brand/cisco-partner-logo-midnight.svg b/public/assets/brand/cisco-partner-logo-midnight.svg index e62e4ed..1fcd574 100644 --- a/public/assets/brand/cisco-partner-logo-midnight.svg +++ b/public/assets/brand/cisco-partner-logo-midnight.svg @@ -1,7 +1,7 @@ + viewBox="82 105 537 463" style="enable-background:new 82 105 537 463;" xml:space="preserve"> diff --git a/public/assets/brand/cisco-partner-logo-white.svg b/public/assets/brand/cisco-partner-logo-white.svg index 971d0fb..3cba3a6 100644 --- a/public/assets/brand/cisco-partner-logo-white.svg +++ b/public/assets/brand/cisco-partner-logo-white.svg @@ -1,7 +1,7 @@ + viewBox="82 105 537 463" style="enable-background:new 82 105 537 463;" xml:space="preserve"> diff --git a/scripts/device-sweep.mjs b/scripts/device-sweep.mjs new file mode 100755 index 0000000..5310309 --- /dev/null +++ b/scripts/device-sweep.mjs @@ -0,0 +1,233 @@ +#!/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 +// 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" +// 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))}` : ''}`, + ` - 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) diff --git a/scripts/lib/css-audit.js b/scripts/lib/css-audit.js new file mode 100644 index 0000000..d2086a1 --- /dev/null +++ b/scripts/lib/css-audit.js @@ -0,0 +1,587 @@ +// Measures a rendered page and reports where the layout is wrong. +// +// This function is not run here. It is serialised with toString() and evaluated +// inside the browser by scripts/device-sweep.mjs, so it may use only what a page +// has: no imports, no Node globals, no closure over anything in this file. +// +// It measures rather than guesses. Every box is compared against its nearest +// CLIPPING ancestor instead of document.scrollWidth, which lies the moment any +// container carries overflow-x: hidden or clip — and this site's body does, so +// a page can slice content off its right edge and still report a scrollWidth +// equal to the viewport. That is exactly how the header CTA at iPad portrait +// survived a fix and a release. +// +// It reports eight kinds: clipped, past_viewport, document_scrolls, +// media_overflow, sticky_occluded, active_tab_offscreen, tiny_text and +// touch_target. Each finding carries a severity, a devtools-pasteable selector +// path, and the numbers it was decided on, so a finding can be re-measured +// rather than re-argued. +// +// Provenance: lifted from the Privacy LLC site's scripts/css-qc.mjs, which is +// where the thresholds were argued out and where the comments explaining each +// one were written. Copied rather than shared because the two repositories have +// no common package; if a threshold changes in one, it does not change in the +// other. The driver here differs from that one in a way that matters: css-qc +// declares Playwright device profiles but only ever calls setViewportSize, so +// its deviceScaleFactor, isMobile and hasTouch fields never take effect and it +// is a width sweep wearing a phone's clothes. + +export const audit = function audit() { + const EPS = 1; + const vw = window.innerWidth; + const vh = window.innerHeight; + const findings = []; + const push = (f) => findings.push(f); + + /** A selector a human can paste into devtools. Short, not unique-at-all-costs. */ + function pathOf(el) { + const parts = []; + let node = el; + + while (node && node.nodeType === 1 && parts.length < 4) { + let part = node.tagName.toLowerCase(); + + // `getAttribute`, not `.id`. A
containing