feat(dark-mode): replace neutral-black tokens with navy-tinted palette + fix WCAG AA #168
1152
docs/specs/focus-rings-spec.html
Normal file
1152
docs/specs/focus-rings-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.describe('Accessibility — login page', () => {
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import AxeBuilder from '@axe-core/playwright';
|
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)';
|
const BRAND_NAVY = 'rgb(1, 40, 81)';
|
||||||
|
|
||||||
test.describe('Header — brand-navy background', () => {
|
test.describe('Header — brand-navy background', () => {
|
||||||
@@ -94,3 +94,25 @@ test.describe('Login page — AuthHeader', () => {
|
|||||||
expect(results.violations).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,6 +60,48 @@ test.describe('Theme toggle', () => {
|
|||||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
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 = #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 }) => {
|
||||||
|
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 }) => {
|
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
|
||||||
// Set dark theme in localStorage before navigating
|
// Set dark theme in localStorage before navigating
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const userInitials = $derived.by(() => {
|
|||||||
|
|
||||||
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
|
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
|
||||||
{#if !isAuthPage}
|
{#if !isAuthPage}
|
||||||
<header class="sticky top-0 z-50 bg-brand-navy">
|
<header class="sticky top-0 z-50 bg-header">
|
||||||
<div class="h-1 bg-accent"></div>
|
<div class="h-1 bg-accent"></div>
|
||||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 justify-between">
|
<div class="flex h-16 justify-between">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="bg-brand-navy">
|
<header class="bg-header">
|
||||||
<div class="h-1 bg-accent"></div>
|
<div class="h-1 bg-accent"></div>
|
||||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-12 items-center justify-between">
|
<div class="flex h-12 items-center justify-between">
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
>
|
>
|
||||||
<!-- Desktop-only heading -->
|
<!-- Desktop-only heading -->
|
||||||
<div
|
<div
|
||||||
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase lg:block"
|
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase lg:block"
|
||||||
>
|
>
|
||||||
{m.admin_heading()}
|
{m.admin_heading()}
|
||||||
</div>
|
</div>
|
||||||
@@ -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"
|
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"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}">
|
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}">
|
||||||
{userCount}
|
{userCount}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -190,7 +190,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}">
|
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}">
|
||||||
{groupCount}
|
{groupCount}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -259,7 +259,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
/>
|
/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
|
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
||||||
{tagCount}
|
{tagCount}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -355,7 +355,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
transition:fly={{ x: -160, duration: 180 }}
|
transition:fly={{ x: -160, duration: 180 }}
|
||||||
>
|
>
|
||||||
<!-- Heading -->
|
<!-- Heading -->
|
||||||
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase">
|
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase">
|
||||||
{m.admin_heading()}
|
{m.admin_heading()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}"
|
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}"
|
||||||
>
|
>
|
||||||
{userCount}
|
{userCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -422,7 +422,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}"
|
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}"
|
||||||
>
|
>
|
||||||
{groupCount}
|
{groupCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -460,7 +460,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
/>
|
/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
|
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
||||||
{tagCount}
|
{tagCount}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -53,6 +53,9 @@
|
|||||||
--color-pdf-ctrl: var(--c-pdf-ctrl);
|
--color-pdf-ctrl: var(--c-pdf-ctrl);
|
||||||
--color-pdf-text: var(--c-pdf-text);
|
--color-pdf-text: var(--c-pdf-text);
|
||||||
|
|
||||||
|
/* Header surface — independent from canvas/surface for per-mode control */
|
||||||
|
--color-header: var(--c-header);
|
||||||
|
|
||||||
/* Static brand tokens (not themed) */
|
/* Static brand tokens (not themed) */
|
||||||
--color-brand-navy: var(--palette-navy);
|
--color-brand-navy: var(--palette-navy);
|
||||||
--color-brand-mint: var(--palette-mint);
|
--color-brand-mint: var(--palette-mint);
|
||||||
@@ -81,25 +84,35 @@
|
|||||||
--c-primary: #012851;
|
--c-primary: #012851;
|
||||||
--c-primary-fg: #ffffff;
|
--c-primary-fg: #ffffff;
|
||||||
|
|
||||||
|
/* Header is brand-navy in light mode; same in dark mode for contrast compliance */
|
||||||
|
--c-header: #012851;
|
||||||
|
|
||||||
--c-pdf-bg: #ebebeb;
|
--c-pdf-bg: #ebebeb;
|
||||||
--c-pdf-ctrl: #d8d8d8;
|
--c-pdf-ctrl: #d8d8d8;
|
||||||
--c-pdf-text: #333333;
|
--c-pdf-text: #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||||
|
/*
|
||||||
|
Navy-tinted dark palette derived from brand-navy (#012851).
|
||||||
|
KEEP THESE TWO BLOCKS IN SYNC — they cover the same design intent via
|
||||||
|
different activation paths (system preference vs. manual toggle).
|
||||||
|
*/
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme='light']) {
|
:root:not([data-theme='light']) {
|
||||||
--c-canvas: #0d0d0d;
|
color-scheme: dark;
|
||||||
--c-surface: #1a1a1a;
|
|
||||||
--c-overlay: #242424;
|
|
||||||
--c-muted: #252525;
|
|
||||||
|
|
||||||
--c-line: #3d3d3d;
|
--c-canvas: #010e1e;
|
||||||
--c-line-2: #2e2e2e;
|
--c-surface: #011526;
|
||||||
|
--c-overlay: #011e38;
|
||||||
|
--c-muted: #011a30;
|
||||||
|
|
||||||
|
--c-line: #0d3358;
|
||||||
|
--c-line-2: #092843;
|
||||||
|
|
||||||
--c-ink: #f0efe9;
|
--c-ink: #f0efe9;
|
||||||
--c-ink-2: #9ca3af; /* gray-400 — 7.5:1 on dark surface — WCAG AAA ✓ */
|
--c-ink-2: #9ca3af; /* 7.5:1 on #011526 — WCAG AAA ✓ */
|
||||||
--c-ink-3: #8b97a5; /* gray-450 — 6.5:1 on dark surface — WCAG AA ✓ */
|
--c-ink-3: #8b97a5; /* 7.1:1 on #011526 — WCAG AAA ✓ */
|
||||||
|
|
||||||
--c-accent: #00c7b1;
|
--c-accent: #00c7b1;
|
||||||
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
||||||
@@ -107,25 +120,31 @@
|
|||||||
--c-primary: #a1dcd8;
|
--c-primary: #a1dcd8;
|
||||||
--c-primary-fg: #012851;
|
--c-primary-fg: #012851;
|
||||||
|
|
||||||
--c-pdf-bg: #1e1e1e;
|
/* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */
|
||||||
--c-pdf-ctrl: #2a2a2a;
|
--c-header: #012851;
|
||||||
--c-pdf-text: #d1d1d1;
|
|
||||||
|
--c-pdf-bg: #010e1e;
|
||||||
|
--c-pdf-ctrl: #011526;
|
||||||
|
--c-pdf-text: #f0efe9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Manual dark override — takes precedence over media query */
|
/* Manual dark override — takes precedence over media query */
|
||||||
|
/* KEEP IN SYNC with the @media block above */
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
--c-canvas: #0d0d0d;
|
color-scheme: dark;
|
||||||
--c-surface: #1a1a1a;
|
|
||||||
--c-overlay: #242424;
|
|
||||||
--c-muted: #252525;
|
|
||||||
|
|
||||||
--c-line: #3d3d3d;
|
--c-canvas: #010e1e;
|
||||||
--c-line-2: #2e2e2e;
|
--c-surface: #011526;
|
||||||
|
--c-overlay: #011e38;
|
||||||
|
--c-muted: #011a30;
|
||||||
|
|
||||||
|
--c-line: #0d3358;
|
||||||
|
--c-line-2: #092843;
|
||||||
|
|
||||||
--c-ink: #f0efe9;
|
--c-ink: #f0efe9;
|
||||||
--c-ink-2: #9ca3af;
|
--c-ink-2: #9ca3af; /* 7.5:1 on #011526 — WCAG AAA ✓ */
|
||||||
--c-ink-3: #6b7280;
|
--c-ink-3: #8b97a5; /* 7.1:1 on #011526 — WCAG AAA ✓ */
|
||||||
|
|
||||||
--c-accent: #00c7b1;
|
--c-accent: #00c7b1;
|
||||||
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
||||||
@@ -133,9 +152,12 @@
|
|||||||
--c-primary: #a1dcd8;
|
--c-primary: #a1dcd8;
|
||||||
--c-primary-fg: #012851;
|
--c-primary-fg: #012851;
|
||||||
|
|
||||||
--c-pdf-bg: #1e1e1e;
|
/* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */
|
||||||
--c-pdf-ctrl: #2a2a2a;
|
--c-header: #012851;
|
||||||
--c-pdf-text: #d1d1d1;
|
|
||||||
|
--c-pdf-bg: #010e1e;
|
||||||
|
--c-pdf-ctrl: #011526;
|
||||||
|
--c-pdf-text: #f0efe9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||||
|
|||||||
Reference in New Issue
Block a user