feat(richtlinien): add /hilfe/transkription page with RichtlinienRuleCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-24 21:28:25 +02:00
parent 7c3a8e7651
commit b234db0472
5 changed files with 276 additions and 0 deletions

View 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>

View File

@@ -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();
});
});

View 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>

View File

@@ -0,0 +1 @@
export const prerender = true;

View 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();
});
});