fix(geschichte-ui): a11y and visual round-3 batch

- JourneyItemCard: 'Brief öffnen' back to a >=44px touch target with the
  height regression spec restored
- GeschichteListRow: REISE badges text-[10px] -> text-xs; drop the
  hardcoded aria-label and the mobile badge's aria-hidden so phone screen
  readers learn a row is a Lesereise; mobile avatar initials -> color dot
- detail page: badge text-xs, metabar Edit/Delete h-9 -> h-11, avatar
  color keyed by name to match the list
- JourneyReader: dead border-subtle class -> border-line-2
- DocumentPickerDropdown: aria-controls only while the listbox exists
- JourneyAddBar: aria-expanded/aria-controls on both toggles + focus
  hand-off into the revealed picker input / interlude textarea
- GeschichteSidebar: section h2s hidden below sm (summary already shows
  the label there)
- JourneyCreate: bg-brand-navy -> semantic bg-primary/text-primary-fg;
  title maxlength=255
- JourneyItemRow interlude: neutral frame border + left accent only,
  token utilities instead of arbitrary var() syntax and inline style

Review round 3: Leonie (1-8 + round-1 leftovers), Elicit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-11 08:30:59 +02:00
parent 836c9594d4
commit 66e9309d8a
10 changed files with 73 additions and 39 deletions

View File

