Compare commits

...

11 Commits

Author SHA1 Message Date
Marcel
7b3334f5e7 docs(richtlinien): shorten prerender comment to essentials
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m25s
CI / OCR Service Tests (push) Successful in 52s
CI / Backend Unit Tests (push) Failing after 3m12s
CI / Unit & Component Tests (pull_request) Failing after 3m2s
CI / OCR Service Tests (pull_request) Successful in 42s
CI / Backend Unit Tests (pull_request) Failing after 3m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:02:51 +02:00
Marcel
ccca8dab18 test(HelpPopover): use userEvent.keyboard for Enter/Space tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:01:41 +02:00
Marcel
4f17c718d8 test(e2e): fix locators, add print assertion, cleanup, remove redundant emulateMedia
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 2m55s
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
- help-popover: replace broad button[aria-expanded] with specific
  getByLabel('Lese- und Bearbeitungsmodus'); update role="tooltip" →
  role="region"; add afterAll doc cleanup (Sara/Tobias)
- richtlinien: assert .new-tab spans are hidden in print media — the
  existing test only checked .app-nav (Sara)
- transcribe-coach: remove 4× redundant page.emulateMedia({reducedMotion})
  calls — playwright.config.ts already sets reducedMotion: 'reduce' globally;
  add afterAll doc cleanup (Tobias)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:25:24 +02:00
Marcel
10768937ba chore(i18n): remove dead transcription_empty_draw_hint key
The key was orphaned when TranscriptionEditView's empty state was replaced
by TranscribeCoachEmptyState. Removed from de/en/es to avoid accumulating
unreferenced strings. (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:23:54 +02:00
Marcel
d2cee2a5b4 docs(richtlinien): document why prerender=true is auth-safe
handleAuth in hooks.server.ts is in the sequence() chain and redirects
unauthenticated users at runtime regardless of prerender. Adding a comment
so the next reader doesn't mistake this for a security hole. (Markus/Nora)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:22:33 +02:00
Marcel
0b1d67ca96 fix(richtlinien): <main> landmark + closing card h2 → h3
- Wrap page content in <main> so AT users can jump to main content (Nora)
- Closing card "Fehlt eine Regel?" was <h2> after two existing <h2> siblings
  but styled like a card title, not a section label; downgrade to <h3> to
  fix the heading hierarchy (Sara/Leonie)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:21:29 +02:00
Marcel
1715ceb53e fix(TranscribeCoachEmptyState): Tailwind grid instead of inline styles; step aria-labels
- Replace style="grid-template-columns: 34px 1fr; align-items: start;"
  with Tailwind grid-cols-[34px_1fr] items-start (Felix: inline styles)
- Add aria-label="Schritt N von 3" on each <li> so screen readers announce
  step position when the numeric badge is aria-hidden (Nora/Sara)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:19:42 +02:00
Marcel
77bd005df7 fix(RichtlinienRuleCard): bg-[#FAF8F1] → bg-parchment design token
Raw hex bypassed the token system and wouldn't remap in dark mode.
Now uses --color-parchment which has a proper dark-mode counterpart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:17:06 +02:00
Marcel
bdd91aa165 fix(TranscribeDragDemo): reactive prefersReducedMotion + bg-parchment token
- Replace one-shot $derived(.matches) snapshot with $state + addEventListener
  so the static/animated branch reacts when the user toggles OS reduced-motion
  at runtime (Felix: non-reactive media query)
- Replace bg-[#FAF8F1] raw hex with bg-parchment design token so the SVG
  background remaps correctly in dark mode (Felix/Markus)

Also update TranscriptionPanelHeader.svelte.test.ts to expect role="region"
after the HelpPopover ARIA fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:16:00 +02:00
Marcel
82af3f85a2 fix(HelpPopover): role=region, 44px touch target, counter-based ID
- role="tooltip" → role="region" + aria-label={label}: tooltip semantics
  are wrong for a click-triggered panel (Nora/Sara)
- expand button to 44×44px with inner visual <span>: WCAG 2.5.8 touch
  target for 60+ transcriber audience (Sara/Leonie)
- replace Math.random() with module-level counter: SSR/hydration mismatch
  when server and client generate different IDs (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:06:52 +02:00
Marcel
71892e7293 feat(tokens): add --color-parchment design token for warm example-block surfaces
Adds --c-parchment (#faf8f1 light / #041828 dark) to :root and both
dark-mode blocks, exposed as --color-parchment via @theme inline.
Prerequisite for replacing bg-[#FAF8F1] raw-hex in RichtlinienRuleCard
and TranscribeDragDemo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:03:43 +02:00
15 changed files with 107 additions and 40 deletions

View File

@@ -8,21 +8,27 @@ test.describe('Help chip — Read/Edit panel header', () => {
docId = await createEmptyDocument(request); docId = await createEmptyDocument(request);
}); });
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => { test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
// Find and click the (?) help chip // Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
const helpBtn = page.locator('button[aria-expanded]'); const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
await expect(helpBtn).toBeVisible({ timeout: 5000 }); await expect(helpBtn).toBeVisible({ timeout: 5000 });
await helpBtn.click(); await helpBtn.click();
// Popover should open // Popover should open (role="region", not tooltip — click-triggered panels are regions)
await expect(page.locator('[role="tooltip"]')).toBeVisible(); await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
// Press Esc // Press Esc
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(page.locator('[role="tooltip"]')).not.toBeVisible(); await expect(
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
).not.toBeVisible();
// Focus should have returned to the chip // Focus should have returned to the chip
await expect(helpBtn).toBeFocused(); await expect(helpBtn).toBeFocused();

View File

@@ -63,6 +63,12 @@ test.describe('Richtlinien page — print media', () => {
await expect(nav).toBeHidden(); await expect(nav).toBeHidden();
} }
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
// text does not clutter the printed output (the print CSS declares display:none)
for (const span of await page.locator('.new-tab').all()) {
await expect(span).toBeHidden();
}
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true }); await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
}); });
}); });

