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")}