fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px

Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarlett 2026-08-18 04:05:37 -05:00
parent cf92354d96
commit 231f07c62d
8 changed files with 287 additions and 4 deletions

View File

@ -179,6 +179,21 @@ error, so it is excluded rather than edited.
Without the exclusion it reports three findings in that file, every time, forever.
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

View File

@ -154,6 +154,7 @@ each row says what it does *here*.
| `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |
| `scripts/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

View File

@ -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

View File

@ -67,6 +67,30 @@ carried as skipped.** No money moves through this site: no checkout, no
subscription, no refund path. A pass that never applies is noise, and one that is
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

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

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

View File

@ -86,7 +86,14 @@ const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 })
return (
<div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}>
<div ref={containerRef} />
{/* 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>
{!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>

View File

@ -73,7 +73,12 @@ const Header = () => {
</div>
{/* 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) => {
const hasDropdown = link.name === 'Services' || link.name === 'Industries'
return (

View File

@ -103,3 +103,23 @@ 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 */
}
}
}