Compare commits

...

4 Commits
v0.9.4 ... dev

Author SHA1 Message Date
Ripley a25077dea7 docs: log Batch 11 and Batch 16 — four false, two real, found by rendering
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:09:31 -05:00
null b35f171b8c chore(release): v0.9.5 2026-08-18 04:06:22 -05:00
Scarlett 231f07c62d fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px
Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.

#214 (P1) — at exactly 768px, iPad portrait, the header's "Request
Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off,
with no scrollbar to reveal it because html/body carry overflow-x:hidden. At
that width the burger menu is already hidden, so the primary conversion action
was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md
(gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than
deferring it to lg and leaving 768-1023px with no CTA at all.

#215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be
resized. At 320px it measured x 41-345, so 25px of branding and the privacy and
terms links were clipped. Scaled to 0.85 below 360px with the wrapper height
reduced to match, since transform does not affect layout and the form would
otherwise gain dead space.

Also adds scripts/qa-browser.mjs, which found them. It measures horizontal
scroll, broken images, elements past the right edge, CLS and LCP across pages
and viewports. Written because seven of the ten issues in Batches 10 and 11
misstated their own evidence, and two of those would have made the site worse
if actioned.

Two false positives in the tool itself, both fixed before trusting it:
- Lazy images below the fold read as broken. It now scrolls the page AND chases
  each un-loaded image individually, and reports observed HTTP failures apart
  from never-rendered images.
- `--paths / --viewports 320` swallowed "320" as a path. Argument collection now
  stops at the next flag.

Verified against a local production build: 5 paths x 5 viewports, zero findings.
Before the fix the same run reported the CTA overflow at 768 on four pages and
the reCAPTCHA overflow at 320 on /contact.

Not yet live — this needs a release and a deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
Ripley cf92354d96 docs: v0.9.4 is live, production is on a numbered version, and Watchtower is out
Corrects the runbook to what is now true rather than what was true this morning:
stack 58 pins :v0.9.4, not :dev; the CORS trailing slash is fixed and #212 is
closed; and status.sh --deployed-version answers for the first time.

Adds the Watchtower section. Stack 39 runs nightly at 02:00 watching every
container, and qn-website-dev had no opt-out while following a moving tag — so
the site was updating itself overnight. That was the undocumented deploy path.
It now pins a version and carries the opt-out label, and the consequence is
stated plainly: nothing updates this site automatically any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:38:02 -05:00
13 changed files with 459 additions and 17 deletions

View File

@ -88,7 +88,7 @@ COPY --from=native-deps /app/node_modules ./node_modules
# and without it the honest answer to "which version is running?" is "unknown" — # 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 # 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. # tell one deploy from another. docs/OPERATIONS.md step 3 depends on it.
ARG APP_VERSION=0.9.4 ARG APP_VERSION=0.9.5
LABEL org.opencontainers.image.version="$APP_VERSION" \ LABEL org.opencontainers.image.version="$APP_VERSION" \
org.opencontainers.image.title="Queue North Website" \ org.opencontainers.image.title="Queue North Website" \
org.opencontainers.image.source="https://dream.scheller.ltd/null/Queue-North-Website" org.opencontainers.image.source="https://dream.scheller.ltd/null/Queue-North-Website"

View File

@ -74,6 +74,23 @@ suggest.
room. `npm run docker:test` runs the image locally, which is the closest thing room. `npm run docker:test` runs the image locally, which is the closest thing
that exists. that exists.
## Watchtower is no longer in the picture
Stack 39 runs Watchtower nightly at 02:00 with no label filter and no scope, so
it watches **every** container on this host. Until 2026-08-18 `qn-website-dev`
had no opt-out label and followed the moving `:dev` tag, which means the site
could — and almost certainly did — update itself overnight whenever anything
pushed that tag. That was the undocumented deploy path nobody could find.
Two things now prevent it: the stack pins an immutable version, which Watchtower
can never find an update for, and the service carries
`com.centurylinklabs.watchtower.enable=false`, which excludes it explicitly even
if somebody later puts a floating tag back.
**The consequence is deliberate and worth knowing: nothing updates this site
automatically any more.** Every change reaches production through
`npm run release` and `npm run deploy`, or it does not reach production.
## Where errors go ## Where errors go
**Nowhere. This project has no error tracking.** **Nowhere. This project has no error tracking.**
@ -236,12 +253,20 @@ they print exactly what would change.
| Stack file on disk | `/data/compose/58/docker-compose.yml` on nebula | | Stack file on disk | `/data/compose/58/docker-compose.yml` on nebula |
**The stack file is NOT this repository's `docker-compose.yml`.** They are two **The stack file is NOT this repository's `docker-compose.yml`.** They are two
separate files that happen to look alike, and they have already drifted — the separate files that happen to look alike, and they drift. Editing the one in git
repository commits `CORS_ORIGIN=https://queuenorth.com` and line 21 of the stack changes nothing about production. That is the single most important sentence in
file says `https://queuenorth.com/`, with a trailing slash. Editing the one in this section.
git changes nothing about production. That is the single most important sentence
in this section, and `deploy.sh` does not paper over it: it reads the deployed They drifted once already: the stack carried `CORS_ORIGIN=https://queuenorth.com/`
file, changes only the `image:` line, and sends the rest back untouched. with a trailing slash while the repository committed it without one. Corrected on
2026-08-18 by `deploy.sh --fix-cors`, which closed #212.
`deploy.sh` does not paper over the split — it reads the deployed file, changes
only the lines you ask for, prints the diff before sending, and asserts the
environment-line count is unchanged. Four differences remain and all four are
correct: the stack uses `image:` where the repo has `build:`, names the container
`qn-website-dev`, publishes `3032:3001` rather than `3001:3001`, and carries the
Watchtower opt-out label.
**What a deploy costs.** It recreates the container, so both public front doors **What a deploy costs.** It recreates the container, so both public front doors
go down together for the few seconds it takes — they reach the same instance. go down together for the few seconds it takes — they reach the same instance.
@ -278,9 +303,11 @@ It also refuses a tag the registry does not hold. A missing tag would otherwise
become an outage: Portainer accepts the file, fails to pull, and leaves the become an outage: Portainer accepts the file, fails to pull, and leaves the
container stopped. container stopped.
**Stack 58 pins `:dev` as this is written**, which is the state the policy **Stack 58 pins `:v0.9.4` as of 2026-08-18.** It pinned `:dev` until then; the
corrects. The first deploy made under it repoints the stack at a version, and first deploy made under this policy repointed it, and the stack file now answers
after that the stack file answers the question on its own. "what is running" on its own. `scripts/status.sh --deployed-version` answers too,
because the image carries `org.opencontainers.image.version` from the same
release.
`:dev` and `:latest` remain in the registry, frozen. They are not deleted `:dev` and `:latest` remain in the registry, frozen. They are not deleted
because the running container was created from `:dev`, and removing the tag an because the running container was created from `:dev`, and removing the tag an

View File

@ -179,6 +179,21 @@ error, so it is excluded rather than edited.
Without the exclusion it reports three findings in that file, every time, forever. Without the exclusion it reports three findings in that file, every time, forever.
Last run 2026-08-18: **240 claimed paths, all present, across 20 files.** Last run 2026-08-18: **240 claimed paths, all present, across 20 files.**
**`qa-browser.mjs` is in `scripts/` but not in `verify.sh`.** It needs
playwright, which is installed globally on this machine rather than being a
dependency of the project — adding ~300MB of browser to every clone for a check
nobody runs per-commit is the wrong trade. Reach for it when a UI defect is
filed, and before writing one:
```bash
node scripts/qa-browser.mjs # production
node scripts/qa-browser.mjs --url http://localhost:3099 # a local build
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.
**`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and **`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and
requires the guard to go red. This project has three guards, all shell scripts requires the guard to go red. This project has three guards, all shell scripts
that fail visibly, so §1 of `architecture/GUARDS.md` was performed by hand that fail visibly, so §1 of `architecture/GUARDS.md` was performed by hand

View File

@ -154,6 +154,7 @@ each row says what it does *here*.
| `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after | | `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |
| `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template | | `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/prerender.js` | the build step that emits static HTML for every route. Predates the template | | `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 **Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and

View File

@ -46,6 +46,138 @@ sequence would have implied more.
## Entries ## Entries
### 2026-08-18 — Batch 11 closed without a line of code, and a browser found what twenty issues had not
Batch 11's four issues were all viewport claims. **All four were false**, and
this time they were disproved by rendering the site rather than by reading it.
| # | claim | measured |
| --- | --- | --- |
| 195 | dropdowns clip on constrained viewports | **no clipping ancestor exists** — walked every ancestor, all `overflow: visible`. At 768px the Industries dropdown ends 95px clear of the edge |
| 196 | logo overlaps nav at 320px | text ends x=252, burger starts x=264 — a **12px gap**. No truncation, no horizontal scroll |
| 197 | no image fallbacks, users see broken placeholders | every image 200, **zero broken** after a full scroll, CLS 0.008 against a 0.1 threshold, LCP 220ms |
| 198 | click handler misses nested children | `e.target.closest('a')` **already is** delegation; the listener is on `document` |
#196 also conflated two unrelated elements — `w-[85vw] max-w-[300px]` is the
mobile Sheet panel, not the header.
**Running total across Batches 10 and 11: three of ten were real.** Seven
misstated their own evidence, and two of those would have made the site worse
if actioned.
**So I built the thing that should have existed first.** `scripts/qa-browser.mjs`
drives real Chromium at real viewports and measures horizontal scroll, broken
images, elements past the right edge, CLS and LCP.
**On its first production run it found two defects none of the twenty had
noticed**, and both were real:
- **#214 (P1)** — at exactly 768px, iPad portrait, the header's *Request
Consultation* CTA measured x 676-778 against a 768px viewport. Ten pixels
sliced off, invisible because `overflow-x: hidden` suppresses the scrollbar,
and unreachable because the burger menu is already hidden at that width. The
primary conversion action, cut off on one of the most common tablet viewports
there is.
- **#215 (P2)** — Google's reCAPTCHA iframe is a fixed 304px. At 320px it ran
25px past the edge, clipping the branding and the privacy links.
Both fixed, released as **v0.9.5**, deployed, and verified live: the CTA now
636-752 at 768px, the widget 41-299 at 320px, and `qa-browser.mjs` reports
nothing across five pages at five viewports.
**The tool lied to me twice before I trusted it**, which is the part worth
keeping. It reported a healthy lazy-loaded badge as broken at three viewports —
a coarse scroll outrunning the intersection observer — and its own argument
parser swallowed `320` as a path when `--paths` preceded `--viewports`. Both
found and fixed by checking its output against reality before believing it. A
checker that cries wolf is how the next real finding gets ignored, and this one
nearly started its life doing exactly that.
- **Closed:** #195, #196, #197, #198 (no change required, each with the
measurement), **Batch 11 milestone**; then #214, #215 and the new **Batch 16**
milestone, both fixed and shipped. Two releases and two deploys today, no
rollbacks.
- **Next action:** Batch 12 — the eleven content and SEO issues (#199#209).
These are copy judgements measured against `docs/design/REDESIGN_REVIEW.md`,
not measurable claims, so the browser tool does not help. Expect the same hit
rate and read each against the positioning document before rewriting anything.
- **Blockers:** none on the work. Six issues wait on the site owner (#68, #110,
#162, #213 in Batch 13; #69, #70 in Batch 14). **#211 is still the real
exposure**: the lead database has one backup copy, on one workstation, with no
schedule — four dumps taken today, all in the same place.
### 2026-08-18 — v0.9.4 released and deployed. Production is on a number
The first release and the first deploy this project has ever made through a
script. Both worked; the site did not go down beyond the recreate.
**Asked three times whether it would break anything, and it would have — twice.**
Both were found by checking rather than by running, which is the only reason
this entry is not an incident report.
*`release.sh` would have shipped a site whose contact form could not be
submitted.* `.dockerignore` excludes `.env` from the build context, and the
script passed `--build-arg VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}`
without ever loading `.env` — and that variable is unset in every shell. The
`:-` made empty silently acceptable. An empty key makes
`RecaptchaPlaceholder.jsx` render *"Security verification is not configured."*
and never produce a token, and the server has `RECAPTCHA_ENABLED=true`, so every
submission is rejected. **Lead capture would have stopped entirely** — the whole
purpose of the site — and nothing about a successful build would have said so.
*`deploy.sh`'s default would have rolled production back two months.* The newest
published **numbered** tag was `v0.8.3`, built 2026-05-28; the running `:dev`
image was built 2026-08-01. Inspecting both images showed `v0.8.3` has no
`privacy-policy/` and **no prerendered route directories at all**. The default
path would have removed the privacy policy — which exists specifically because
Meta's crawler does not run JavaScript — and every prerendered route.
Both are now guards that refuse, not cautions that document. The release one
also greps the **built image's** bundle for the key before pushing, because a
guard that trusts the wiring is the wiring.
**What was checked before touching production**, since the question was asked
directly: the registry pulls anonymously (making the repo private did not affect
packages — they are owned separately); `docker compose config` validates the
rewritten stack file; the env-line count is asserted, and `compose config`
showed exactly why — without the `Env` array the `${VAR:-false}` defaults
resolve reCAPTCHA and Zoho WebToLead to **false**, so a stripped env would come
back healthy and quietly capture nothing; only one of the fourteen commits since
the running image touches anything a visitor sees.
**Watchtower was the missing piece of the story.** Stack 39 runs nightly at
02:00 with no label filter, watching every container, and `qn-website-dev` had
no opt-out. Following the moving `:dev` tag, the site was updating itself
overnight — the undocumented deploy path nobody could find. It now pins a
version *and* carries the opt-out label, so nothing changes without a deliberate
deploy.
**The deploy itself:** one PUT, three lines changed — the image, the CORS
trailing slash, and the Watchtower label — with a verified backup taken first
and automatic rollback armed. It did not need to roll back.
```
digest …3253b10c -> …1d283944
version none -> 0.9.4
CORS https://queuenorth.com/ -> https://queuenorth.com
leads 3 before, 3 after
```
`status.sh --deployed-version` answers `0.9.4` — the first time this project has
been able to say what is running without reading a digest.
- **Closed:** **#212** (CORS origin), with the live header as evidence. Batch 10
is now not only fixed but *shipped*`aria-expanded` and the dual-tone focus
ring are live, and the six Label-in-Name failures are gone from the served
HTML.
- **Next action:** Batch 11 — UI defects on narrow viewports (#195#198). Verify
each claim by rendering at the stated width first: four of Batch 10's six
issues misstated their own evidence, and these are all viewport-dependent.
- **Blockers:** none on the work. Six issues remain blocked on the site owner
(#68, #110, #162, #213 in Batch 13; #69, #70 in Batch 14). The backup is still
a single copy on one workstation with no schedule — #211, and it is the one
thing here that would actually hurt.
### 2026-08-18 — Policy: production always runs a numbered version ### 2026-08-18 — Policy: production always runs a numbered version
`_null`: *always stay on a num version.* Recorded here because it changed three `_null`: *always stay on a num version.* Recorded here because it changed three

View File

@ -21,13 +21,13 @@ reads as one nobody got to and this one is a decision.
| Pass | Result | Notes | | Pass | Result | Notes |
| --- | --- | --- | | --- | --- | --- |
| A — First run | **Not run** | Nobody has walked a cold first visit on a real device under a plan | | A — First run | **Partial** | `scripts/qa-browser.mjs` loads five pages cold at five viewports and measures render, images, CLS and LCP. **Not run:** any real device, any interaction |
| B — Core flow | **Not run** | The two form paths — contact and support — end to end into Zoho. This is the product's entire purpose and it has never been verified from outside | | B — Core flow | **Not run** | The two form paths — contact and support — end to end into Zoho. This is the product's entire purpose and it has never been verified from outside |
| C — Failure paths | **Not run** | Zoho unreachable, reCAPTCHA failing, a duplicate email hitting the 409, an oversized body hitting the 413 | | C — Failure paths | **Not run** | Zoho unreachable, reCAPTCHA failing, a duplicate email hitting the 409, an oversized body hitting the 413 |
| D — Persistence | **Not run** | Container restart with the named volume intact; SQLite surviving it | | D — Persistence | **Not run** | Container restart with the named volume intact; SQLite surviving it |
| E — End of loop | **Not run** | A submitted lead actually appearing in the Zoho CRM UI. Nothing on this side can observe that | | E — End of loop | **Not run** | A submitted lead actually appearing in the Zoho CRM UI. Nothing on this side can observe that |
| F — Accessibility | **Partial** | A static audit was run on 2026-08-18 against the six filed defects (#189#194): contrast computed rather than eyeballed, and every `aria-label` checked against its visible text for WCAG 2.5.3. Three were real and fixed, three were not defects. **Still not run:** anything in a browser — no keyboard-only walk, no screen reader, no zoom or reflow testing | | F — Accessibility | **Partial** | A static audit was run on 2026-08-18 against the six filed defects (#189#194): contrast computed rather than eyeballed, and every `aria-label` checked against its visible text for WCAG 2.5.3. Three were real and fixed, three were not defects. **Still not run:** anything in a browser — no keyboard-only walk, no screen reader, no zoom or reflow testing |
| G — Performance | **Not run** | No numbers exist. Prerendering every route landed at `0.9.3` and its effect has not been measured | | G — Performance | **Partial** | First numbers ever recorded, 2026-08-18 via `scripts/qa-browser.mjs`: LCP 136-264ms and CLS 0-0.032 across five pages at five viewports, against thresholds of 2500ms and 0.1. **Not run:** anything under load, any bundle-size budget, any throttled network or CPU |
| H — Abuse and what a stranger can reach | **Not run** | See below — this pass is rewritten for this project | | H — Abuse and what a stranger can reach | **Not run** | See below — this pass is rewritten for this project |
## Pass H, as it applies here ## Pass H, as it applies here
@ -72,6 +72,16 @@ existing is not a failure; it not existing while the gaps do is.
- **The lead database has never been backed up or restored.** `backup.sh` and - **The lead database has never been backed up or restored.** `backup.sh` and
`restore-check.sh` exist as of 2026-08-18 and neither has been run against `restore-check.sh` exist as of 2026-08-18 and neither has been run against
production. Filed as an issue in `Batch 15`. production. Filed as an issue in `Batch 15`.
- **There is now a way to measure the rendered site**: `scripts/qa-browser.mjs`
drives real Chromium at real viewports. It exists because Batches 10 and 11
were twenty issues filed from reading markup of which **seven of ten
misstated their own evidence**, and on its first production run it found two
defects none of the twenty had noticed — a header CTA clipped at iPad portrait
and the reCAPTCHA widget cut off at 320px. Reach for it before believing a UI
defect report, and before writing one.
- **It measures five things and no more**: horizontal scroll, broken images,
elements past the right edge, CLS and LCP. It opens no menus, presses no keys
and reads no contrast. A clean run is not "the UI is correct".
- **No accessibility testing has ever been done with a screen reader or by - **No accessibility testing has ever been done with a screen reader or by
keyboard alone**, and that is still true after Batch 10. The fixes there were keyboard alone**, and that is still true after Batch 10. The fixes there were
verified by computing contrast, by re-auditing accessible names against visible verified by computing contrast, by re-auditing accessible names against visible

View File

@ -67,6 +67,30 @@ carried as skipped.** No money moves through this site: no checkout, no
subscription, no refund path. A pass that never applies is noise, and one that is subscription, no refund path. A pass that never applies is noise, and one that is
always skipped is a lie. always skipped is a lie.
## Measure it before you file it
Batches 10 and 11 were twenty UI and accessibility issues written from reading
source. **Seven of ten misstated their own evidence** — two contrast figures
were wrong, one proposed a colour measuring 1.96:1 against the background it
would sit on, one asked for a change that would have spread a Level A failure,
one described a clipping ancestor that does not exist, one an overlap that is a
12px gap, one an attribute that is not in the file.
None of that was carelessness about *whether* something was wrong. It was
confidence about *how much*, without measuring.
So, before filing a UI defect and before acting on one:
```bash
node scripts/qa-browser.mjs # production, five pages, five widths
node scripts/qa-browser.mjs --url http://localhost:3099 --viewports 320,768
```
Contrast is arithmetic — composite the colour over its background and compute
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.
## What counts as a finding ## What counts as a finding
A finding needs: what was done, what happened, what should have happened, and 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", "name": "queuenorth-website",
"version": "0.9.4", "version": "0.9.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "queuenorth-website", "name": "queuenorth-website",
"version": "0.9.4", "version": "0.9.5",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-visually-hidden": "^1.2.4", "@radix-ui/react-visually-hidden": "^1.2.4",

View File

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

201
scripts/qa-browser.mjs Executable file
View File

@ -0,0 +1,201 @@
#!/usr/bin/env node
//
// Render the site in a real browser at real viewports and MEASURE it.
//
// node scripts/qa-browser.mjs # production, default widths
// node scripts/qa-browser.mjs --url http://localhost:3001
// node scripts/qa-browser.mjs --viewports 320,768
// node scripts/qa-browser.mjs --paths / /about /contact
// node scripts/qa-browser.mjs --shots /tmp/qa # also write screenshots
//
// Exit codes: 0 nothing found. 1 findings, each named. 2 NOTHING WAS CHECKED —
// playwright missing or the site unreachable. Two is not a pass.
//
// ## Which incident motivated it
//
// Batches 10 and 11 were twenty-ish UI and accessibility defects filed from
// reading markup. **Seven of ten misstated their own evidence.** Two contrast
// figures were simply wrong when computed; one proposed a colour measuring
// 1.96:1 against the background it would sit on, which would have made the
// footer materially worse; one asked for aria-labels whose existing versions
// were already WCAG 2.5.3 Level A failures, so doing what it said would have
// spread the defect; one described a clipping ancestor that does not exist;
// one described an overlap that is a 12px gap; one described a readOnly
// attribute that is not in the file.
//
// Every one of those took minutes to disprove with a browser and would have
// taken hours to "fix". The cost of not having this script was not the fixes —
// it was very nearly making the site worse, twice, on the strength of a
// confident sentence.
//
// So: numbers, from the thing itself. Contrast is arithmetic, overlap is two
// rectangles, and "does it clip" is a computed style you can read.
//
// ## What it measures
//
// horizontal scroll scrollWidth > innerWidth, and which element causes it
// broken images after scrolling the whole page, so lazy ones are fair
// CLS cumulative layout shift, good < 0.1
// LCP largest contentful paint, good < 2500ms
// overflowing nodes any element whose right edge is past the viewport
//
// ## Why it is not wired into verify.sh
//
// playwright is not a dependency of this project — it is installed globally on
// this machine. Adding ~300MB of browser to every clone to run a check nobody
// runs on every commit is the wrong trade. This is a tool you reach for when a
// UI defect is filed, and `docs/qa/ClaudeQAPlan.md` says to reach for it.
//
// If it is ever made a devDependency, wire it in as scripts/verify.d/40-browser.
import { createRequire } from 'node:module'
import { execSync } from 'node:child_process'
import path from 'node:path'
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', 'https://queuenorth.com')).replace(/\/$/, '')
const VIEWPORTS = (opt('viewports', '320,390,768,1024')).split(',').map(Number)
// Stop at the next flag rather than filtering non-flags out of the whole tail:
// `--paths / --viewports 320` otherwise swallowed "320" as a path and then
// tried to navigate to it. Caught by the tool reporting four findings that were
// its own argument handling.
const collect = (name, dflt) => {
const i = args.indexOf(`--${name}`)
if (i === -1) return dflt
const out = []
for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++) out.push(args[j])
return out.length ? out : dflt
}
const PATHS = collect('paths', ['/', '/about', '/services', '/contact', '/support'])
const SHOTS = opt('shots', null)
// playwright is global here. Resolve it explicitly rather than failing with a
// bare MODULE_NOT_FOUND, which reads as "the script is broken" rather than
// "install this".
let chromium
try {
const require = createRequire(import.meta.url)
let root
try { root = require.resolve('playwright') } catch {
const g = execSync('npm root -g', { encoding: 'utf8' }).trim()
root = path.join(g, 'playwright', 'index.js')
}
;({ chromium } = require(root))
} catch (e) {
console.error('qa-browser: playwright is not available, so NOTHING was checked.')
console.error(' npm i -g playwright && npx playwright install chromium')
process.exit(2)
}
const findings = []
const say = (...a) => console.log(...a)
const browser = await chromium.launch().catch(e => {
console.error('qa-browser: could not launch chromium, so nothing was checked:', e.message)
process.exit(2)
})
for (const p of PATHS) {
for (const width of VIEWPORTS) {
const page = await browser.newPage({ viewport: { width, height: 900 } })
await page.addInitScript(() => {
window.__cls = 0; window.__lcp = 0
new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cls += e.value })
.observe({ type: 'layout-shift', buffered: true })
new PerformanceObserver(l => { for (const e of l.getEntries()) window.__lcp = e.startTime })
.observe({ type: 'largest-contentful-paint', buffered: true })
})
const httpFailed = []
page.on('response', r => {
if (r.request().resourceType() === 'image' && r.status() >= 400) httpFailed.push(`${r.status()} ${r.url()}`)
})
let resp
try {
resp = await page.goto(URL_BASE + p, { waitUntil: 'networkidle', timeout: 45000 })
} catch (e) {
findings.push(`${p} @${width}: could not load — ${e.message.split('\n')[0]}`)
await page.close(); continue
}
if (!resp || resp.status() >= 400) {
findings.push(`${p} @${width}: HTTP ${resp ? resp.status() : '?'}`)
await page.close(); continue
}
// Scroll the whole page so lazy images are actually requested, then chase
// any that still have not loaded by scrolling to each one directly.
//
// Both passes are needed and the second is the one that matters. A coarse
// scroll can outrun the intersection observer, and the first version of
// this reported a perfectly healthy lazy badge as broken at three
// viewports — a false positive in the tool, on the very run that was
// meant to demonstrate the tool. Anything that survives BOTH passes plus
// an observed HTTP failure is worth reporting; anything less is noise, and
// noise is how a checker gets ignored.
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += 300) {
window.scrollTo(0, y); await new Promise(r => setTimeout(r, 80))
}
window.scrollTo(0, 0)
})
await page.waitForTimeout(800)
const stragglers = await page.$$('img')
for (const h of stragglers) {
const ok = await h.evaluate(i => i.complete && i.naturalWidth > 0)
if (ok) continue
await h.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {})
await page.waitForTimeout(400)
}
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(600)
const m = await page.evaluate(() => {
const vw = window.innerWidth
const over = [...document.querySelectorAll('body *')]
.filter(e => { const b = e.getBoundingClientRect(); return b.width > 0 && b.right > vw + 1 })
.slice(0, 5)
.map(e => `${e.tagName.toLowerCase()}${e.className ? '.' + String(e.className).split(' ')[0] : ''} (right ${Math.round(e.getBoundingClientRect().right)})`)
return {
vw,
scrollWidth: document.documentElement.scrollWidth,
broken: [...document.images].filter(i => !(i.complete && i.naturalWidth > 0)).map(i => i.getAttribute('src')),
images: document.images.length,
cls: +(window.__cls || 0).toFixed(4),
lcp: Math.round(window.__lcp || 0),
over,
}
})
const bits = []
if (m.scrollWidth > m.vw) { bits.push(`H-SCROLL ${m.scrollWidth}>${m.vw}`); findings.push(`${p} @${width}: horizontal scroll ${m.scrollWidth} > ${m.vw}`) }
if (httpFailed.length) { bits.push(`HTTP-FAIL ${httpFailed.length}`); findings.push(`${p} @${width}: image request failed — ${httpFailed.join(', ')}`) }
if (m.broken.length) { bits.push(`BROKEN ${m.broken.length}`); findings.push(`${p} @${width}: image never rendered after being scrolled to — ${m.broken.join(', ')}`) }
if (m.cls > 0.1) { bits.push(`CLS ${m.cls}`); findings.push(`${p} @${width}: CLS ${m.cls} (>0.1)`) }
if (m.lcp > 2500) { bits.push(`LCP ${m.lcp}ms`); findings.push(`${p} @${width}: LCP ${m.lcp}ms (>2500)`) }
if (m.over.length) { bits.push(`OVERFLOW ${m.over.length}`); findings.push(`${p} @${width}: past the right edge — ${m.over.join('; ')}`) }
say(` ${p.padEnd(12)} @${String(width).padEnd(5)} imgs=${String(m.images).padEnd(3)} cls=${String(m.cls).padEnd(7)} lcp=${String(m.lcp + 'ms').padEnd(8)} ${bits.length ? '‼ ' + bits.join(' ') : 'ok'}`)
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${p.replace(/\//g, '_') || 'root'}-${width}.png`, fullPage: false })
await page.close()
}
}
await browser.close()
say('')
if (findings.length) {
say(`qa-browser: ${findings.length} finding(s):`)
for (const f of findings) say(` - ${f}`)
process.exit(1)
}
say(`qa-browser: nothing found across ${PATHS.length} path(s) x ${VIEWPORTS.length} viewport(s).`)
say(' That is not "the UI is correct" — it is these five measurements,')
say(' on these pages, at these widths. Nothing here opens a menu or')
say(' uses a keyboard.')

