diff --git a/frontend/e2e/help-popover.spec.ts b/frontend/e2e/help-popover.spec.ts new file mode 100644 index 00000000..bc0f77c8 --- /dev/null +++ b/frontend/e2e/help-popover.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; +import { createEmptyDocument } from './helpers/upload-empty-document.js'; + +test.describe('Help chip — Read/Edit panel header', () => { + let docId: string; + + test.beforeAll(async ({ request }) => { + docId = await createEmptyDocument(request); + }); + + test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => { + await page.goto(`/documents/${docId}`); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + + // Find and click the (?) help chip + const helpBtn = page.locator('button[aria-expanded]'); + await expect(helpBtn).toBeVisible({ timeout: 5000 }); + await helpBtn.click(); + + // Popover should open + await expect(page.locator('[role="tooltip"]')).toBeVisible(); + + // Press Esc + await page.keyboard.press('Escape'); + await expect(page.locator('[role="tooltip"]')).not.toBeVisible(); + + // Focus should have returned to the chip + await expect(helpBtn).toBeFocused(); + }); +}); diff --git a/frontend/e2e/helpers/upload-empty-document.ts b/frontend/e2e/helpers/upload-empty-document.ts new file mode 100644 index 00000000..caba3615 --- /dev/null +++ b/frontend/e2e/helpers/upload-empty-document.ts @@ -0,0 +1,10 @@ +import type { APIRequestContext } from '@playwright/test'; + +export async function createEmptyDocument(request: APIRequestContext): Promise { + const res = await request.post('/api/documents', { + multipart: { title: 'E2E Transcribe Coach Test' } + }); + if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`); + const doc = await res.json(); + return doc.id as string; +} diff --git a/frontend/e2e/richtlinien.spec.ts b/frontend/e2e/richtlinien.spec.ts new file mode 100644 index 00000000..1b5e52ea --- /dev/null +++ b/frontend/e2e/richtlinien.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +function buildAxe(page: Parameters[0]['page']) { + return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']); +} + +test.describe('Richtlinien page — content', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/hilfe/transkription'); + }); + + test('renders h1 title, intro, five rules, four chips, closing card', async ({ page }) => { + await expect( + page.getByRole('heading', { level: 1, name: /Transkriptions-Richtlinien/ }) + ).toBeVisible(); + await expect(page.getByText(/Damit alle Briefe einheitlich/)).toBeVisible(); + await expect(page.getByText('Nicht lesbare Wörter')).toBeVisible(); + await expect(page.getByText('Durchgestrichene Wörter')).toBeVisible(); + await expect(page.getByText(/Das lange s/)).toBeVisible(); + await expect(page.getByText('Unsichere Namen')).toBeVisible(); + await expect(page.getByText(/Dialekt/)).toBeVisible(); + await expect(page.getByText('Abkürzungen')).toBeVisible(); + await expect(page.getByText('Datumsformate')).toBeVisible(); + await expect(page.getByText(/Fehlt eine Regel/)).toBeVisible(); + }); + + test('Wikipedia link opens in new tab with annotation', async ({ page }) => { + const wikiLink = page.getByRole('link', { name: /Wikipedia/ }); + await expect(wikiLink).toHaveAttribute('target', '_blank'); + await expect(wikiLink).toHaveAttribute('rel', 'noopener noreferrer'); + await expect(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer'); + await expect(wikiLink).toContainText(/öffnet in neuem Tab/); + }); +}); + +test.describe('Richtlinien page — accessibility', () => { + for (const viewport of [320, 768, 1440]) { + test(`axe: light theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => { + await page.setViewportSize({ width: viewport, height: 800 }); + await page.goto('/hilfe/transkription'); + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + }); + + test(`axe: dark theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => { + await page.setViewportSize({ width: viewport, height: 800 }); + await page.goto('/hilfe/transkription'); + await page.getByRole('button', { name: /Farbmodus|theme/i }).click(); + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + }); + } +}); + +test.describe('Richtlinien page — print media', () => { + test('print snapshot hides nav, annotation chip, and new-tab spans', async ({ page }) => { + await page.emulateMedia({ media: 'print' }); + await page.goto('/hilfe/transkription'); + + const nav = page.locator('.app-nav'); + if ((await nav.count()) > 0) { + await expect(nav).toBeHidden(); + } + + await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true }); + }); +}); diff --git a/frontend/e2e/transcribe-coach.spec.ts b/frontend/e2e/transcribe-coach.spec.ts new file mode 100644 index 00000000..b54c7215 --- /dev/null +++ b/frontend/e2e/transcribe-coach.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { createEmptyDocument } from './helpers/upload-empty-document.js'; + +function buildAxe(page: Parameters[0]['page']) { + return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']); +} + +test.describe('Transcribe coach — empty state', () => { + let docId: string; + + test.beforeAll(async ({ request }) => { + docId = await createEmptyDocument(request); + }); + + test('shows coach card (title, preamble, three step bodies, animation region)', async ({ + page + }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`/documents/${docId}`); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + + await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ + timeout: 5000 + }); + await expect(page.getByText(/Kurrent-Erkenner lernt noch/)).toBeVisible(); + await expect(page.getByText(/Rahmen ziehen/)).toBeVisible(); + await expect(page.getByText(/Text eingeben/)).toBeVisible(); + await expect(page.getByText(/Speichert automatisch/)).toBeVisible(); + await expect(page.getByRole('img', { name: /Rahmen ziehen|Animation/i })).toBeVisible(); + }); + + test('training footer is NOT visible on empty doc', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`/documents/${docId}`); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + 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 }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`/documents/${docId}`); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ + timeout: 5000 + }); + + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + }); + + 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}`); + // Toggle dark theme + await page.getByRole('button', { name: /Farbmodus|theme/i }).click(); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ + timeout: 5000 + }); + + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + }); +}); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5e56ed54..1b90d913 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -499,7 +499,7 @@ "transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?", "transcription_block_history_btn": "Verlauf", "transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen", - "transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen", + "transcription_next_block_cta": "Einen Rahmen ziehen, um Block {number} anzulegen", "transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren", "transcription_quote_stale": "Zitat aus älterer Version", "transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden", @@ -810,5 +810,45 @@ "pagination_prev": "Zurück", "pagination_next": "Weiter", "pagination_page_of": "Seite {page} von {total}", - "pagination_nav_label": "Seitennavigation" + "pagination_nav_label": "Seitennavigation", + + "common_opens_new_tab": "(öffnet in neuem Tab)", + + "transcribe_coach_title": "Erste Transkription?", + "transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:", + "transcribe_coach_step_1_title": "Rahmen ziehen.", + "transcribe_coach_step_1_body": "Klicken und ziehen Sie mit der Maus einen Rahmen um den Text, den Sie transkribieren möchten.", + "transcribe_coach_step_2_title": "Text eingeben.", + "transcribe_coach_step_2_body": "Geben Sie den Text, den Sie im Rahmen sehen, in das neue Textfeld ein.", + "transcribe_coach_step_3_title": "Speichert automatisch.", + "transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗", + "transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗", + + "transcription_mode_help_label": "Lese- und Bearbeitungsmodus", + "transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.", + + "richtlinien_title": "Transkriptions-Richtlinien", + "richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.", + "richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.", + "richtlinien_wiki_link": "Wikipedia →", + "richtlinien_rules_label": "Regeln für die Transkription", + "richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter", + "richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.", + "richtlinien_rule_durchgestrichen_title": "Durchgestrichene Wörter", + "richtlinien_rule_durchgestrichen_body": "Auch durchgestrichener Text gehört zum Brief. Schreiben Sie ihn in eckigen Klammern mit Präfix durchgestrichen:", + "richtlinien_rule_langes_s_title": "Das lange s (ſ)", + "richtlinien_rule_langes_s_body": "Das ſ ist nur eine alte Schriftform des Buchstabens s — kein eigener Laut. Schreiben Sie immer ein normales s.", + "richtlinien_rule_name_title": "Unsichere Namen", + "richtlinien_rule_name_body": "Wenn Sie einen Namen zu erkennen meinen, aber nicht sicher sind, ergänzen Sie ein Fragezeichen in eckigen Klammern.", + "richtlinien_rule_dialekt_title": "Dialekt, Fremdwörter, fremde Zitate", + "richtlinien_rule_dialekt_body": "Plattdeutsch, Französisch, lateinische Phrasen — wörtlich übernehmen, genau wie sie geschrieben stehen.", + "richtlinien_beispiel_label": "Beispiel", + "richtlinien_klaerung_label": "Noch in Klärung", + "richtlinien_klaerung_intro": "Diese Fragen klären wir noch — stoßen Sie beim Transkribieren darauf, treffen Sie eine plausible Wahl und notieren Sie es in den Kommentaren:", + "richtlinien_klaer_abkuerzungen": "Abkürzungen", + "richtlinien_klaer_datumsformate": "Datumsformate", + "richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche", + "richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung", + "richtlinien_closing_title": "Fehlt eine Regel?", + "richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen." } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5e2591ef..f5862f0f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -499,7 +499,7 @@ "transcription_block_delete_confirm": "Really delete this block and all its comments?", "transcription_block_history_btn": "History", "transcription_empty_cta": "Mark a region on the scan to start transcribing", - "transcription_next_block_cta": "Mark another passage on the scan to create block {number}", + "transcription_next_block_cta": "Draw a frame on the scan to create block {number}", "transcription_draw_tooltip": "Click and drag to mark a text region", "transcription_quote_stale": "Quote from an older version", "transcription_block_conflict": "This block was changed by someone else — please reload", @@ -810,5 +810,45 @@ "pagination_prev": "Previous", "pagination_next": "Next", "pagination_page_of": "Page {page} of {total}", - "pagination_nav_label": "Pagination" + "pagination_nav_label": "Pagination", + + "common_opens_new_tab": "(opens in new tab)", + + "transcribe_coach_title": "First transcription?", + "transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:", + "transcribe_coach_step_1_title": "Draw a frame.", + "transcribe_coach_step_1_body": "Click and drag a frame around the text you want to transcribe.", + "transcribe_coach_step_2_title": "Enter the text.", + "transcribe_coach_step_2_body": "Type the text you see inside the frame into the new text field.", + "transcribe_coach_step_3_title": "Saves automatically.", + "transcribe_coach_footer_kurrent": "Kurrent help ↗", + "transcribe_coach_footer_richtlinien": "Transcription guidelines ↗", + + "transcription_mode_help_label": "Read and edit mode", + "transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.", + + "richtlinien_title": "Transcription Guidelines", + "richtlinien_intro": "So every letter is transcribed consistently — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.", + "richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.", + "richtlinien_wiki_link": "Wikipedia →", + "richtlinien_rules_label": "Transcription rules", + "richtlinien_rule_unleserlich_title": "Illegible words", + "richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.", + "richtlinien_rule_durchgestrichen_title": "Struck-through words", + "richtlinien_rule_durchgestrichen_body": "Struck-through text still belongs to the letter. Write it in square brackets with prefix durchgestrichen:", + "richtlinien_rule_langes_s_title": "The long s (ſ)", + "richtlinien_rule_langes_s_body": "The ſ is just an old written form of the letter s — not a separate sound. Always write a normal s.", + "richtlinien_rule_name_title": "Uncertain names", + "richtlinien_rule_name_body": "If you think you can read a name but aren't sure, add a question mark in square brackets.", + "richtlinien_rule_dialekt_title": "Dialect, foreign words, foreign quotes", + "richtlinien_rule_dialekt_body": "Low German, French, Latin phrases — copy them verbatim, exactly as written.", + "richtlinien_beispiel_label": "Example", + "richtlinien_klaerung_label": "Still to be decided", + "richtlinien_klaerung_intro": "These questions are still open — if you hit one while transcribing, make a plausible choice and note it in the comments:", + "richtlinien_klaer_abkuerzungen": "Abbreviations", + "richtlinien_klaer_datumsformate": "Date formats", + "richtlinien_klaer_umbrueche": "Original line breaks", + "richtlinien_klaer_caps": "Old capitalisation", + "richtlinien_closing_title": "Missing a rule?", + "richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering." } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index b64d0405..c949ce2c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -499,7 +499,7 @@ "transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?", "transcription_block_history_btn": "Historial", "transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir", - "transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}", + "transcription_next_block_cta": "Dibuje un marco en el escáner para crear el bloque {number}", "transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto", "transcription_quote_stale": "Cita de una versión anterior", "transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue", @@ -810,5 +810,45 @@ "pagination_prev": "Anterior", "pagination_next": "Siguiente", "pagination_page_of": "Página {page} de {total}", - "pagination_nav_label": "Paginación" + "pagination_nav_label": "Paginación", + + "common_opens_new_tab": "(abre en pestaña nueva)", + + "transcribe_coach_title": "¿Primera transcripción?", + "transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:", + "transcribe_coach_step_1_title": "Dibujar un marco.", + "transcribe_coach_step_1_body": "Haga clic y arrastre un marco alrededor del texto que desea transcribir.", + "transcribe_coach_step_2_title": "Ingresar el texto.", + "transcribe_coach_step_2_body": "Escriba el texto que ve dentro del marco en el nuevo campo de texto.", + "transcribe_coach_step_3_title": "Se guarda automáticamente.", + "transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗", + "transcribe_coach_footer_richtlinien": "Normas de transcripción ↗", + + "transcription_mode_help_label": "Modo lectura y edición", + "transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.", + + "richtlinien_title": "Normas de transcripción", + "richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.", + "richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.", + "richtlinien_wiki_link": "Wikipedia →", + "richtlinien_rules_label": "Reglas de transcripción", + "richtlinien_rule_unleserlich_title": "Palabras ilegibles", + "richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.", + "richtlinien_rule_durchgestrichen_title": "Palabras tachadas", + "richtlinien_rule_durchgestrichen_body": "El texto tachado también pertenece a la carta. Escríbelo entre corchetes con el prefijo durchgestrichen:", + "richtlinien_rule_langes_s_title": "La s larga (ſ)", + "richtlinien_rule_langes_s_body": "La ſ es solo una forma antigua de la letra s. Escribe siempre una s normal.", + "richtlinien_rule_name_title": "Nombres inciertos", + "richtlinien_rule_name_body": "Si crees reconocer un nombre pero no estás seguro, añade un signo de interrogación entre corchetes.", + "richtlinien_rule_dialekt_title": "Dialecto, palabras extranjeras, citas", + "richtlinien_rule_dialekt_body": "Bajo alemán, francés, frases latinas — cópialas tal cual están escritas.", + "richtlinien_beispiel_label": "Ejemplo", + "richtlinien_klaerung_label": "Aún por decidir", + "richtlinien_klaerung_intro": "Estas preguntas aún están abiertas — si encuentras alguna mientras transcribes, elige algo razonable y nótalo en los comentarios:", + "richtlinien_klaer_abkuerzungen": "Abreviaturas", + "richtlinien_klaer_datumsformate": "Formatos de fecha", + "richtlinien_klaer_umbrueche": "Saltos de línea originales", + "richtlinien_klaer_caps": "Mayúsculas antiguas", + "richtlinien_closing_title": "¿Falta una regla?", + "richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar." } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 87e0c57a..d7bbd635 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German + reducedMotion: 'reduce', // prevents SMIL/CSS animations from flaking tests screenshot: 'on', // always capture screenshots video: 'retain-on-failure', trace: 'retain-on-failure' diff --git a/frontend/src/lib/components/HelpPopover.svelte b/frontend/src/lib/components/HelpPopover.svelte new file mode 100644 index 00000000..e1d118ca --- /dev/null +++ b/frontend/src/lib/components/HelpPopover.svelte @@ -0,0 +1,84 @@ + + +
+ + + {#if open} + + {/if} +
diff --git a/frontend/src/lib/components/HelpPopover.svelte.spec.ts b/frontend/src/lib/components/HelpPopover.svelte.spec.ts new file mode 100644 index 00000000..b2e5ae16 --- /dev/null +++ b/frontend/src/lib/components/HelpPopover.svelte.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import HelpPopover from './HelpPopover.svelte'; + +afterEach(cleanup); + +function renderPopover(label = 'Help') { + return render(HelpPopover, { props: { label } }); +} + +describe('HelpPopover — initial state', () => { + it('renders a trigger button with the given label', async () => { + renderPopover(); + const btn = page.getByRole('button', { name: /Help/ }); + await expect.element(btn).toBeInTheDocument(); + }); + + it('starts closed: aria-expanded is false, popover not in DOM', async () => { + renderPopover(); + const btn = page.getByRole('button', { name: /Help/ }); + await expect.element(btn).toHaveAttribute('aria-expanded', 'false'); + expect(document.querySelector('[role="tooltip"]')).toBeNull(); + }); +}); + +describe('HelpPopover — open / close interactions', () => { + it('opens on click: aria-expanded true, popover in DOM', async () => { + renderPopover(); + await page.getByRole('button', { name: /Help/ }).click(); + const btn = page.getByRole('button', { name: /Help/ }); + await expect.element(btn).toHaveAttribute('aria-expanded', 'true'); + expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); + }); + + it('closes on Esc key', async () => { + renderPopover(); + await page.getByRole('button', { name: /Help/ }).click(); + expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); + }); + + it('closes on outside click', async () => { + renderPopover(); + await page.getByRole('button', { name: /Help/ }).click(); + expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); + + document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); + }); + + it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => { + renderPopover(); + await page.getByRole('button', { name: /Help/ }).click(); + expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); + }); + + it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => { + renderPopover(); + await page.getByRole('button', { name: /Help/ }).click(); + expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); + }); +}); + +describe('HelpPopover — aria wiring', () => { + it('trigger aria-controls matches popover element id', async () => { + renderPopover(); + await page.getByRole('button', { name: /Help/ }).click(); + const btn = document.querySelector('button[aria-expanded]') as HTMLButtonElement; + const controls = btn.getAttribute('aria-controls'); + expect(controls).toBeTruthy(); + const popover = document.getElementById(controls!); + expect(popover).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/RichtlinienRuleCard.svelte b/frontend/src/lib/components/RichtlinienRuleCard.svelte new file mode 100644 index 00000000..cbf9b163 --- /dev/null +++ b/frontend/src/lib/components/RichtlinienRuleCard.svelte @@ -0,0 +1,30 @@ + + +
+
+ +

{title}

+
+

{body}

+ + {#if beispielOutput !== undefined} +
+

+ {beispielLabel} +

+

+ → {beispielOutput} +

+
+ {/if} +
diff --git a/frontend/src/lib/components/RichtlinienRuleCard.svelte.spec.ts b/frontend/src/lib/components/RichtlinienRuleCard.svelte.spec.ts new file mode 100644 index 00000000..55852c1b --- /dev/null +++ b/frontend/src/lib/components/RichtlinienRuleCard.svelte.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import RichtlinienRuleCard from './RichtlinienRuleCard.svelte'; + +afterEach(cleanup); + +const defaultProps = { + icon: '✍', + title: 'Unleserliche Wörter', + body: 'Schreiben Sie [unleserlich].', + beispielOutput: '[unleserlich]' +}; + +describe('RichtlinienRuleCard', () => { + it('renders an h3 with the title', async () => { + render(RichtlinienRuleCard, { props: defaultProps }); + await expect + .element(page.getByRole('heading', { level: 3 })) + .toHaveTextContent('Unleserliche Wörter'); + }); + + it('renders the body text', async () => { + render(RichtlinienRuleCard, { props: defaultProps }); + await expect.element(page.getByText('Schreiben Sie [unleserlich].')).toBeInTheDocument(); + }); + + it('renders icon in a span with aria-hidden="true"', async () => { + render(RichtlinienRuleCard, { props: defaultProps }); + const iconSpan = document.querySelector('span[aria-hidden="true"]'); + expect(iconSpan).not.toBeNull(); + expect(iconSpan!.textContent).toContain('✍'); + }); + + it('renders beispielOutput in monospace with → arrow', async () => { + render(RichtlinienRuleCard, { props: defaultProps }); + const mono = document.querySelector('code, [class*="font-mono"]'); + expect(mono).not.toBeNull(); + expect(mono!.textContent).toContain('[unleserlich]'); + await expect.element(page.getByText(/→/)).toBeInTheDocument(); + }); + + it('does not render beispiel section when beispielOutput is absent', async () => { + render(RichtlinienRuleCard, { + props: { icon: '✍', title: 'Test', body: 'Body' } + }); + expect(document.querySelector('code, [class*="font-mono"]')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/TranscribeCoachEmptyState.svelte b/frontend/src/lib/components/TranscribeCoachEmptyState.svelte new file mode 100644 index 00000000..8bb18705 --- /dev/null +++ b/frontend/src/lib/components/TranscribeCoachEmptyState.svelte @@ -0,0 +1,76 @@ + + +
+

+ {m.transcribe_coach_title()} +

+

+ {m.transcribe_coach_preamble()} +

+ +
    + +
  1. + +
    + {m.transcribe_coach_step_1_title()} + {m.transcribe_coach_step_1_body()} + +
    +
  2. + + +
  3. + +
    + {m.transcribe_coach_step_2_title()} + {m.transcribe_coach_step_2_body()} +
    +
  4. + + +
  5. + +
    + {m.transcribe_coach_step_3_title()} +
    +
  6. +
+ + +
diff --git a/frontend/src/lib/components/TranscribeCoachEmptyState.svelte.spec.ts b/frontend/src/lib/components/TranscribeCoachEmptyState.svelte.spec.ts new file mode 100644 index 00000000..5f8d397a --- /dev/null +++ b/frontend/src/lib/components/TranscribeCoachEmptyState.svelte.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte'; + +vi.mock('$lib/paraglide/messages.js', () => ({ + m: { + transcribe_coach_title: () => 'Erste Transkription?', + transcribe_coach_preamble: () => 'Unser Kurrent-Erkenner lernt noch.', + transcribe_coach_step_1_title: () => 'Rahmen ziehen.', + transcribe_coach_step_1_body: () => 'Klicken und ziehen Sie mit der Maus einen Rahmen.', + transcribe_coach_step_2_title: () => 'Text eingeben.', + transcribe_coach_step_2_body: () => 'Geben Sie den Text ein.', + transcribe_coach_step_3_title: () => 'Speichert automatisch.', + transcribe_coach_footer_kurrent: () => 'Hilfe zu Kurrent ↗', + transcribe_coach_footer_richtlinien: () => 'Transkriptions-Richtlinien ↗', + common_opens_new_tab: () => '(öffnet in neuem Tab)' + } +})); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('TranscribeCoachEmptyState', () => { + it('renders the title and preamble', async () => { + render(TranscribeCoachEmptyState); + await expect + .element(page.getByRole('heading', { level: 2 })) + .toHaveTextContent('Erste Transkription?'); + await expect.element(page.getByText('Unser Kurrent-Erkenner lernt noch.')).toBeInTheDocument(); + }); + + it('renders three numbered steps', async () => { + render(TranscribeCoachEmptyState); + await expect.element(page.getByText('Rahmen ziehen.')).toBeInTheDocument(); + await expect + .element(page.getByText('Klicken und ziehen Sie mit der Maus einen Rahmen.')) + .toBeInTheDocument(); + await expect.element(page.getByText('Text eingeben.')).toBeInTheDocument(); + await expect.element(page.getByText('Geben Sie den Text ein.')).toBeInTheDocument(); + await expect.element(page.getByText('Speichert automatisch.')).toBeInTheDocument(); + }); + + it('renders footer links to Wikipedia Kurrent and Richtlinien page', async () => { + render(TranscribeCoachEmptyState); + const kurrentLink = page.getByRole('link', { name: /Hilfe zu Kurrent/ }); + await expect.element(kurrentLink).toBeInTheDocument(); + await expect.element(kurrentLink).toHaveAttribute('target', '_blank'); + await expect.element(kurrentLink).toHaveAttribute('rel', 'noopener noreferrer'); + await expect.element(kurrentLink).toHaveAttribute('referrerpolicy', 'no-referrer'); + + const richtlinienLink = page.getByRole('link', { name: /Transkriptions-Richtlinien/ }); + await expect.element(richtlinienLink).toBeInTheDocument(); + await expect.element(richtlinienLink).toHaveAttribute('target', '_blank'); + await expect.element(richtlinienLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders visible "(öffnet in neuem Tab)" annotation on each footer link', async () => { + render(TranscribeCoachEmptyState); + const annotations = page.getByText('(öffnet in neuem Tab)'); + await expect.element(annotations.first()).toBeInTheDocument(); + }); + + it('renders the drag demo animation region inside step 1', async () => { + render(TranscribeCoachEmptyState); + const demo = page.getByRole('img', { name: /Rahmen ziehen|Animation/i }); + await expect.element(demo).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/TranscribeDragDemo.svelte b/frontend/src/lib/components/TranscribeDragDemo.svelte new file mode 100644 index 00000000..d2c5753f --- /dev/null +++ b/frontend/src/lib/components/TranscribeDragDemo.svelte @@ -0,0 +1,205 @@ + + +{#if prefersReducedMotion} + + + + + + + + + + + + + + + + +{:else} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{/if} diff --git a/frontend/src/lib/components/TranscribeDragDemo.svelte.spec.ts b/frontend/src/lib/components/TranscribeDragDemo.svelte.spec.ts new file mode 100644 index 00000000..154063de --- /dev/null +++ b/frontend/src/lib/components/TranscribeDragDemo.svelte.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscribeDragDemo from './TranscribeDragDemo.svelte'; + +afterEach(() => { + cleanup(); +}); + +describe('TranscribeDragDemo', () => { + it('renders an SVG with an aria-label describing the animation', async () => { + render(TranscribeDragDemo); + const svg = page.getByRole('img'); + await expect.element(svg).toBeInTheDocument(); + await expect.element(svg).toHaveAttribute('aria-label'); + }); + + it('contains a dashed-border rectangle animation element', async () => { + const { container } = render(TranscribeDragDemo); + const rect = container.querySelector('rect'); + expect(rect).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index c156f6c0..c4188982 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -2,6 +2,7 @@ import { m } from '$lib/paraglide/messages.js'; import TranscriptionBlock from './TranscriptionBlock.svelte'; import OcrTrigger from './OcrTrigger.svelte'; +import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte'; import type { TranscriptionBlockData } from '$lib/types'; import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte'; import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte'; @@ -231,28 +232,12 @@ async function handleLabelToggle(label: string) { {:else} -
- - - - -

- {m.transcription_empty_draw_hint()} -

+
+
{/if} - {#if canWrite} + {#if canWrite && hasBlocks}

Für Training vormerken

diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index b076e590..a48f0148 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -61,9 +61,21 @@ describe('TranscriptionEditView — rendering', () => { await expect.element(page.getByText(/Block 3/)).toBeInTheDocument(); }); - it('shows empty state when no blocks', async () => { + it('shows coach card when no blocks', async () => { renderView({ blocks: [] }); - await expect.element(page.getByText(/Zeichnen Sie Bereiche/)).toBeInTheDocument(); + await expect + .element(page.getByRole('heading', { level: 2 })) + .toHaveTextContent('Erste Transkription?'); + }); + + it('hides training footer when no blocks', async () => { + renderView({ blocks: [], canWrite: true }); + await expect.element(page.getByText('Für Training vormerken')).not.toBeInTheDocument(); + }); + + it('shows training footer when blocks exist', async () => { + renderView({ blocks: [block1], canWrite: true }); + await expect.element(page.getByText('Für Training vormerken')).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/components/TranscriptionPanelHeader.svelte b/frontend/src/lib/components/TranscriptionPanelHeader.svelte index c3ceeb69..6bf40a5d 100644 --- a/frontend/src/lib/components/TranscriptionPanelHeader.svelte +++ b/frontend/src/lib/components/TranscriptionPanelHeader.svelte @@ -1,6 +1,7 @@ + + + {m.richtlinien_title()} — Familienarchiv + + +
+ +

{m.richtlinien_title()}

+ + +

{m.richtlinien_intro()}

+ + + + + +

+ {m.richtlinien_rules_label()} +

+
+ {#each rules as rule (rule.title)} + + {/each} +
+ + +

+ {m.richtlinien_klaerung_label()} +

+

+ {m.richtlinien_klaerung_intro()} +

+
+ {#each klaerungChips as chip (chip)} + {chip} + {/each} +
+ + +
+

{m.richtlinien_closing_title()}

+

{m.richtlinien_closing_body()}

+
+
+ + diff --git a/frontend/src/routes/hilfe/transkription/+page.ts b/frontend/src/routes/hilfe/transkription/+page.ts new file mode 100644 index 00000000..189f71e2 --- /dev/null +++ b/frontend/src/routes/hilfe/transkription/+page.ts @@ -0,0 +1 @@ +export const prerender = true; diff --git a/frontend/src/routes/hilfe/transkription/page.svelte.spec.ts b/frontend/src/routes/hilfe/transkription/page.svelte.spec.ts new file mode 100644 index 00000000..259b9f5c --- /dev/null +++ b/frontend/src/routes/hilfe/transkription/page.svelte.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +afterEach(cleanup); + +describe('Richtlinien page — structure', () => { + it('renders h1 with richtlinien title', async () => { + render(Page); + await expect + .element(page.getByRole('heading', { level: 1 })) + .toHaveTextContent('Transkriptions-Richtlinien'); + }); + + it('renders intro paragraph', async () => { + render(Page); + await expect.element(page.getByText(/Damit alle Briefe einheitlich/)).toBeInTheDocument(); + }); + + it('renders Wikipedia external link with security attributes and new-tab annotation', async () => { + render(Page); + const wikiLink = page.getByRole('link', { name: /Wikipedia/ }); + await expect.element(wikiLink).toBeInTheDocument(); + await expect.element(wikiLink).toHaveAttribute('target', '_blank'); + await expect.element(wikiLink).toHaveAttribute('rel', 'noopener noreferrer'); + await expect.element(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer'); + // visible annotation (not sr-only) + const link = document.querySelector('a[href*="wikipedia"]') as HTMLAnchorElement; + expect(link.textContent).toContain('öffnet in neuem Tab'); + }); + + it('renders Regeln h2 section', async () => { + render(Page); + await expect + .element(page.getByRole('heading', { level: 2, name: /Regeln für die Transkription/ })) + .toBeInTheDocument(); + }); + + it('renders Noch in Klärung h2 section', async () => { + render(Page); + await expect + .element(page.getByRole('heading', { level: 2, name: /Noch in Klärung/ })) + .toBeInTheDocument(); + }); + + it('renders closing invitation card', async () => { + render(Page); + await expect.element(page.getByText(/Fehlt eine Regel/)).toBeInTheDocument(); + }); +}); + +describe('Richtlinien page — rule cards', () => { + it('renders five rule card titles', async () => { + render(Page); + await expect.element(page.getByText('Nicht lesbare Wörter')).toBeInTheDocument(); + await expect.element(page.getByText('Durchgestrichene Wörter')).toBeInTheDocument(); + await expect.element(page.getByText(/Das lange s/)).toBeInTheDocument(); + await expect.element(page.getByText('Unsichere Namen')).toBeInTheDocument(); + await expect.element(page.getByText(/Dialekt/)).toBeInTheDocument(); + }); +}); + +describe('Richtlinien page — Noch in Klärung chips', () => { + it('renders four clarification chips', async () => { + render(Page); + await expect.element(page.getByText('Abkürzungen')).toBeInTheDocument(); + await expect.element(page.getByText('Datumsformate')).toBeInTheDocument(); + await expect.element(page.getByText(/Zeilenumbrüche/)).toBeInTheDocument(); + await expect.element(page.getByText(/Groß-\/Kleinschreibung/)).toBeInTheDocument(); + }); +});