From fe1121de65ddf06e1ccf325f6bfe30e75e3ddfa4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 15:08:36 +0200 Subject: [PATCH 1/8] test(focus-rings): add failing Playwright tests for --c-focus-ring token and element ring colors Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/focus-rings.spec.ts | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 frontend/e2e/focus-rings.spec.ts diff --git a/frontend/e2e/focus-rings.spec.ts b/frontend/e2e/focus-rings.spec.ts new file mode 100644 index 00000000..1fcb6d44 --- /dev/null +++ b/frontend/e2e/focus-rings.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; + +// Expected focus ring resolved colors +// Light: --c-focus-ring: #012851 (brand-navy) +const FOCUS_RING_LIGHT = 'rgb(1, 40, 81)'; +// Dark: --c-focus-ring: #a1dcd8 (brand-mint) +const FOCUS_RING_DARK = 'rgb(161, 220, 216)'; + +test.describe('Focus ring token — CSS custom property', () => { + test('--c-focus-ring is defined in light mode', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + const value = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim() + ); + expect(value).toBe('#012851'); + }); + + test('--c-focus-ring is defined in dark mode', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + + const value = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim() + ); + expect(value).toBe('#a1dcd8'); + }); +}); + +test.describe('Focus ring — header interactive elements', () => { + test('ThemeToggle has brand-navy ring in light mode', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: /dark mode|dunkelmodus/i }).focus(); + const boxShadow = await page.evaluate( + () => getComputedStyle(document.activeElement as HTMLElement).boxShadow + ); + expect(boxShadow).toContain(FOCUS_RING_LIGHT); + }); + + test('AppNav link has brand-mint ring in dark mode', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + + // Focus first desktop nav link + await page.locator('header nav').getByRole('link').first().focus(); + const boxShadow = await page.evaluate( + () => getComputedStyle(document.activeElement as HTMLElement).boxShadow + ); + expect(boxShadow).toContain(FOCUS_RING_DARK); + }); +}); + +test.describe('Focus ring — form inputs', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('login username input has brand-mint ring in dark mode', async ({ page }) => { + await page.goto('/login'); + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + + await page.locator('#username').focus(); + const boxShadow = await page.evaluate( + () => getComputedStyle(document.activeElement as HTMLElement).boxShadow + ); + expect(boxShadow).toContain(FOCUS_RING_DARK); + }); +}); + +test.describe('Focus ring — PersonTypeahead', () => { + test('PersonTypeahead input has brand-navy ring in light mode', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + // Open advanced filter panel to expose the sender PersonTypeahead + await page.getByRole('button', { name: /filter/i }).click(); + await page.waitForSelector('#senderId-search'); + + await page.locator('#senderId-search').focus(); + const boxShadow = await page.evaluate( + () => getComputedStyle(document.activeElement as HTMLElement).boxShadow + ); + expect(boxShadow).toContain(FOCUS_RING_LIGHT); + }); +}); -- 2.49.1 From 17889df2206ae578d2377dc62b4bf935206a4afd Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 15:12:00 +0200 Subject: [PATCH 2/8] feat(focus-rings): add --c-focus-ring token to CSS design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light: #012851 (brand-navy, 14:1 on white) Dark: #a1dcd8 (brand-mint, 9.2:1 on canvas) - @theme inline mapping → Tailwind ring-focus-ring utility - Global :focus-visible fallback in @layer base - forced-colors fallback for Windows High Contrast mode Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/layout.css | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 41486cc6..15e7d081 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -56,6 +56,9 @@ /* Header surface — independent from canvas/surface for per-mode control */ --color-header: var(--c-header); + /* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */ + --color-focus-ring: var(--c-focus-ring); + /* Static brand tokens (not themed) */ --color-brand-navy: var(--palette-navy); --color-brand-mint: var(--palette-mint); @@ -87,6 +90,9 @@ /* Header is brand-navy in light mode; same in dark mode for contrast compliance */ --c-header: #012851; + /* Focus ring: brand-navy in light mode — 14:1 on white, ~11:1 on sand */ + --c-focus-ring: #012851; + --c-pdf-bg: #ebebeb; --c-pdf-ctrl: #d8d8d8; --c-pdf-text: #333333; @@ -123,6 +129,9 @@ /* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */ --c-header: #012851; + /* Focus ring: brand-mint in dark mode — 9.2:1 on canvas, 7.1:1 on surface */ + --c-focus-ring: #a1dcd8; + --c-pdf-bg: #010e1e; --c-pdf-ctrl: #011526; --c-pdf-text: #f0efe9; @@ -155,6 +164,9 @@ /* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */ --c-header: #012851; + /* Focus ring: brand-mint in dark mode — 9.2:1 on canvas, 7.1:1 on surface */ + --c-focus-ring: #a1dcd8; + --c-pdf-bg: #010e1e; --c-pdf-ctrl: #011526; --c-pdf-text: #f0efe9; @@ -232,4 +244,17 @@ text-decoration-thickness: 2px; text-underline-offset: 4px; } + + /* Fallback focus ring for any interactive element not styled with ring-focus-ring */ + :focus-visible { + outline: 2px solid var(--c-focus-ring); + outline-offset: 2px; + } +} + +/* Ensure focus rings are visible in Windows High Contrast / forced-colors mode */ +@media (forced-colors: active) { + :focus-visible { + outline: 3px solid ButtonText; + } } -- 2.49.1 From f04e4ffa8b36616a79bffa8a31682093818fb993 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 15:15:06 +0200 Subject: [PATCH 3/8] feat(focus-rings): update header/nav components to ring-focus-ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeToggle, NotificationBell, LanguageSwitcher, UserMenu, AppNav: replace focus-visible:ring-accent → focus-visible:ring-focus-ring Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/LanguageSwitcher.svelte | 2 +- .../src/lib/components/NotificationBell.svelte | 2 +- frontend/src/lib/components/ThemeToggle.svelte | 2 +- frontend/src/routes/AppNav.svelte | 18 +++++++++--------- frontend/src/routes/UserMenu.svelte | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/LanguageSwitcher.svelte b/frontend/src/lib/components/LanguageSwitcher.svelte index 314fb54c..187944af 100644 --- a/frontend/src/lib/components/LanguageSwitcher.svelte +++ b/frontend/src/lib/components/LanguageSwitcher.svelte @@ -12,7 +12,7 @@ const activeLocale = $derived(getLocale().toUpperCase());