Compare commits
No commits in common. "dev" and "v0.9.4" have entirely different histories.
|
|
@ -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" —
|
||||
# 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.5
|
||||
ARG APP_VERSION=0.9.4
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -74,23 +74,6 @@ suggest.
|
|||
room. `npm run docker:test` runs the image locally, which is the closest thing
|
||||
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
|
||||
|
||||
**Nowhere. This project has no error tracking.**
|
||||
|
|
@ -253,20 +236,12 @@ they print exactly what would change.
|
|||
| 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
|
||||
separate files that happen to look alike, and they drift. Editing the one in git
|
||||
changes nothing about production. That is the single most important sentence in
|
||||
this section.
|
||||
|
||||
They drifted once already: the stack carried `CORS_ORIGIN=https://queuenorth.com/`
|
||||
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.
|
||||
separate files that happen to look alike, and they have already drifted — the
|
||||
repository commits `CORS_ORIGIN=https://queuenorth.com` and line 21 of the stack
|
||||
file says `https://queuenorth.com/`, with a trailing slash. Editing the one in
|
||||
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
|
||||
file, changes only the `image:` line, and sends the rest back untouched.
|
||||
|
||||
**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.
|
||||
|
|
@ -303,11 +278,9 @@ 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
|
||||
container stopped.
|
||||
|
||||
**Stack 58 pins `:v0.9.4` as of 2026-08-18.** It pinned `:dev` until then; the
|
||||
first deploy made under this policy repointed it, and the stack file now answers
|
||||
"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.
|
||||
**Stack 58 pins `:dev` as this is written**, which is the state the policy
|
||||
corrects. The first deploy made under it repoints the stack at a version, and
|
||||
after that the stack file answers the question on its own.
|
||||
|
||||
`: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
|
||||
|
|
|
|||
|
|
@ -179,21 +179,6 @@ error, so it is excluded rather than edited.
|
|||
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.**
|
||||
|
||||
**`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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -154,7 +154,6 @@ 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/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 |
|
||||
|
||||
**Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and
|
||||
|
|
|
|||
|
|
@ -46,138 +46,6 @@ sequence would have implied more.
|
|||
|
||||
## 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
|
||||
|
||||
`_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 |
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
| A — First run | **Not run** | Nobody has walked a cold first visit on a real device under a plan |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 | **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 |
|
||||
| G — Performance | **Not run** | No numbers exist. Prerendering every route landed at `0.9.3` and its effect has not been measured |
|
||||
| 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
|
||||
|
|
@ -72,16 +72,6 @@ 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
|
||||
`restore-check.sh` exist as of 2026-08-18 and neither has been run against
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -67,30 +67,6 @@ 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
|
||||
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
|
||||
|
||||
A finding needs: what was done, what happened, what should have happened, and
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.4",
|
||||
"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.5",
|
||||
"version": "0.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
#!/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,14 +86,7 @@ const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 })
|
|||
|
||||
return (
|
||||
<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>
|
||||
<div ref={containerRef} />
|
||||
{!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>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,12 +73,7 @@ const Header = () => {
|
|||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
{/* gap-4 at md, gap-6 from lg. At exactly 768px — iPad portrait — the five
|
||||
gap-6 gaps pushed the Request Consultation CTA 10px past the viewport,
|
||||
where body{overflow-x:hidden} sliced it off with no scrollbar to reveal
|
||||
it. Tightening to gap-4 frees 40px, which keeps the CTA on screen at md
|
||||
rather than deferring it to lg and leaving 768-1023px with none. */}
|
||||
<nav className="hidden md:flex items-center gap-4 lg:gap-6" aria-label="Main navigation">
|
||||
<nav className="hidden md:flex items-center gap-6" aria-label="Main navigation">
|
||||
{navLinks.map((link) => {
|
||||
const hasDropdown = link.name === 'Services' || link.name === 'Industries'
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -103,23 +103,3 @@ a:hover {
|
|||
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