fix(a11y): add skip-to-main-content link in layout for keyboard navigation #117
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
There is no skip-to-main-content link in the layout. Keyboard users must tab through the entire navigation bar on every page before reaching page content. For a family member using a keyboard or screen reader, this is significant friction.
Fix
Add a visually hidden link as the first focusable element in
+layout.svelte. It becomes visible on focus (standard pattern):Add the translation key
skip_to_main_contenttode.json,en.json,es.json.Acceptance Criteria
#main-content👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
sr-only focus:not-sr-onlyTailwind pattern is the standard implementation — no surprises here.tabindex="-1"on<main>is required: without it, programmaticfocus()(which the browser does when following the#main-contentanchor) won't work on non-interactive elements in all browsers. This is not optional.m.skip_to_main_content()needs to be added to all three locale files before the component is implemented, otherwise the build will fail on type checking.Suggestions
npm run generate:apiif needed, (3) implement the Svelte, (4) runnpm run check.await page.keyboard.press('Tab')on any route → assert the skip link is visible →await page.keyboard.press('Enter')→ assertpage.locator('#main-content')has focus.+layout.sveltehas any<link rel="prefetch">or other elements at the top of<body>, the skip link might not be first in tab order.🔒 Nora "NullX" Steiner — Application Security Engineer
No security concerns with this change. The
href="#main-content"is an internal page anchor — no open redirect risk, no injection surface.One minor note:
tabindex="-1"on<main>means the element is programmatically focusable but not in the natural tab order. This is the correct and intentional use oftabindex="-1"— it's not a security issue, just confirming the pattern is sound.The skip link is also relevant to issue #116 (CSP): the
focus:absolutepositioning is applied via a CSS class, not an inline style, so it won't generate a CSP violation when the enforcement header goes live. Good.Clean from my perspective — this is a pure accessibility improvement with no security impact.
🧪 Sara Holt — QA Engineer & Test Strategist
Test Strategy
Skip links require a real browser and keyboard simulation — Playwright only, no Vitest.
E2E — Playwright:
axe check:
Observations
+layout.svelte— one change breaks it everywhere.🏗️ Markus Keller — Application Architect
Questions & Observations
+layout.svelteis the right place — one change, every route gets the skip link. No module boundary concerns.tabindex="-1"on<main>is required. Without it,focus()silently fails on non-interactive elements in Safari and some older browsers. The issue has this right.m.skip_to_main_content()will be a TypeScript compile error if the key is missing. This enforces correctness at build time, which is exactly how it should work.Suggestions
🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist
Questions & Observations
bg-brand-navy text-whitematches the project's primary colour tokens. The skip link should feel like part of the app when focused, not a generic browser affordance — this approach is correct.focus:not-sr-onlyactually makes it visible and that no globaloutline: nonereset suppresses the focus indicator.Suggestions
focus:top-4 focus:left-4places it in the top-left corner on focus — this is the standard position and correct for this pattern.🚀 Tobias Wendt — DevOps & Platform Engineer
No infrastructure concerns — single-file layout change with no deployment, config, or Docker impact.
The only CI consideration: the Playwright test for skip link focus behaviour will run against the full stack in the E2E job. That's appropriate — skip link behaviour depends on the real rendered HTML including the Paraglide i18n keys, which requires a running SvelteKit instance. No additional CI setup needed beyond what the E2E job already does.