Compare commits

..

28 Commits

Author SHA1 Message Date
Marcel
527d174e9c fix(focus-rings): remove broken [&_input]:focus selectors and fix error state focus-visible
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- Strip malformed [[&_input]:focus:*] class fragments from PersonTypeahead
  wrapper divs in both ConversationFilterBar components — PersonTypeahead
  manages its own focus ring; parent selectors were redundant and broken
- Fix WhoWhenSection error state: focus:ring-red-500 → focus-visible:ring-red-500
  so invalid date field ring no longer fires on mouse click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:42:11 +02:00
Marcel
f1bf32ee05 feat(focus-rings): CommentThread selection highlight → dotted outline
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m19s
CI / Backend Unit Tests (pull_request) Failing after 2m29s
CI / E2E Tests (pull_request) Failing after 1h47m37s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
ring-2 ring-accent (box-shadow) replaced with outline-2 outline-dotted
outline-accent — visually distinct from the focus ring (solid, navy/mint),
making selection state and keyboard focus clearly different

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:27:48 +02:00
Marcel
a5cc8fd16e feat(focus-rings): update interactive widgets to ring-focus-ring
PersonTypeahead, MentionEditor, PanelHistory, UserGroupsSection,
notifications filter buttons, CorrespondentSuggestionsDropdown:
replace ring-accent/ring-primary with ring-focus-ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:25:02 +02:00
Marcel
1541afd470 feat(focus-rings): update all form inputs and document components to ring-focus-ring
Replaces focus:border-ink, focus:ring-ink, focus:ring-primary, focus:ring-accent
patterns with focus-visible:ring-2 focus-visible:ring-focus-ring focus:outline-none
across: PersonEditForm, profile forms, admin forms, document sections,
conversation filter bars, persons/documents new forms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:22:11 +02:00
Marcel
d0deb26065 feat(focus-rings): update auth and search inputs to ring-focus-ring
login, forgot-password, reset-password, persons search,
CorrespondenzFilterControls: replace focus:border-ink/ring-ink
with focus-visible:ring-2 focus-visible:ring-focus-ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:18:42 +02:00
Marcel
f04e4ffa8b feat(focus-rings): update header/nav components to ring-focus-ring
ThemeToggle, NotificationBell, LanguageSwitcher, UserMenu, AppNav:
replace focus-visible:ring-accent → focus-visible:ring-focus-ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:15:06 +02:00
Marcel
17889df220 feat(focus-rings): add --c-focus-ring token to CSS design system
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 <noreply@anthropic.com>
2026-03-31 15:12:00 +02:00
Marcel
fe1121de65 test(focus-rings): add failing Playwright tests for --c-focus-ring token and element ring colors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:08:36 +02:00
Marcel
2004a80055 fix(a11y): UserMenu avatar bg-white/text-brand-navy — WCAG AA contrast
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m21s
CI / Backend Unit Tests (push) Failing after 2m27s
CI / E2E Tests (push) Failing after 1h50m38s
CI / Unit & Component Tests (pull_request) Failing after 1m23s
CI / Backend Unit Tests (pull_request) Failing after 2m30s
CI / E2E Tests (pull_request) Failing after 1h53m14s
bg-brand-mint (#A6DAD8) on text-brand-navy (#012851) = 3.5:1, fails AA
for text-xs (12px). bg-white (#fff) on text-brand-navy = 14:1 AAA.
White also reads as a distinct shape against the navy header background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:07:55 +02:00
Marcel
f70b5ae6bd fix(dark-mode): address PR #168 review blockers
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
- 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 <noreply@anthropic.com>
2026-03-31 13:30:00 +02:00
Marcel
12b8324245 chore: merge main into feat/issue-166 — resolve blue header conflicts
Some checks failed
CI / E2E Tests (pull_request) Failing after 1h51m28s
CI / Unit & Component Tests (pull_request) Failing after 1m30s
CI / Backend Unit Tests (pull_request) Failing after 2m23s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- +layout.svelte: adopt main's blue header structure (accent stripe, no
  border-b, bg-header instead of bg-brand-navy)
- layout.css light mode: drop --c-nav-active (removed by main); set
  --c-header: #012851 (confirmed correct now that header is brand-navy)
- layout.css dark mode: drop --c-nav-active; keep navy PDF tokens and
  --c-header: #012851 from our branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:24:20 +02:00
Marcel
a9b648454e fix(dark-mode): use bg-header on layout header; set --c-header to brand-navy
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m36s
CI / Backend Unit Tests (pull_request) Failing after 2m53s
CI / E2E Tests (pull_request) Failing after 1h51m31s
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
- 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 <noreply@anthropic.com>
2026-03-31 11:53:14 +02:00
Marcel
938a4b07bf 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 <noreply@anthropic.com>
2026-03-31 11:39:37 +02:00
Marcel
7e43bd43a4 feat(dark-mode): replace neutral tokens with navy-tinted palette + fix WCAG AA
- 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 <noreply@anthropic.com>
2026-03-31 11:37:30 +02:00
Marcel
56926efd03 test(a11y): add dark mode axe + color-scheme tests for issue #166
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 <noreply@anthropic.com>
2026-03-31 11:22:35 +02:00
Marcel
a6ee444f3b 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 <noreply@anthropic.com>
2026-03-31 10:33:50 +02:00
Marcel
2dd73cf594 test(LanguageSwitcher): add Vitest unit tests for inverted prop
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m27s
CI / Backend Unit Tests (pull_request) Failing after 2m38s
CI / E2E Tests (pull_request) Failing after 1h49m47s
CI / Unit & Component Tests (push) Failing after 1m30s
CI / Backend Unit Tests (push) Failing after 2m28s
CI / E2E Tests (push) Failing after 1h52m7s
Covers active/inactive class tokens for both inverted=true and inverted=false,
and verifies setLocale is called with the correct locale on button click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:43:29 +02:00
Marcel
53038dea68 fix(header): address PR review blockers
- AuthHeader: remove duplicated locale logic, use <LanguageSwitcher inverted />
- Fix text-white/55 → text-white/70 in AppNav and LanguageSwitcher (WCAG AA)
- E2E: add axe accessibility checks, replace [data-hydrated] with role selectors,
  add 768px hamburger test and BRAND_NAVY comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:37:24 +02:00
Marcel
281934529e fix(header): consistent icon styling, focus rings, and responsive breakpoints
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Normalize all header icon buttons to white/65 + white/10 hover bg
- Fix guest person icon (img tag needs brightness-0 invert, not text color)
- Add missing focus-visible rings to ThemeToggle and LanguageSwitcher
- Use focus-visible:rounded on nav links so active underline stays sharp
- Bump burger/nav breakpoint from sm→lg to prevent overflow on tablets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:54:04 +02:00
Marcel
c905f136d2 test(header): add Playwright tests for brand-navy header
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Asserts header background is rgb(1,40,81) in light mode
- Asserts header stays navy after switching to dark mode
- Asserts logo text visible at 375px viewport
- Asserts login page has AuthHeader with navy background and lang switcher

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:03:38 +02:00
Marcel
36bf591afe feat(forgot-password): add AuthHeader for consistent auth page branding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:02:29 +02:00
Marcel
550a9704ad feat(login): replace floating lang switcher with AuthHeader
Removes the absolutely-positioned language switcher div and replaces it
with the shared AuthHeader component (logo + lang switcher on navy bar).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:01:40 +02:00
Marcel
55e681c209 feat(AuthHeader): slim brand-navy header for auth pages
Provides logo + language switcher on brand-navy background with
4px accent strip. Used on login and forgot-password pages in place
of the floating language switcher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:01:02 +02:00
Marcel
e65ddc655e feat(UserMenu): brand-mint avatar, white guest icon, focus rings
- Avatar: bg-brand-mint text-brand-navy (mint circle, navy initials)
- Guest icon button: text-white/60, hover text-white
- Both buttons: focus-visible:ring-2 ring-accent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:00:22 +02:00
Marcel
14b1cc7539 feat(AppNav): brand-navy header styles for logo and nav links
- Logo: always visible (remove hidden md:flex), text-white
- Outer wrapper: items-stretch so active border reaches header bottom
- Desktop nav: items-stretch, active = border-b-2 border-accent text-white
- Inactive links: text-white/55, hover text-white/85
- Hamburger: text-white/70, hover text-white
- Mobile drawer active: bg-accent-bg replacing removed bg-nav-active
- Focus rings: focus-visible:ring-2 ring-accent on all interactive elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:59:38 +02:00
Marcel
adc1f343b2 feat(layout): apply brand-navy header with accent strip
- Replace bg-surface border-b with bg-brand-navy (always #012851)
- Add 4px bg-accent strip above the nav bar
- Remove border-r separator from language switcher wrapper
- Pass inverted prop to LanguageSwitcher for white text on dark bg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:57:39 +02:00
Marcel
3dfaf69fb1 feat(LanguageSwitcher): add inverted prop for dark-header context
When inverted=true, buttons render white text instead of ink tokens,
suitable for placement on brand-navy background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:57:01 +02:00
Marcel
fd2a7a8e96 refactor(layout): remove --c-nav-active CSS token
The nav active state moves from a background pill to a bottom-border
underline, so the rgba purple tint variable is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:55:48 +02:00
45 changed files with 1770 additions and 175 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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: [] } });