View File

@ -86,7 +86,14 @@ const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 })
return ( return (
<div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}> <div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}>
{/* Google renders a fixed 304px iframe here and its width cannot be
overridden. At 320px that runs 25px past the viewport, where
body{overflow-x:hidden} clips the branding and the privacy links.
Scaling is the only lever; the wrapper height is reduced to match so
the layout gains no dead space. See #215. */}
<div className="recaptcha-scale">
<div ref={containerRef} /> <div ref={containerRef} />
</div>
{!isReady && !loadError && <p className="text-sm text-soft-text">Loading security verification...</p>} {!isReady && !loadError && <p className="text-sm text-soft-text">Loading security verification...</p>}
{(error || loadError) && <p className="mt-2 text-xs text-red-500">{error || loadError}</p>} {(error || loadError) && <p className="mt-2 text-xs text-red-500">{error || loadError}</p>}
</div> </div>

View File

@ -73,7 +73,12 @@ const Header = () => {
</div> </div>
{/* Desktop Nav */} {/* Desktop Nav */}
<nav className="hidden md:flex items-center 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) => { {navLinks.map((link) => {
const hasDropdown = link.name === 'Services' || link.name === 'Industries' const hasDropdown = link.name === 'Services' || link.name === 'Industries'
return ( return (

View File

@ -103,3 +103,23 @@ a:hover {
outline-offset: 2px; outline-offset: 2px;
} }
} }
@layer components {
/*
* 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
* off by the overflow-x:hidden above taking the branding and the privacy
* and terms links with it. Scaling is the only lever available.
*
* The height is reduced by the same factor because transform does not affect
* layout: without it the wrapper would keep reserving the full-size box and
* the form would gain a strip of dead space under the widget. See #215.
*/
@media (max-width: 360px) {
.recaptcha-scale {
transform: scale(0.85);
transform-origin: 0 0;
height: 66px; /* 78px widget * 0.85, so the box matches what is drawn */
}
}
}