From 786b3951f699b1edda8bf564e3a7166d2710f1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 20:15:21 +0200 Subject: [PATCH] fix(a11y): skip-link no longer blocks logo click outside focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Skip to content" link is `position: fixed; top-2 left-2 z-[100]` on every authenticated page. Even with `opacity-0` it still occupied its bounding box, so a mouse click on the desktop logo (also at the top-left corner) sometimes hit the invisible link instead. Fix: keep the visual treatment (opacity, focus styles) but combine `pointer-events-none` + `-translate-y-full` in the unfocused state so the element neither receives clicks nor occupies any visible space. On focus, both modifiers reverse — the link slides into view and becomes interactive again. Existing keyboard flow is unchanged: Tab focuses the link, the focus ring + translucent slide-in is preserved. Tests: extend `e2e/a11y.spec.ts` with a Playwright check that the desktop logo is clickable from its centre on the dashboard and the URL stays at "/". Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/a11y.spec.ts | 39 ++++++++++++++++++++++++++++ src/components/layout/auth-shell.tsx | 10 ++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 74a5221ff..c25b66e1e 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -110,4 +110,43 @@ test.describe("axe-core authenticated surfaces", () => { expect(blocking).toHaveLength(0); }); } + + // The keyboard-only "skip to content" link is fixed at the top-left + // corner of every authenticated page. Marc reported it intercepting + // logo clicks in v1.4.14 — the fix combines `pointer-events-none` + // with `-translate-y-full` so the element sits offscreen until + // focused. Regression guard: with no keyboard focus, the logo's own + // bounding box must accept the click. + test("skip-link does not block logo click outside focus", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + // Find the desktop sidebar logo link — `` is rendered inside + // a `` and the only logo at the absolute top-left + // of the layout. We resolve it by role / accessible name to avoid + // brittle selectors. + const logoLink = page + .locator("a[href='/']") + .filter({ hasText: "HealthLog" }) + .first(); + await expect(logoLink).toBeVisible(); + + const logoBox = await logoLink.boundingBox(); + expect(logoBox).not.toBeNull(); + if (!logoBox) return; + + // Click the centre of the logo. If the skip-link is intercepting + // pointer events, Playwright's auto-waiting click would either time + // out or report the wrong element. The defensive `force: false` + // (default) ensures we go through the full hit-test pipeline. + await logoLink.click({ + position: { x: logoBox.width / 2, y: logoBox.height / 2 }, + }); + + // Logo points at "/", so we either stay on "/" or get a no-op + // reload. Either way the URL must remain on the dashboard. + await page.waitForURL(/\/$/); + }); }); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index b3228fbf2..97bb9cc41 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -114,9 +114,17 @@ export function AuthShell({ children }: { children: React.ReactNode }) { {showUnlockNotifier && user?.id ? ( ) : null} + {/* + Keyboard-only skip link. The non-focused state has both + `pointer-events-none` (so a mouse click in the top-left corner + passes through to the logo behind it) AND `-translate-y-full` + (so even an accidental tap with stylus / touch can't activate + it). Focus restores both — the element jumps back into the + viewport and becomes clickable. + */} {t("nav.skipToContent")}