View File

@@ -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);
});
});

118
frontend/e2e/header.spec.ts Normal file
View File

@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
// #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', () => {
test('header background is brand-navy in light mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
});
test('header passes accessibility audit in light mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
test('header background stays brand-navy after switching to dark mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
});
test('header passes accessibility audit in dark mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
test('logo text is visible at 375px viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
await expect(page.getByRole('banner').getByText('Familienarchiv')).toBeVisible();
});
test('hamburger menu opens on tablet viewport (768px)', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const hamburger = page.getByRole('button', { name: /menü öffnen/i });
await expect(hamburger).toBeVisible();
await hamburger.click();
await expect(
page.getByRole('navigation', { name: /mobile/i }).or(page.locator('#mobile-nav'))
).toBeVisible();
});
});
test.describe('Login page — AuthHeader', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('login page has brand-navy header with language switcher', async ({ page }) => {
await page.goto('/login');
const header = page.locator('header');
await expect(header).toBeVisible();
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
await expect(header.getByRole('button', { name: 'DE' })).toBeVisible();
});
test('login page header passes accessibility audit', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('header')).toBeVisible();
const results = await new AxeBuilder({ page }).include('header').analyze();
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([]);
});
});

View File

@@ -60,6 +60,48 @@ 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 = #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 }) => {
// Set dark theme in localStorage before navigating
await page.goto('/');

