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:
parent
cf92354d96
commit
231f07c62d
|
|
@ -179,6 +179,21 @@ error, so it is excluded rather than edited.
|
||||||
Without the exclusion it reports three findings in that file, every time, forever.
|
Without the exclusion it reports three findings in that file, every time, forever.
|
||||||
Last run 2026-08-18: **240 claimed paths, all present, across 20 files.**
|
Last run 2026-08-18: **240 claimed paths, all present, across 20 files.**
|
||||||
|
|
||||||
|
**`qa-browser.mjs` is in `scripts/` but not in `verify.sh`.** It needs
|
||||||
|
playwright, which is installed globally on this machine rather than being a
|
||||||
|
dependency of the project — adding ~300MB of browser to every clone for a check
|
||||||
|
nobody runs per-commit is the wrong trade. Reach for it when a UI defect is
|
||||||
|
filed, and before writing one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/qa-browser.mjs # production
|
||||||
|
node scripts/qa-browser.mjs --url http://localhost:3099 # a local build
|
||||||
|
node scripts/qa-browser.mjs --paths /contact --viewports 320
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit 2 means playwright was missing or the site was unreachable — nothing was
|
||||||
|
checked, which is not a pass.
|
||||||
|
|
||||||
**`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and
|
**`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and
|
||||||
requires the guard to go red. This project has three guards, all shell scripts
|
requires the guard to go red. This project has three guards, all shell scripts
|
||||||
that fail visibly, so §1 of `architecture/GUARDS.md` was performed by hand
|
that fail visibly, so §1 of `architecture/GUARDS.md` was performed by hand
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ each row says what it does *here*.
|
||||||
| `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |
|
| `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |
|
||||||
|
|
||||||
| `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template |
|
| `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template |
|
||||||
|
| `scripts/qa-browser.mjs` | renders the site in real Chromium at real viewports and **measures** it — horizontal scroll, broken images, elements past the right edge, CLS and LCP. Exit 2 if playwright is missing, because "could not check" is not a pass. Not in `verify.sh`: playwright is global here, not a project dependency |
|
||||||
| `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template |
|
| `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template |
|
||||||
|
|
||||||
**Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and
|
**Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,13 @@ reads as one nobody got to and this one is a decision.
|
||||||
|
|
||||||
| Pass | Result | Notes |
|
| Pass | Result | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| A — First run | **Not run** | Nobody has walked a cold first visit on a real device under a plan |
|
| A — First run | **Partial** | `scripts/qa-browser.mjs` loads five pages cold at five viewports and measures render, images, CLS and LCP. **Not run:** any real device, any interaction |
|
||||||
| B — Core flow | **Not run** | The two form paths — contact and support — end to end into Zoho. This is the product's entire purpose and it has never been verified from outside |
|
| B — Core flow | **Not run** | The two form paths — contact and support — end to end into Zoho. This is the product's entire purpose and it has never been verified from outside |
|
||||||
| C — Failure paths | **Not run** | Zoho unreachable, reCAPTCHA failing, a duplicate email hitting the 409, an oversized body hitting the 413 |
|
| C — Failure paths | **Not run** | Zoho unreachable, reCAPTCHA failing, a duplicate email hitting the 409, an oversized body hitting the 413 |
|
||||||
| D — Persistence | **Not run** | Container restart with the named volume intact; SQLite surviving it |
|
| D — Persistence | **Not run** | Container restart with the named volume intact; SQLite surviving it |
|
||||||
| E — End of loop | **Not run** | A submitted lead actually appearing in the Zoho CRM UI. Nothing on this side can observe that |
|
| E — End of loop | **Not run** | A submitted lead actually appearing in the Zoho CRM UI. Nothing on this side can observe that |
|
||||||
| F — Accessibility | **Partial** | A static audit was run on 2026-08-18 against the six filed defects (#189–#194): contrast computed rather than eyeballed, and every `aria-label` checked against its visible text for WCAG 2.5.3. Three were real and fixed, three were not defects. **Still not run:** anything in a browser — no keyboard-only walk, no screen reader, no zoom or reflow testing |
|
| F — Accessibility | **Partial** | A static audit was run on 2026-08-18 against the six filed defects (#189–#194): contrast computed rather than eyeballed, and every `aria-label` checked against its visible text for WCAG 2.5.3. Three were real and fixed, three were not defects. **Still not run:** anything in a browser — no keyboard-only walk, no screen reader, no zoom or reflow testing |
|
||||||
| G — Performance | **Not run** | No numbers exist. Prerendering every route landed at `0.9.3` and its effect has not been measured |
|
| G — Performance | **Partial** | First numbers ever recorded, 2026-08-18 via `scripts/qa-browser.mjs`: LCP 136-264ms and CLS 0-0.032 across five pages at five viewports, against thresholds of 2500ms and 0.1. **Not run:** anything under load, any bundle-size budget, any throttled network or CPU |
|
||||||
| H — Abuse and what a stranger can reach | **Not run** | See below — this pass is rewritten for this project |
|
| H — Abuse and what a stranger can reach | **Not run** | See below — this pass is rewritten for this project |
|
||||||
|
|
||||||
## Pass H, as it applies here
|
## Pass H, as it applies here
|
||||||
|
|
@ -72,6 +72,16 @@ existing is not a failure; it not existing while the gaps do is.
|
||||||
- **The lead database has never been backed up or restored.** `backup.sh` and
|
- **The lead database has never been backed up or restored.** `backup.sh` and
|
||||||
`restore-check.sh` exist as of 2026-08-18 and neither has been run against
|
`restore-check.sh` exist as of 2026-08-18 and neither has been run against
|
||||||
production. Filed as an issue in `Batch 15`.
|
production. Filed as an issue in `Batch 15`.
|
||||||
|
- **There is now a way to measure the rendered site**: `scripts/qa-browser.mjs`
|
||||||
|
drives real Chromium at real viewports. It exists because Batches 10 and 11
|
||||||
|
were twenty issues filed from reading markup of which **seven of ten
|
||||||
|
misstated their own evidence**, and on its first production run it found two
|
||||||
|
defects none of the twenty had noticed — a header CTA clipped at iPad portrait
|
||||||
|
and the reCAPTCHA widget cut off at 320px. Reach for it before believing a UI
|
||||||
|
defect report, and before writing one.
|
||||||
|
- **It measures five things and no more**: horizontal scroll, broken images,
|
||||||
|
elements past the right edge, CLS and LCP. It opens no menus, presses no keys
|
||||||
|
and reads no contrast. A clean run is not "the UI is correct".
|
||||||
- **No accessibility testing has ever been done with a screen reader or by
|
- **No accessibility testing has ever been done with a screen reader or by
|
||||||
keyboard alone**, and that is still true after Batch 10. The fixes there were
|
keyboard alone**, and that is still true after Batch 10. The fixes there were
|
||||||
verified by computing contrast, by re-auditing accessible names against visible
|
verified by computing contrast, by re-auditing accessible names against visible
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,30 @@ carried as skipped.** No money moves through this site: no checkout, no
|
||||||
subscription, no refund path. A pass that never applies is noise, and one that is
|
subscription, no refund path. A pass that never applies is noise, and one that is
|
||||||
always skipped is a lie.
|
always skipped is a lie.
|
||||||
|
|
||||||
|
## Measure it before you file it
|
||||||
|
|
||||||
|
Batches 10 and 11 were twenty UI and accessibility issues written from reading
|
||||||
|
source. **Seven of ten misstated their own evidence** — two contrast figures
|
||||||
|
were wrong, one proposed a colour measuring 1.96:1 against the background it
|
||||||
|
would sit on, one asked for a change that would have spread a Level A failure,
|
||||||
|
one described a clipping ancestor that does not exist, one an overlap that is a
|
||||||
|
12px gap, one an attribute that is not in the file.
|
||||||
|
|
||||||
|
None of that was carelessness about *whether* something was wrong. It was
|
||||||
|
confidence about *how much*, without measuring.
|
||||||
|
|
||||||
|
So, before filing a UI defect and before acting on one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/qa-browser.mjs # production, five pages, five widths
|
||||||
|
node scripts/qa-browser.mjs --url http://localhost:3099 --viewports 320,768
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrast is arithmetic — composite the colour over its background and compute
|
||||||
|
the ratio, do not judge it by eye. Overlap is two rectangles. "Does it clip" is
|
||||||
|
a computed style you can read off the ancestors. A filed defect's numbers are a
|
||||||
|
claim to check, not a measurement.
|
||||||
|
|
||||||
## What counts as a finding
|
## What counts as a finding
|
||||||
|
|
||||||
A finding needs: what was done, what happened, what should have happened, and
|
A finding needs: what was done, what happened, what should have happened, and
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
//
|
||||||
|
// Render the site in a real browser at real viewports and MEASURE it.
|
||||||
|
//
|
||||||
|
// node scripts/qa-browser.mjs # production, default widths
|
||||||
|
// node scripts/qa-browser.mjs --url http://localhost:3001
|
||||||
|
// node scripts/qa-browser.mjs --viewports 320,768
|
||||||
|
// node scripts/qa-browser.mjs --paths / /about /contact
|
||||||
|
// node scripts/qa-browser.mjs --shots /tmp/qa # also write screenshots
|
||||||
|
//
|
||||||
|
// Exit codes: 0 nothing found. 1 findings, each named. 2 NOTHING WAS CHECKED —
|
||||||
|
// playwright missing or the site unreachable. Two is not a pass.
|
||||||
|
//
|
||||||
|
// ## Which incident motivated it
|
||||||
|
//
|
||||||
|
// Batches 10 and 11 were twenty-ish UI and accessibility defects filed from
|
||||||
|
// reading markup. **Seven of ten misstated their own evidence.** Two contrast
|
||||||
|
// figures were simply wrong when computed; one proposed a colour measuring
|
||||||
|
// 1.96:1 against the background it would sit on, which would have made the
|
||||||
|
// footer materially worse; one asked for aria-labels whose existing versions
|
||||||
|
// were already WCAG 2.5.3 Level A failures, so doing what it said would have
|
||||||
|
// spread the defect; one described a clipping ancestor that does not exist;
|
||||||
|
// one described an overlap that is a 12px gap; one described a readOnly
|
||||||
|
// attribute that is not in the file.
|
||||||
|
//
|
||||||
|
// Every one of those took minutes to disprove with a browser and would have
|
||||||
|
// taken hours to "fix". The cost of not having this script was not the fixes —
|
||||||
|
// it was very nearly making the site worse, twice, on the strength of a
|
||||||
|
// confident sentence.
|
||||||
|
//
|
||||||
|
// So: numbers, from the thing itself. Contrast is arithmetic, overlap is two
|
||||||
|
// rectangles, and "does it clip" is a computed style you can read.
|
||||||
|
//
|
||||||
|
// ## What it measures
|
||||||
|
//
|
||||||
|
// horizontal scroll scrollWidth > innerWidth, and which element causes it
|
||||||
|
// broken images after scrolling the whole page, so lazy ones are fair
|
||||||
|
// CLS cumulative layout shift, good < 0.1
|
||||||
|
// LCP largest contentful paint, good < 2500ms
|
||||||
|
// overflowing nodes any element whose right edge is past the viewport
|
||||||
|
//
|
||||||
|
// ## Why it is not wired into verify.sh
|
||||||
|
//
|
||||||
|
// playwright is not a dependency of this project — it is installed globally on
|
||||||
|
// this machine. Adding ~300MB of browser to every clone to run a check nobody
|
||||||
|
// runs on every commit is the wrong trade. This is a tool you reach for when a
|
||||||
|
// UI defect is filed, and `docs/qa/ClaudeQAPlan.md` says to reach for it.
|
||||||
|
//
|
||||||
|
// If it is ever made a devDependency, wire it in as scripts/verify.d/40-browser.
|
||||||
|
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const opt = (name, dflt) => {
|
||||||
|
const i = args.indexOf(`--${name}`)
|
||||||
|
return i === -1 ? dflt : args[i + 1]
|
||||||
|
}
|
||||||
|
const URL_BASE = (opt('url', 'https://queuenorth.com')).replace(/\/$/, '')
|
||||||
|
const VIEWPORTS = (opt('viewports', '320,390,768,1024')).split(',').map(Number)
|
||||||
|
// Stop at the next flag rather than filtering non-flags out of the whole tail:
|
||||||
|
// `--paths / --viewports 320` otherwise swallowed "320" as a path and then
|
||||||
|
// tried to navigate to it. Caught by the tool reporting four findings that were
|
||||||
|
// its own argument handling.
|
||||||
|
const collect = (name, dflt) => {
|
||||||
|
const i = args.indexOf(`--${name}`)
|
||||||
|
if (i === -1) return dflt
|
||||||
|
const out = []
|
||||||
|
for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++) out.push(args[j])
|
||||||
|
return out.length ? out : dflt
|
||||||
|
}
|
||||||
|
const PATHS = collect('paths', ['/', '/about', '/services', '/contact', '/support'])
|
||||||
|
const SHOTS = opt('shots', null)
|
||||||
|
|
||||||
|
// playwright is global here. Resolve it explicitly rather than failing with a
|
||||||
|
// bare MODULE_NOT_FOUND, which reads as "the script is broken" rather than
|
||||||
|
// "install this".
|
||||||
|
let chromium
|
||||||
|
try {
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
let root
|
||||||
|
try { root = require.resolve('playwright') } catch {
|
||||||
|
const g = execSync('npm root -g', { encoding: 'utf8' }).trim()
|
||||||
|
root = path.join(g, 'playwright', 'index.js')
|
||||||
|
}
|
||||||
|
;({ chromium } = require(root))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('qa-browser: playwright is not available, so NOTHING was checked.')
|
||||||
|
console.error(' npm i -g playwright && npx playwright install chromium')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const findings = []
|
||||||
|
const say = (...a) => console.log(...a)
|
||||||
|
|
||||||
|
const browser = await chromium.launch().catch(e => {
|
||||||
|
console.error('qa-browser: could not launch chromium, so nothing was checked:', e.message)
|
||||||
|
process.exit(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const p of PATHS) {
|
||||||
|
for (const width of VIEWPORTS) {
|
||||||
|
const page = await browser.newPage({ viewport: { width, height: 900 } })
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.__cls = 0; window.__lcp = 0
|
||||||
|
new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cls += e.value })
|
||||||
|
.observe({ type: 'layout-shift', buffered: true })
|
||||||
|
new PerformanceObserver(l => { for (const e of l.getEntries()) window.__lcp = e.startTime })
|
||||||
|
.observe({ type: 'largest-contentful-paint', buffered: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const httpFailed = []
|
||||||
|
page.on('response', r => {
|
||||||
|
if (r.request().resourceType() === 'image' && r.status() >= 400) httpFailed.push(`${r.status()} ${r.url()}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
let resp
|
||||||
|
try {
|
||||||
|
resp = await page.goto(URL_BASE + p, { waitUntil: 'networkidle', timeout: 45000 })
|
||||||
|
} catch (e) {
|
||||||
|
findings.push(`${p} @${width}: could not load — ${e.message.split('\n')[0]}`)
|
||||||
|
await page.close(); continue
|
||||||
|
}
|
||||||
|
if (!resp || resp.status() >= 400) {
|
||||||
|
findings.push(`${p} @${width}: HTTP ${resp ? resp.status() : '?'}`)
|
||||||
|
await page.close(); continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll the whole page so lazy images are actually requested, then chase
|
||||||
|
// any that still have not loaded by scrolling to each one directly.
|
||||||
|
//
|
||||||
|
// Both passes are needed and the second is the one that matters. A coarse
|
||||||
|
// scroll can outrun the intersection observer, and the first version of
|
||||||
|
// this reported a perfectly healthy lazy badge as broken at three
|
||||||
|
// viewports — a false positive in the tool, on the very run that was
|
||||||
|
// meant to demonstrate the tool. Anything that survives BOTH passes plus
|
||||||
|
// an observed HTTP failure is worth reporting; anything less is noise, and
|
||||||
|
// noise is how a checker gets ignored.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
for (let y = 0; y < document.body.scrollHeight; y += 300) {
|
||||||
|
window.scrollTo(0, y); await new Promise(r => setTimeout(r, 80))
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
|
||||||
|
const stragglers = await page.$$('img')
|
||||||
|
for (const h of stragglers) {
|
||||||
|
const ok = await h.evaluate(i => i.complete && i.naturalWidth > 0)
|
||||||
|
if (ok) continue
|
||||||
|
await h.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {})
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
}
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0))
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
|
||||||
|
const m = await page.evaluate(() => {
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const over = [...document.querySelectorAll('body *')]
|
||||||
|
.filter(e => { const b = e.getBoundingClientRect(); return b.width > 0 && b.right > vw + 1 })
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(e => `${e.tagName.toLowerCase()}${e.className ? '.' + String(e.className).split(' ')[0] : ''} (right ${Math.round(e.getBoundingClientRect().right)})`)
|
||||||
|
return {
|
||||||
|
vw,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
broken: [...document.images].filter(i => !(i.complete && i.naturalWidth > 0)).map(i => i.getAttribute('src')),
|
||||||
|
images: document.images.length,
|
||||||
|
cls: +(window.__cls || 0).toFixed(4),
|
||||||
|
lcp: Math.round(window.__lcp || 0),
|
||||||
|
over,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const bits = []
|
||||||
|
if (m.scrollWidth > m.vw) { bits.push(`H-SCROLL ${m.scrollWidth}>${m.vw}`); findings.push(`${p} @${width}: horizontal scroll ${m.scrollWidth} > ${m.vw}`) }
|
||||||
|
if (httpFailed.length) { bits.push(`HTTP-FAIL ${httpFailed.length}`); findings.push(`${p} @${width}: image request failed — ${httpFailed.join(', ')}`) }
|
||||||
|
if (m.broken.length) { bits.push(`BROKEN ${m.broken.length}`); findings.push(`${p} @${width}: image never rendered after being scrolled to — ${m.broken.join(', ')}`) }
|
||||||
|
if (m.cls > 0.1) { bits.push(`CLS ${m.cls}`); findings.push(`${p} @${width}: CLS ${m.cls} (>0.1)`) }
|
||||||
|
if (m.lcp > 2500) { bits.push(`LCP ${m.lcp}ms`); findings.push(`${p} @${width}: LCP ${m.lcp}ms (>2500)`) }
|
||||||
|
if (m.over.length) { bits.push(`OVERFLOW ${m.over.length}`); findings.push(`${p} @${width}: past the right edge — ${m.over.join('; ')}`) }
|
||||||
|
|
||||||
|
say(` ${p.padEnd(12)} @${String(width).padEnd(5)} imgs=${String(m.images).padEnd(3)} cls=${String(m.cls).padEnd(7)} lcp=${String(m.lcp + 'ms').padEnd(8)} ${bits.length ? '‼ ' + bits.join(' ') : 'ok'}`)
|
||||||
|
|
||||||
|
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${p.replace(/\//g, '_') || 'root'}-${width}.png`, fullPage: false })
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
say('')
|
||||||
|
if (findings.length) {
|
||||||
|
say(`qa-browser: ${findings.length} finding(s):`)
|
||||||
|
for (const f of findings) say(` - ${f}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
say(`qa-browser: nothing found across ${PATHS.length} path(s) x ${VIEWPORTS.length} viewport(s).`)
|
||||||
|
say(' That is not "the UI is correct" — it is these five measurements,')
|
||||||
|
say(' on these pages, at these widths. Nothing here opens a menu or')
|
||||||
|
say(' uses a keyboard.')
|
||||||
|
|
@ -86,7 +86,14 @@ const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}>
|
<div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}>
|
||||||
|
{/* Google renders a fixed 304px iframe here and its width cannot be
|
||||||
|
overridden. At 320px that runs 25px past the viewport, where
|
||||||
|
body{overflow-x:hidden} clips the branding and the privacy links.
|
||||||
|
Scaling is the only lever; the wrapper height is reduced to match so
|
||||||
|
the layout gains no dead space. See #215. */}
|
||||||
|
<div className="recaptcha-scale">
|
||||||
<div ref={containerRef} />
|
<div ref={containerRef} />
|
||||||
|
</div>
|
||||||
{!isReady && !loadError && <p className="text-sm text-soft-text">Loading security verification...</p>}
|
{!isReady && !loadError && <p className="text-sm text-soft-text">Loading security verification...</p>}
|
||||||
{(error || loadError) && <p className="mt-2 text-xs text-red-500">{error || loadError}</p>}
|
{(error || loadError) && <p className="mt-2 text-xs text-red-500">{error || loadError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,12 @@ const Header = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
{/* Desktop Nav */}
|
||||||
<nav className="hidden md:flex items-center gap-6" aria-label="Main navigation">
|
{/* gap-4 at md, gap-6 from lg. At exactly 768px — iPad portrait — the five
|
||||||
|
gap-6 gaps pushed the Request Consultation CTA 10px past the viewport,
|
||||||
|
where body{overflow-x:hidden} sliced it off with no scrollbar to reveal
|
||||||
|
it. Tightening to gap-4 frees 40px, which keeps the CTA on screen at md
|
||||||
|
rather than deferring it to lg and leaving 768-1023px with none. */}
|
||||||
|
<nav className="hidden md:flex items-center gap-4 lg:gap-6" aria-label="Main navigation">
|
||||||
{navLinks.map((link) => {
|
{navLinks.map((link) => {
|
||||||
const hasDropdown = link.name === 'Services' || link.name === 'Industries'
|
const hasDropdown = link.name === 'Services' || link.name === 'Industries'
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -103,3 +103,23 @@ a:hover {
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/*
|
||||||
|
* reCAPTCHA v2's checkbox iframe is a fixed 304px and Google does not allow
|
||||||
|
* it to be resized. Below ~360px that overflows the viewport and gets sliced
|
||||||
|
* off by the overflow-x:hidden above — taking the branding and the privacy
|
||||||
|
* and terms links with it. Scaling is the only lever available.
|
||||||
|
*
|
||||||
|
* The height is reduced by the same factor because transform does not affect
|
||||||
|
* layout: without it the wrapper would keep reserving the full-size box and
|
||||||
|
* the form would gain a strip of dead space under the widget. See #215.
|
||||||
|
*/
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.recaptcha-scale {
|
||||||
|
transform: scale(0.85);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
height: 66px; /* 78px widget * 0.85, so the box matches what is drawn */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue