fix(search): the mobile hero search was indented by a card's padding #130

Merged
tudor merged 2 commits from fix/mobile-hero-search into main 2026-08-26 20:51:21 +00:00
3 changed files with 137 additions and 1 deletions
Showing only changes of commit 0b15497c09 - Show all commits

No files matched your search

+36
View File
@@ -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));
});
@@ -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([]);
});
});
+19 -1
View File
@@ -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;
}