fix(a11y): add skip-to-main-content link in layout for keyboard navigation #117

Open
opened 2026-03-27 17:53:51 +01:00 by marcel · 6 comments
Owner

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):

<!-- +layout.svelte — first element inside <body> -->
<a
  href="#main-content"
  class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-brand-navy focus:text-white focus:rounded"
>
  {m.skip_to_main_content()}
</a>

<!-- Main content area -->
<main id="main-content" tabindex="-1">
  {@render children()}
</main>

Add the translation key skip_to_main_content to de.json, en.json, es.json.

Acceptance Criteria

  • First Tab press on any page reveals the skip link
  • Activating the link moves focus to #main-content
  • Link is invisible when not focused
  • Translation key exists in all three locales
## 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): ```svelte <!-- +layout.svelte — first element inside <body> --> <a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-brand-navy focus:text-white focus:rounded" > {m.skip_to_main_content()} </a> <!-- Main content area --> <main id="main-content" tabindex="-1"> {@render children()} </main> ``` Add the translation key `skip_to_main_content` to `de.json`, `en.json`, `es.json`. ## Acceptance Criteria - First Tab press on any page reveals the skip link - Activating the link moves focus to `#main-content` - Link is invisible when not focused - Translation key exists in all three locales
marcel added the bugui labels 2026-03-31 20:49:41 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

  • The Svelte code in the issue is correct and idiomatic. The sr-only focus:not-sr-only Tailwind pattern is the standard implementation — no surprises here.
  • tabindex="-1" on <main> is required: without it, programmatic focus() (which the browser does when following the #main-content anchor) won't work on non-interactive elements in all browsers. This is not optional.
  • The Paraglide key 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

  • Implementation order: (1) add the translation keys to de/en/es JSON, (2) run npm run generate:api if needed, (3) implement the Svelte, (4) run npm run check.
  • Write a Playwright test: await page.keyboard.press('Tab') on any route → assert the skip link is visible → await page.keyboard.press('Enter') → assert page.locator('#main-content') has focus.
  • Verify the link is genuinely the first focusable element in the rendered HTML. If +layout.svelte has any <link rel="prefetch"> or other elements at the top of <body>, the skip link might not be first in tab order.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations - The Svelte code in the issue is correct and idiomatic. The `sr-only focus:not-sr-only` Tailwind pattern is the standard implementation — no surprises here. - `tabindex="-1"` on `<main>` is required: without it, programmatic `focus()` (which the browser does when following the `#main-content` anchor) won't work on non-interactive elements in all browsers. This is not optional. - The Paraglide key `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 - Implementation order: (1) add the translation keys to de/en/es JSON, (2) run `npm run generate:api` if needed, (3) implement the Svelte, (4) run `npm run check`. - Write a Playwright test: `await page.keyboard.press('Tab')` on any route → assert the skip link is visible → `await page.keyboard.press('Enter')` → assert `page.locator('#main-content')` has focus. - Verify the link is genuinely the *first* focusable element in the rendered HTML. If `+layout.svelte` has any `<link rel="prefetch">` or other elements at the top of `<body>`, the skip link might not be first in tab order.
Author
Owner

🔒 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 of tabindex="-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:absolute positioning 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.

## 🔒 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 of `tabindex="-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:absolute` positioning 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.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Test Strategy

Skip links require a real browser and keyboard simulation — Playwright only, no Vitest.

E2E — Playwright:

test('skip link appears on first Tab press', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('link', { name: /skip to main content/i })).not.toBeVisible();
  await page.keyboard.press('Tab');
  await expect(page.getByRole('link', { name: /skip to main content/i })).toBeVisible();
});

test('skip link moves focus to main content', async ({ page }) => {
  await page.goto('/');
  await page.keyboard.press('Tab');
  await page.keyboard.press('Enter');
  await expect(page.locator('#main-content')).toBeFocused();
});

test('skip link works in all locales', async ({ page }) => {
  for (const locale of ['/', '/en/', '/es/']) {
    await page.goto(locale);
    await page.keyboard.press('Tab');
    const link = page.locator('a[href="#main-content"]');
    await expect(link).toBeVisible();
  }
});

axe check:

test('layout has no WCAG bypass-blocks violations', async ({ page }) => {
  await page.goto('/');
  await checkA11y(page, undefined, {
    runOnly: { type: 'rule', values: ['bypass'] }
  });
});

