From ae966e8f56c815cd3d38d6d1081a717136e28ef9 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 10 Sep 2026 07:09:32 -0500 Subject: [PATCH] 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) --- docs/TOOLS.md | 32 +- docs/architecture/README.md | 3 +- docs/design/OVERHAUL_PLAN.md | 46 ++ docs/history/DEVELOPMENT_LOG.md | 78 +++ docs/qa/ClaudeQAPlan.md | 25 + .../brand/cisco-partner-logo-midnight.svg | 2 +- .../assets/brand/cisco-partner-logo-white.svg | 2 +- scripts/device-sweep.mjs | 233 +++++++ scripts/lib/css-audit.js | 587 ++++++++++++++++++ src/components/content/ContentBlocks.jsx | 8 +- src/components/content/RelatedLinks.jsx | 5 +- src/components/layout/Footer.jsx | 24 +- src/components/layout/Header.jsx | 44 +- src/index.css | 33 + src/pages/About.jsx | 6 +- src/pages/Home.jsx | 8 +- src/pages/Industries.jsx | 2 +- src/pages/IndustryDetail.jsx | 4 +- src/pages/PrivacyPolicy.jsx | 8 +- src/pages/ServiceDetail.jsx | 4 +- src/pages/Services.jsx | 2 +- 21 files changed, 1108 insertions(+), 48 deletions(-) create mode 100755 scripts/device-sweep.mjs create mode 100644 scripts/lib/css-audit.js 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