Compare commits

...

4 Commits
v0.9.7 ... dev

Author SHA1 Message Date
null 652669920b docs: record the v0.9.8 deploy and what was checked after it
deploy.sh reported "0.9.6 -> 0.9.6" and an unchanged digest, which is #236
doing exactly what it is filed for: it reads both before the container is
replaced. What actually happened, checked independently: qn-website-dev is
healthy on v0.9.8 with 0 restarts, and its image id 62d076e3800d is the id of
the image release.sh built. Both front doors answer ok, the crawler audit is
clean across 18 pages as all five agents, 18 sitemap entries carry a lastmod,
and the device sweep against production reports zero findings across ten
profiles and 190 page loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 19:32:32 -05:00
null 0e4718334b chore(release): v0.9.8 2026-09-10 19:21:51 -05:00
null 12f1fd54df docs: no em dashes in what ae966e8 added
The rule is none in replies, commits, issues, docs or site copy, and the last
commit put 40 of them into five documents, two scripts and two comments. Nothing
reached the site: dist/ has none, and the content validator refuses U+2014 in
src/data. Rewritten by sentence rather than substituted, so the punctuation
carries the same meaning it did.

scripts/lib/css-audit.js is a copy of another repository's file and its 20 were
repunctuated with it. Its header now says so, because "lifted verbatim" would
otherwise be a claim a byte diff disproves. The logic is untouched, and the
sweep still reports zero findings on all ten devices.

The four Batch 19 issue bodies were edited in the tracker for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 19:12:23 -05:00
null ae966e8f56 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
24 changed files with 1134 additions and 52 deletions

View File

@ -95,7 +95,7 @@ COPY --from=native-deps /app/node_modules ./node_modules
# and without it the honest answer to "which version is running?" is "unknown" —
# which is what it reported on 2026-08-18, leaving the digest as the only way to
# tell one deploy from another. docs/OPERATIONS.md step 3 depends on it.
ARG APP_VERSION=0.9.7
ARG APP_VERSION=0.9.8
LABEL org.opencontainers.image.version="$APP_VERSION" \
org.opencontainers.image.title="Queue North Website" \
org.opencontainers.image.source="https://dream.scheller.ltd/null/Queue-North-Website"

View File

@ -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

View File

@ -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

View File

@ -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
---

View File

@ -46,6 +46,104 @@ 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, on 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".
**Deployed as v0.9.8**, from 0e47183. Production ran 0.9.6 until now, so this
is the first deploy carrying both the sweep fixes and the "25+ years of industry
experience" wording.
Checked after the recreate rather than trusting the deploy's own report, because
#236 is still open and did exactly what it says it does: `deploy.sh` printed
"0.9.6 -> 0.9.6" and an unchanged digest, having read both before the container
was replaced. Independently: `status.sh` shows `qn-website-dev` healthy on
v0.9.8 with 0 restarts, and its image id `62d076e3800d` is the id of the image
`release.sh` built. Both front doors answer `{"status":"ok","db":"ok"}`. The
crawler audit reports nothing wrong across 18 pages as all five agents, and 18
sitemap entries carry a lastmod. The device sweep against production, all ten
profiles and 190 page loads, reports zero findings, which is the first time this
site has been measured on devices in production rather than locally.
`qn.isnull.dev` reports one finding, the already-filed #237.
**Next action:** #217, submitting the two service URLs to Google Search Console
and Bing Webmaster Tools. Its release and deploy halves are done, at v0.9.8
rather than the v0.9.6 the issue was written against.
**Blockers:** #216 needs Levi's four vendor screenshots. #217 needs Null's or
Levi's Search Console and Bing accounts.
**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

View File

@ -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

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "queuenorth-website",
"version": "0.9.7",
"version": "0.9.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "queuenorth-website",
"version": "0.9.7",
"version": "0.9.8",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-visually-hidden": "^1.2.4",

View File