Observations

  • This test suite should run on every PR since it lives in +layout.svelte — one change breaks it everywhere.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Test Strategy Skip links require a real browser and keyboard simulation — Playwright only, no Vitest. **E2E — Playwright:** ```typescript test('skip link appears on first Tab press', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('link', { name: /skip to main content/i })).not.toBeVisible(); await page.keyboard.press('Tab'); await expect(page.getByRole('link', { name: /skip to main content/i })).toBeVisible(); }); test('skip link moves focus to main content', async ({ page }) => { await page.goto('/'); await page.keyboard.press('Tab'); await page.keyboard.press('Enter'); await expect(page.locator('#main-content')).toBeFocused(); }); test('skip link works in all locales', async ({ page }) => { for (const locale of ['/', '/en/', '/es/']) { await page.goto(locale); await page.keyboard.press('Tab'); const link = page.locator('a[href="#main-content"]'); await expect(link).toBeVisible(); } }); ``` **axe check:** ```typescript test('layout has no WCAG bypass-blocks violations', async ({ page }) => { await page.goto('/'); await checkA11y(page, undefined, { runOnly: { type: 'rule', values: ['bypass'] } }); }); ``` ### Observations - This test suite should run on every PR since it lives in `+layout.svelte` — one change breaks it everywhere.
Author
Owner

🏗️ Markus Keller — Application Architect

Questions & Observations

  • Trivial change, correct approach. +layout.svelte is 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.
  • The i18n key must exist in all three locale files before the component references it. Paraglide generates typed message functions — 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

  • No architectural escalation. Ship this in the same pass as #114 (accessible buttons) since both are WCAG fixes touching the document viewer and layout respectively. They're independent changes but related in purpose — batching them in one PR is reasonable.
  • WCAG 2.4.1 is a Level A criterion. It's not optional for any public-facing or multi-user application.
## 🏗️ Markus Keller — Application Architect ### Questions & Observations - **Trivial change, correct approach.** `+layout.svelte` is 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. - **The i18n key must exist in all three locale files before the component references it.** Paraglide generates typed message functions — `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 - No architectural escalation. Ship this in the same pass as #114 (accessible buttons) since both are WCAG fixes touching the document viewer and layout respectively. They're independent changes but related in purpose — batching them in one PR is reasonable. - WCAG 2.4.1 is a Level A criterion. It's not optional for any public-facing or multi-user application.
Author
Owner

🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist

Questions & Observations

  • This is a WCAG Level A requirement (2.4.1 — Bypass Blocks). It's not optional. Every keyboard and screen reader user on every page is affected by its absence.
  • The proposed styling is brand-consistent. bg-brand-navy text-white matches 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.
  • Visible focus state matters here more than anywhere else. The skip link only exists for keyboard users — if it appears but has no visible focus ring, it's useless. Verify focus:not-sr-only actually makes it visible and that no global outline: none reset suppresses the focus indicator.

Suggestions

  • Translation strings for all three locales:
    • DE: "Zum Hauptinhalt springen"
    • EN: "Skip to main content"
    • ES: "Saltar al contenido principal"
  • Position: focus:top-4 focus:left-4 places it in the top-left corner on focus — this is the standard position and correct for this pattern.
  • After implementation, manually tab through the layout at 320px on a mobile viewport to confirm the link doesn't cause layout shift when it appears.
## 🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist ### Questions & Observations - **This is a WCAG Level A requirement** (2.4.1 — Bypass Blocks). It's not optional. Every keyboard and screen reader user on every page is affected by its absence. - **The proposed styling is brand-consistent.** `bg-brand-navy text-white` matches 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. - **Visible focus state matters here more than anywhere else.** The skip link only exists for keyboard users — if it appears but has no visible focus ring, it's useless. Verify `focus:not-sr-only` actually makes it visible and that no global `outline: none` reset suppresses the focus indicator. ### Suggestions - Translation strings for all three locales: - DE: *"Zum Hauptinhalt springen"* - EN: *"Skip to main content"* - ES: *"Saltar al contenido principal"* - Position: `focus:top-4 focus:left-4` places it in the top-left corner on focus — this is the standard position and correct for this pattern. - After implementation, manually tab through the layout at 320px on a mobile viewport to confirm the link doesn't cause layout shift when it appears.
Author
Owner

🚀 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.

## 🚀 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.
marcel added this to the Demo Day — family get-together milestone 2026-04-24 13:35:17 +02:00
Sign in to join this conversation.
No Label bug ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#117