View File

@@ -13,10 +13,13 @@ test.describe('Transcribe coach — empty state', () => {
docId = await createEmptyDocument(request); docId = await createEmptyDocument(request);
}); });
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('shows coach card (title, preamble, three step bodies, animation region)', async ({ test('shows coach card (title, preamble, three step bodies, animation region)', async ({
page page
}) => { }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
@@ -31,14 +34,12 @@ test.describe('Transcribe coach — empty state', () => {
}); });
test('training footer is NOT visible on empty doc', async ({ page }) => { test('training footer is NOT visible on empty doc', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 }); await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
}); });
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => { test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click(); await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
@@ -50,7 +51,6 @@ test.describe('Transcribe coach — empty state', () => {
}); });
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => { test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`); await page.goto(`/documents/${docId}`);
// Toggle dark theme // Toggle dark theme
await page.getByRole('button', { name: /Farbmodus|theme/i }).click(); await page.getByRole('button', { name: /Farbmodus|theme/i }).click();

View File

@@ -515,7 +515,6 @@
"scan_collapse": "Scan verkleinern", "scan_collapse": "Scan verkleinern",
"transcription_empty_title": "Noch keine Transkription", "transcription_empty_title": "Noch keine Transkription",
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.", "transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
"transcription_panel_close": "Panel schließen", "transcription_panel_close": "Panel schließen",
"person_alias_heading": "Namensverlauf", "person_alias_heading": "Namensverlauf",
"person_alias_empty": "Noch keine Namensaenderungen erfasst.", "person_alias_empty": "Noch keine Namensaenderungen erfasst.",

View File

@@ -515,7 +515,6 @@
"scan_collapse": "Collapse scan", "scan_collapse": "Collapse scan",
"transcription_empty_title": "No transcription yet", "transcription_empty_title": "No transcription yet",
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.", "transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
"transcription_panel_close": "Close panel", "transcription_panel_close": "Close panel",
"person_alias_heading": "Name history", "person_alias_heading": "Name history",
"person_alias_empty": "No name changes recorded yet.", "person_alias_empty": "No name changes recorded yet.",

View File

@@ -515,7 +515,6 @@
"scan_collapse": "Reducir escaneo", "scan_collapse": "Reducir escaneo",
"transcription_empty_title": "Sin transcripcion", "transcription_empty_title": "Sin transcripcion",
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.", "transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
"transcription_panel_close": "Cerrar panel", "transcription_panel_close": "Cerrar panel",
"person_alias_heading": "Historial de nombres", "person_alias_heading": "Historial de nombres",
"person_alias_empty": "Aun no se han registrado cambios de nombre.", "person_alias_empty": "Aun no se han registrado cambios de nombre.",

View File

@@ -1,3 +1,10 @@
<script module>
// Module-level counter produces stable, predictable IDs across SSR + hydration.
// Math.random() would generate different values server-side vs client-side,
// causing a hydration mismatch on first render.
let _counter = 0;
</script>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -11,8 +18,9 @@ type Props = {
let { label, placement = 'bottom', children }: Props = $props(); let { label, placement = 'bottom', children }: Props = $props();
const popoverId = `help-popover-${_counter++}`;
let open = $state(false); let open = $state(false);
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
let triggerEl: HTMLButtonElement | null = $state(null); let triggerEl: HTMLButtonElement | null = $state(null);
function toggle() { function toggle() {
@@ -58,6 +66,10 @@ const placementClass: Record<Placement, string> = {
</script> </script>
<div class="relative inline-block"> <div class="relative inline-block">
<!--
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
audience is 60+). The inner <span> carries the visual 20×20px circle.
-->
<button <button
bind:this={triggerEl} bind:this={triggerEl}
type="button" type="button"
@@ -65,15 +77,20 @@ const placementClass: Record<Placement, string> = {
aria-expanded={open} aria-expanded={open}
aria-controls={popoverId} aria-controls={popoverId}
onclick={toggle} onclick={toggle}
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy" class="flex h-[44px] w-[44px] items-center justify-center"
> >
? <span
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy"
>
?
</span>
</button> </button>
{#if open} {#if open}
<div <div
id={popoverId} id={popoverId}
role="tooltip" role="region"
aria-label={label}
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}" class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
> >
{#if children} {#if children}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach, vi } from 'vitest'; import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import HelpPopover from './HelpPopover.svelte'; import HelpPopover from './HelpPopover.svelte';
afterEach(cleanup); afterEach(cleanup);
@@ -20,7 +20,7 @@ describe('HelpPopover — initial state', () => {
renderPopover(); renderPopover();
const btn = page.getByRole('button', { name: /Help/ }); const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'false'); await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('[role="tooltip"]')).toBeNull(); expect(document.querySelector('[role="region"]')).toBeNull();
}); });
}); });
@@ -30,37 +30,39 @@ describe('HelpPopover — open / close interactions', () => {
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
const btn = page.getByRole('button', { name: /Help/ }); const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'true'); await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
}); });
it('closes on Esc key', async () => { it('closes on Esc key', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
}); });
it('closes on outside click', async () => { it('closes on outside click', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
}); });
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => { it('opens on Enter key', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); (document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); await userEvent.keyboard('{Enter}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
}); });
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => { it('opens on Space key', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); (document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); await userEvent.keyboard('{Space}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
}); });
}); });
@@ -74,4 +76,17 @@ describe('HelpPopover — aria wiring', () => {
const popover = document.getElementById(controls!); const popover = document.getElementById(controls!);
expect(popover).not.toBeNull(); expect(popover).not.toBeNull();
}); });
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
expect(id1).toBeTruthy();
expect(id2).toBeTruthy();
expect(id1).not.toBe(id2);
// IDs must be deterministic (counter-based), not random hex
expect(id1).toMatch(/^help-popover-\d+$/);
expect(id2).toMatch(/^help-popover-\d+$/);
});
}); });

View File

@@ -18,7 +18,7 @@ let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p> <p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
{#if beispielOutput !== undefined} {#if beispielOutput !== undefined}
<div class="border-brand-sand mt-4 rounded-sm border bg-[#FAF8F1] px-4 py-3"> <div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase"> <p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
{beispielLabel} {beispielLabel}
</p> </p>

View File

@@ -13,7 +13,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
<ol class="m-0 flex list-none flex-col gap-[18px] p-0"> <ol class="m-0 flex list-none flex-col gap-[18px] p-0">
<!-- Step 1 --> <!-- Step 1 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;"> <li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span <span
aria-hidden="true" aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white" class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
@@ -27,7 +27,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</li> </li>
<!-- Step 2 --> <!-- Step 2 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;"> <li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span <span
aria-hidden="true" aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white" class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
@@ -40,7 +40,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</li> </li>
<!-- Step 3 --> <!-- Step 3 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;"> <li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span <span
aria-hidden="true" aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white" class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"

View File

@@ -1,7 +1,19 @@
<script lang="ts"> <script lang="ts">
const prefersReducedMotion = $derived( // $derived from .matches is a one-shot snapshot — it doesn't react when the
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
let prefersReducedMotion = $state(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
); );
$effect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotion = e.matches;
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
});
</script> </script>
{#if prefersReducedMotion} {#if prefersReducedMotion}
@@ -10,7 +22,7 @@ const prefersReducedMotion = $derived(
role="img" role="img"
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument." aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
viewBox="0 0 600 180" viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]" class="border-brand-sand block w-full rounded-sm border bg-parchment"
> >
<g <g
stroke="#2a2a2a" stroke="#2a2a2a"
@@ -61,7 +73,7 @@ const prefersReducedMotion = $derived(
role="img" role="img"
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint." aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
viewBox="0 0 600 180" viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]" class="border-brand-sand block w-full rounded-sm border bg-parchment"
> >
<!-- Kurrent writing (static) --> <!-- Kurrent writing (static) -->
<g <g

View File

@@ -177,6 +177,6 @@ describe('TranscriptionPanelHeader', () => {
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement; const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
}); });
}); });

View File

@@ -46,7 +46,7 @@ const klaerungChips = [
<title>{m.richtlinien_title()} — Familienarchiv</title> <title>{m.richtlinien_title()} — Familienarchiv</title>
</svelte:head> </svelte:head>
<div class="mx-auto max-w-2xl px-4 py-10 font-serif"> <main class="mx-auto max-w-2xl px-4 py-10 font-serif">
<!-- Title --> <!-- Title -->
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1> <h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
@@ -102,10 +102,10 @@ const klaerungChips = [
<!-- Closing card --> <!-- Closing card -->
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm"> <div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
<h2 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h2> <h3 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h3>
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p> <p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
</div> </div>
</div> </main>
<style> <style>
@media print { @media print {

View File

@@ -1 +1,3 @@
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
// before prerendered HTML is visible.
export const prerender = true; export const prerender = true;

View File

@@ -66,6 +66,9 @@
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */ /* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
--color-focus-ring: var(--c-focus-ring); --color-focus-ring: var(--c-focus-ring);
/* Parchment — warm background for code/example blocks inside cards */
--color-parchment: var(--c-parchment);
/* Danger — destructive action color */ /* Danger — destructive action color */
--color-danger: var(--c-danger); --color-danger: var(--c-danger);
--color-danger-fg: var(--c-danger-fg); --color-danger-fg: var(--c-danger-fg);
@@ -122,6 +125,9 @@
--c-danger: #c0392b; --c-danger: #c0392b;
--c-danger-fg: #ffffff; --c-danger-fg: #ffffff;
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
--c-parchment: #faf8f1;
/* Tag color tokens — decorative dot colors on tag chips */ /* Tag color tokens — decorative dot colors on tag chips */
--c-tag-sage: #5a8a6a; --c-tag-sage: #5a8a6a;
--c-tag-sienna: #a0522d; --c-tag-sienna: #a0522d;
@@ -203,6 +209,9 @@
--c-danger: #e55347; --c-danger: #e55347;
--c-danger-fg: #ffffff; --c-danger-fg: #ffffff;
/* Parchment — subtle surface shift for example blocks on dark navy */
--c-parchment: #041828;
/* Tag color tokens — lighter for visibility on dark backgrounds */ /* Tag color tokens — lighter for visibility on dark backgrounds */
--c-tag-sage: #7abf8a; --c-tag-sage: #7abf8a;
--c-tag-sienna: #cc7050; --c-tag-sienna: #cc7050;
@@ -267,6 +276,9 @@
--c-danger: #e55347; --c-danger: #e55347;
--c-danger-fg: #ffffff; --c-danger-fg: #ffffff;
/* Parchment — subtle surface shift for example blocks on dark navy */
--c-parchment: #041828;
/* Tag color tokens — lighter for visibility on dark backgrounds */ /* Tag color tokens — lighter for visibility on dark backgrounds */
--c-tag-sage: #7abf8a; --c-tag-sage: #7abf8a;
--c-tag-sienna: #cc7050; --c-tag-sienna: #cc7050;