@ -1,7 +1,7 @@
{
"name": "queuenorth-website",
"private": true,
"version": "0.9.7",
"version": "0.9.8",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"",

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 700 700" style="enable-background:new 0 0 700 700;" xml:space="preserve">
viewBox="82 105 537 463" style="enable-background:new 82 105 537 463;" xml:space="preserve">
<style type="text/css">
.st0{fill:#07182D;}
</style>

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 700 700" style="enable-background:new 0 0 700 700;" xml:space="preserve">
viewBox="82 105 537 463" style="enable-background:new 82 105 537 463;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

233
scripts/device-sweep.mjs Executable file
View File

@ -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)

589
scripts/lib/css-audit.js Normal file
View File

@ -0,0 +1,589 @@
// 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 logic is that file's, unchanged. The prose is not byte-identical:
// this repository does not use em dashes, so the comments and the two report
// strings were repunctuated. Diff it on words, not bytes. 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 <form> containing <input name="id"> has
// its `id` property clobbered by that input, so `.id` returns an element
// and the path printed as `form#[object HTMLInputElement]`.
const id = node.getAttribute("id");
if (id) {
parts.unshift(`${part}#${id}`);
break;
}
const cls = (node.getAttribute("class") || "")
.split(/\s+/)
.filter((c) => c && !c.includes("[") && !c.includes(":"))
.slice(0, 2)
.join(".");
if (cls) part += `.${cls}`;
parts.unshift(part);
node = node.parentElement;
}
return parts.join(" > ");
}
const text = (el) => (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60);
const els = Array.from(document.body.querySelectorAll("*"));
const info = new Map();
const ellipsis = new Set();
/**
* Inside a closed disclosure, and therefore not on screen at all.
*
* Chrome does not `display: none` a closed `<details>`. It skips the
* subtree with `content-visibility`, and the descendants keep reporting
* layout boxes at their unconstrained size. The signature form in the
* documents table measured 149px wide at x=255 on a 320px screen while the
* closed `<details>` around it correctly measured 48px. Reporting that is
* reporting content nobody can see, and it is the third distinct class of
* false positive this audit had to learn about.
*/
function inClosedDisclosure(el) {
const details = el.closest("details:not([open])");
if (!details) return false;
const summary = details.querySelector(":scope > summary");
return !(summary && summary.contains(el));
}
for (const el of els) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") continue;
if (cs.contentVisibility === "hidden") continue;
if (inClosedDisclosure(el)) continue;
info.set(el, { rect, cs });
if (cs.textOverflow === "ellipsis") ellipsis.add(el);
}
/** The nearest ancestor that scrolls horizontally on purpose. */
function scrollerOf(el) {
let node = el.parentElement;
while (node && node !== document.body) {
const rec = info.get(node);
if (rec && (rec.cs.overflowX === "auto" || rec.cs.overflowX === "scroll")) return node;
node = node.parentElement;
}
return null;
}
/** The nearest ancestor that cuts content off without letting anyone scroll to it. */
function clipperOf(el) {
let node = el.parentElement;
while (node && node !== document.documentElement) {
const rec = info.get(node);
if (!rec) { node = node.parentElement; continue; }
if (rec.cs.overflowX === "auto" || rec.cs.overflowX === "scroll") return null;
if (rec.cs.overflowX === "hidden" || rec.cs.overflowX === "clip") return node;
node = node.parentElement;
}
return null;
}
// --- 1. Content past the right edge of the viewport -----------------------
//
// Leaves only: an element that overflows and has no overflowing descendant is
// the thing that is actually too wide. Reporting its ancestors as well would
// bury the one line that names the culprit under the whole chain it pushed.
const overViewport = new Set();
for (const [el, { rect }] of info) {
if (rect.right <= vw + EPS && rect.left >= -EPS) continue;
if (scrollerOf(el)) continue;
// Contained by something that clips: the reader does not see this past the
// edge, they see it cut off, which check 2 reports, with the clipper named.
// Reporting it here as well was the single largest source of noise in the
// first run: every `truncate` in the admin has a child span whose rect runs
// off the viewport by design, ellipsis and all.
const clipper = clipperOf(el);
if (clipper) {
const box = info.get(clipper);
if (box && box.rect.right <= vw + EPS) continue;
}
overViewport.add(el);
}
for (const el of overViewport) {
if (Array.from(overViewport).some((other) => other !== el && el.contains(other))) continue;
const { rect, cs } = info.get(el);
push({
kind: "past_viewport",
severity: "blocking",
path: pathOf(el),
text: text(el),
detail: {
right: Math.round(rect.right),
viewport: vw,
over: Math.round(rect.right - vw),
width: cs.width,
minWidth: cs.minWidth,
whiteSpace: cs.whiteSpace,
position: cs.position,
},
says: `${Math.round(rect.right - vw)}px past the right edge`,
});
}
// --- 2. Content clipped by an ancestor, with no way to scroll to it -------
const clipped = new Map();
for (const [el, { rect }] of info) {
const clipper = clipperOf(el);
if (!clipper) continue;
const box = info.get(clipper);
if (!box) continue;
if (rect.right <= box.rect.right + EPS && rect.left >= box.rect.left - EPS) continue;
// A clipper wider than the viewport is already reported by check 1.
if (box.rect.right > vw + EPS) continue;
const seen = clipped.get(clipper) || [];
seen.push({ el, rect });
clipped.set(clipper, seen);
}
for (const [clipper, children] of clipped) {
const leaves = children.filter(
({ el }) => !children.some((other) => other.el !== el && el.contains(other.el)),
);
const box = info.get(clipper);
// Overflow has two sides. Picking the right-most leaf and subtracting made
// a left-side overflow report as "cut off by -150px", which is not a
// sentence. Measure how far each leaf escapes in whichever direction it
// escapes, and rank by that.
const escape = ({ rect }) =>
Math.max(0, rect.right - box.rect.right, box.rect.left - rect.left);
const worst = leaves.reduce((a, b) => (escape(b) > escape(a) ? b : a), leaves[0]);
const side = worst.rect.right - box.rect.right >= box.rect.left - worst.rect.left ? "right" : "left";
// `text-overflow: ellipsis` is a container saying "I will cut text off and
// show that I did". That is an affordance, not silent loss: the reader can
// see there is more. It stops being one the moment something interactive or
// replaced is inside, because a button behind an ellipsis is still a button
// nobody can press.
const INTERACTIVE = "a[href], button, summary, input, select, textarea, img, video, iframe, [role=button], [role=tab]";
const carries = ({ el }) =>
(el.textContent || "").trim().length > 0 || el.matches(INTERACTIVE) || el.querySelector(INTERACTIVE);
/**
* A control is only *swallowed* when little enough of it survives the clip
* to stop being aimable.
*
* The first version of this asked whether a control was present at all, and
* that is too coarse for the commonest shape in the admin: a truncated cell
* whose text *is* a link. `span.block.truncate > a` reports the anchor's
* full 189px box against a 144px cell, so the anchor counted as swallowed,
* while on screen 161px of it is visible, ellipsised, and perfectly
* clickable. Three blocking findings on the projects board, all of them the
* repository link reading `null/Privacy-Period-Tr...`, none of them a fault.
*
* What the rule is really protecting against is a control the clip puts out
* of reach, so measure that: how much of it is left inside the box. Below
* the 24px WCAG floor (or its own width, for a control smaller than that)
* there is nothing to press and the ellipsis is not an affordance any more.
*/
const MIN_AIMABLE = 24;
const controlsIn = (el) => [
...(el.matches(INTERACTIVE) ? [el] : []),
...el.querySelectorAll(INTERACTIVE),
];
const swallowed = (control) => {
const rect = control.getBoundingClientRect();
const visible = Math.min(rect.right, box.rect.right) - Math.max(rect.left, box.rect.left);
return visible < Math.min(MIN_AIMABLE, rect.width);
};
const swallowsControls = leaves.some(({ el }) => controlsIn(el).some(swallowed));
// Clipping only costs something when something was in it. A decorative
// element parked outside its box is the technique, not a fault: the hover
// shimmer on the radar capture button is `absolute inset-0 -translate-x-full`
// and lives entirely to the left of the button until you hover it, which
// this reported as "148px past the left edge" at every width for twenty
// runs. An `aria-hidden` span with no text and no controls has nothing to
// lose.
if (!leaves.some(carries)) continue;
if (ellipsis.has(clipper) && !swallowsControls) continue;
push({
kind: "clipped",
severity: "blocking",
path: pathOf(clipper),
text: text(worst.el),
detail: {
side,
clipper: [Math.round(box.rect.left), Math.round(box.rect.right)],
child: [Math.round(worst.rect.left), Math.round(worst.rect.right)],
over: Math.round(escape(worst)),
overflowX: box.cs.overflowX,
childPath: pathOf(worst.el),
hiddenChildren: leaves.length,
},
says:
`${leaves.length} element(s) cut off by ${Math.round(escape(worst))}px past the ${side} edge ` +
`of an overflow-x:${box.cs.overflowX} box, with no scrollbar and no hint`,
});
}
// --- 3. The weakest signal, kept for completeness -------------------------
const doc = document.documentElement;
if (doc.scrollWidth > doc.clientWidth + EPS) {
push({
kind: "document_scrolls",
severity: "blocking",
path: "html",
text: "",
detail: { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth },
says: `the page itself scrolls sideways by ${doc.scrollWidth - doc.clientWidth}px`,
});
}
// --- 4. Touch targets ------------------------------------------------------
//
// 44px is the number both platform guidelines land on. Elements nested inside
// a larger tappable ancestor are skipped: the ancestor is the target.
const TAPPABLE = "a[href], button, summary, input, select, textarea, [role=button], [role=tab]";
for (const el of document.body.querySelectorAll(TAPPABLE)) {
const rec = info.get(el);
if (!rec) continue;
// A control inside a <label> is aimed at through the label, so the label is
// what gets measured. Skipping it outright, which this did first, means
// wrapping a 16px checkbox in a label silences the check without making the
// target any bigger, and the fix for the one real instance of that was
// exactly such a wrapper. A checker you can satisfy by adding an element is
// not a checker.
const label = el.closest("label");
const measured = label ? info.get(label) : null;
const rect = measured ? measured.rect : rec.rect;
if (label && !measured) continue;
const parent = el.parentElement && el.parentElement.closest(TAPPABLE);
if (parent) continue;
// Two rules, because a link and a button are not the same shape of target.
//
// A control must be at least **32px thick and 44px long**, not 44x44.
//
// 44x44 is WCAG 2.5.5, the AAA figure, and applying it to anything matching
// `button` produced ninety findings that all said the same thing: the admin
// renders lists whose rows are controls. A milestone row is 300px wide and
// 20px tall; taking it to 44 doubles the height of a twenty-item list, and
// that is a decision about how much the board shows per screen rather than
// a fix. 2.5.8 (AA) sets the floor at 24.
//
// So the rule is about the shape of a finger, not a square: you need
// thickness in whichever axis is scarce, and length in the other. An icon
// button is 44x44 and passes on both counts; a list row at 300x32 passes;
// a row at 300x20 fails on thickness; a 40x40 icon button fails on length,
// which is what caught `size="icon"` being `size-10`. Decided deliberately,
// and 32 rather than 24 so there is margin above the AA floor.
//
// A text link is judged on height and on its smaller dimension only. The
// first version demanded 44px in both axes and duly reported "FAQ", 27px
// wide, 44px tall, with 24px of gap either side, as a defect on nine
// routes. Padding a three-letter word out to 44px to satisfy a checker is
// the checker driving the design. WCAG 2.5.8 sets 24px as the floor and
// exempts inline links in text for exactly this reason; the axis that is
// actually scarce in a horizontal nav row is the vertical one.
//
// A link inside a sentence is exempt, and this is not a loophole: WCAG
// 2.5.8 says so in as many words. Its height is the line height of the
// prose around it; the only way to give it 44px is to break the paragraph.
// Nine of these were being reported on the FAQ and the legal pages.
if (el.tagName === "A") {
const parent = el.parentElement;
const around = parent ? parent.textContent.trim().length : 0;
if (around > (el.textContent || "").trim().length + 3) continue;
}
const isControl = el.matches("button, summary, input, select, textarea, [role=button]");
const thickness = Math.min(rect.width, rect.height);
const length = Math.max(rect.width, rect.height);
// Row-shaped: twice as wide as it is tall. A list row, not a thing you aim
// at. This is what separates "the milestone row" from "the icon button",
// and it has to be measured rather than guessed from the tag, because both
// are `<button>`.
// 1.5x, not 2x. A 60x32 link in a dashboard widget is a row by every
// sensible reading and missed a 2x test by four pixels. "FAQ" at 27x44 is
// still nowhere near it, which is the case this threshold exists to keep out.
const rowShaped = rect.width >= rect.height * 1.5;
let short;
let narrow;
if (isControl && rowShaped) {
// A control that is a row. Thickness is what a thumb needs; the length
// takes care of itself.
short = thickness < 32 - EPS;
narrow = length < 44 - EPS;
} else if (isControl) {
// A compact control: an icon button, a checkbox. You aim at a point, so
// it needs the full 44 in both axes. The 32px relaxation above is for
// rows and must not leak here: it would accept `size="icon"` back at
// 40x44, which is the exact defect this caught a few commits ago.
short = thickness < 44 - EPS;
narrow = length < 44 - EPS;
} else if (rowShaped) {
// A link that is a row obeys the row rule. The docs file list and the
// dashboard's widget links are links by tag and rows by shape.
short = rect.height < 32 - EPS;
narrow = false;
} else {
// A link that is a word in a nav. Judged on height, because the vertical
// axis is the scarce one in a horizontal row, and on 24px of thickness.
// demanding 32 here would report "FAQ" at 27px wide, which is the
// checker driving the design again.
short = rect.height < 44 - EPS;
narrow = thickness < 24 - EPS;
}
if (!short && !narrow) continue;
push({
kind: "touch_target",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { width: Math.round(rect.width), height: Math.round(rect.height) },
says:
`${Math.round(rect.width)}x${Math.round(rect.height)}px, ` +
(short
? `thinner than the ${isControl && !rowShaped ? 44 : rowShaped ? 32 : 44}px a touch target needs`
: `shorter than the ${isControl ? 44 : 24}px minimum`),
});
}
// --- 5. Text too small to read --------------------------------------------
for (const [el, { cs }] of info) {
const own = Array.from(el.childNodes).some(
(n) => n.nodeType === 3 && n.textContent.trim().length > 3,
);
if (!own) continue;
const size = parseFloat(cs.fontSize);
if (size >= 12 - 0.01) continue;
// Visually hidden. `sr-only` clips text to a 1px box so a screen reader
// still reads it and nobody sees it, so its font-size is not a legibility
// question, and 28 of the 87 findings here were the Ripley quote's
// `sr-only` companion saying the same thing on every admin screen.
const box = info.get(el).rect;
if (box.width <= 1 || box.height <= 1) continue;
// Small uppercase tracked text is a label, not prose.
//
// This codebase writes badges, eyebrows and machine values as 10-11px mono
// uppercase with positive letter-spacing: a deliberate typographic
// register, used in over two hundred places. Reporting every one of them at
// "high" produces a list nobody will ever work through, which is how a
// report stops being read. What actually harms a reader is small *prose*,
// so that is what this reports. There is no WCAG minimum font size to
// appeal to here; this is a judgement, and it is written down so the next
// person can disagree with it on purpose.
const tracked = parseFloat(cs.letterSpacing) > 0;
if (cs.textTransform === "uppercase" && tracked) continue;
push({
kind: "tiny_text",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { fontSize: cs.fontSize },
says: `${cs.fontSize} text`,
});
}
// --- 6. Media wider than the box holding it -------------------------------
for (const el of document.body.querySelectorAll("img, video, iframe, canvas, svg")) {
const rec = info.get(el);
const parent = el.parentElement && info.get(el.parentElement);
if (!rec || !parent) continue;
if (rec.rect.width <= parent.rect.width + EPS) continue;
push({
kind: "media_overflow",
severity: "high",
path: pathOf(el),
text: el.getAttribute("alt") || el.getAttribute("src") || "",
detail: {
mediaWidth: Math.round(rec.rect.width),
containerWidth: Math.round(parent.rect.width),
},
says: `${Math.round(rec.rect.width - parent.rect.width)}px wider than its container`,
});
}
// --- 7. Two sticky boxes fighting over the same edge ----------------------
//
// The project header is `sticky top-0 z-20` under a shell bar that is also
// stuck at 0 with an opaque background and z-40. Nothing errors; the header
// just slides underneath and is never seen again.
const sticky = [];
for (const [el, { cs, rect }] of info) {
if (cs.position !== "sticky" && cs.position !== "fixed") continue;
if (cs.top === "auto") continue;
// A decoration cannot occlude anything: it does not take clicks and it is
// not what the reader is looking for. The navigation progress bar is
// `pointer-events-none fixed top-0 z-[100] h-[3px]`, and without this it
// reported the entire admin header as hidden behind it on all 25 screens.
if (cs.pointerEvents === "none") continue;
sticky.push({ el, top: parseFloat(cs.top) || 0, z: parseInt(cs.zIndex, 10) || 0, rect, cs });
}
for (const a of sticky) {
for (const b of sticky) {
if (a === b || a.el.contains(b.el) || b.el.contains(a.el)) continue;
if (Math.abs(a.top - b.top) > EPS) continue;
if (a.z >= b.z) continue;
// Same offset and behind only matters if the thing in front actually
// covers it, in both axes.
//
// Vertically, a quarter is enough: what a stuck header loses first is its
// top, which is where its title is. A 50% threshold, which this used
// first, let the real case through, because the project header is
// 294px tall and the bar over it is 132px, so it was "only" 45% hidden.
//
// Horizontally is not optional. The admin sidebar is `fixed inset-y-0
// z-40` and overlaps every sticky header on the page vertically while
// sitting entirely to their left, which without this reads as the whole
// admin being permanently occluded by its own navigation.
const vertical =
Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top);
const horizontal =
Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left);
if (vertical < Math.max(24, a.rect.height * 0.25)) continue;
if (horizontal < a.rect.width * 0.5) continue;
// a is behind b at the same offset: a is the one that disappears.
push({
kind: "sticky_occluded",
severity: "high",
path: pathOf(a.el),
text: text(a.el),
detail: { top: a.top, zIndex: a.z, coveredBy: pathOf(b.el), coveredByZ: b.z },
says: `sticky at top:${a.top}px with z-index ${a.z}, behind another stuck at the same offset with z-index ${b.z}`,
});
break;
}
}
// --- 8. The selected tab is scrolled out of sight -------------------------
for (const el of document.body.querySelectorAll('[aria-selected="true"], [data-state="active"]')) {
const rec = info.get(el);
if (!rec) continue;
const scroller = scrollerOf(el);
if (!scroller) continue;
const box = info.get(scroller);
if (!box) continue;
if (rec.rect.left >= box.rect.left - EPS && rec.rect.right <= box.rect.right + EPS) continue;
push({
kind: "active_tab_offscreen",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { tabLeft: Math.round(rec.rect.left), visibleFrom: Math.round(box.rect.left), visibleTo: Math.round(box.rect.right) },
says: "the selected tab is outside the visible part of its scroller, so nothing on screen looks selected",
});
}
// There is no check here for `100vh`.
//
// There was, and it could never fire: `getComputedStyle` resolves `100vh` to
// a pixel value, so nothing in the browser can tell it apart from a height
// that was written in pixels. A check that cannot fail is worse than no
// check, because it reads as coverage. The rule it was reaching for ("use dvh,
// because 100vh is the viewport with the mobile URL bar hidden") is a property
// of the source, so it lives in `tests/responsive-guards.test.ts` where a
// source grep is the honest instrument.
return findings;
};

