From a6ee444f3b26bc00594dfc6e59d959da015f3402 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 10:33:50 +0200 Subject: [PATCH 1/6] docs(specs): add focus rings design spec for issue #167 Spec covers the --c-focus-ring token definition, full audit of all 19 affected files, WCAG 2.4.11 analysis, element-by-element mockups (light and dark), and exact CSS/Tailwind diffs ready for implementation. Co-Authored-By: Claude Sonnet 4.6 --- docs/specs/focus-rings-spec.html | 1152 ++++++++++++++++++++++++++++++ 1 file changed, 1152 insertions(+) create mode 100644 docs/specs/focus-rings-spec.html diff --git a/docs/specs/focus-rings-spec.html b/docs/specs/focus-rings-spec.html new file mode 100644 index 00000000..e753560d --- /dev/null +++ b/docs/specs/focus-rings-spec.html @@ -0,0 +1,1152 @@ + + + + + +Focus Rings — Design Spec · Familienarchiv + + + +
+ + +
+
+
+

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 VariableModeValueContrast (typical bg)WCAG
--c-focus-ringLight +
+ + #012851 +
+
14:1 on white · 13:1 on sand (#f0efe9)AAA ✓
--c-focus-ringDark +
+ + #a1dcd8 +
+
14.3:1 on #010e1e · 9.2:1 on #011526AAA ✓
Tailwind utility classring-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 +
+ + + + + + + + + + + + + + + + + + + + +
ElementTailwind classes to ADD / CHANGEReal sizeNotes
Any text input, textarea, selectfocus:outline-none focus:ring-2 focus:ring-focus-ring focus:border-focus-ringring 2pxUse 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 2pxThe 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 updateWhoWhenSection.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 +
+ + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesReal sizeNotes
Any button (primary, ghost, destructive)focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2ring 2px, offset 2pxfocus-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-canvasCritical: 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 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesReal sizeNotes
Header icon button (light + dark)focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ringring 2px, no offsetNo 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-ringring 2pxFiles: AppNav.svelte lines 44, 54, 64, 74, 86, 145, 155, 165, 176
UserMenu avatar + closeRemove focus-visible:ring-accent, add focus-visible:ring-focus-ringring 2pxFile: UserMenu.svelte lines 36, 47
ThemeToggle, LanguageSwitcher, NotificationBellRemove focus-visible:ring-accent, add focus-visible:ring-focus-ringring 2pxFiles: 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 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesReal sizeNotes
Chip close button (×)focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring roundedring 2px, no offsetCURRENTLY: 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 inputfocus:outline-none focus:ring-2 focus:ring-focus-ringring 2pxCURRENTLY: 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 inputfocus:outline-none focus:ring-2 focus:ring-focus-ringring 2pxFile: PersonMultiSelect.svelte:117 — same outline-none focus:ring-0 pattern. Same fix.
Touch target for close buttonmin-h-[44px] min-w-[44px] or ensure chip row is ≥44px tall44×44px minMost 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
+
+
+
+ + +
+
+
+
Ring: 2px navy #012851 · Offset: 2px white gap · Standard Tailwind checkbox pattern
+
+
+
3E — Checkboxes Dark
+
+
+
+ +
+
+
+
Ring: 2px mint #a1dcd8 · Offset: 2px dark canvas gap
+
+
+ +
+
Implementation Reference — Checkboxes + Real values · mockup above is ~55% scale +
+ + + + + + + + + + + + + + + + +
ElementTailwind classesReal sizeNotes
input[type=checkbox]rounded focus:ring-2 focus:ring-focus-ring focus:ring-offset-2ring 2px, offset 2pxCURRENTLY: 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-canvasWithout 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 correctfocus-visible:ring-2 focus-visible:ring-offset-2 with ring-accent. Only change needed: replace ring-accentring-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
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileCurrent ring colorCurrent ring widthLight contrastStatusChange
AppNav.svelte (×9)ring-accentring-21.52:1 on navy bgFail 2.4.11ring-focus-ring (mint on navy = 4.1:1 ✓)
UserMenu.svelte (×2)ring-accentring-21.52:1 on navy bgFail 2.4.11ring-focus-ring
ThemeToggle.sveltering-accentring-21.52:1 on navy bgFail 2.4.11ring-focus-ring
LanguageSwitcher.sveltering-accentring-21.52:1 on navy bgFail 2.4.11ring-focus-ring
NotificationBell.sveltering-accentring-21.52:1 on navy bgFail 2.4.11ring-focus-ring
notifications/+page.svelte (×4)ring-accentring-21.52:1 (white offset)Fail 2.4.11ring-focus-ring
PersonMultiSelect.svelte chip closenone (outline-none, no ring)0 — invisibleFail 2.4.11Add focus-visible:ring-2 focus-visible:ring-focus-ring
PersonMultiSelect.svelte inner inputnone (outline-none focus:ring-0)0 — invisibleFail 2.4.11Add focus:ring-2 focus:ring-focus-ring
TagInput.svelte chip closenone (outline-none, no ring)0 — invisibleFail 2.4.11Add focus-visible:ring-2 focus-visible:ring-focus-ring
TagInput.svelte inner inputnone (outline-none focus:ring-0)0 — invisibleFail 2.4.11Add focus:ring-2 focus:ring-focus-ring
UserGroupsSection.svelte checkboxring-accentdefault (1px)1.52:1 on whiteFail 2.4.11ring-2 ring-focus-ring ring-offset-2
PanelHistory.svelte inputs (×2)ring-accentring-1 (thin)1.52:1 on whiteFail 2.4.11ring-2 ring-focus-ring
MentionEditor.svelte textarearing-accentring-1 (thin)1.52:1 on whiteFail 2.4.11ring-2 ring-focus-ring
PersonTypeahead.svelte input (compact)border-primary onlyno ringborder only, no ringNo ringAdd ring-2 ring-focus-ring
PersonTypeahead.svelte input (standard)ring-accentdefault (1px)1.52:1 on whiteFail 2.4.11ring-2 ring-focus-ring
WhoWhenSection, DescriptionSection, TranscriptionSection inputsring-inkdefault (1px)14:1 on white ✓Color OK Width thinring-2 ring-focus-ring (and add border-focus-ring)
Profile forms, password formsborder-ink only (outline-none, no ring)no ringborder onlyNo ringAdd ring-2 ring-focus-ring alongside border-focus-ring
SearchFilterBar, ConversationFilterBar inputsring-inkdefault (1px)14:1 on white ✓Color OK Width thinring-2 ring-focus-ring
forgot-password/+page.svelte inputring-inkring-1 (thin)14:1 on white ✓Color OK Width thinring-2 ring-focus-ring
+
+ + + +
+
6 Acceptance Criteria
+ +
+
Issue #167 — All items must pass before closing
+
+ + + + + + + + + + + + + + +
+
+
+ +
+ + -- 2.49.1 From 56926efd0326bc8508b2f3c5a1d294655b6b944d Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 11:22:35 +0200 Subject: [PATCH 2/6] test(a11y): add dark mode axe + color-scheme tests for issue #166 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failing test suites that encode the regressions this issue fixes: - accessibility.spec.ts: axe wcag2aa in both prefers-color-scheme:dark and data-theme='dark' — fails because --c-ink-3:#6b7280 on #1a1a1a = 3.2:1 - theme.spec.ts: color-scheme computed property is 'dark' in dark mode — fails because neither dark CSS block sets color-scheme: dark Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/accessibility.spec.ts | 51 ++++++++++++++++++++++++++++++ frontend/e2e/theme.spec.ts | 28 ++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 06ac90ab..c5b23944 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -37,6 +37,57 @@ test.describe('Accessibility — authenticated pages', () => { } }); +test.describe('Accessibility — dark mode (system preference)', () => { + for (const { name, path } of AUTHENTICATED_PAGES) { + test(`${name} page has no wcag2a/wcag2aa violations in prefers-color-scheme: dark`, async ({ + browser + }) => { + const context = await browser.newContext({ + colorScheme: 'dark', + storageState: 'e2e/.auth/user.json' + }); + const page = await context.newPage(); + await page.goto(path); + await page.waitForSelector('[data-hydrated]'); + + const results = await buildAxe(page).analyze(); + + if (results.violations.length > 0) { + const summary = results.violations + .map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`) + .join('\n'); + console.log(`\nAccessibility violations on ${name} (dark/media):\n${summary}`); + } + + await context.close(); + expect(results.violations).toEqual([]); + }); + } +}); + +test.describe('Accessibility — dark mode (manual toggle)', () => { + for (const { name, path } of AUTHENTICATED_PAGES) { + test(`${name} page has no wcag2a/wcag2aa violations with data-theme='dark'`, async ({ + page + }) => { + await page.goto(path); + await page.waitForSelector('[data-hydrated]'); + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + + const results = await buildAxe(page).analyze(); + + if (results.violations.length > 0) { + const summary = results.violations + .map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`) + .join('\n'); + console.log(`\nAccessibility violations on ${name} (dark/manual):\n${summary}`); + } + + expect(results.violations).toEqual([]); + }); + } +}); + test.describe('Accessibility — login page', () => { test.use({ storageState: { cookies: [], origins: [] } }); diff --git a/frontend/e2e/theme.spec.ts b/frontend/e2e/theme.spec.ts index 95b87392..037b9fcf 100644 --- a/frontend/e2e/theme.spec.ts +++ b/frontend/e2e/theme.spec.ts @@ -60,6 +60,34 @@ test.describe('Theme toggle', () => { await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); }); + test('color-scheme is dark when data-theme=dark is set', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + + const colorScheme = await page.evaluate( + () => getComputedStyle(document.documentElement).colorScheme + ); + expect(colorScheme).toBe('dark'); + }); + + test('color-scheme is dark in prefers-color-scheme: dark media', async ({ browser }) => { + const context = await browser.newContext({ + colorScheme: 'dark', + storageState: 'e2e/.auth/user.json' + }); + const page = await context.newPage(); + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + const colorScheme = await page.evaluate( + () => getComputedStyle(document.documentElement).colorScheme + ); + await context.close(); + expect(colorScheme).toBe('dark'); + }); + test('saved theme is applied before first paint (no flash)', async ({ page }) => { // Set dark theme in localStorage before navigating await page.goto('/'); -- 2.49.1 From 7e43bd43a431e62bdb1821055c7b9bc62c59c824 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 11:37:30 +0200 Subject: [PATCH 3/6] feat(dark-mode): replace neutral tokens with navy-tinted palette + fix WCAG AA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace neutral dark tokens (#0d0d0d, #1a1a1a, etc.) with navy-tinted values derived from brand-navy: canvas #010e1e, surface #011526, overlay #011e38, muted #011a30 - Fix --c-ink-3 WCAG AA failure in [data-theme='dark'] block: #6b7280 (3.2:1, fail) → #8b97a5 (7.1:1, AAA ✓) - Add color-scheme: dark to both dark blocks for native OS scrollbar theming - Update PDF viewer tokens to navy palette (bg #010e1e, ctrl #011526, text #f0efe9) - Add --c-header token (#ffffff light / #01335e dark) for independent header surface control; mapped to --color-header in @theme inline - Fix EntityNav contrast: text-white/30 → /50 (heading) and text-white/20 → /50 (inactive count badges) to pass WCAG AA 4.5:1 on bg-brand-navy Closes #166 Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/EntityNav.svelte | 16 +++--- frontend/src/routes/layout.css | 66 ++++++++++++++-------- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/frontend/src/routes/admin/EntityNav.svelte b/frontend/src/routes/admin/EntityNav.svelte index 6af4e062..bffc5b76 100644 --- a/frontend/src/routes/admin/EntityNav.svelte +++ b/frontend/src/routes/admin/EntityNav.svelte @@ -63,7 +63,7 @@ function handleKeydown(event: KeyboardEvent) { > @@ -123,7 +123,7 @@ function handleKeydown(event: KeyboardEvent) { d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> - + {userCount} - + {groupCount} - + {tagCount} -
+
{m.admin_heading()}
@@ -384,7 +384,7 @@ function handleKeydown(event: KeyboardEvent) { /> {userCount} @@ -422,7 +422,7 @@ function handleKeydown(event: KeyboardEvent) { /> {groupCount} @@ -460,7 +460,7 @@ function handleKeydown(event: KeyboardEvent) { /> - + {tagCount} ──── */ -- 2.49.1 From 938a4b07bf6b764dc954e884b8fa319bac777951 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 11:39:37 +0200 Subject: [PATCH 4/6] test(dark-mode): add failing test for --c-header token on header element Header should use bg-header (rgb(1,51,94) = #01335e) in dark mode instead of bg-surface. Currently fails because header still uses bg-surface. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/theme.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/e2e/theme.spec.ts b/frontend/e2e/theme.spec.ts index 037b9fcf..168f4fcf 100644 --- a/frontend/e2e/theme.spec.ts +++ b/frontend/e2e/theme.spec.ts @@ -60,6 +60,20 @@ test.describe('Theme toggle', () => { await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); }); + test('header uses --c-header token background in dark mode', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + + const headerBg = await page.evaluate(() => { + const header = document.querySelector('header'); + return header ? getComputedStyle(header).backgroundColor : null; + }); + // --c-header in dark mode = #01335e → rgb(1, 51, 94) + expect(headerBg).toBe('rgb(1, 51, 94)'); + }); + test('color-scheme is dark when data-theme=dark is set', async ({ page }) => { await page.goto('/'); await page.waitForSelector('[data-hydrated]'); -- 2.49.1 From a9b648454ee9159c3a0e7f4af2619e862c91f327 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 11:53:14 +0200 Subject: [PATCH 5/6] fix(dark-mode): use bg-header on layout header; set --c-header to brand-navy - Change header --c-header dark value from #01335e to #012851 (brand navy): #01335e gave 4.3:1 with ink-3 (WCAG AA fail); #012851 gives 4.99:1 (pass) - Switch header element from bg-surface to bg-header so dark mode uses the independent --c-header token instead of inheriting the surface background - Fix both dark blocks (media query and manual override) to stay in sync Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/theme.spec.ts | 4 ++-- frontend/src/routes/+layout.svelte | 2 +- frontend/src/routes/layout.css | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/e2e/theme.spec.ts b/frontend/e2e/theme.spec.ts index 168f4fcf..cda1f2d0 100644 --- a/frontend/e2e/theme.spec.ts +++ b/frontend/e2e/theme.spec.ts @@ -70,8 +70,8 @@ test.describe('Theme toggle', () => { const header = document.querySelector('header'); return header ? getComputedStyle(header).backgroundColor : null; }); - // --c-header in dark mode = #01335e → rgb(1, 51, 94) - expect(headerBg).toBe('rgb(1, 51, 94)'); + // --c-header in dark mode = #012851 (brand navy) → rgb(1, 40, 81) + expect(headerBg).toBe('rgb(1, 40, 81)'); }); test('color-scheme is dark when data-theme=dark is set', async ({ page }) => { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4ae6c1d1..7a1df195 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -35,7 +35,7 @@ const userInitials = $derived.by(() => {
{#if !isAuthPage} -
+
diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 6b07a3d6..2fcbe95a 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -127,8 +127,8 @@ --c-nav-active: rgba(180, 185, 255, 0.12); - /* Header elevated above canvas for visual prominence */ - --c-header: #01335e; + /* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */ + --c-header: #012851; --c-pdf-bg: #010e1e; --c-pdf-ctrl: #011526; @@ -161,8 +161,8 @@ --c-nav-active: rgba(180, 185, 255, 0.12); - /* Header elevated above canvas for visual prominence */ - --c-header: #01335e; + /* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */ + --c-header: #012851; --c-pdf-bg: #010e1e; --c-pdf-ctrl: #011526; -- 2.49.1 From f70b5ae6bda0f6212a913e4dc6c5d8d35ada6892 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 13:30:00 +0200 Subject: [PATCH 6/6] fix(dark-mode): address PR #168 review blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthHeader: bg-brand-navy → bg-header (semantic token, responds to dark mode) - header.spec.ts: add forgot-password AuthHeader tests (bg + axe) - header.spec.ts: fix BRAND_NAVY comment — references --c-header, not --c-primary Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/header.spec.ts | 24 +++++++++++++++++++++++- frontend/src/routes/AuthHeader.svelte | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/header.spec.ts b/frontend/e2e/header.spec.ts index 8317c742..813316a7 100644 --- a/frontend/e2e/header.spec.ts +++ b/frontend/e2e/header.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; -// #012851 — brand-navy, defined as --c-primary in layout.css +// #012851 — brand-navy, set as --c-header in layout.css (both light and dark mode) const BRAND_NAVY = 'rgb(1, 40, 81)'; test.describe('Header — brand-navy background', () => { @@ -94,3 +94,25 @@ test.describe('Login page — AuthHeader', () => { expect(results.violations).toEqual([]); }); }); + +test.describe('Forgot-password page — AuthHeader', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('forgot-password page has brand-navy header', async ({ page }) => { + await page.goto('/forgot-password'); + + const header = page.locator('header'); + await expect(header).toBeVisible(); + + const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(bg).toBe(BRAND_NAVY); + }); + + test('forgot-password page header passes accessibility audit', async ({ page }) => { + await page.goto('/forgot-password'); + await expect(page.locator('header')).toBeVisible(); + + const results = await new AxeBuilder({ page }).include('header').analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/frontend/src/routes/AuthHeader.svelte b/frontend/src/routes/AuthHeader.svelte index 6e8aafd6..5520bf8f 100644 --- a/frontend/src/routes/AuthHeader.svelte +++ b/frontend/src/routes/AuthHeader.svelte @@ -2,7 +2,7 @@ import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; -
+
-- 2.49.1