Compare commits
19 Commits
feat/issue
...
f70b5ae6bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f70b5ae6bd | ||
|
|
12b8324245 | ||
|
|
a9b648454e | ||
|
|
938a4b07bf | ||
|
|
7e43bd43a4 | ||
|
|
56926efd03 | ||
|
|
a6ee444f3b | ||
|
|
2dd73cf594 | ||
|
|
53038dea68 | ||
|
|
281934529e | ||
|
|
c905f136d2 | ||
|
|
36bf591afe | ||
|
|
550a9704ad | ||
|
|
55e681c209 | ||
|
|
e65ddc655e | ||
|
|
14b1cc7539 | ||
|
|
adc1f343b2 | ||
|
|
3dfaf69fb1 | ||
|
|
fd2a7a8e96 |
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.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
|
||||
118
frontend/e2e/header.spec.ts
Normal file
118
frontend/e2e/header.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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-accent
|
||||
{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>
|
||||
|
||||
94
frontend/src/lib/components/LanguageSwitcher.svelte.spec.ts
Normal file
94
frontend/src/lib/components/LanguageSwitcher.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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-accent"
|
||||
>
|
||||
<!-- Bell SVG -->
|
||||
<svg
|
||||
|
||||
@@ -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-accent"
|
||||
>
|
||||
{#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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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-accent
|
||||
{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-accent
|
||||
{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-accent
|
||||
{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-accent
|
||||
{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-accent 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-accent 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-accent 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-accent 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-accent 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>
|
||||
|
||||
19
frontend/src/routes/AuthHeader.svelte
Normal file
19
frontend/src/routes/AuthHeader.svelte
Normal 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>
|
||||
@@ -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-brand-mint font-sans text-xs font-bold text-brand-navy transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{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-accent"
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -48,14 +48,14 @@
|
||||
--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);
|
||||
|
||||
/* Static brand tokens (not themed) */
|
||||
--color-brand-navy: var(--palette-navy);
|
||||
--color-brand-mint: var(--palette-mint);
|
||||
@@ -84,7 +84,8 @@
|
||||
--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;
|
||||
|
||||
--c-pdf-bg: #ebebeb;
|
||||
--c-pdf-ctrl: #d8d8d8;
|
||||
@@ -92,19 +93,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 +120,31 @@
|
||||
--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;
|
||||
--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 +152,12 @@
|
||||
--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;
|
||||
--c-pdf-bg: #010e1e;
|
||||
--c-pdf-ctrl: #011526;
|
||||
--c-pdf-text: #f0efe9;
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user