Compare commits
No commits in common. "dev" and "v0.9.7" have entirely different histories.
|
|
@ -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.8
|
||||
ARG APP_VERSION=0.9.7
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
## Three checks that are run by hand
|
||||
## Two checks that are run by hand
|
||||
|
||||
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.
|
||||
Neither is adopted into `scripts/`, so neither runs in `verify.sh`. Both are
|
||||
worth running when the documents change a lot.
|
||||
|
||||
**`doc-claims.sh` — every path a document names must exist.** Run from the
|
||||
template, and **exclude `docs/history/`**:
|
||||
|
|
@ -209,32 +209,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -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), `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/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/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,7 +216,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -826,46 +826,6 @@ 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
|
||||
|
||||
|
|
@ -896,12 +856,6 @@ four pixels of room; at `text-xl` it has sixty-nine.
|
|||
❌ 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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -46,104 +46,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ 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
|
||||
|
|
@ -92,30 +91,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.7",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"private": true,
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||
|
|
|
|||
|
|
@ -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="82 105 537 463" style="enable-background:new 82 105 537 463;" xml:space="preserve">
|
||||
viewBox="0 0 700 700" style="enable-background:new 0 0 700 700;" 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 |
|
|
@ -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="82 105 537 463" style="enable-background:new 82 105 537 463;" xml:space="preserve">
|
||||
viewBox="0 0 700 700" style="enable-background:new 0 0 700 700;" 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 |
|
|
@ -1,233 +0,0 @@
|
|||
#!/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)
|
||||
|
|
@ -1,589 +0,0 @@
|
|||
// 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;
|
||||
};
|
||||
|
|
@ -104,16 +104,12 @@ 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-4">
|
||||
<ul className="mt-4 space-y-2">
|
||||
{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={`tap-target ${LINK_CLASS}`}>
|
||||
<Link to={item.to} className={LINK_CLASS}>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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={`tap-target ${LINK_CLASS}`}>
|
||||
<Link to={link.to} className={LINK_CLASS}>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
|
|
@ -28,8 +28,7 @@ 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>
|
||||
{/* 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" />
|
||||
<LinkList links={links} className="mt-4 space-y-2" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,16 +56,12 @@ 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>
|
||||
{/* 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 className="space-y-2 text-navy-light text-sm mb-6">
|
||||
<div>
|
||||
<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>
|
||||
<a href={`tel:+1${companyInfo.phone.replace(/\D/g, '')}`} className="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="tap-target block w-fit 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="hover:text-primary-cyan transition-colors" aria-label={`Call toll-free ${companyInfo.tollFree}`}>{companyInfo.tollFree} (Toll-Free)</a>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
|
|
@ -83,12 +79,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-4">
|
||||
<ul className="space-y-2">
|
||||
{quickLinks.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
to={link.href}
|
||||
className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
|
||||
className="text-navy-light hover:text-white transition-colors text-sm"
|
||||
aria-label={link.name}
|
||||
>
|
||||
{link.name}
|
||||
|
|
@ -101,12 +97,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-4">
|
||||
<ul className="space-y-2">
|
||||
{services.map((service) => (
|
||||
<li key={service.name}>
|
||||
<Link
|
||||
to={service.href}
|
||||
className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
|
||||
className="text-navy-light hover:text-white transition-colors text-sm"
|
||||
aria-label={service.name}
|
||||
>
|
||||
{service.name}
|
||||
|
|
@ -119,12 +115,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-4">
|
||||
<ul className="space-y-2">
|
||||
{industries.map((industry) => (
|
||||
<li key={industry.name}>
|
||||
<Link
|
||||
to={industry.href}
|
||||
className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
|
||||
className="text-navy-light hover:text-white transition-colors text-sm"
|
||||
aria-label={industry.name}
|
||||
>
|
||||
{industry.name}
|
||||
|
|
@ -170,7 +166,7 @@ const Footer = () => {
|
|||
</p>
|
||||
<Link
|
||||
to="/privacy-policy"
|
||||
className="tap-target text-navy-light text-xs hover:text-primary-cyan transition-colors w-fit"
|
||||
className="text-navy-light text-xs hover:text-primary-cyan transition-colors w-fit"
|
||||
aria-label="Read the Queue North Technologies Privacy Policy"
|
||||
>
|
||||
Privacy Policy
|
||||
|
|
|
|||
|
|
@ -68,31 +68,17 @@ const Header = () => {
|
|||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<span className="font-bold text-sm sm:text-xl xl:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
|
||||
<span className="font-bold text-sm sm:text-xl lg:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
{/*
|
||||
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">
|
||||
{/* 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">
|
||||
{navLinks.map((link) => {
|
||||
const hasDropdown = link.name === 'Services' || link.name === 'Industries'
|
||||
return (
|
||||
|
|
@ -116,7 +102,7 @@ const Header = () => {
|
|||
to={link.href}
|
||||
onFocus={() => hasDropdown && setOpenDropdown(link.name)}
|
||||
onClick={closeDropdown}
|
||||
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'}`}
|
||||
className={`text-sm font-medium transition-colors ${isActive(link.href) ? 'text-white underline underline-offset-4' : 'text-white/70 hover:text-white'}`}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
|
|
@ -160,23 +146,17 @@ const Header = () => {
|
|||
</nav>
|
||||
|
||||
{/* CTA Button */}
|
||||
{/* 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">
|
||||
<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">
|
||||
Request Consultation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="lg:hidden">
|
||||
<div className="md:hidden">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
{/* 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">
|
||||
<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">
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -105,39 +105,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ const proofPoints = [
|
|||
{
|
||||
label: 'Cisco Partner',
|
||||
detail: 'Networking and communications implementation',
|
||||
logo: '/assets/brand/cisco-partner-logo-white.svg',
|
||||
logo: '/assets/brand/Cisco-Partner-Logo_trasnp_w.png',
|
||||
logoAlt: 'Cisco Partner certification logo',
|
||||
logoClassName: 'h-full w-full',
|
||||
containerClass: 'p-1',
|
||||
logoClassName: 'h-full w-full scale-[2]',
|
||||
containerClass: 'p-1 overflow-hidden',
|
||||
},
|
||||
{
|
||||
label: 'Veteran-Owned Certified',
|
||||
|
|
|
|||
|
|
@ -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-2">
|
||||
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1 overflow-hidden">
|
||||
<img
|
||||
src="/assets/brand/cisco-partner-logo-midnight.svg"
|
||||
alt="Cisco Partner certification logo"
|
||||
className="h-full w-full object-contain"
|
||||
className="h-full w-full object-contain scale-[1.5]"
|
||||
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={`tap-target 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={`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="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`}>
|
||||
<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`}>
|
||||
Learn more
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ const Industries = () => {
|
|||
</ul>
|
||||
<Link
|
||||
to={`/industries/${industry.id}`}
|
||||
className="tap-target inline-flex items-center gap-1.5 text-sm font-semibold text-primary-navy hover:text-primary-blue transition-colors"
|
||||
className="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
|
||||
|
|
|
|||
|
|
@ -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="tap-target text-primary-navy hover:underline">
|
||||
<Link to="/industries" className="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="tap-target text-primary-navy hover:underline">
|
||||
<Link to="/industries" className="text-primary-navy hover:underline">
|
||||
← Back to Industries
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,11 +30,9 @@ 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 className="tap-target" />
|
||||
<EmailLink />
|
||||
</p>
|
||||
)
|
||||
|
||||
|
|
@ -104,12 +102,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-4 sm:grid-cols-2">
|
||||
<ol className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
|
||||
{numberedSections.map((section) => (
|
||||
<li key={section.id} className="text-sm">
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
className="tap-target text-soft-text hover:text-primary-blue transition-colors"
|
||||
className="text-soft-text hover:text-primary-blue transition-colors"
|
||||
>
|
||||
<span className="font-numeric font-semibold text-primary-navy">{section.number}.</span>{' '}
|
||||
{section.title}
|
||||
|
|
|
|||
|
|
@ -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="tap-target text-primary-navy hover:underline">
|
||||
<Link to="/services" className="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="tap-target text-primary-navy hover:underline">
|
||||
<Link to="/services" className="text-primary-navy hover:underline">
|
||||
← Back to Services
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ const Services = () => {
|
|||
)}
|
||||
<Link
|
||||
to={`/services/${service.id}`}
|
||||
className={`tap-target inline-flex items-center gap-1.5 text-sm font-semibold transition-colors ${accent.link}`}
|
||||
className={`inline-flex items-center gap-1.5 text-sm font-semibold transition-colors ${accent.link}`}
|
||||
aria-label={`Learn more about ${service.name}`}
|
||||
>
|
||||
Learn more
|
||||
|
|
|
|||
Loading…
Reference in New Issue