View File

@@ -312,7 +312,7 @@ onMount(async () => {
<div
data-comment-id={thread.id}
class={highlightedCommentId === thread.id
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
: ''}
>
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
@@ -323,7 +323,7 @@ onMount(async () => {
<div
data-comment-id={reply.id}
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
: ''}"
>
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { setLocale, getLocale } from '$lib/paraglide/runtime';
let { inverted = false }: { inverted?: boolean } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
@@ -10,8 +12,14 @@ const activeLocale = $derived(getLocale().toUpperCase());
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="font-sans tracking-widest transition-colors
{activeLocale === locale ? 'font-bold text-ink' : 'font-normal text-ink-3 hover:text-ink'}"
class="rounded px-1 font-sans tracking-widest transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring
{activeLocale === locale
? inverted
? 'font-bold text-white'
: 'font-bold text-ink'
: inverted
? 'font-normal text-white/70 hover:text-white'
: 'font-normal text-ink-3 hover:text-ink'}"
>
{locale}
</button>

View File

@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import LanguageSwitcher from './LanguageSwitcher.svelte';
const mockSetLocale = vi.hoisted(() => vi.fn());
vi.mock('$lib/paraglide/runtime', () => ({
getLocale: vi.fn(() => 'de'),
setLocale: mockSetLocale
}));
beforeEach(() => mockSetLocale.mockClear());
afterEach(cleanup);
// ─── inverted=true (dark background) ──────────────────────────────────────
describe('LanguageSwitcher inverted=true', () => {
it('active locale button has text-white and font-bold', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'DE' }).element();
expect(el.className).toMatch(/\btext-white\b/);
expect(el.className).toMatch(/\bfont-bold\b/);
});
it('inactive locale buttons have text-white/70', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).toMatch(/text-white\/70/);
});
it('inactive locale buttons do not have font-bold', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).not.toMatch(/\bfont-bold\b/);
});
});
// ─── inverted=false (light background) ─────────────────────────────────────
describe('LanguageSwitcher inverted=false', () => {
it('active locale button has text-ink and font-bold', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'DE' }).element();
expect(el.className).toMatch(/\btext-ink\b/);
expect(el.className).toMatch(/\bfont-bold\b/);
});
it('inactive locale buttons have text-ink-3', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).toMatch(/\btext-ink-3\b/);
});
it('inactive locale buttons do not have text-white', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).not.toMatch(/\btext-white\b/);
});
});
// ─── locale switching ──────────────────────────────────────────────────────
describe('LanguageSwitcher locale switching', () => {
it('calls setLocale with en when EN button is clicked', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
el.click();
expect(mockSetLocale).toHaveBeenCalledWith('en');
});
it('calls setLocale with es when ES button is clicked', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'ES' }).element();
el.click();
expect(mockSetLocale).toHaveBeenCalledWith('es');
});
});

