diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 6136f76..5038902 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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. 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 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 30f72ac..60a14fe 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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/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 diff --git a/docs/qa/ClaudeQACoverage.md b/docs/qa/ClaudeQACoverage.md index 20a7454..0f1446e 100644 --- a/docs/qa/ClaudeQACoverage.md +++ b/docs/qa/ClaudeQACoverage.md @@ -21,13 +21,13 @@ reads as one nobody got to and this one is a decision. | 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 | | 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 | **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 | ## 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 `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 diff --git a/docs/qa/ClaudeQAPlan.md b/docs/qa/ClaudeQAPlan.md index 7bcdfd5..4005abb 100644 --- a/docs/qa/ClaudeQAPlan.md +++ b/docs/qa/ClaudeQAPlan.md @@ -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 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 diff --git a/scripts/qa-browser.mjs b/scripts/qa-browser.mjs new file mode 100755 index 0000000..0e424ea --- /dev/null +++ b/scripts/qa-browser.mjs @@ -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.') diff --git a/src/components/RecaptchaPlaceholder.jsx b/src/components/RecaptchaPlaceholder.jsx index 2e6a846..cc95607 100644 --- a/src/components/RecaptchaPlaceholder.jsx +++ b/src/components/RecaptchaPlaceholder.jsx @@ -86,7 +86,14 @@ const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 }) return (
Loading security verification...
} {(error || loadError) &&{error || loadError}
}