diff --git a/e2e/tests/__m.spec.ts b/e2e/tests/__m.spec.ts new file mode 100644 index 0000000..83240c0 --- /dev/null +++ b/e2e/tests/__m.spec.ts @@ -0,0 +1,36 @@ +import { test } from '@playwright/test'; +test('mobile hero geometry', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + const out = await page.evaluate(() => { + const pick = (sel: string) => { + const el = document.querySelector(sel) as HTMLElement | null; + if (!el) return null; + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + return { left: Math.round(r.left), right: Math.round(r.right), + width: Math.round(r.width), height: Math.round(r.height), + pad: cs.padding, cls: el.className.toString().slice(0, 40) }; + }; + const input = document.querySelector('input[type="search"]') as HTMLElement; + const box = input?.closest('div'); + const form = input?.closest('form'); + return { + h1: pick('h1'), + lede: pick('h1 + p') ?? pick('p'), + omniBox: box ? { left: Math.round(box.getBoundingClientRect().left), + width: Math.round(box.getBoundingClientRect().width), + pad: getComputedStyle(box).padding, + dir: getComputedStyle(box).flexDirection, + cls: box.className.toString().slice(0,40) } : null, + input: pick('input[type="search"]'), + form: form ? { left: Math.round(form.getBoundingClientRect().left), + width: Math.round(form.getBoundingClientRect().width), + pad: getComputedStyle(form).padding, + cls: form.className.toString().slice(0,40) } : null, + filterBar: pick('form'). // placeholder + constructor === Object ? null : null, + }; + }); + console.log(JSON.stringify(out, null, 2)); +}); diff --git a/nextjs-app/__tests__/components/filterBarScoping.test.ts b/nextjs-app/__tests__/components/filterBarScoping.test.ts new file mode 100644 index 0000000..38ddf3b --- /dev/null +++ b/nextjs-app/__tests__/components/filterBarScoping.test.ts @@ -0,0 +1,82 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * The hero search and the results filter bar are the same component in two + * costumes. `.filterBar` is the card — background, border, shadow, padding — + * and `.heroMode` strips all of it so the search sits directly on the hero + * panel. + * + * Both selectors have specificity (0,1,0), so **source order decides**, and + * `.heroMode` only wins because it is declared immediately after. Any later + * bare `.filterBar` rule — which in practice means one inside a media query — + * silently wins instead, and the hero grows a card's padding back. + * + * That is exactly what happened: `@media (max-width: 768px) { .filterBar { + * padding: 0.875rem } }` re-added 14px in hero mode, indenting the search box, + * the hint and the location link 14px past the headline above them and costing + * the search field 28px of width on a 390px screen. The two rules directly + * below it in the same block were correctly written as + * `.filterBar:not(.heroMode)`; this one was missed, and nothing caught it + * because the result is a plausible-looking layout rather than a broken one. + */ + +const CSS = path.join(__dirname, '..', '..', 'components', 'FilterBar.module.css'); + +/** Properties `.heroMode` resets. A later bare `.filterBar` rule setting any + * of these puts the card back on the hero. */ +const RESET_BY_HERO_MODE = [ + 'background', 'border', 'border-radius', 'box-shadow', 'padding', +]; + +function mediaQueryBodies(css: string): string[] { + const bodies: string[] = []; + const re = /@media[^{]*\{/g; + let m: RegExpExecArray | null; + while ((m = re.exec(css)) !== null) { + // Walk braces from the opening one to find this at-rule's whole body. + let depth = 1; + let i = m.index + m[0].length; + const start = i; + while (i < css.length && depth > 0) { + if (css[i] === '{') depth++; + else if (css[i] === '}') depth--; + i++; + } + bodies.push(css.slice(start, i - 1)); + } + return bodies; +} + +describe('FilterBar hero-mode scoping', () => { + const css = fs.readFileSync(CSS, 'utf8'); + + it('confirms heroMode still resets the card, which is what makes this matter', () => { + const hero = css.match(/\.heroMode\s*\{([^}]*)\}/); + expect(hero).not.toBeNull(); + expect(hero![1]).toMatch(/padding:\s*0/); + }); + + it('never re-applies card styling to the hero from inside a media query', () => { + const offenders: string[] = []; + + for (const body of mediaQueryBodies(css)) { + for (const rule of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const selector = rule[1].trim().split('\n').pop()!.trim(); + // Only a *bare* .filterBar is dangerous. Scoped variants + // (`.filterBar:not(.heroMode)`) and descendants are fine. + if (selector !== '.filterBar') continue; + + for (const prop of RESET_BY_HERO_MODE) { + if (new RegExp(`(^|[;\\s])${prop}\\s*:`).test(rule[2])) { + offenders.push(`${selector} sets ${prop}`); + } + } + } + } + + // Fix by scoping the rule as `.filterBar:not(.heroMode)`, the way the + // neighbouring rules in the same block already are. + expect(offenders).toEqual([]); + }); +}); diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css index 7384f60..287c6d3 100644 --- a/nextjs-app/components/FilterBar.module.css +++ b/nextjs-app/components/FilterBar.module.css @@ -413,7 +413,17 @@ /* ── Narrow ───────────────────────────────────────────────────────── */ @media (max-width: 768px) { - .filterBar { + /* + * Scoped, like the two rules below it. + * + * The results filter bar is a card — background, border, shadow — and needs + * inner padding. The hero's search is not a card: .heroMode zeroes the + * padding, border and background so the search sits directly on the panel. + * Unscoped, this rule put 14px back, which indented the search box, the hint + * and the location link 14px past the headline they sit under, and cost the + * search field 28px of width on a 390px screen. + */ + .filterBar:not(.heroMode) { padding: 0.875rem; } @@ -457,6 +467,14 @@ align-items: flex-start; } + /* Optical alignment: the button's own 6px of padding is what makes its + label start further right than the hint above it, even once both boxes + share a left edge. Pulling the padding back off lines the text up while + keeping the tap target. */ + .heroMode .nearMeBtn { + margin-left: -0.375rem; + } + .geoError { text-align: left; }