Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/forms/ContactForm/ContactForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ export const ContactForm: React.FC<ContactFormProps> = ({
<div className="mt-6">
<button
type="submit"
className={`btn btn-primary ${isSubmitting ? 'loading' : ''} ${!isOnline ? 'btn-warning' : ''}`}
className={`btn btn-primary min-h-11 ${isSubmitting ? 'loading' : ''} ${!isOnline ? 'btn-warning' : ''}`}
disabled={isSubmitting || !!honeypotValue}
>
{isSubmitting
Expand Down
79 changes: 51 additions & 28 deletions tests/e2e/tests/mobile-buttons.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,46 +32,69 @@ test.describe('Mobile Button Standards', () => {
const MINIMUM = TOUCH_TARGET_STANDARDS.AAA.minWidth;
const TOLERANCE = 1;

/**
* Routes this gate visits. `/` ALONE WAS NOT ENOUGH (#396).
*
* Its three visible `.btn` elements all pass, so the gate ran green while the
* contact form's PRIMARY ACTION rendered at 40px — a bare `btn btn-primary` with
* no height floor, on the one button that page exists for. Measured on production
* at 390px. A gate is only as wide as what it points at.
*
* Each route earns its place by holding buttons the others do not:
* / nav and hero chrome
* /contact/ a form submit — the case that was missed
* /blog/ card actions
*/
const BUTTON_ROUTES = ['/', '/contact/', '/blog/'];

test('All buttons meet 44x44px minimum on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await dismissCookieBanner(page);
await waitForLayoutStability(page);

// Check primary action buttons (btn class), not all buttons
// Small icon buttons and decorative buttons are exempt
const buttons = await page.locator('.btn').all();
const failures: string[] = [];
let measured = 0;

for (let i = 0; i < buttons.length; i++) {
const button = buttons[i];
for (const route of BUTTON_ROUTES) {
await page.goto(route);
await dismissCookieBanner(page);
await waitForLayoutStability(page);

if (await button.isVisible()) {
// Check primary action buttons (btn class), not all buttons
// Small icon buttons and decorative buttons are exempt
const buttons = await page.locator('.btn').all();

for (const button of buttons) {
if (!(await button.isVisible())) continue;
const box = await button.boundingBox();
if (!box) continue;

measured++;
const text =
(await button.textContent())?.trim().substring(0, 20) || '';

if (box) {
const text =
(await button.textContent())?.trim().substring(0, 20) || '';

// Primary buttons should meet the 44px minimum
if (
box.width < MINIMUM - TOLERANCE ||
box.height < MINIMUM - TOLERANCE
) {
failures.push(
`Button "${text}": ${box.width.toFixed(0)}x${box.height.toFixed(0)}px`
);
}
if (
box.width < MINIMUM - TOLERANCE ||
box.height < MINIMUM - TOLERANCE
) {
failures.push(
`${route} — "${text}": ${box.width.toFixed(0)}x${box.height.toFixed(0)}px`
);
}
}
}

if (failures.length > 0) {
expect(
failures.length,
`${failures.length} buttons too small:\n${failures.join('\n')}`
).toBe(0);
}
// COVERAGE FLOOR, asserted unconditionally. Without it a selector change or an
// empty route makes this pass having measured nothing — and it would look
// identical to a clean sweep in the report (#396).
expect(
measured,
`no visible .btn elements found across ${BUTTON_ROUTES.join(', ')} — this ` +
'gate measured nothing'
).toBeGreaterThanOrEqual(BUTTON_ROUTES.length);

expect(
failures,
`${failures.length} button(s) below ${MINIMUM}px:\n${failures.join('\n')}`
).toEqual([]);
});

test('Buttons have 8px minimum spacing', async ({ page }) => {
Expand Down
Loading