feat(transcribe): add TranscribeCoachEmptyState and TranscribeDragDemo components
New coach card replaces the icon+sentence empty state in the Transcribe panel (edit mode). Three-step guide with 5-s SMIL drawing animation in step 1 only. Animation freezes at the final frame when prefers-reduced-motion is active. Footer links to Wikipedia Kurrent and the Richtlinien page open in new tabs with visible '(öffnet in neuem Tab)' annotations. 34 new i18n keys in de/en/es. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user