Timeline: polish & accessibility pass #783
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Milestone: Zeitstrahl — Family Timeline
Spec:
docs/superpowers/specs/2026-06-07-family-timeline-design.md§ "Frontend" / "Testing"Depends on: global view (#7), filters (#8), forms (#9), per-person view (#10).
Scope
Final cross-cutting polish once all timeline surfaces exist (issues #7–#10). No new behavior, no new endpoints, no new entity fields. If a gap found during this pass requires new behavior, open a new issue and link it here.
Implementation Notes
Component discipline
$derivedinTimelineView.svelte:const isEmpty = $derived(years.length === 0 && undated.length === 0). Do not compute inline in the template.{#each}blocks must be keyed:(year.year)for year bands,(entry.id)for entries. Filter interactions reorder both simultaneously — unkeyed reconciliation corrupts card state.<BackButton>component; no static<a href>for back.TimelineView.sveltemust use semantic structure: each year band is a<section aria-labelledby="year-{year}">with an<h2 id="year-{year}">heading — screen-reader users must be able to jump between years by landmark/heading. axe will not catch a missing heading hierarchy; this is a manual check item.Dark-mode token rule
Every timeline component in
frontend/src/lib/timeline/*.sveltemust use semantic tokens only (bg-surface,text-ink,border-line, etc.). No raw hex values.Machine-checkable gate:
grep -rE 'bg-\[#|text-\[#|#[0-9a-fA-F]{3,6}' frontend/src/lib/timeline/→ zero hits is green.PERSONAL vs HISTORICAL distinction
The visual distinction between PERSONAL events and HISTORICAL events must use icon + label, never color alone (WCAG 1.4.1). Use a person glyph for PERSONAL and a globe/world glyph for HISTORICAL. axe will not catch color-only distinction; this is an explicit AC.
Date rendering (dateLabel.ts)
The precision-aware date helper lives in
frontend/src/lib/timeline/dateLabel.ts, separate fromfrontend/src/lib/document/timeline.ts(which is the document density bar helper —formatTickLabel,monthBoundaryFrom, etc.). Reuse locale logic fromformatTickLabelwhere it genuinely fits; do not force it.Write failing unit tests for all 7
DatePrecisionvariants first (red/green TDD):28. Juli 1914Juli 1914Sommer 19141914ca. 19141914–1918Ohne Datumi18n namespace
All new Zeitstrahl translation keys use the prefix
zeitstrahl_(underscore, matching the existingtimeline_*convention for the density bar). Do not usetimeline.*— that namespace is reserved for the document density bar. Addzeitstrahl_*keys tode.jsonfirst, thenen.jsonandes.json.Backend contract
TimelineDTOmust serializeyearsandundatedas empty arrays, nevernull, for an empty archive. Ensure@Schema(requiredMode = REQUIRED)is present on both collection fields — this drives the TypeScript types to non-optional, making the frontend$derivedhonest. If a regression exists from issue #5, add a@DataJpaTestasserting empty-array serialization here.Curator forms at mobile
Curator forms (
/zeitstrahl/events/new|edit) are tablet-primary surfaces for transcribers (60+, laptop/tablet). They are excluded from mobile verification at 375px and 320px. This is an intentional constraint, not an oversight.E2E tests and CI
E2E tests (
frontend/e2e/) are not currently wired into CI (pipeline stops at unit/component tests). Thetimeline-a11y.spec.tsspec is a local developer gate until a companiondevops-labeled issue wires a Playwright E2E job intoci.yml. Before closing this issue, verifytimeline-a11y.spec.tsruns clean locally against a seeded stack.Empty States (Resolved)
Four distinct empty variants, each with explicit copy and a component spec:
Role-aware CTA rule (resolved): The "Create event" CTA is rendered only when
data.canWriteistrue(server-evaluatedWRITE_ALLin+page.server.ts, passed as prop). Readers always see the neutral message. Client-side hiding alone is not sufficient.Empty-state containers must use
role="status"oraria-live="polite"so screen readers announce the state.Responsive Breakpoints (Resolved)
Test at 320, 375, 768, and 1440px — matching the
dashboard-enrichment-block.spec.tshouse standard./zeitstrahl/events/new|edit) are excluded from the 320/375 sweep (tablet-primary surfaces).Tasks
frontend/src/lib/timeline/dateLabel.ts— TDD: 7 failing tests → implement → green.TimelineView.svelte— add named$derivedempty-state flag; key all{#each}blocks; use<section aria-labelledby>+<h2>for year bands.data.canWriteprop from server).aria-live="polite"on the timeline region container;aria-labelon each year section.grep -rE 'bg-\[#|text-\[#|#[0-9a-fA-F]{3,6}' frontend/src/lib/timeline/→ zero hits./zeitstrahland Lebensweg section — light + dark, all 4 viewports.zeitstrahl_*keys to de/en/es; verify with key-parity unit test.TimelineDTO.yearsand.undatedserialize as[], notnull(@Schema(requiredMode = REQUIRED)).timeline-a11y.spec.tsruns clean locally against a seeded stack before closing.rel="noopener noreferrer".Acceptance Criteria
/zeitstrahland the Lebensweg section at 320/375/768/1440px in both light and dark mode (withTags(['wcag2a','wcag2aa'])).<section aria-labelledby>+<h2>— navigable by landmark/heading.aria-live="polite"on the timeline container.data.canWriteis true (server-evaluated); readers see neutral messages.grep -rE 'bg-\[#|text-\[#|#[0-9a-fA-F]{3,6}' frontend/src/lib/timeline/→ zero hits.zeitstrahl_*keys present in de/en/es with matching key sets.TimelineDTOserializesyearsandundatedas empty arrays (nevernull) for an empty archive.Tests
E2E (Playwright) —
frontend/e2e/timeline-a11y.spec.tsdashboard-enrichment-block.spec.ts(NOTaccessibility.spec.ts— the latter is fixed viewport).VIEWPORTS = [320, 375, 768, 1440]×SCHEMES = ['light', 'dark']×PAGES = ['/zeitstrahl', person-detail Lebensweg].new AxeBuilder({ page }).withTags(['wcag2a','wcag2aa']).analyze()— assertviolationsis empty.browser.newContext({ colorScheme: 'dark' })ANDdocument.documentElement.setAttribute('data-theme', 'dark').devopsissue).Unit/Component —
frontend/src/lib/timeline/dateLabel.spec.ts— 7 tests, one perDatePrecisionvariant. Red first.TimelineView.empty-no-events.svelte.spec.ts— no events, no letters; curator sees CTA, reader sees neutral.TimelineView.empty-no-letters.svelte.spec.ts— events only; no "0 letters" noise.TimelineView.empty-undated.svelte.spec.ts— fully undated; year-band spine absent; explanatory line present.TimelineView.empty-person.svelte.spec.ts— per-person Lebensweg with zero entries.--project=client; land in existingunit-testsCI job automatically.i18n key-parity unit test
Object.keys(de).filter(k => k.startsWith('zeitstrahl_')).sort()must exactly equal the same expression over en and es — fails immediately on missing or extra keys.Decisions Resolved
dashboard-enrichment-block.spec.ts; protects against small Android overflow at zero extra design cost.data.canWriteprop — worth it.