feat(#320): guided empty state + Kurrent primer for first-time transcribers
- Three-step coach card replaces Transcribe panel empty state (edit mode) - TranscribeDragDemo: 5-second SMIL animation, static final frame for prefers-reduced-motion - HelpPopover reusable primitive with Esc/outside-click/focus-return - (?) help chip in TranscriptionPanelHeader next to Read/Edit toggle - Copy pass: markieren → einrahmen in transcription_next_block_cta - New route /hilfe/transkription (prerendered, auth-required) with 5 RichtlinienRuleCard instances, 4 Klärung chips, closing card, @media print styles - 34 new i18n keys across de/en/es - E2E specs: transcribe-coach, richtlinien (axe + print), help-popover; reducedMotion: 'reduce' project-wide default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
30
frontend/e2e/help-popover.spec.ts
Normal file
30
frontend/e2e/help-popover.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
10
frontend/e2e/helpers/upload-empty-document.ts
Normal file
10
frontend/e2e/helpers/upload-empty-document.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||
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;
|
||||
}
|
||||
68
frontend/e2e/richtlinien.spec.ts
Normal file
68
frontend/e2e/richtlinien.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[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 });
|
||||
});
|
||||
});
|
||||
65
frontend/e2e/transcribe-coach.spec.ts
Normal file
65
frontend/e2e/transcribe-coach.spec.ts
Normal file
@@ -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<typeof AxeBuilder>[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);
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
84
frontend/src/lib/components/HelpPopover.svelte
Normal file
84
frontend/src/lib/components/HelpPopover.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Placement = 'bottom' | 'top' | 'left' | 'right';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
placement?: Placement;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { label, placement = 'bottom', children }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
|
||||
let triggerEl: HTMLButtonElement | null = $state(null);
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
triggerEl?.focus();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (triggerEl && (e.target === triggerEl || triggerEl.contains(e.target as Node))) return;
|
||||
const popoverEl = document.getElementById(popoverId);
|
||||
if (popoverEl && popoverEl.contains(e.target as Node)) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
};
|
||||
});
|
||||
|
||||
const placementClass: Record<Placement, string> = {
|
||||
bottom: 'top-full mt-1.5 left-1/2 -translate-x-1/2',
|
||||
top: 'bottom-full mb-1.5 left-1/2 -translate-x-1/2',
|
||||
left: 'right-full mr-1.5 top-1/2 -translate-y-1/2',
|
||||
right: 'left-full ml-1.5 top-1/2 -translate-y-1/2'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
aria-controls={popoverId}
|
||||
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"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id={popoverId}
|
||||
role="tooltip"
|
||||
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}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
77
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
77
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
30
frontend/src/lib/components/RichtlinienRuleCard.svelte
Normal file
30
frontend/src/lib/components/RichtlinienRuleCard.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
beispielOutput?: string;
|
||||
beispielLabel?: string;
|
||||
};
|
||||
|
||||
let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span aria-hidden="true" class="text-xl">{icon}</span>
|
||||
<h3 class="font-serif text-base font-bold text-ink">{title}</h3>
|
||||
</div>
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
|
||||
|
||||
{#if beispielOutput !== undefined}
|
||||
<div class="border-brand-sand mt-4 rounded-sm border bg-[#FAF8F1] px-4 py-3">
|
||||
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
|
||||
{beispielLabel}
|
||||
</p>
|
||||
<p class="mt-1 font-sans text-sm text-ink">
|
||||
→ <code class="font-mono">{beispielOutput}</code>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
76
frontend/src/lib/components/TranscribeCoachEmptyState.svelte
Normal file
76
frontend/src/lib/components/TranscribeCoachEmptyState.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-7 shadow-sm">
|
||||
<h2 class="mb-3 font-serif text-[22px] font-bold text-ink">
|
||||
{m.transcribe_coach_title()}
|
||||
</h2>
|
||||
<p class="mb-6 font-serif text-[15px] leading-relaxed text-ink-2">
|
||||
{m.transcribe_coach_preamble()}
|
||||
</p>
|
||||
|
||||
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
|
||||
<!-- Step 1 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<span
|
||||
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"
|
||||
>1</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_1_title()}</strong>
|
||||
{m.transcribe_coach_step_1_body()}
|
||||
<TranscribeDragDemo />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<span
|
||||
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"
|
||||
>2</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_2_title()}</strong>
|
||||
{m.transcribe_coach_step_2_body()}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<span
|
||||
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"
|
||||
>3</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_3_title()}</strong>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="border-brand-sand mt-6 flex flex-wrap gap-4 border-t pt-3.5 font-sans text-[13px]">
|
||||
<a
|
||||
href="https://de.wikipedia.org/wiki/Kurrent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.transcribe_coach_footer_kurrent()}
|
||||
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
|
||||
</a>
|
||||
<a
|
||||
href="/hilfe/transkription"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.transcribe_coach_footer_richtlinien()}
|
||||
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
205
frontend/src/lib/components/TranscribeDragDemo.svelte
Normal file
205
frontend/src/lib/components/TranscribeDragDemo.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
const prefersReducedMotion = $derived(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if prefersReducedMotion}
|
||||
<!-- Static final frame for reduced-motion users -->
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
||||
>
|
||||
<g
|
||||
stroke="#2a2a2a"
|
||||
stroke-width="1.6"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
|
||||
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
|
||||
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
|
||||
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
|
||||
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
|
||||
</g>
|
||||
<line
|
||||
x1="60"
|
||||
y1="120"
|
||||
x2="540"
|
||||
y2="120"
|
||||
stroke="#D4D1C4"
|
||||
stroke-width="0.8"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
<rect
|
||||
x="55"
|
||||
y="68"
|
||||
width="470"
|
||||
height="57"
|
||||
fill="rgba(166, 218, 216, 0.12)"
|
||||
stroke="#002850"
|
||||
stroke-width="2.2"
|
||||
/>
|
||||
<g transform="translate(515, 58)">
|
||||
<circle cx="0" cy="0" r="9" fill="#002850" />
|
||||
<path
|
||||
d="M -4 0 L -1 3 L 4 -3"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Animated 5-second drawing loop -->
|
||||
<svg
|
||||
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."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
||||
>
|
||||
<!-- Kurrent writing (static) -->
|
||||
<g
|
||||
stroke="#2a2a2a"
|
||||
stroke-width="1.6"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
|
||||
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
|
||||
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
|
||||
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
|
||||
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
|
||||
</g>
|
||||
<line
|
||||
x1="60"
|
||||
y1="120"
|
||||
x2="540"
|
||||
y2="120"
|
||||
stroke="#D4D1C4"
|
||||
stroke-width="0.8"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
|
||||
<!-- Click ripple -->
|
||||
<circle cx="55" cy="68" r="0" fill="none" stroke="#A6DAD8" stroke-width="2.5" opacity="0">
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="0;0;4;18;0;0"
|
||||
keyTimes="0;0.17;0.19;0.24;0.26;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;0;0;0"
|
||||
keyTimes="0;0.17;0.19;0.24;0.26;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
<!-- Growing selection rectangle -->
|
||||
<rect
|
||||
x="55"
|
||||
y="68"
|
||||
width="0"
|
||||
height="0"
|
||||
fill="rgba(166, 218, 216, 0.12)"
|
||||
stroke="#002850"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="5 4"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;1;1;1;0;0"
|
||||
keyTimes="0;0.18;0.20;0.88;0.92;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="width"
|
||||
values="0;0;470;470;470;470;0"
|
||||
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="height"
|
||||
values="0;0;57;57;57;57;0"
|
||||
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
values="5 4;5 4;5 4;1 0;1 0;5 4"
|
||||
keyTimes="0;0.60;0.64;0.68;0.94;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-width"
|
||||
values="2;2;2;3.2;2.2;2;2"
|
||||
keyTimes="0;0.64;0.66;0.68;0.72;0.90;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
|
||||
<!-- Confirmation checkmark badge -->
|
||||
<g opacity="0" transform="translate(515, 58)">
|
||||
<circle cx="0" cy="0" r="9" fill="#002850" />
|
||||
<path
|
||||
d="M -4 0 L -1 3 L 4 -3"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;1;0;0"
|
||||
keyTimes="0;0.66;0.70;0.86;0.92;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Cursor arrow -->
|
||||
<g>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="translate"
|
||||
values="15,20; 55,68; 55,68; 525,125; 525,125; 15,20"
|
||||
keyTimes="0; 0.15; 0.20; 0.62; 0.92; 1"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="1;1;1;0;0;1"
|
||||
keyTimes="0;0.92;0.94;0.96;0.99;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<path
|
||||
d="M 0 0 L 0 16 L 4.5 12 L 7.5 18 L 10.5 16.6 L 7.8 10.6 L 13 9 Z"
|
||||
fill="#002850"
|
||||
stroke="white"
|
||||
stroke-width="0.8"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<svg
|
||||
class="mb-4 h-16 w-16 text-ink-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
|
||||
{m.transcription_empty_draw_hint()}
|
||||
</p>
|
||||
<div class="p-4">
|
||||
<TranscribeCoachEmptyState />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
{#if canWrite && hasBlocks}
|
||||
<div class="border-t border-line px-4 py-3">
|
||||
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import HelpPopover from './HelpPopover.svelte';
|
||||
|
||||
type Props = {
|
||||
mode: 'read' | 'edit';
|
||||
@@ -33,31 +34,36 @@ function handleReadClick() {
|
||||
<div
|
||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||
>
|
||||
<!-- Segmented toggle -->
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
<!-- Segmented toggle + help chip -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
</div>
|
||||
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||
</HelpPopover>
|
||||
</div>
|
||||
|
||||
<!-- Status line (hidden on mobile to save space) -->
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('TranscriptionPanelHeader', () => {
|
||||
it('should render Lesen and Bearbeiten buttons', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
@@ -148,4 +150,33 @@ describe('TranscriptionPanelHeader', () => {
|
||||
expect(statusText).not.toBeNull();
|
||||
expect(statusText!.textContent).toContain('2026');
|
||||
});
|
||||
|
||||
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
expect(helpBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens a help popover with mode explanation when the chip is clicked', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
124
frontend/src/routes/hilfe/transkription/+page.svelte
Normal file
124
frontend/src/routes/hilfe/transkription/+page.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import RichtlinienRuleCard from '$lib/components/RichtlinienRuleCard.svelte';
|
||||
|
||||
const rules = [
|
||||
{
|
||||
icon: '❓',
|
||||
title: m.richtlinien_rule_unleserlich_title(),
|
||||
body: m.richtlinien_rule_unleserlich_body(),
|
||||
beispielOutput: '[unleserlich]'
|
||||
},
|
||||
{
|
||||
icon: '✗',
|
||||
title: m.richtlinien_rule_durchgestrichen_title(),
|
||||
body: m.richtlinien_rule_durchgestrichen_body(),
|
||||
beispielOutput: '[durchgestrichen: der Text]'
|
||||
},
|
||||
{
|
||||
icon: 'ſ',
|
||||
title: m.richtlinien_rule_langes_s_title(),
|
||||
body: m.richtlinien_rule_langes_s_body(),
|
||||
beispielOutput: 's'
|
||||
},
|
||||
{
|
||||
icon: '?',
|
||||
title: m.richtlinien_rule_name_title(),
|
||||
body: m.richtlinien_rule_name_body(),
|
||||
beispielOutput: '[Müller?]'
|
||||
},
|
||||
{
|
||||
icon: '💬',
|
||||
title: m.richtlinien_rule_dialekt_title(),
|
||||
body: m.richtlinien_rule_dialekt_body()
|
||||
}
|
||||
];
|
||||
|
||||
const klaerungChips = [
|
||||
m.richtlinien_klaer_abkuerzungen(),
|
||||
m.richtlinien_klaer_datumsformate(),
|
||||
m.richtlinien_klaer_umbrueche(),
|
||||
m.richtlinien_klaer_caps()
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.richtlinien_title()} — Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-10 font-serif">
|
||||
<!-- Title -->
|
||||
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
|
||||
|
||||
<!-- Intro -->
|
||||
<p class="mb-8 text-base leading-relaxed text-ink-2">{m.richtlinien_intro()}</p>
|
||||
|
||||
<!-- Wikipedia info card -->
|
||||
<div class="border-brand-sand mb-10 rounded-sm border bg-white p-5 shadow-sm">
|
||||
<p class="mb-3 font-sans text-sm text-ink-2">{m.richtlinien_wiki_text()}</p>
|
||||
<a
|
||||
href="https://de.wikipedia.org/wiki/Kurrent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
class="inline-flex items-center gap-1 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.richtlinien_wiki_link()}
|
||||
<span class="new-tab ml-1 text-[11px] text-ink-3">({m.common_opens_new_tab()})</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Rules section -->
|
||||
<h2 class="mb-5 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.richtlinien_rules_label()}
|
||||
</h2>
|
||||
<div class="mb-10 flex flex-col gap-4">
|
||||
{#each rules as rule (rule.title)}
|
||||
<RichtlinienRuleCard
|
||||
icon={rule.icon}
|
||||
title={rule.title}
|
||||
body={rule.body}
|
||||
beispielOutput={rule.beispielOutput}
|
||||
beispielLabel={m.richtlinien_beispiel_label()}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Noch in Klärung -->
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.richtlinien_klaerung_label()}
|
||||
</h2>
|
||||
<p class="mb-4 font-serif text-sm leading-relaxed text-ink-2">
|
||||
{m.richtlinien_klaerung_intro()}
|
||||
</p>
|
||||
<div class="mb-10 flex flex-wrap gap-2">
|
||||
{#each klaerungChips as chip (chip)}
|
||||
<span
|
||||
class="border-brand-sand rounded-full border bg-white px-3 py-1 font-sans text-xs text-ink-2"
|
||||
>{chip}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Closing card -->
|
||||
<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>
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
:global(.app-nav) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new-tab {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 1.5cm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
frontend/src/routes/hilfe/transkription/+page.ts
Normal file
1
frontend/src/routes/hilfe/transkription/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
72
frontend/src/routes/hilfe/transkription/page.svelte.spec.ts
Normal file
72
frontend/src/routes/hilfe/transkription/page.svelte.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user