Compare commits
4 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
a25077dea7 | |
|
|
b35f171b8c | |
|
|
231f07c62d | |
|
|
cf92354d96 |
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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\"",
|
||||||
|
|
|
||||||
|
|
@ -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.')
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue