feat(#320): guided empty state + Kurrent primer for first-time transcribers #330
@@ -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();
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import type { APIRequestContext } from '@playwright/test';
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, '../fixtures/minimal.pdf');
|
||||||
|
|
||||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||||
const res = await request.post('/api/documents', {
|
const createRes = await request.post('/api/documents', {
|
||||||
multipart: { title: 'E2E Transcribe Coach Test' }
|
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||||
});
|
});
|
||||||
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
const doc = await res.json();
|
const doc = await createRes.json();
|
||||||
return doc.id as string;
|
const docId = doc.id as string;
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
return docId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { test, expect } from '@playwright/test';
|
|||||||
import AxeBuilder from '@axe-core/playwright';
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||||
|
|
||||||
|
async function createBlock(
|
||||||
|
request: Parameters<typeof createEmptyDocument>[0],
|
||||||
|
docId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||||
|
data: {
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.1,
|
||||||
|
text: 'Liebe Mutter,',
|
||||||
|
label: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||||
}
|
}
|
||||||
@@ -13,10 +31,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 +52,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,10 +69,9 @@ 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: /dark mode/i }).click();
|
||||||
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({
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
@@ -63,3 +81,25 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Transcribe coach — with blocks', () => {
|
||||||
|
let docId: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
docId = await createEmptyDocument(request);
|
||||||
|
await createBlock(request, docId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await request.delete(`/api/documents/${docId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('training footer IS visible when at least one block exists', async ({ page }) => {
|
||||||
|
await page.goto(`/documents/${docId}`);
|
||||||
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
|
||||||
|
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
|
||||||
|
await page.locator('[data-testid="mode-edit"]').click();
|
||||||
|
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
>
|
>
|
||||||
?
|
<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 group-hover:border-brand-navy group-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}
|
||||||
|
|||||||
@@ -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,61 @@ 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HelpPopover — hover-target', () => {
|
||||||
|
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
|
||||||
|
const { container } = renderPopover();
|
||||||
|
const btn = container.querySelector('button[aria-expanded]')!;
|
||||||
|
const span = btn.querySelector('span')!;
|
||||||
|
const btnClasses = btn.className.split(/\s+/);
|
||||||
|
const spanClasses = span.className.split(/\s+/);
|
||||||
|
expect(btnClasses).toContain('group');
|
||||||
|
expect(spanClasses).not.toContain('hover:border-brand-navy');
|
||||||
|
expect(spanClasses).toContain('group-hover:border-brand-navy');
|
||||||
|
expect(spanClasses).not.toContain('hover:text-brand-navy');
|
||||||
|
expect(spanClasses).toContain('group-hover:text-brand-navy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outer button has focus-visible ring for keyboard users', () => {
|
||||||
|
const { container } = renderPopover();
|
||||||
|
const btn = container.querySelector('button[aria-expanded]')!;
|
||||||
|
expect(btn.className).toContain('focus-visible:ring-2');
|
||||||
|
expect(btn.className).toContain('focus-visible:ring-brand-navy');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,4 +98,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+$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user