View File

@@ -187,7 +187,7 @@ const popupOpen = $derived(query !== null);
<div class="relative">
<textarea
{@attach attachTextarea}
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
rows={rows}
placeholder={placeholder}
disabled={disabled}

View File

@@ -154,7 +154,7 @@ onDestroy(() => {
: m.notification_bell_label()}
aria-expanded={open}
aria-haspopup="true"
class="relative rounded-sm p-2 text-ink-2 transition-colors hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<!-- Bell SVG -->
<svg

View File

@@ -302,7 +302,7 @@ $effect(() => {
<select
id="compare-a"
bind:value={compareA}
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value=""></option>
{#each versions as v, i (v.id)}
@@ -317,7 +317,7 @@ $effect(() => {
<select
id="compare-b"
bind:value={compareB}
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value=""></option>
{#each versions as v, i (v.id)}

View File

@@ -154,8 +154,8 @@ function clickOutside(node: HTMLElement) {
onfocus={handleFocus}
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
class={compact
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none'
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:border-accent focus:ring-accent'}
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
/>
{#if showDropdown && (results.length > 0 || loading)}

View File

@@ -31,12 +31,12 @@ function toggle() {
onclick={toggle}
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
title={theme === 'dark' ? 'light mode' : 'dark mode'}
class="rounded p-1.5 text-ink-2 transition-colors hover:bg-muted hover:text-ink"
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if theme === 'dark'}
<!-- Sun icon — click to go light -->
<svg
class="h-4 w-4"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -52,7 +52,7 @@ function toggle() {
{:else}
<!-- Moon icon — click to go dark -->
<svg
class="h-4 w-4"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@@ -49,7 +49,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
titleDirty = true;
}}
required={titleRequired}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{/if}
@@ -65,7 +65,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
name="documentLocation"
value={initialDocumentLocation}
placeholder={m.form_placeholder_archive_location()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
</div>
@@ -87,7 +87,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{initialSummary}</textarea
>
</div>

View File

@@ -13,7 +13,7 @@ let { initialTranscription = '' }: { initialTranscription?: string } = $props();
name="transcription"
rows="12"
placeholder={m.form_placeholder_transcription()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{initialTranscription}</textarea
>
</div>

View File

@@ -71,7 +71,7 @@ $effect(() => {
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line p-2 text-sm shadow-sm
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-ink focus:ring-ink'}"
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
@@ -91,7 +91,7 @@ $effect(() => {
name="location"
value={initialLocation}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -18,7 +18,7 @@ let selected = $derived([...selectedGroupIds]);
name="groupIds"
value={group.id}
bind:group={selected}
class="rounded border-line text-ink focus:ring-accent"
class="rounded border-line text-ink focus:ring-focus-ring"
/>
{group.name}
</label>

View File

@@ -13,7 +13,7 @@ let { required = false }: { required?: boolean } = $props();
type="password"
name="newPassword"
required={required}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -25,7 +25,7 @@ let { required = false }: { required?: boolean } = $props();
type="password"
name="confirmPassword"
required={required}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
</div>

View File

@@ -37,7 +37,7 @@ function handleBirthDateInput(e: Event) {
type="text"
name="firstName"
value={firstName}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -49,7 +49,7 @@ function handleBirthDateInput(e: Event) {
type="text"
name="lastName"
value={lastName}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
</div>
@@ -63,7 +63,7 @@ function handleBirthDateInput(e: Event) {
placeholder="TT.MM.JJJJ"
value={birthDateDisplay}
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
@@ -76,7 +76,7 @@ function handleBirthDateInput(e: Event) {
type="email"
name="email"
value={email}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -88,7 +88,7 @@ function handleBirthDateInput(e: Event) {
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{contact}</textarea
>
</label>

View File

@@ -35,7 +35,8 @@ const userInitials = $derived.by(() => {
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
{#if !isAuthPage}
<header class="sticky top-0 z-50 border-b border-line-2 bg-surface">
<header class="sticky top-0 z-50 bg-header">
<div class="h-1 bg-accent"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<!-- Logo & Nav -->
@@ -45,9 +46,9 @@ const userInitials = $derived.by(() => {
<div class="flex items-center gap-3">
<!-- Language selector (desktop only — mobile lives in nav drawer) -->
<div
class="hidden items-center gap-1 border-r border-line pr-3 sm:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
class="hidden items-center gap-1 pr-3 lg:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
>
<LanguageSwitcher />
<LanguageSwitcher inverted />
</div>
<!-- Theme toggle -->

View File

@@ -28,53 +28,53 @@ function handleOverlayKeydown(event: KeyboardEvent) {
}
</script>
<div class="flex items-center">
<div class="mr-10 hidden flex-shrink-0 items-center md:flex">
<div class="flex items-stretch">
<div class="mr-10 flex flex-shrink-0 items-center">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
<span class="font-sans text-xl font-bold tracking-widest text-white uppercase"
>Familienarchiv</span
>
</a>
</div>
<!-- Desktop nav -->
<nav class="hidden items-center sm:flex sm:space-x-1">
<nav class="hidden items-stretch lg:flex lg:space-x-1">
<a
href="/"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_documents()}
</a>
<a
href="/persons"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/persons')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_persons()}
</a>
<a
href="/korrespondenz"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/korrespondenz')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
href="/admin"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/admin')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_admin()}
</a>
@@ -83,7 +83,7 @@ function handleOverlayKeydown(event: KeyboardEvent) {
<!-- Hamburger toggle (mobile only) -->
<button
class="ml-auto flex h-11 w-11 items-center justify-center rounded text-ink-2 transition-colors hover:bg-muted hover:text-ink sm:hidden"
class="ml-auto flex h-11 w-11 items-center justify-center self-center rounded text-white/70 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring lg:hidden"
aria-label={mobileNavOpen ? 'Menü schließen' : 'Menü öffnen'}
aria-expanded={mobileNavOpen}
aria-controls="mobile-nav"
@@ -131,41 +131,41 @@ function handleOverlayKeydown(event: KeyboardEvent) {
<!-- Mobile nav overlay -->
{#if mobileNavOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 top-[68px] z-40 sm:hidden" onkeydown={handleOverlayKeydown}>
<div class="fixed inset-0 top-[68px] z-40 lg:hidden" onkeydown={handleOverlayKeydown}>
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="absolute inset-0 bg-black/20" onclick={closeMobileNav}></div>
<!-- Panel -->
<!-- Panel — white background with navy text (reverses the dark header) -->
<div class="relative border-b border-line bg-surface shadow-md">
<nav id="mobile-nav">
<a
href="/"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_documents()}
</a>
<a
href="/persons"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/persons')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_persons()}
</a>
<a
href="/korrespondenz"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/korrespondenz')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_conversations()}
</a>
@@ -173,10 +173,10 @@ function handleOverlayKeydown(event: KeyboardEvent) {
{#if isAdmin}
<a
href="/admin"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/admin')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_admin()}
</a>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
</script>
<header class="bg-header">
<div class="h-1 bg-accent"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-12 items-center justify-between">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-sm font-bold tracking-widest text-white uppercase"
>Familienarchiv</span
>
</a>
<div class="flex items-center gap-1">
<LanguageSwitcher inverted />
</div>
</div>
</div>
</header>

View File

@@ -46,7 +46,7 @@ let {
onblur={onblur}
aria-label={m.docs_search_placeholder()}
placeholder={m.docs_search_placeholder()}
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<img

View File

@@ -33,7 +33,7 @@ function clickOutside(node: HTMLElement) {
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-primary-fg transition-opacity hover:opacity-80"
class="flex h-8 w-8 items-center justify-center rounded-full bg-white font-sans text-xs font-bold text-brand-navy transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{userInitials}
</button>
@@ -44,13 +44,13 @@ function clickOutside(node: HTMLElement) {
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
class="group rounded-sm p-2 transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
class="h-5 w-5 opacity-65 brightness-0 invert transition-opacity group-hover:opacity-100"
/>
</button>
{/if}

View File

@@ -63,7 +63,7 @@ function handleKeydown(event: KeyboardEvent) {
>
<!-- Desktop-only heading -->
<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()}
</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"
/>
</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}
</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"
/>
</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}
</span>
<span
@@ -259,7 +259,7 @@ function handleKeydown(event: KeyboardEvent) {
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</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}
</span>
<span
@@ -355,7 +355,7 @@ function handleKeydown(event: KeyboardEvent) {
transition:fly={{ x: -160, duration: 180 }}
>
<!-- 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()}
</div>
@@ -384,7 +384,7 @@ function handleKeydown(event: KeyboardEvent) {
/>
</svg>
<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}
</span>
@@ -422,7 +422,7 @@ function handleKeydown(event: KeyboardEvent) {
/>
</svg>
<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}
</span>
@@ -460,7 +460,7 @@ function handleKeydown(event: KeyboardEvent) {
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</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}
</span>
<span

View File

@@ -133,7 +133,7 @@ const ADMIN_PERMISSIONS = $derived([
name="name"
value={data.group.name}
required
class="bg-background w-full rounded-sm border border-line px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
class="bg-background w-full rounded-sm border border-line px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -150,7 +150,7 @@ const ADMIN_PERMISSIONS = $derived([
name="permissions"
value={perm.value}
checked={data.group.permissions.includes(perm.value)}
class="h-4 w-4 rounded border-line text-primary focus:ring-primary"
class="h-4 w-4 rounded border-line text-primary focus:ring-focus-ring"
/>
{perm.label}
</label>

View File

@@ -101,7 +101,7 @@ beforeNavigate(({ cancel, to }) => {
name="name"
placeholder={m.admin_group_name_placeholder()}
required
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:ring-1 focus:ring-primary focus:outline-none"
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -117,7 +117,7 @@ beforeNavigate(({ cancel, to }) => {
type="checkbox"
name="permissions"
value={perm.value}
class="rounded border-line text-primary focus:ring-primary"
class="rounded border-line text-primary focus:ring-focus-ring"
/>
<span class="font-mono text-xs font-bold uppercase">{perm.value}</span>
<span class="text-ink-3">{perm.label}</span>
@@ -146,7 +146,7 @@ beforeNavigate(({ cancel, to }) => {
type="checkbox"
name="permissions"
value={perm.value}
class="rounded border-line text-primary focus:ring-primary"
class="rounded border-line text-primary focus:ring-focus-ring"
/>
<span class="font-mono text-xs font-bold uppercase">{perm.value}</span>
<span class="font-normal text-ink-3">{perm.label}</span>

View File

@@ -104,7 +104,7 @@ $effect(() => {
name="name"
value={data.tag.name}
required
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:ring-1 focus:ring-primary focus:outline-none"
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
</form>

View File

@@ -106,7 +106,7 @@ const filtered = $derived(
type="search"
bind:value={searchQuery}
placeholder={m.admin_users_search_placeholder()}
class="w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-3 focus:ring-1 focus:ring-primary focus:outline-none"
class="w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -14,7 +14,7 @@ import { m } from '$lib/paraglide/messages.js';
type="text"
name="username"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -26,6 +26,6 @@ import { m } from '$lib/paraglide/messages.js';
type="password"
name="password"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>

View File

@@ -31,7 +31,7 @@ let {
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
@@ -73,7 +73,7 @@ let {
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
@@ -99,7 +99,7 @@ let {
type="date"
bind:value={fromDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -113,7 +113,7 @@ let {
type="date"
bind:value={toDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -90,7 +90,7 @@ $effect(() => {
titleOverride = (e.target as HTMLInputElement).value;
titleDirty = true;
}}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
placeholder="Titel eingeben…"
/>
</div>

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import AuthHeader from '../AuthHeader.svelte';
let { form }: { form?: { error?: string; success?: boolean } } = $props();
</script>
<div class="relative flex min-h-screen flex-col bg-surface">
<div class="flex min-h-screen flex-col bg-canvas">
<AuthHeader />
<div class="flex flex-1 items-center justify-center px-4">
<div class="w-full max-w-sm">
<!-- Logo -->
@@ -44,7 +46,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
id="email"
required
autocomplete="email"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -31,7 +31,7 @@ let {
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
@@ -73,7 +73,7 @@ let {
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
@@ -99,7 +99,7 @@ let {
type="date"
bind:value={fromDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -113,7 +113,7 @@ let {
type="date"
bind:value={toDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -78,7 +78,7 @@ function getInitials(person: Correspondent): string {
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
onclick={() => onselect(person.id)}
onkeydown={(e) => e.key === 'Enter' && onselect(person.id)}
>
@@ -103,7 +103,7 @@ function getInitials(person: Correspondent): string {
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
onclick={() => onselect('')}
onkeydown={(e) => e.key === 'Enter' && onselect('')}
>

View File

@@ -42,7 +42,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
bind:value={fromDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_from_placeholder()}
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {fromDate ? 'border-primary' : 'border-line'}"
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {fromDate ? 'border-primary' : 'border-line'}"
/>
<span class="text-xs text-ink-3"></span>
@@ -52,7 +52,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
bind:value={toDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_to_placeholder()}
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {toDate ? 'border-primary' : 'border-line'}"
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {toDate ? 'border-primary' : 'border-line'}"
/>
<!-- Document count -->

View File

@@ -48,14 +48,17 @@
--color-primary: var(--c-primary);
--color-primary-fg: var(--c-primary-fg);
/* Nav active state */
--color-nav-active: var(--c-nav-active);
/* PDF viewer */
--color-pdf-bg: var(--c-pdf-bg);
--color-pdf-ctrl: var(--c-pdf-ctrl);
--color-pdf-text: var(--c-pdf-text);
/* 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);
@@ -84,7 +87,11 @@
--c-primary: #012851;
--c-primary-fg: #ffffff;
--c-nav-active: rgba(180, 185, 255, 0.15);
/* 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;
@@ -92,19 +99,26 @@
}
/* ─── 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) {
:root:not([data-theme='light']) {
--c-canvas: #0d0d0d;
--c-surface: #1a1a1a;
--c-overlay: #242424;
--c-muted: #252525;
color-scheme: dark;
--c-line: #3d3d3d;
--c-line-2: #2e2e2e;
--c-canvas: #010e1e;
--c-surface: #011526;
--c-overlay: #011e38;
--c-muted: #011a30;
--c-line: #0d3358;
--c-line-2: #092843;
--c-ink: #f0efe9;
--c-ink-2: #9ca3af; /* gray-400 — 7.5:1 on dark surface — WCAG AAA ✓ */
--c-ink-3: #8b97a5; /* gray-450 — 6.5:1 on dark surface — WCAG AA ✓ */
--c-ink-2: #9ca3af; /* 7.5:1 on #011526 — WCAG AAA ✓ */
--c-ink-3: #8b97a5; /* 7.1:1 on #011526 — WCAG AAA ✓ */
--c-accent: #00c7b1;
--c-accent-bg: rgba(0, 199, 177, 0.12);
@@ -112,27 +126,34 @@
--c-primary: #a1dcd8;
--c-primary-fg: #012851;
--c-nav-active: rgba(180, 185, 255, 0.12);
/* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */
--c-header: #012851;
--c-pdf-bg: #1e1e1e;
--c-pdf-ctrl: #2a2a2a;
--c-pdf-text: #d1d1d1;
/* 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;
}
}
/* Manual dark override — takes precedence over media query */
/* KEEP IN SYNC with the @media block above */
:root[data-theme='dark'] {
--c-canvas: #0d0d0d;
--c-surface: #1a1a1a;
--c-overlay: #242424;
--c-muted: #252525;
color-scheme: dark;
--c-line: #3d3d3d;
--c-line-2: #2e2e2e;
--c-canvas: #010e1e;
--c-surface: #011526;
--c-overlay: #011e38;
--c-muted: #011a30;
--c-line: #0d3358;
--c-line-2: #092843;
--c-ink: #f0efe9;
--c-ink-2: #9ca3af;
--c-ink-3: #6b7280;
--c-ink-2: #9ca3af; /* 7.5:1 on #011526 — WCAG AAA ✓ */
--c-ink-3: #8b97a5; /* 7.1:1 on #011526 — WCAG AAA ✓ */
--c-accent: #00c7b1;
--c-accent-bg: rgba(0, 199, 177, 0.12);
@@ -140,11 +161,15 @@
--c-primary: #a1dcd8;
--c-primary-fg: #012851;
--c-nav-active: rgba(180, 185, 255, 0.12);
/* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */
--c-header: #012851;
--c-pdf-bg: #1e1e1e;
--c-pdf-ctrl: #2a2a2a;
--c-pdf-text: #d1d1d1;
/* 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;
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
@@ -219,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;
}
}

View File

@@ -1,34 +1,16 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { setLocale, getLocale } from '$lib/paraglide/runtime';
import AuthHeader from '../AuthHeader.svelte';
let { form }: { form?: { error?: string; success?: boolean } } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
</script>
<svelte:head>
<title>{m.page_title_login()}</title>
</svelte:head>
<div class="relative flex min-h-screen flex-col bg-canvas">
<!-- Language switcher -->
<div class="absolute top-4 right-4 flex items-center gap-1">
{#each locales as locale (locale)}
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
{activeLocale === locale
? 'font-bold text-ink'
: 'font-normal text-ink-3 hover:text-ink'}"
>
{locale}
</button>
{/each}
</div>
<div class="flex min-h-screen flex-col bg-canvas">
<AuthHeader />
<div class="flex flex-1 items-center justify-center px-4">
<div class="w-full max-w-sm">
@@ -60,7 +42,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
id="username"
required
autocomplete="username"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -76,7 +58,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
id="password"
required
autocomplete="current-password"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -111,7 +111,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
aria-checked={activeType === null && activeReadFilter === null}
onclick={() => setFilter({ type: null, read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === null && activeReadFilter === null
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
@@ -126,7 +126,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
aria-checked={activeReadFilter === 'false'}
onclick={() => setFilter({ read: 'false', type: null })}
class={[
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeReadFilter === 'false'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
@@ -149,7 +149,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
aria-checked={activeType === 'MENTION'}
onclick={() => setFilter({ type: 'MENTION', read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === 'MENTION'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
@@ -164,7 +164,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
aria-checked={activeType === 'REPLY'}
onclick={() => setFilter({ type: 'REPLY', read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === 'REPLY'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'

View File

@@ -54,7 +54,7 @@ function handleSearch() {
oninput={handleSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-ink-3"

View File

@@ -26,7 +26,7 @@ let {
type="text"
required
value={person.firstName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
@@ -39,7 +39,7 @@ let {
type="text"
required
value={person.lastName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="md:col-span-2">
@@ -51,7 +51,7 @@ let {
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
@@ -66,7 +66,7 @@ let {
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
@@ -81,7 +81,7 @@ let {
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="md:col-span-2">
@@ -93,7 +93,7 @@ let {
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{person.notes ?? ''}</textarea
>
</div>

View File

@@ -48,7 +48,7 @@ let { form } = $props();
name="firstName"
type="text"
required
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -61,7 +61,7 @@ let { form } = $props();
name="lastName"
type="text"
required
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -74,7 +74,7 @@ let { form } = $props();
name="alias"
type="text"
placeholder={m.form_placeholder_alias()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -89,7 +89,7 @@ let { form } = $props();
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -104,7 +104,7 @@ let { form } = $props();
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -117,7 +117,7 @@ let { form } = $props();
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
class="block w-full resize-y rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
</div>
</div>

View File

@@ -39,7 +39,7 @@ let {
type="password"
name="currentPassword"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -51,7 +51,7 @@ let {
type="password"
name="newPassword"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -63,7 +63,7 @@ let {
type="password"
name="confirmPassword"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
</div>

View File

@@ -57,7 +57,7 @@ function handleBirthDateInput(e: Event) {
type="text"
name="firstName"
value={user?.firstName ?? ''}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -69,7 +69,7 @@ function handleBirthDateInput(e: Event) {
type="text"
name="lastName"
value={user?.lastName ?? ''}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -82,7 +82,7 @@ function handleBirthDateInput(e: Event) {
placeholder="TT.MM.JJJJ"
value={birthDateDisplay}
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
@@ -95,7 +95,7 @@ function handleBirthDateInput(e: Event) {
type="email"
name="email"
value={user?.email ?? ''}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>
@@ -107,7 +107,7 @@ function handleBirthDateInput(e: Event) {
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{user?.contact ?? ''}</textarea
>
</label>

View File

@@ -53,7 +53,7 @@ let {
id="newPassword"
required
autocomplete="new-password"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
@@ -69,7 +69,7 @@ let {
id="confirmPassword"
required
autocomplete="new-password"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>