@@ -84,7 +84,9 @@ function handleKeydown(e: KeyboardEvent) {
autocomplete="off" autocomplete="off"
aria-label={placeholder} aria-label={placeholder}
aria-expanded={picker.isOpen} aria-expanded={picker.isOpen}
aria-controls={listboxId} aria-controls={picker.isOpen && !picker.loading && !picker.error && picker.results.length > 0
? listboxId
: undefined}
aria-autocomplete="list" aria-autocomplete="list"
aria-activedescendant={activeOptionId} aria-activedescendant={activeOptionId}
placeholder={placeholder} placeholder={placeholder}

View File

@@ -39,9 +39,7 @@ const authorName = $derived(formatAuthorName(geschichte.author));
{#if isJourney} {#if isJourney}
<span <span
data-testid="journey-badge" data-testid="journey-badge"
aria-label="Lesereise" class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase"
style="font-size: 10px"
class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-[10px] font-bold tracking-wide text-journey uppercase"
> >
{m.journey_badge_list()} {m.journey_badge_list()}
</span> </span>
@@ -52,13 +50,12 @@ const authorName = $derived(formatAuthorName(geschichte.author));
<div class="min-w-0 flex-1 p-3 sm:px-4"> <div class="min-w-0 flex-1 p-3 sm:px-4">
<!-- Compact meta line (mobile only) --> <!-- Compact meta line (mobile only) -->
<div class="mb-1 flex items-center gap-1.5 sm:hidden"> <div class="mb-1 flex items-center gap-1.5 sm:hidden">
<!-- 7px initials render as smudge at this size — a plain color dot reads better -->
<span <span
aria-hidden="true" aria-hidden="true"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full font-sans text-[7px] font-bold text-white" class="h-2.5 w-2.5 shrink-0 rounded-full"
style="background-color: {personAvatarColor(authorName)}" style="background-color: {personAvatarColor(authorName)}"
> ></span>
{getInitials(authorName)}
</span>
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span> <span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
{#if publishedAt} {#if publishedAt}
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span> <span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
@@ -72,9 +69,7 @@ const authorName = $derived(formatAuthorName(geschichte.author));
{#if isJourney} {#if isJourney}
<span <span
data-testid="journey-badge-mobile" data-testid="journey-badge-mobile"
aria-hidden="true" class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase sm:hidden"
style="font-size: 10px"
class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-[10px] font-bold tracking-wide text-journey uppercase sm:hidden"
> >
{m.journey_badge_list()} {m.journey_badge_list()}
</span> </span>

View File

@@ -79,12 +79,12 @@ describe('GeschichteListRow', () => {
expect(badge?.tagName.toLowerCase()).toBe('span'); expect(badge?.tagName.toLowerCase()).toBe('span');
}); });
it('badge has small font size appropriate for a label', async () => { it('badge uses the 12px label size — text-xs is the visible-text floor', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]'); const badge = document.querySelector('[data-testid="journey-badge"]');
const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize); expect(badge!.className).toContain('text-xs');
expect(fontSize).toBeGreaterThan(0); // 10px was below the house floor for the 60+ audience (round-3 review)
expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size expect(badge!.className).not.toContain('text-[10px]');
}); });
it('renders author name in meta line', async () => { it('renders author name in meta line', async () => {

View File

@@ -22,7 +22,10 @@ const isDraft = $derived(status === 'DRAFT');
{m.geschichte_sidebar_status()} {m.geschichte_sidebar_status()}
</summary> </summary>
<section class="rounded border border-line bg-surface p-4 shadow-sm"> <section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"> <!-- hidden below sm: the <summary> already shows this label there -->
<h2
class="mb-1 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{m.geschichte_sidebar_status()} {m.geschichte_sidebar_status()}
</h2> </h2>
<p class="mb-3"> <p class="mb-3">
@@ -50,7 +53,9 @@ const isDraft = $derived(status === 'DRAFT');
{m.geschichte_editor_personen_heading()} {m.geschichte_editor_personen_heading()}
</summary> </summary>
<section class="rounded border border-line bg-surface p-4 shadow-sm"> <section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"> <h2
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{m.geschichte_editor_personen_heading()} {m.geschichte_editor_personen_heading()}
</h2> </h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p> <p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte'; import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead'; import type { DocumentOption } from '$lib/document/documentTypeahead';
@@ -14,9 +15,29 @@ let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $pro
let showPicker = $state(false); let showPicker = $state(false);
let showInterludeForm = $state(false); let showInterludeForm = $state(false);
let interludeDraft = $state(''); let interludeDraft = $state('');
let rootEl: HTMLElement | null = $state(null);
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0); const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
async function togglePicker() {
showPicker = !showPicker;
showInterludeForm = false;
if (showPicker) {
// Keyboard users need a perceivable result of activating the toggle.
await tick();
rootEl?.querySelector<HTMLInputElement>('#journey-add-picker input')?.focus();
}
}
async function toggleInterludeForm() {
showInterludeForm = !showInterludeForm;
showPicker = false;
if (showInterludeForm) {
await tick();
rootEl?.querySelector<HTMLTextAreaElement>('#journey-add-interlude textarea')?.focus();
}
}
function handleDocumentSelect(doc: DocumentOption) { function handleDocumentSelect(doc: DocumentOption) {
showPicker = false; showPicker = false;
onAddDocument(doc); onAddDocument(doc);
@@ -36,25 +57,23 @@ function handleInterludeCancel() {
} }
</script> </script>
<div class="flex flex-col gap-3"> <div bind:this={rootEl} class="flex flex-col gap-3">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
data-add-document data-add-document
onclick={() => { onclick={togglePicker}
showPicker = !showPicker; aria-expanded={showPicker}
showInterludeForm = false; aria-controls={showPicker ? 'journey-add-picker' : undefined}
}}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
+ {m.journey_add_document()} + {m.journey_add_document()}
</button> </button>
<button <button
type="button" type="button"
onclick={() => { onclick={toggleInterludeForm}
showInterludeForm = !showInterludeForm; aria-expanded={showInterludeForm}
showPicker = false; aria-controls={showInterludeForm ? 'journey-add-interlude' : undefined}
}}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
+ {m.journey_add_interlude()} + {m.journey_add_interlude()}
@@ -62,15 +81,17 @@ function handleInterludeCancel() {
</div> </div>
{#if showPicker} {#if showPicker}
<DocumentPickerDropdown <div id="journey-add-picker">
alreadyAddedIds={alreadyAddedIds} <DocumentPickerDropdown
onSelect={handleDocumentSelect} alreadyAddedIds={alreadyAddedIds}
placeholder={m.journey_add_document()} onSelect={handleDocumentSelect}
/> placeholder={m.journey_add_document()}
/>
</div>
{/if} {/if}
{#if showInterludeForm} {#if showInterludeForm}
<div class="flex flex-col gap-2"> <div id="journey-add-interlude" class="flex flex-col gap-2">
<textarea <textarea
bind:value={interludeDraft} bind:value={interludeDraft}
placeholder={m.journey_interlude_placeholder()} placeholder={m.journey_interlude_placeholder()}

View File

@@ -34,7 +34,7 @@ const hasNote = $derived(item.note != null && item.note.trim().length > 0);
<a <a
href="/documents/{doc.id}" href="/documents/{doc.id}"
aria-label={openAriaLabel} aria-label={openAriaLabel}
class="inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="-my-2 inline-flex min-h-[44px] items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none"> <svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none">
<rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" /> <rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" />

View File

@@ -51,6 +51,16 @@ describe('JourneyItemCard', () => {
expect(el.getAttribute('href')).toContain('/documents/d1'); expect(el.getAttribute('href')).toContain('/documents/d1');
}); });
it('"Brief öffnen" link meets the 44px touch-target floor', async () => {
// Primary tap action of the phone read path — WCAG 2.5.5 / house rule.
render(JourneyItemCard, { props: { item: baseItem() } });
const link = page.getByRole('link', { name: /öffnen/i });
await expect.element(link).toBeInTheDocument();
const height = link.element().getBoundingClientRect().height;
expect(height).toBeGreaterThanOrEqual(44);
});
it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => { it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } }); render(JourneyItemCard, { props: { item: baseItem() } });

View File

@@ -28,7 +28,7 @@ const validItems = $derived(
{#if introText} {#if introText}
<!-- plaintext — do NOT use {@html} here --> <!-- plaintext — do NOT use {@html} here -->
<p <p
class="border-subtle mb-6 border-b border-dashed pb-4 font-serif text-lg leading-relaxed text-ink-2 italic" class="mb-6 border-b border-dashed border-line-2 pb-4 font-serif text-lg leading-relaxed text-ink-2 italic"
> >
{introText} {introText}
</p> </p>

View File

@@ -61,7 +61,7 @@ async function handleDelete() {
<header class="mb-6"> <header class="mb-6">
{#if isJourney} {#if isJourney}
<span <span
class="mb-2 inline-flex rounded-sm border border-journey-border bg-journey-tint px-2 py-px text-[10px] font-bold tracking-widest text-journey uppercase" class="mb-2 inline-flex rounded-sm border border-journey-border bg-journey-tint px-2 py-px text-xs font-bold tracking-widest text-journey uppercase"
> >
{m.journey_badge_detail()} {m.journey_badge_detail()}
</span> </span>
@@ -74,7 +74,7 @@ async function handleDelete() {
<span <span
aria-hidden="true" aria-hidden="true"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white"
style="background-color: {personAvatarColor(g.author?.id ?? authorName)}" style="background-color: {personAvatarColor(authorName)}"
> >
{getInitials(authorName)} {getInitials(authorName)}
</span> </span>
@@ -97,14 +97,14 @@ async function handleDelete() {
<div class="ml-auto flex items-center gap-3"> <div class="ml-auto flex items-center gap-3">
<a <a
href="/geschichten/{g.id}/edit" href="/geschichten/{g.id}/edit"
class="inline-flex h-9 items-center rounded border border-line bg-surface px-3 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex h-11 items-center rounded border border-line bg-surface px-3 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{m.btn_edit()} {m.btn_edit()}
</a> </a>
<button <button
type="button" type="button"
onclick={handleDelete} onclick={handleDelete}
class="inline-flex h-9 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{m.btn_delete()} {m.btn_delete()}
</button> </button>

View File

@@ -61,6 +61,7 @@ async function handleSubmit(e: SubmitEvent) {
<input <input
type="text" type="text"
bind:value={title} bind:value={title}
maxlength="255"
onblur={() => (titleTouched = true)} onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()} placeholder={m.geschichte_editor_title_placeholder()}
aria-label={m.journey_title_aria_label()} aria-label={m.journey_title_aria_label()}
@@ -78,7 +79,7 @@ async function handleSubmit(e: SubmitEvent) {
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting}
class="inline-flex h-11 items-center rounded bg-brand-navy px-4 font-sans text-sm font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50" class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
> >
{m.journey_create_submit()} {m.journey_create_submit()}
</button> </button>