# Focus Rings — Design Spec

Most interactive elements currently use browser-default focus rings. There is no `--c-focus-ring` semantic token, leading to 5 different ad-hoc approaches across the codebase. This spec defines a single token, proves WCAG 2.4.11 compliance in both modes, and gives exact Tailwind classes for every element type. Relates to issue #167.

Design Spec
Root cause
No --c-focus-ring token — 5 different ring colors across the codebase
WCAG 2.4.11 failure
ring-accent in light mode (#a1dcd8 on white) = 1.52:1 — fails 3:1 minimum
Light token
#012851 brand-navy — 14:1 on white, 13:1 on sand — WCAG AAA ✓
Dark token
#a1dcd8 brand-mint — 14.3:1 on canvas (#010e1e) — WCAG AAA ✓
📐 Mockup scale notice — all font-size, height, and padding values in the mockup CSS are scaled to ~55% of actual implementation values. Do not copy sizes from mockup CSS. Use the ⚙ Implementation Reference tables after each section.
1 Issue Catalog
Issue 01 · Critical — WCAG 2.4.11 Failure
ring-accent in light mode = 1.52:1 contrast — fails the focus indicator minimum
The header (AppNav, UserMenu, LanguageSwitcher, ThemeToggle, NotificationBell) uses `focus-visible:ring-accent`. In light mode `--c-accent: #a1dcd8`. That mint ring on the white/sand background has a contrast ratio of 1.52:1 — the WCAG 2.4.11 minimum is 3:1 for the focus indicator against adjacent colors.
Notifications page filter pills (`focus-visible:ring-accent focus-visible:ring-offset-2`) have the same failure — the ring-offset is white (#fff), so the contrast is #a1dcd8 vs #fff = 1.52:1.
Fix: Replace `ring-accent` with `ring-focus-ring` throughout. In light mode `--c-focus-ring: #012851` gives 14:1 on white and 13:1 on sand — WCAG AAA.
Issue 02 · Critical — WCAG 2.4.11 Failure
Chip close buttons: focus:outline-none with no ring replacement
`PersonMultiSelect.svelte:94` and `TagInput.svelte:98` apply `focus:outline-none` to the chip × buttons with no replacement focus indicator. These are keyboard-operable interactive controls. Removing the browser outline without a replacement is a hard WCAG 2.4.11 fail — keyboard users cannot see which chip they are about to remove.
Additionally, `TagInput.svelte:125` sets `outline-none focus:ring-0` on the inner text input, also leaving it with zero focus indicator.
Fix: Remove the bare `focus:outline-none` from both chip close buttons and replace with `focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring`. For the TagInput inner input: remove `focus:ring-0` and add `focus:ring-2 focus:ring-focus-ring` (show on focus, not just focus-visible, since this is a text field).
Issue 03 · High
No --c-focus-ring token — 5 different ring colors across the codebase
Current audit of `focus` / `focus-visible` classes across all Svelte files reveals five distinct ring-color decisions:
• `ring-accent` — header elements, PanelHistory, MentionEditor, AppNav, UserGroupsSection
• `ring-ink` — form inputs, textareas, selects (WhoWhenSection, DescriptionSection, TranscriptionSection, SearchFilterBar, ConversationFilterBar, ForgotPassword, profile forms)
• `ring-primary` — PersonTypeahead compact mode
• `ring-black` — PersonTypeahead dropdown (Headless UI default)
• nothing — TagInput inner input, chip close buttons
With no single token to update, switching focus color for dark mode requires editing every file independently — and future components will continue to drift.
Fix: Add `--c-focus-ring` to `layout.css` (light + dark blocks) and `--color-focus-ring: var(--c-focus-ring)` in `@theme inline`. All components then use `ring-focus-ring` — one token to retheme all focus rings at once.
Issue 04 · Medium
Inconsistent ring width and missing ring-offset
Ring widths vary: some elements use `ring-1` (1px, barely visible), some `ring-2` (2px), and form inputs omit the width class entirely (Tailwind 4 default ring is 1px). No element outside the notifications page uses `ring-offset`, so the ring is drawn on top of the element border rather than floating outside it — making it hard to distinguish the focus ring from the border color change.
The correct visual: 2px ring + 2px offset on elements sitting on surface/canvas backgrounds. On the header (dark background), the ring draws directly without offset (ring appears clearly against navy).
Fix: Standardize on `focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2` for all buttons, links, and icon buttons on light backgrounds. For form inputs: `focus:ring-2 focus:ring-focus-ring focus:ring-offset-0` (inputs need the ring to show on click, not just keyboard nav; offset-0 because the ring replaces the border-color signal). For header elements: `focus-visible:ring-2 focus-visible:ring-focus-ring` (no offset — ring reads clearly on navy bg).
2 Token Definition
Semantic Token Values

CSS Variable | Mode | Value | Contrast (typical bg) | WCAG
--- | --- | --- | --- | ---
`--c-focus-ring` | Light | #012851 | 14:1 on white · 13:1 on sand (#f0efe9) | AAA ✓
`--c-focus-ring` | Dark | #a1dcd8 | 14.3:1 on #010e1e · 9.2:1 on #011526 | AAA ✓
Tailwind utility class | `ring-focus-ring` (via `--color-focus-ring: var(--c-focus-ring)`)

Why these values? Light mode reuses `--c-primary` (#012851), which already scores 14:1 on white for text — a free contrast win. Dark mode reuses `--c-primary` dark value (#a1dcd8 brand-mint), which scores 14.3:1 on the darkest canvas. Both exceed WCAG AAA (7:1) and comfortably pass WCAG 2.4.11's 3:1 minimum.
Why not reuse --c-primary?

`--c-primary` is used for button backgrounds and interactive text. If we map `--c-focus-ring` to the same value as `--c-primary`, the token works identically — and that is exactly the right choice here.

The distinction matters for clarity: `--c-focus-ring` is a semantic token with a specific purpose (focus indicators). Even if it resolves to the same hex today, a future redesign can update one without touching the other.

Having an explicit `--c-focus-ring` also makes it immediately clear in component code that a focus style is intentional, not an accidental color reuse.

Do not use `--c-accent` as a focus ring in light mode. `--c-accent` in light mode is #a1dcd8, a mint that scores only 1.52:1 on white backgrounds — decorative use only. This is the root cause of the current WCAG 2.4.11 failures in the header.
3 Element Gallery — Focus States

Each element shown idle (no focus) and focused. Left panel = light mode. Right panel = dark mode. Mockup values are ~55% scale.

3A — Text Inputs & Textareas Light
Idle
Focused (keyboard or click)
Focused + error border
Ring: 2px navy #012851 · Offset: 0px (ring sits flush against border) · Border also changes to focus-ring color on focus
3A — Text Inputs & Textareas Dark
Idle
Focused
Ring: 2px mint #a1dcd8 · Offset: 0px · Border also changes to ring-focus-ring
Implementation Reference — Text Inputs / Textareas / Selects Real values · mockup above is ~55% scale

Element | Tailwind classes to ADD / CHANGE | Real size | Notes
--- | --- | --- | ---
Any text input, textarea, select | `focus:outline-none focus:ring-2 focus:ring-focus-ring focus:border-focus-ring` | ring 2px | Use `focus:` not `focus-visible:` — user must see which text field is active even on mouse click. Remove any `focus:ring-ink`, `focus:ring-accent`, `focus:ring-primary`, `focus:ring-black`. Do NOT add ring-offset — offset-0 is correct for inputs.
Error-state input (focused) | `focus:ring-focus-ring` (ring color stays navy/mint) | ring 2px | The error border color (red-400) is the border — the focus ring is always ring-focus-ring. Two separate visual signals: border = validation state, ring = keyboard position.

Files / components to update

`WhoWhenSection.svelte` · `DescriptionSection.svelte` · `TranscriptionSection.svelte` · `SearchFilterBar.svelte` · `ConversationFilterBar.svelte` (both instances) · `forgot-password/+page.svelte` · `PanelHistory.svelte` · `MentionEditor.svelte` · `UserPasswordSection.svelte` · `UserProfileSection.svelte` · `PersonalInfoForm.svelte` · `PasswordChangeForm.svelte` · `PersonTypeahead.svelte`

3B — Buttons (Primary & Ghost) Light
Primary idle
Primary focused
Ghost focused
Ring: 2px navy #012851 · Offset: 2px (white gap) · focus-visible only
3B — Buttons (Primary & Ghost) Dark
Primary focused
Ghost focused
Ring: 2px mint #a1dcd8 · Offset: 2px (dark canvas gap) · focus-visible only
Implementation Reference — Buttons Real values · mockup above is ~55% scale

Element | Tailwind classes | Real size | Notes
--- | --- | --- | ---
Any button (primary, ghost, destructive) | `focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2` | ring 2px, offset 2px | `focus-visible:` not `focus:` — buttons only need the ring for keyboard navigation, not mouse clicks. Min height 44px for all touch targets.
ring-offset color (light) | Tailwind default `ring-offset-white` or omit (default = white) | — | On light backgrounds the default white offset is correct. On sand background (`bg-canvas`), add `focus-visible:ring-offset-canvas` so the gap matches the page background.
ring-offset color (dark) | `focus-visible:ring-offset-canvas` | — | Critical: without this, the white offset flashes on dark backgrounds. Add `focus-visible:ring-offset-canvas` to all buttons that appear on dark/canvas backgrounds.

3C — Icon Buttons on Header Light (header = navy bg)
Idle
🔔
Focused
🔔
Nav link focused
Dokumente
Idle link
Personen
Ring: 2px mint #a1dcd8 · No offset (ring reads clearly on navy) · focus-visible only
3C — Icon Buttons on Header Dark (header = mid-navy)
Focused
🔔
Nav link focused
Dokumente
Ring: 2px mint #a1dcd8 · 1px navy offset (#01335e) so ring floats · focus-visible only
Implementation Reference — Header Icon Buttons & Nav Links Real values · mockup above is ~55% scale

Element | Tailwind classes | Real size | Notes
--- | --- | --- | ---

Header icon button (light + dark)

`focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring`

ring 2px, no offset

No ring-offset: ring appears directly on navy bg. In light mode, `ring-focus-ring` = navy → same as header bg → use mint instead? No: the navy header IS the background, so the ring colour is always mint (token resolves correctly in both modes). Contrast of mint (#a1dcd8) on navy header (#012851) = 4.1:1 — WCAG AA ✓.

AppNav links (desktop) | Remove `focus-visible:ring-accent`, add `focus-visible:ring-focus-ring` | ring 2px | Files: `AppNav.svelte` lines 44, 54, 64, 74, 86, 145, 155, 165, 176
UserMenu avatar + close | Remove `focus-visible:ring-accent`, add `focus-visible:ring-focus-ring` | ring 2px | File: `UserMenu.svelte` lines 36, 47
ThemeToggle, LanguageSwitcher, NotificationBell | Remove `focus-visible:ring-accent`, add `focus-visible:ring-focus-ring` | ring 2px | Files: `ThemeToggle.svelte:34`, `LanguageSwitcher.svelte:15`, `NotificationBell.svelte:157`

3D — Tag / Person Chips with Close Button Light
Chip — close button focused
Briefe
Chip — close button idle (no focus)
Briefe
Input inside chip container (focused)
Briefe
Weiteres Tag…
Close button: 2px navy ring, no offset. Inner text input: 2px navy ring on the wrapping container.
3D — Tag / Person Chips with Close Button Dark
Chip — close button focused
Briefe
Close button: 2px mint ring, no offset. Touch target: min 44×44px (chip row padding pads to this).
Implementation Reference — Chip Close Buttons & TagInput / PersonMultiSelect Real values · mockup above is ~55% scale

Element | Tailwind classes | Real size | Notes
--- | --- | --- | ---
Chip close button (×) | `focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded` | ring 2px, no offset | CURRENTLY: `focus:outline-none` with no ring — zero visible focus indicator. This is a hard WCAG 2.4.11 fail. Files: `PersonMultiSelect.svelte:94`, `TagInput.svelte:98`.
TagInput inner text input | `focus:outline-none focus:ring-2 focus:ring-focus-ring` | ring 2px | CURRENTLY: `outline-none focus:ring-0` — completely suppressed. Remove both classes, add the focus ring. File: `TagInput.svelte:125`. The ring appears on the input itself (narrow), which is visually fine inside the chip container.
PersonMultiSelect inner input | `focus:outline-none focus:ring-2 focus:ring-focus-ring` | ring 2px | File: `PersonMultiSelect.svelte:117` — same `outline-none focus:ring-0` pattern. Same fix.
Touch target for close button | `min-h-[44px] min-w-[44px]` or ensure chip row is ≥44px tall | 44×44px min | Most commonly undersized element — the × button inside chips is often 20–24px. Pad the chip row to 44px height or use `p-2` on the button itself.

3E — Checkboxes Light
Aktiv Inaktiv (idle)
Ring: 2px navy #012851 · Offset: 2px white gap · Standard Tailwind checkbox pattern
3E — Checkboxes Dark
Aktiv (focused)
Ring: 2px mint #a1dcd8 · Offset: 2px dark canvas gap
Implementation Reference — Checkboxes Real values · mockup above is ~55% scale

Element | Tailwind classes | Real size | Notes
--- | --- | --- | ---
input[type=checkbox] | `rounded focus:ring-2 focus:ring-focus-ring focus:ring-offset-2` | ring 2px, offset 2px | CURRENTLY: `focus:ring-accent` — fails light mode. File: `UserGroupsSection.svelte:21`. Tailwind does not need explicit `focus:outline-none` for checkboxes — it resets the outline via the preflight layer.
ring-offset-color (dark) | `focus:ring-offset-canvas` | — | Without this, the 2px ring offset shows as white on dark backgrounds. Add alongside the ring classes.

3F — Filter Pills (Notifications Page)
Current pattern is structurally correct — `focus-visible:ring-2 focus-visible:ring-offset-2` with `ring-accent`. Only change needed: replace `ring-accent` → `ring-focus-ring`.
File: `notifications/+page.svelte` lines 114, 129, 152, 167 — four pill variants. Each currently has `focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2`.
4 CSS Implementation — Exact Diff

Apply these changes first — all component-level fixes depend on the token existing.

frontend/src/routes/layout.css — token additions

    /* ─── 3. Semantic tokens ───────────────────────────────────────────────────── */
    @theme inline {
      /* ... existing tokens ... */
    +
    +  /* Focus ring */
    +  --color-focus-ring: var(--c-focus-ring);
    }

    /* ─── 4. Light mode (default) ─────────────────────────────────────────────── */
    :root {
      /* ... existing tokens ... */
    +
    +  /* Focus ring — brand-navy on white/sand = 14:1 WCAG AAA */
    +  --c-focus-ring: #012851;
    }

    /* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
    @media (prefers-color-scheme: dark) {
      :root:not([data-theme='light']) {
        /* ... existing tokens ... */
    +
    +    /* Focus ring — brand-mint on dark canvas = 14.3:1 WCAG AAA */
    +    --c-focus-ring: #a1dcd8;
      }
    }

    /* Manual dark override */
    :root[data-theme='dark'] {
      /* ... existing tokens ... */
    +
    +  --c-focus-ring: #a1dcd8;
    }

Component changes — ring-accent → ring-focus-ring (header elements)

    AppNav.svelte (lines 44, 54, 64, 74, 86, 145, 155, 165, 176):
    - focus-visible:ring-accent
    + focus-visible:ring-focus-ring

    UserMenu.svelte (lines 36, 47):
    - focus-visible:ring-accent
    + focus-visible:ring-focus-ring

    ThemeToggle.svelte (line 34):
    - focus-visible:ring-accent
    + focus-visible:ring-focus-ring

    LanguageSwitcher.svelte (line 15):
    - focus-visible:ring-accent
    + focus-visible:ring-focus-ring

    NotificationBell.svelte (line 157):
    - focus-visible:ring-accent
    + focus-visible:ring-focus-ring

    notifications/+page.svelte (lines 114, 129, 152, 167):
    - focus-visible:ring-accent
    + focus-visible:ring-focus-ring

Component changes — form inputs (ring-ink → ring-focus-ring + add ring-2)

    WhoWhenSection.svelte, DescriptionSection.svelte, TranscriptionSection.svelte,
    SearchFilterBar.svelte, ConversationFilterBar.svelte (×2),
    forgot-password/+page.svelte, UserPasswordSection.svelte, UserProfileSection.svelte,
    PersonalInfoForm.svelte, PasswordChangeForm.svelte:
    - focus:border-ink focus:ring-ink
    - (or) focus:border-ink focus:outline-none
    - (or) focus:border-ink focus:ring-1 focus:ring-ink
    + focus:outline-none focus:ring-2 focus:ring-focus-ring focus:border-focus-ring

    PanelHistory.svelte (lines 305, 320):
    - focus:ring-1 focus:ring-accent focus:outline-none
    + focus:outline-none focus:ring-2 focus:ring-focus-ring

    MentionEditor.svelte (line 190):
    - focus:ring-1 focus:ring-accent focus:outline-none
    + focus:outline-none focus:ring-2 focus:ring-focus-ring

Component changes — critical fixes (outline suppressed with no replacement)

    PersonMultiSelect.svelte (line 94) — chip close button:
    - class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
    + class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded"

    PersonMultiSelect.svelte (line 117) — inner text input:
    - class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
    + class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-2 focus:ring-focus-ring"

    TagInput.svelte (line 98) — chip close button:
    - class="text-ink/50 hover:text-red-500 focus:outline-none"
    + class="text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded"

    TagInput.svelte (line 125) — inner text input:
    - class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
    + class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-2 focus:ring-focus-ring"

    UserGroupsSection.svelte (line 21) — checkbox:
    - class="rounded border-line text-ink focus:ring-accent"
    + class="rounded border-line text-ink focus:ring-2 focus:ring-focus-ring focus:ring-offset-2"

    PersonTypeahead.svelte (lines 157–158) — both input variants:
    - focus:border-primary focus:outline-none
    - focus:border-accent focus:ring-accent
    + focus:outline-none focus:ring-2 focus:ring-focus-ring focus:border-focus-ring

    PersonTypeahead.svelte (line 163) — dropdown list:
    - ring-1 ring-black
    + ring-1 ring-line   (decorative shadow border — not a focus ring, leave as semantic border color)

Tailwind 4 ring default is 1px (changed from 3px in v3). Always specify `ring-2` explicitly — never rely on the default. Any component that has only `focus:ring` without a width is getting a 1px ring, which is too thin for WCAG 2.4.11 (the indicator perimeter area requirement).
5 Full Audit — Current vs Target

File | Current ring color | Current ring width | Light contrast | Status | Change
--- | --- | --- | --- | --- | ---
AppNav.svelte (×9) | `ring-accent` | `ring-2` ✓ | 1.52:1 on navy bg | Fail 2.4.11 | → `ring-focus-ring` (mint on navy = 4.1:1 ✓)
UserMenu.svelte (×2) | `ring-accent` | `ring-2` ✓ | 1.52:1 on navy bg | Fail 2.4.11 | → `ring-focus-ring`
ThemeToggle.svelte | `ring-accent` | `ring-2` ✓ | 1.52:1 on navy bg | Fail 2.4.11 | → `ring-focus-ring`
LanguageSwitcher.svelte | `ring-accent` | `ring-2` ✓ | 1.52:1 on navy bg | Fail 2.4.11 | → `ring-focus-ring`
NotificationBell.svelte | `ring-accent` | `ring-2` ✓ | 1.52:1 on navy bg | Fail 2.4.11 | → `ring-focus-ring`
notifications/+page.svelte (×4) | `ring-accent` | `ring-2` ✓ | 1.52:1 (white offset) | Fail 2.4.11 | → `ring-focus-ring`
PersonMultiSelect.svelte chip close | none (outline-none, no ring) | — | 0 — invisible | Fail 2.4.11 | Add `focus-visible:ring-2 focus-visible:ring-focus-ring`
PersonMultiSelect.svelte inner input | none (outline-none focus:ring-0) | — | 0 — invisible | Fail 2.4.11 | Add `focus:ring-2 focus:ring-focus-ring`
TagInput.svelte chip close | none (outline-none, no ring) | — | 0 — invisible | Fail 2.4.11 | Add `focus-visible:ring-2 focus-visible:ring-focus-ring`
TagInput.svelte inner input | none (outline-none focus:ring-0) | — | 0 — invisible | Fail 2.4.11 | Add `focus:ring-2 focus:ring-focus-ring`
UserGroupsSection.svelte checkbox | `ring-accent` | default (1px) | 1.52:1 on white | Fail 2.4.11 | → `ring-2 ring-focus-ring ring-offset-2`
PanelHistory.svelte inputs (×2) | `ring-accent` | `ring-1` (thin) | 1.52:1 on white | Fail 2.4.11 | → `ring-2 ring-focus-ring`
MentionEditor.svelte textarea | `ring-accent` | `ring-1` (thin) | 1.52:1 on white | Fail 2.4.11 | → `ring-2 ring-focus-ring`
PersonTypeahead.svelte input (compact) | `border-primary` only | no ring | border only, no ring | No ring | Add `ring-2 ring-focus-ring`
PersonTypeahead.svelte input (standard) | `ring-accent` | default (1px) | 1.52:1 on white | Fail 2.4.11 | → `ring-2 ring-focus-ring`
WhoWhenSection, DescriptionSection, TranscriptionSection inputs | `ring-ink` | default (1px) | 14:1 on white ✓ | Color OK Width thin | → `ring-2 ring-focus-ring` (and add `border-focus-ring`)
Profile forms, password forms | `border-ink` only (outline-none, no ring) | no ring | border only | No ring | Add `ring-2 ring-focus-ring` alongside `border-focus-ring`
SearchFilterBar, ConversationFilterBar inputs | `ring-ink` | default (1px) | 14:1 on white ✓ | Color OK Width thin | → `ring-2 ring-focus-ring`
forgot-password/+page.svelte input | `ring-ink` | `ring-1` (thin) | 14:1 on white ✓ | Color OK Width thin | → `ring-2 ring-focus-ring`

6 Acceptance Criteria
Issue #167 — All items must pass before closing
`--c-focus-ring` token defined in `layout.css` in all three blocks: `:root`, `@media (prefers-color-scheme: dark) :root:not([data-theme='light'])`, and `:root[data-theme='dark']` `--color-focus-ring: var(--c-focus-ring)` added to `@theme inline` block All 5 header components (AppNav, UserMenu, ThemeToggle, LanguageSwitcher, NotificationBell) updated from `ring-accent` → `ring-focus-ring` All 4 notifications page filter pills updated from `ring-accent` → `ring-focus-ring` TagInput chip close button: `focus:outline-none` replaced with `focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring` TagInput inner text input: `focus:ring-0` removed, `focus:ring-2 focus:ring-focus-ring` added PersonMultiSelect chip close button: same fix as TagInput close button PersonMultiSelect inner input: `focus:ring-0` removed, `focus:ring-2 focus:ring-focus-ring` added All form inputs (text, textarea, select) updated to `ring-2 ring-focus-ring` — no remaining `ring-ink`, `ring-accent`, `ring-primary`, `ring-black`, or bare `ring-1` UserGroupsSection checkbox updated to `ring-2 ring-focus-ring ring-offset-2` Keyboard-navigate all major pages in light mode — every focusable element shows a visible navy ring at ≥ 3:1 contrast against its background Keyboard-navigate all major pages in dark mode — every focusable element shows a visible mint ring at ≥ 3:1 contrast against its background Tab through TagInput and PersonMultiSelect — chip close buttons show a visible focus ring when reached by keyboard No element in the app has `focus:outline-none` or `outline: none` without a corresponding `focus-visible:ring-*` or `focus:ring-*` replacement