fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px
Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.
#214 (P1) — at exactly 768px, iPad portrait, the header's "Request
Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off,
with no scrollbar to reveal it because html/body carry overflow-x:hidden. At
that width the burger menu is already hidden, so the primary conversion action
was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md
(gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than
deferring it to lg and leaving 768-1023px with no CTA at all.
#215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be
resized. At 320px it measured x 41-345, so 25px of branding and the privacy and
terms links were clipped. Scaled to 0.85 below 360px with the wrapper height
reduced to match, since transform does not affect layout and the form would
otherwise gain dead space.
Also adds scripts/qa-browser.mjs, which found them. It measures horizontal
scroll, broken images, elements past the right edge, CLS and LCP across pages
and viewports. Written because seven of the ten issues in Batches 10 and 11
misstated their own evidence, and two of those would have made the site worse
if actioned.
Two false positives in the tool itself, both fixed before trusting it:
- Lazy images below the fold read as broken. It now scrolls the page AND chases
each un-loaded image individually, and reports observed HTTP failures apart
from never-rendered images.
- `--paths / --viewports 320` swallowed "320" as a path. Argument collection now
stops at the next flag.
Verified against a local production build: 5 paths x 5 viewports, zero findings.
Before the fix the same run reported the CTA overflow at 768 on four pages and
the reCAPTCHA overflow at 320 on /contact.
Not yet live — this needs a release and a deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
#!/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'
fix(qa): the browser check measured 5 of 18 pages and heard no errors
Two gaps in the tool that exists to find defects.
Its default path list was five pages typed by hand: home, about, services,
contact and support. That is 5 of the 18 this site serves, and it included no
service and no industry page, so a defect on any of them was invisible to it.
The default is now whatever the target's own sitemap says it serves, and a
sitemap it cannot read exits 2 rather than quietly checking nothing.
It also listened for nothing the page said. A page that throws still paints, so
every measurement it takes can look healthy while the page is broken: React
reported a hydration mismatch on every page of this site for months and this
tool never noticed, because nothing here was listening. It now captures page
errors and console errors and reports them as findings.
Proven: a console.error added to the About page produced
"/about @390: the page logged an error", and a clean build reports nothing
across all 18 sitemap pages.
docs/qa/ClaudeQAPlan.md said "five pages, five widths" and it is now every page
in the sitemap, at four widths.
Closes #227.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:53:51 -05:00
import { parseSitemap } from './lib/html-audit.js'
fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px
Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.
#214 (P1) — at exactly 768px, iPad portrait, the header's "Request
Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off,
with no scrollbar to reveal it because html/body carry overflow-x:hidden. At
that width the burger menu is already hidden, so the primary conversion action
was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md
(gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than
deferring it to lg and leaving 768-1023px with no CTA at all.
#215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be
resized. At 320px it measured x 41-345, so 25px of branding and the privacy and
terms links were clipped. Scaled to 0.85 below 360px with the wrapper height
reduced to match, since transform does not affect layout and the form would
otherwise gain dead space.
Also adds scripts/qa-browser.mjs, which found them. It measures horizontal
scroll, broken images, elements past the right edge, CLS and LCP across pages
and viewports. Written because seven of the ten issues in Batches 10 and 11
misstated their own evidence, and two of those would have made the site worse
if actioned.
Two false positives in the tool itself, both fixed before trusting it:
- Lazy images below the fold read as broken. It now scrolls the page AND chases
each un-loaded image individually, and reports observed HTTP failures apart
from never-rendered images.
- `--paths / --viewports 320` swallowed "320" as a path. Argument collection now
stops at the next flag.
Verified against a local production build: 5 paths x 5 viewports, zero findings.
Before the fix the same run reported the CTA overflow at 768 on four pages and
the reCAPTCHA overflow at 320 on /contact.
Not yet live — this needs a release and a deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
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 SHOTS = opt ( 'shots' , null )
fix(qa): the browser check measured 5 of 18 pages and heard no errors
Two gaps in the tool that exists to find defects.
Its default path list was five pages typed by hand: home, about, services,
contact and support. That is 5 of the 18 this site serves, and it included no
service and no industry page, so a defect on any of them was invisible to it.
The default is now whatever the target's own sitemap says it serves, and a
sitemap it cannot read exits 2 rather than quietly checking nothing.
It also listened for nothing the page said. A page that throws still paints, so
every measurement it takes can look healthy while the page is broken: React
reported a hydration mismatch on every page of this site for months and this
tool never noticed, because nothing here was listening. It now captures page
errors and console errors and reports them as findings.
Proven: a console.error added to the About page produced
"/about @390: the page logged an error", and a clean build reports nothing
across all 18 sitemap pages.
docs/qa/ClaudeQAPlan.md said "five pages, five widths" and it is now every page
in the sitemap, at four widths.
Closes #227.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:53:51 -05:00
// The default path list used to be five hand-typed pages: the home page, about,
// services, contact and support. That is 5 of the 18 this site serves, and it
// included none of the service or industry pages, so a defect on any of them was
// invisible to the tool that exists to find defects. The default is now whatever
// the target itself says it serves.
//
// Failing to read the sitemap exits 2. "I checked nothing" must never look like
// "I found nothing".
let PATHS = collect ( 'paths' , null )
if ( ! PATHS ) {
try {
const response = await fetch ( ` ${ URL _BASE } /sitemap.xml ` , { signal : AbortSignal . timeout ( 20000 ) } )
if ( ! response . ok ) throw new Error ( ` HTTP ${ response . status } ` )
PATHS = parseSitemap ( await response . text ( ) ) . map ( ( entry ) => entry . path )
if ( ! PATHS . length ) throw new Error ( 'it lists no pages' )
} catch ( e ) {
console . error ( ` qa-browser: could not read ${ URL _BASE } /sitemap.xml ( ${ e . message } ), so NOTHING was checked. ` )
console . error ( ' Pass --paths to check specific pages instead.' )
process . exit ( 2 )
}
}
fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px
Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.
#214 (P1) — at exactly 768px, iPad portrait, the header's "Request
Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off,
with no scrollbar to reveal it because html/body carry overflow-x:hidden. At
that width the burger menu is already hidden, so the primary conversion action
was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md
(gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than
deferring it to lg and leaving 768-1023px with no CTA at all.
#215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be
resized. At 320px it measured x 41-345, so 25px of branding and the privacy and
terms links were clipped. Scaled to 0.85 below 360px with the wrapper height
reduced to match, since transform does not affect layout and the form would
otherwise gain dead space.
Also adds scripts/qa-browser.mjs, which found them. It measures horizontal
scroll, broken images, elements past the right edge, CLS and LCP across pages
and viewports. Written because seven of the ten issues in Batches 10 and 11
misstated their own evidence, and two of those would have made the site worse
if actioned.
Two false positives in the tool itself, both fixed before trusting it:
- Lazy images below the fold read as broken. It now scrolls the page AND chases
each un-loaded image individually, and reports observed HTTP failures apart
from never-rendered images.
- `--paths / --viewports 320` swallowed "320" as a path. Argument collection now
stops at the next flag.
Verified against a local production build: 5 paths x 5 viewports, zero findings.
Before the fix the same run reported the CTA overflow at 768 on four pages and
the reCAPTCHA overflow at 320 on /contact.
Not yet live — this needs a release and a deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
// 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 ( ) } ` )
} )
fix(qa): the browser check measured 5 of 18 pages and heard no errors
Two gaps in the tool that exists to find defects.
Its default path list was five pages typed by hand: home, about, services,
contact and support. That is 5 of the 18 this site serves, and it included no
service and no industry page, so a defect on any of them was invisible to it.
The default is now whatever the target's own sitemap says it serves, and a
sitemap it cannot read exits 2 rather than quietly checking nothing.
It also listened for nothing the page said. A page that throws still paints, so
every measurement it takes can look healthy while the page is broken: React
reported a hydration mismatch on every page of this site for months and this
tool never noticed, because nothing here was listening. It now captures page
errors and console errors and reports them as findings.
Proven: a console.error added to the About page produced
"/about @390: the page logged an error", and a clean build reports nothing
across all 18 sitemap pages.
docs/qa/ClaudeQAPlan.md said "five pages, five widths" and it is now every page
in the sitemap, at four widths.
Closes #227.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:53:51 -05:00
// A page that throws still paints, so every measurement below can look
// healthy while the page is broken. React reported a hydration mismatch on
// every page of this site for months and nothing here noticed, because
// nothing here was listening.
const consoleErrors = [ ]
page . on ( 'pageerror' , e => consoleErrors . push ( String ( e ) . split ( '\n' ) [ 0 ] ) )
page . on ( 'console' , msg => { if ( msg . type ( ) === 'error' ) consoleErrors . push ( msg . text ( ) . split ( '\n' ) [ 0 ] ) } )
fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px
Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.
#214 (P1) — at exactly 768px, iPad portrait, the header's "Request
Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off,
with no scrollbar to reveal it because html/body carry overflow-x:hidden. At
that width the burger menu is already hidden, so the primary conversion action
was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md
(gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than
deferring it to lg and leaving 768-1023px with no CTA at all.
#215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be
resized. At 320px it measured x 41-345, so 25px of branding and the privacy and
terms links were clipped. Scaled to 0.85 below 360px with the wrapper height
reduced to match, since transform does not affect layout and the form would
otherwise gain dead space.
Also adds scripts/qa-browser.mjs, which found them. It measures horizontal
scroll, broken images, elements past the right edge, CLS and LCP across pages
and viewports. Written because seven of the ten issues in Batches 10 and 11
misstated their own evidence, and two of those would have made the site worse
if actioned.
Two false positives in the tool itself, both fixed before trusting it:
- Lazy images below the fold read as broken. It now scrolls the page AND chases
each un-loaded image individually, and reports observed HTTP failures apart
from never-rendered images.
- `--paths / --viewports 320` swallowed "320" as a path. Argument collection now
stops at the next flag.
Verified against a local production build: 5 paths x 5 viewports, zero findings.
Before the fix the same run reported the CTA overflow at 768 on four pages and
the reCAPTCHA overflow at 320 on /contact.
Not yet live — this needs a release and a deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
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 ( '; ' ) } ` ) }
fix(qa): the browser check measured 5 of 18 pages and heard no errors
Two gaps in the tool that exists to find defects.
Its default path list was five pages typed by hand: home, about, services,
contact and support. That is 5 of the 18 this site serves, and it included no
service and no industry page, so a defect on any of them was invisible to it.
The default is now whatever the target's own sitemap says it serves, and a
sitemap it cannot read exits 2 rather than quietly checking nothing.
It also listened for nothing the page said. A page that throws still paints, so
every measurement it takes can look healthy while the page is broken: React
reported a hydration mismatch on every page of this site for months and this
tool never noticed, because nothing here was listening. It now captures page
errors and console errors and reports them as findings.
Proven: a console.error added to the About page produced
"/about @390: the page logged an error", and a clean build reports nothing
across all 18 sitemap pages.
docs/qa/ClaudeQAPlan.md said "five pages, five widths" and it is now every page
in the sitemap, at four widths.
Closes #227.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:53:51 -05:00
if ( consoleErrors . length ) { bits . push ( ` JS-ERROR ${ consoleErrors . length } ` ) ; findings . push ( ` ${ p } @ ${ width } : the page logged an error — ${ [ ... new Set ( consoleErrors ) ] . slice ( 0 , 2 ) . join ( ' | ' ) } ` ) }
fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px
Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.
#214 (P1) — at exactly 768px, iPad portrait, the header's "Request
Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off,
with no scrollbar to reveal it because html/body carry overflow-x:hidden. At
that width the burger menu is already hidden, so the primary conversion action
was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md
(gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than
deferring it to lg and leaving 768-1023px with no CTA at all.
#215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be
resized. At 320px it measured x 41-345, so 25px of branding and the privacy and
terms links were clipped. Scaled to 0.85 below 360px with the wrapper height
reduced to match, since transform does not affect layout and the form would
otherwise gain dead space.
Also adds scripts/qa-browser.mjs, which found them. It measures horizontal
scroll, broken images, elements past the right edge, CLS and LCP across pages
and viewports. Written because seven of the ten issues in Batches 10 and 11
misstated their own evidence, and two of those would have made the site worse
if actioned.
Two false positives in the tool itself, both fixed before trusting it:
- Lazy images below the fold read as broken. It now scrolls the page AND chases
each un-loaded image individually, and reports observed HTTP failures apart
from never-rendered images.
- `--paths / --viewports 320` swallowed "320" as a path. Argument collection now
stops at the next flag.
Verified against a local production build: 5 paths x 5 viewports, zero findings.
Before the fix the same run reported the CTA overflow at 768 on four pages and
the reCAPTCHA overflow at 320 on /contact.
Not yet live — this needs a release and a deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
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). ` )
fix(qa): the browser check measured 5 of 18 pages and heard no errors
Two gaps in the tool that exists to find defects.
Its default path list was five pages typed by hand: home, about, services,
contact and support. That is 5 of the 18 this site serves, and it included no
service and no industry page, so a defect on any of them was invisible to it.
The default is now whatever the target's own sitemap says it serves, and a
sitemap it cannot read exits 2 rather than quietly checking nothing.
It also listened for nothing the page said. A page that throws still paints, so
every measurement it takes can look healthy while the page is broken: React
reported a hydration mismatch on every page of this site for months and this
tool never noticed, because nothing here was listening. It now captures page
errors and console errors and reports them as findings.
Proven: a console.error added to the About page produced
"/about @390: the page logged an error", and a clean build reports nothing
across all 18 sitemap pages.
docs/qa/ClaudeQAPlan.md said "five pages, five widths" and it is now every page
in the sitemap, at four widths.
Closes #227.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:53:51 -05:00
say ( ' That is not "the UI is correct" — it is these six measurements,' )
fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px
Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.
#214 (P1) — at exactly 768px, iPad portrait, the header's "Request
Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off,
with no scrollbar to reveal it because html/body carry overflow-x:hidden. At
that width the burger menu is already hidden, so the primary conversion action
was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md
(gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than
deferring it to lg and leaving 768-1023px with no CTA at all.
#215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be
resized. At 320px it measured x 41-345, so 25px of branding and the privacy and
terms links were clipped. Scaled to 0.85 below 360px with the wrapper height
reduced to match, since transform does not affect layout and the form would
otherwise gain dead space.
Also adds scripts/qa-browser.mjs, which found them. It measures horizontal
scroll, broken images, elements past the right edge, CLS and LCP across pages
and viewports. Written because seven of the ten issues in Batches 10 and 11
misstated their own evidence, and two of those would have made the site worse
if actioned.
Two false positives in the tool itself, both fixed before trusting it:
- Lazy images below the fold read as broken. It now scrolls the page AND chases
each un-loaded image individually, and reports observed HTTP failures apart
from never-rendered images.
- `--paths / --viewports 320` swallowed "320" as a path. Argument collection now
stops at the next flag.
Verified against a local production build: 5 paths x 5 viewports, zero findings.
Before the fix the same run reported the CTA overflow at 768 on four pages and
the reCAPTCHA overflow at 320 on /contact.
Not yet live — this needs a release and a deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
say ( ' on these pages, at these widths. Nothing here opens a menu or' )
say ( ' uses a keyboard.' )