View File

@ -104,12 +104,16 @@ const Figure = ({ block }) =>
</figure>
) : null
// space-y-4 and tap-target, not the space-y-2 this had: a standalone link in a
// list is a tap target, and .tap-target (index.css) needs 16px between rows to
// keep neighbouring hit boxes from overlapping. LINK_CLASS itself must stay
// padding-free, because it is also used inside sentences.
const LinkList = ({ block }) => (
<ul className="mt-4 space-y-2">
<ul className="mt-4 space-y-4">
{block.items.map((item) => (
<li key={item.to} className="flex gap-3 text-base leading-relaxed text-soft-text">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-cyan" aria-hidden="true" />
<Link to={item.to} className={LINK_CLASS}>
<Link to={item.to} className={`tap-target ${LINK_CLASS}`}>
{item.label}
</Link>
</li>

View File

@ -14,7 +14,7 @@ export const LinkList = ({ links, className = '' }) => (
{links.map((link) => (
<li key={link.to} className="flex gap-3 text-base leading-relaxed text-soft-text">
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-primary-blue" aria-hidden="true" />
<Link to={link.to} className={LINK_CLASS}>
<Link to={link.to} className={`tap-target ${LINK_CLASS}`}>
{link.label}
</Link>
</li>
@ -28,7 +28,8 @@ const RelatedLinks = ({ links, title = 'Related services', as: Heading = 'h2', c
return (
<section className={className}>
<Heading className={headingClassName ?? 'text-2xl font-bold text-primary-navy'}>{title}</Heading>
<LinkList links={links} className="mt-4 space-y-2" />
{/* space-y-4 because each row is a tap target: see .tap-target in index.css. */}
<LinkList links={links} className="mt-4 space-y-4" />
</section>
)
}

View File

@ -56,12 +56,16 @@ const Footer = () => {
<span className="font-bold text-sm leading-tight tracking-tight text-white sm:text-xl sm:whitespace-nowrap">Queue North Technologies</span>
</Link>
<p className="text-navy-light text-sm leading-relaxed mb-5">{companyInfo.tagline}</p>
<div className="space-y-2 text-navy-light text-sm mb-6">
{/* space-y-4, not space-y-2: see .tap-target in index.css. The links
are 17px tall and their hit boxes are 33px, so the rows have to be
at least 33px apart or the boxes overlap and only the last one
painted is really tappable. */}
<div className="space-y-4 text-navy-light text-sm mb-6">
<div>
<a href={`tel:+1${companyInfo.phone.replace(/\D/g, '')}`} className="hover:text-primary-cyan transition-colors" aria-label={`Call ${companyInfo.phone}`}>{companyInfo.phone}</a>
<a href={`tel:+1${companyInfo.phone.replace(/\D/g, '')}`} className="tap-target block w-fit hover:text-primary-cyan transition-colors" aria-label={`Call ${companyInfo.phone}`}>{companyInfo.phone}</a>
</div>
<div>
<a href={`tel:+1${companyInfo.tollFree.replace(/\D/g, '')}`} className="hover:text-primary-cyan transition-colors" aria-label={`Call toll-free ${companyInfo.tollFree}`}>{companyInfo.tollFree} (Toll-Free)</a>
<a href={`tel:+1${companyInfo.tollFree.replace(/\D/g, '')}`} className="tap-target block w-fit hover:text-primary-cyan transition-colors" aria-label={`Call toll-free ${companyInfo.tollFree}`}>{companyInfo.tollFree} (Toll-Free)</a>
</div>
</div>
<Link
@ -79,12 +83,12 @@ const Footer = () => {
{/* Quick Links */}
<div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Quick Links</h3>
<ul className="space-y-2">
<ul className="space-y-4">
{quickLinks.map((link) => (
<li key={link.name}>
<Link
to={link.href}
className="text-navy-light hover:text-white transition-colors text-sm"
className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
aria-label={link.name}
>
{link.name}
@ -97,12 +101,12 @@ const Footer = () => {
{/* Services */}
<div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Services</h3>
<ul className="space-y-2">
<ul className="space-y-4">
{services.map((service) => (
<li key={service.name}>
<Link
to={service.href}
className="text-navy-light hover:text-white transition-colors text-sm"
className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
aria-label={service.name}
>
{service.name}
@ -115,12 +119,12 @@ const Footer = () => {
{/* Industries */}
<div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Industries</h3>
<ul className="space-y-2">
<ul className="space-y-4">
{industries.map((industry) => (
<li key={industry.name}>
<Link
to={industry.href}
className="text-navy-light hover:text-white transition-colors text-sm"
className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
aria-label={industry.name}
>
{industry.name}
@ -166,7 +170,7 @@ const Footer = () => {
</p>
<Link
to="/privacy-policy"
className="text-navy-light text-xs hover:text-primary-cyan transition-colors w-fit"
className="tap-target text-navy-light text-xs hover:text-primary-cyan transition-colors w-fit"
aria-label="Read the Queue North Technologies Privacy Policy"
>
Privacy Policy

View File

@ -68,17 +68,31 @@ const Header = () => {
width="200"
height="200"
/>
<span className="font-bold text-sm sm:text-xl lg:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
<span className="font-bold text-sm sm:text-xl xl:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
</Link>
</div>
{/* Desktop Nav */}
{/* gap-4 at md, gap-6 from lg. At exactly 768px iPad portrait the five
gap-6 gaps pushed the Request Consultation CTA 10px past the viewport,
where body{overflow-x:hidden} sliced it off with no scrollbar to reveal
it. Tightening to gap-4 frees 40px, which keeps the CTA on screen at md
rather than deferring it to lg and leaving 768-1023px with none. */}
<nav className="hidden md:flex items-center gap-4 lg:gap-6" aria-label="Main navigation">
{/*
The desktop row starts at lg, not md. Tightening the gaps at md (#214)
did not fix iPad portrait, it only hid the overflow better: measured at
a true 768px the three items want 787px of their natural width against
736px of container, so flex shrank the CTA to 114px, wrapped "Request
Consultation" inside it, and still pushed it 25px past the viewport,
where body{overflow-x:hidden} sliced it off with no scrollbar. That
gap-4 was verified in a desktop window whose scrollbar left ~753px, so
md never engaged and the fix was never actually exercised.
768 to 1023 gets the menu button instead, which is the better tablet
experience anyway: 44px rows rather than 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 stays at text-xl until xl. At exactly 1024 the row was
988px inside 992px of container: four pixels, which a font fallback
or one more nav item would eat. At text-xl it has room.
*/}
<nav className="hidden lg:flex items-center gap-5 xl:gap-6" aria-label="Main navigation">
{navLinks.map((link) => {
const hasDropdown = link.name === 'Services' || link.name === 'Industries'
return (
@ -102,7 +116,7 @@ const Header = () => {
to={link.href}
onFocus={() => hasDropdown && setOpenDropdown(link.name)}
onClick={closeDropdown}
className={`text-sm font-medium transition-colors ${isActive(link.href) ? 'text-white underline underline-offset-4' : 'text-white/70 hover:text-white'}`}
className={`block py-3 text-sm font-medium transition-colors ${isActive(link.href) ? 'text-white underline underline-offset-4' : 'text-white/70 hover:text-white'}`}
>
{link.name}
</Link>
@ -146,17 +160,23 @@ const Header = () => {
</nav>
{/* CTA Button */}
<div className="hidden md:block">
<Link to="/contact#contact-form" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 bg-primary-cyan text-primary-navy hover:bg-cyan-600 transition-colors">
{/* shrink-0 and whitespace-nowrap so a row that no longer fits overflows
visibly and the device sweep catches it, instead of flex quietly
squeezing the button and wrapping its label to keep the total inside
a viewport it is already past. */}
<div className="hidden lg:block shrink-0">
<Link to="/contact#contact-form" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium h-9 px-3 bg-primary-cyan text-primary-navy hover:bg-cyan-600 transition-colors">
Request Consultation
</Link>
</div>
{/* Mobile Menu */}
<div className="md:hidden">
<div className="lg:hidden">
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<button className="p-2 text-white hover:text-primary-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-primary-cyan rounded-md" aria-label="Open navigation menu">
{/* p-2.5, so the 24px icon sits in a 44x44 target. It is now the
only navigation up to 1023px, and 40x40 was under the mark. */}
<button className="p-2.5 text-white hover:text-primary-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-primary-cyan rounded-md" aria-label="Open navigation menu">
<span className="sr-only">Open menu</span>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />

View File

@ -105,6 +105,39 @@ a:hover {
}
@layer components {
/*
* A standalone text link is 17 to 20px tall. A finger needs about 32, and the
* device sweep found 491 links under that across the site: every footer
* column, the privacy contents, the related-services lists, the back links.
*
* This grows the hit box by 8px above and below and then takes those 8px back
* out of the layout, so the line the link sits on does not move.
*
* **The list it sits in must leave at least 16px between rows.** The negative
* margin does not shrink the box, only its effect on layout, so at the 8px
* spacing these lists used to have, each link's box reached 8px into a gap the
* neighbour was already reaching 8px into. They overlapped, hit-testing gave
* the whole gap to whichever painted last, and getBoundingClientRect still
* read 33px: a checker satisfied by a target that was not really there. Every
* caller therefore pairs this with space-y-4 or gap-y-4, which makes the row
* pitch 33 and the boxes tile exactly.
*
* Not for a link inside a sentence: WCAG 2.5.8 exempts those, and the padding
* would reach into the lines above and below it.
*
* `display` is deliberately inline-block rather than a padding-only rule.
* Vertical padding on an inline box is painted and hit-tested but does not
* enter the line box, so an inline link would report a taller rect while
* overlapping its neighbours. Any caller that needs a different box (the
* "Learn more" links are inline-flex) sets it with a Tailwind utility, which
* wins on layer order.
*/
.tap-target {
display: inline-block;
padding-block: 0.5rem;
margin-block: -0.5rem;
}
/*
* reCAPTCHA v2's checkbox iframe is a fixed 304px and Google does not allow
* it to be resized. Below ~360px that overflows the viewport and gets sliced

View File

@ -16,10 +16,10 @@ const proofPoints = [
{
label: 'Cisco Partner',
detail: 'Networking and communications implementation',
logo: '/assets/brand/Cisco-Partner-Logo_trasnp_w.png',
logo: '/assets/brand/cisco-partner-logo-white.svg',
logoAlt: 'Cisco Partner certification logo',
logoClassName: 'h-full w-full scale-[2]',
containerClass: 'p-1 overflow-hidden',
logoClassName: 'h-full w-full',
containerClass: 'p-1',
},
{
label: 'Veteran-Owned Certified',

View File

@ -154,11 +154,11 @@ const Home = () => {
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">8x8 Certified Partner</span>
</div>
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1 overflow-hidden">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-2">
<img
src="/assets/brand/cisco-partner-logo-midnight.svg"
alt="Cisco Partner certification logo"
className="h-full w-full object-contain scale-[1.5]"
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
/>
@ -219,7 +219,7 @@ const Home = () => {
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<Link to={`/services/${service.id}`} className={`inline-flex items-center gap-1 text-sm font-semibold ${accent.link}`} aria-label={`Learn more about ${service.name}`}>
<Link to={`/services/${service.id}`} className={`tap-target inline-flex items-center gap-1 text-sm font-semibold ${accent.link}`} aria-label={`Learn more about ${service.name}`}>
Learn more
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
@ -317,7 +317,7 @@ const Home = () => {
</div>
<h3 className="text-left text-xl font-semibold text-primary-navy mb-3" aria-label={industry.name}>{industry.name}</h3>
<p className="text-sm text-soft-text mb-4" aria-label={industry.homeDesc || 'Industry-specific solutions designed to address your unique challenges and requirements.'}>{industry.homeDesc || 'Industry-specific solutions designed to address your unique challenges and requirements.'}</p>
<Link to={`/industries/${industry.id}`} className="inline-flex items-center gap-1 text-sm font-semibold text-primary-navy hover:text-primary-blue" aria-label={`Learn more about ${industry.name} industry solutions`}>
<Link to={`/industries/${industry.id}`} className="tap-target inline-flex items-center gap-1 text-sm font-semibold text-primary-navy hover:text-primary-blue" aria-label={`Learn more about ${industry.name} industry solutions`}>
Learn more
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>

View File

@ -100,7 +100,7 @@ const Industries = () => {
</ul>
<Link
to={`/industries/${industry.id}`}
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary-navy hover:text-primary-blue transition-colors"
className="tap-target inline-flex items-center gap-1.5 text-sm font-semibold text-primary-navy hover:text-primary-blue transition-colors"
aria-label={`Learn more about ${industry.name} solutions`}
>
See how we help

View File

@ -24,7 +24,7 @@ const IndustryDetail = () => {
<div className="text-center">
<h1 className="text-3xl font-bold text-primary-navy mb-4">Industry Not Found</h1>
<p className="text-xl text-soft-text mb-8">The industry you're looking for doesn't exist.</p>
<Link to="/industries" className="text-primary-navy hover:underline">
<Link to="/industries" className="tap-target text-primary-navy hover:underline">
Back to Industries
</Link>
</div>
@ -148,7 +148,7 @@ const IndustryDetail = () => {
</Link>
</div>
<div className="pt-2">
<Link to="/industries" className="text-primary-navy hover:underline">
<Link to="/industries" className="tap-target text-primary-navy hover:underline">
Back to Industries
</Link>
</div>

View File

@ -30,9 +30,11 @@ const PolicyParagraph = ({ block }) => {
)
}
// A paragraph that is nothing but the address is a tap target, unlike the same
// link inside the sentences above and below, which WCAG 2.5.8 exempts.
const EmailBlock = () => (
<p className="mt-4 text-base leading-relaxed">
<EmailLink />
<EmailLink className="tap-target" />
</p>
)
@ -102,12 +104,12 @@ const PrivacyPolicy = () => {
{/* Contents */}
<nav aria-label="Privacy policy contents" className="rounded-md border border-border bg-white p-6 shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wide text-primary-blue">Contents</h2>
<ol className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
<ol className="mt-4 grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
{numberedSections.map((section) => (
<li key={section.id} className="text-sm">
<a
href={`#${section.id}`}
className="text-soft-text hover:text-primary-blue transition-colors"
className="tap-target text-soft-text hover:text-primary-blue transition-colors"
>
<span className="font-numeric font-semibold text-primary-navy">{section.number}.</span>{' '}
{section.title}

View File

@ -31,7 +31,7 @@ const ServiceDetail = () => {
<div className="text-center">
<h1 className="text-3xl font-bold text-primary-navy mb-4">Service Not Found</h1>
<p className="text-xl text-soft-text mb-8">The service you're looking for doesn't exist.</p>
<Link to="/services" className="text-primary-navy hover:underline">
<Link to="/services" className="tap-target text-primary-navy hover:underline">
Back to Services
</Link>
</div>
@ -206,7 +206,7 @@ const ServiceDetail = () => {
</Link>
</div>
<div className="pt-2">
<Link to="/services" className="text-primary-navy hover:underline">
<Link to="/services" className="tap-target text-primary-navy hover:underline">
Back to Services
</Link>
</div>

View File

@ -167,7 +167,7 @@ const Services = () => {
)}
<Link
to={`/services/${service.id}`}
className={`inline-flex items-center gap-1.5 text-sm font-semibold transition-colors ${accent.link}`}
className={`tap-target inline-flex items-center gap-1.5 text-sm font-semibold transition-colors ${accent.link}`}
aria-label={`Learn more about ${service.name}`}
>
Learn more