Timeline: polish & accessibility pass #783

Open
opened 2026-06-07 19:29:42 +02:00 by marcel · 0 comments
Owner

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

  • Empty-state logic must be a named $derived in TimelineView.svelte: const isEmpty = $derived(years.length === 0 && undated.length === 0). Do not compute inline in the template.
  • All {#each} blocks must be keyed: (year.year) for year bands, (entry.id) for entries. Filter interactions reorder both simultaneously — unkeyed reconciliation corrupts card state.
  • All back navigation must use the shared <BackButton> component; no static <a href> for back.
  • TimelineView.svelte must 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/*.svelte must 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 from frontend/src/lib/document/timeline.ts (which is the document density bar helper — formatTickLabel, monthBoundaryFrom, etc.). Reuse locale logic from formatTickLabel where it genuinely fits; do not force it.

Write failing unit tests for all 7 DatePrecision variants first (red/green TDD):

Precision German render
DAY 28. Juli 1914
MONTH Juli 1914
SEASON Sommer 1914
YEAR 1914
APPROX ca. 1914
RANGE 1914–1918
UNKNOWN Ohne Datum

i18n namespace

All new Zeitstrahl translation keys use the prefix zeitstrahl_ (underscore, matching the existing timeline_* convention for the density bar). Do not use timeline.* — that namespace is reserved for the document density bar. Add zeitstrahl_* keys to de.json first, then en.json and es.json.

Backend contract

TimelineDTO must serialize years and undated as empty arrays, never null, 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 $derived honest. If a regression exists from issue #5, add a @DataJpaTest asserting 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). The timeline-a11y.spec.ts spec is a local developer gate until a companion devops-labeled issue wires a Playwright E2E job into ci.yml. Before closing this issue, verify timeline-a11y.spec.ts runs clean locally against a seeded stack.


Empty States (Resolved)

Four distinct empty variants, each with explicit copy and a component spec:

Variant Condition Curator (WRITE_ALL) Reader
No events, no letters Fresh archive, nothing "Noch keine Ereignisse. [Ereignis erstellen →]" (CTA button) "Noch nichts hier."
No letters Events exist, no documents Events render normally; no letter rows, no "0 letters" noise same
Fully undated archive All dates UNKNOWN Year-band spine does not render; "Ohne Datum"-bucket shows with count; explanatory line: "Alle Dokumente haben noch kein Datum." same (no CTA)
Per-person Lebensweg empty Person has no dates, no events, no letters "Noch keine Einträge für diese Person. [Ereignis erstellen →]" "Für diese Person sind noch keine Einträge vorhanden."

Role-aware CTA rule (resolved): The "Create event" CTA is rendered only when data.canWrite is true (server-evaluated WRITE_ALL in +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" or aria-live="polite" so screen readers announce the state.


Responsive Breakpoints (Resolved)

Test at 320, 375, 768, and 1440px — matching the dashboard-enrichment-block.spec.ts house standard.

  • 320px is the floor (real-world Android minimum — non-negotiable).
  • 375px is the iPhone check (kept in addition to 320).
  • 768px tablet, 1440px desktop.
  • Curator forms (/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 $derived empty-state flag; key all {#each} blocks; use <section aria-labelledby> + <h2> for year bands.
  • Empty states — implement all 4 variants with role-aware CTA logic (data.canWrite prop from server).
  • PERSONAL vs HISTORICAL — icon + label on every EventCard (not color alone).
  • aria-live="polite" on the timeline region container; aria-label on each year section.
  • Touch targets — all interactive elements (EventCard, LetterCard, filter controls) must have a minimum 44×44px touch target.
  • Dark-mode grep gate: grep -rE 'bg-\[#|text-\[#|#[0-9a-fA-F]{3,6}' frontend/src/lib/timeline/ → zero hits.
  • Mobile layout verified at 320, 375, 768, 1440px (bands, cards, filters — forms excluded).
  • axe-playwright sweep on /zeitstrahl and Lebensweg section — light + dark, all 4 viewports.
  • i18n completeness: add zeitstrahl_* keys to de/en/es; verify with key-parity unit test.
  • Backend: confirm TimelineDTO.years and .undated serialize as [], not null (@Schema(requiredMode = REQUIRED)).
  • Verify timeline-a11y.spec.ts runs clean locally against a seeded stack before closing.
  • External links (if any): carry rel="noopener noreferrer".

Acceptance Criteria

  1. No axe violations on /zeitstrahl and the Lebensweg section at 320/375/768/1440px in both light and dark mode (withTags(['wcag2a','wcag2aa'])).
  2. PERSONAL/HISTORICAL distinction uses icon + label, never color alone (WCAG 1.4.1).
  3. Year bands use semantic <section aria-labelledby> + <h2> — navigable by landmark/heading.
  4. All interactive timeline elements (EventCard, LetterCard, filter controls) have a minimum 44×44px touch target.
  5. Filter changes are announced to screen readers via aria-live="polite" on the timeline container.
  6. Empty states — all 4 variants render correct copy; curator sees "Create event" CTA only when data.canWrite is true (server-evaluated); readers see neutral messages.
  7. Dark-mode grep gate passes: grep -rE 'bg-\[#|text-\[#|#[0-9a-fA-F]{3,6}' frontend/src/lib/timeline/ → zero hits.
  8. i18n completeness — key-parity unit test passes: all zeitstrahl_* keys present in de/en/es with matching key sets.
  9. TimelineDTO serializes years and undated as empty arrays (never null) for an empty archive.
  10. No new behavior introduced — any gap requiring new endpoints or entity fields is filed as a separate issue.
  11. Curator forms excluded from mobile sweep — documented constraint, not an oversight.

Tests

E2E (Playwright) — frontend/e2e/timeline-a11y.spec.ts

  • Template: dashboard-enrichment-block.spec.ts (NOT accessibility.spec.ts — the latter is fixed viewport).
  • Loop: VIEWPORTS = [320, 375, 768, 1440] × SCHEMES = ['light', 'dark'] × PAGES = ['/zeitstrahl', person-detail Lebensweg].
  • axe: new AxeBuilder({ page }).withTags(['wcag2a','wcag2aa']).analyze() — assert violations is empty.
  • Dark mode: use both browser.newContext({ colorScheme: 'dark' }) AND document.documentElement.setAttribute('data-theme', 'dark').
  • Fixture requirement: at least one curated event, one person with birth/death dates, and one dated letter seeded in the DB — prevents false-green sweeps against the empty state.
  • Note: runs locally only until CI E2E job is wired (companion devops issue).

Unit/Component — frontend/src/lib/timeline/

  • dateLabel.spec.ts — 7 tests, one per DatePrecision variant. 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.
  • Run with --project=client; land in existing unit-tests CI 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

Decision Resolution Rationale
Breakpoint floor: 375px vs 320px 320px floor + keep 375px Matches house standard in dashboard-enrichment-block.spec.ts; protects against small Android overflow at zero extra design cost.
Empty-state CTA: role-aware vs neutral Role-aware — curator sees CTA, reader sees neutral Cleaner UX (no dangling 403 buttons); security is correct (server-evaluated); cost is one data.canWrite prop — worth it.
**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 - Empty-state logic must be a named `$derived` in `TimelineView.svelte`: `const isEmpty = $derived(years.length === 0 && undated.length === 0)`. Do not compute inline in the template. - All `{#each}` blocks must be keyed: `(year.year)` for year bands, `(entry.id)` for entries. Filter interactions reorder both simultaneously — unkeyed reconciliation corrupts card state. - All back navigation must use the shared `<BackButton>` component; no static `<a href>` for back. - `TimelineView.svelte` must 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/*.svelte` must 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 from `frontend/src/lib/document/timeline.ts` (which is the document density bar helper — `formatTickLabel`, `monthBoundaryFrom`, etc.). Reuse locale logic from `formatTickLabel` where it genuinely fits; do not force it. Write failing unit tests for all 7 `DatePrecision` variants first (red/green TDD): | Precision | German render | |-----------|--------------| | DAY | `28. Juli 1914` | | MONTH | `Juli 1914` | | SEASON | `Sommer 1914` | | YEAR | `1914` | | APPROX | `ca. 1914` | | RANGE | `1914–1918` | | UNKNOWN | `Ohne Datum` | ### i18n namespace All new Zeitstrahl translation keys use the prefix `zeitstrahl_` (underscore, matching the existing `timeline_*` convention for the density bar). Do not use `timeline.*` — that namespace is reserved for the document density bar. Add `zeitstrahl_*` keys to `de.json` first, then `en.json` and `es.json`. ### Backend contract `TimelineDTO` must serialize `years` and `undated` as empty arrays, never `null`, 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 `$derived` honest. If a regression exists from issue #5, add a `@DataJpaTest` asserting 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). The `timeline-a11y.spec.ts` spec is a local developer gate until a companion `devops`-labeled issue wires a Playwright E2E job into `ci.yml`. Before closing this issue, verify `timeline-a11y.spec.ts` runs clean locally against a seeded stack. --- ## Empty States (Resolved) Four distinct empty variants, each with explicit copy and a component spec: | Variant | Condition | Curator (WRITE_ALL) | Reader | |---------|-----------|---------------------|--------| | **No events, no letters** | Fresh archive, nothing | "Noch keine Ereignisse. [Ereignis erstellen →]" (CTA button) | "Noch nichts hier." | | **No letters** | Events exist, no documents | Events render normally; no letter rows, no "0 letters" noise | same | | **Fully undated archive** | All dates UNKNOWN | Year-band spine does not render; "Ohne Datum"-bucket shows with count; explanatory line: "Alle Dokumente haben noch kein Datum." | same (no CTA) | | **Per-person Lebensweg empty** | Person has no dates, no events, no letters | "Noch keine Einträge für diese Person. [Ereignis erstellen →]" | "Für diese Person sind noch keine Einträge vorhanden." | **Role-aware CTA rule (resolved):** The "Create event" CTA is rendered only when `data.canWrite` is `true` (server-evaluated `WRITE_ALL` in `+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"` or `aria-live="polite"` so screen readers announce the state. --- ## Responsive Breakpoints (Resolved) Test at **320, 375, 768, and 1440px** — matching the `dashboard-enrichment-block.spec.ts` house standard. - **320px** is the floor (real-world Android minimum — non-negotiable). - **375px** is the iPhone check (kept in addition to 320). - **768px** tablet, **1440px** desktop. - Curator forms (`/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 `$derived` empty-state flag; key all `{#each}` blocks; use `<section aria-labelledby>` + `<h2>` for year bands. - [ ] Empty states — implement all 4 variants with role-aware CTA logic (`data.canWrite` prop from server). - [ ] PERSONAL vs HISTORICAL — icon + label on every EventCard (not color alone). - [ ] `aria-live="polite"` on the timeline region container; `aria-label` on each year section. - [ ] Touch targets — all interactive elements (EventCard, LetterCard, filter controls) must have a minimum 44×44px touch target. - [ ] Dark-mode grep gate: `grep -rE 'bg-\[#|text-\[#|#[0-9a-fA-F]{3,6}' frontend/src/lib/timeline/` → zero hits. - [ ] Mobile layout verified at 320, 375, 768, 1440px (bands, cards, filters — forms excluded). - [ ] axe-playwright sweep on `/zeitstrahl` and Lebensweg section — light + dark, all 4 viewports. - [ ] i18n completeness: add `zeitstrahl_*` keys to de/en/es; verify with key-parity unit test. - [ ] Backend: confirm `TimelineDTO.years` and `.undated` serialize as `[]`, not `null` (`@Schema(requiredMode = REQUIRED)`). - [ ] Verify `timeline-a11y.spec.ts` runs clean locally against a seeded stack before closing. - [ ] External links (if any): carry `rel="noopener noreferrer"`. --- ## Acceptance Criteria 1. **No axe violations** on `/zeitstrahl` and the Lebensweg section at 320/375/768/1440px in both light and dark mode (`withTags(['wcag2a','wcag2aa'])`). 2. **PERSONAL/HISTORICAL distinction** uses icon + label, never color alone (WCAG 1.4.1). 3. **Year bands** use semantic `<section aria-labelledby>` + `<h2>` — navigable by landmark/heading. 4. **All interactive timeline elements** (EventCard, LetterCard, filter controls) have a minimum 44×44px touch target. 5. **Filter changes** are announced to screen readers via `aria-live="polite"` on the timeline container. 6. **Empty states** — all 4 variants render correct copy; curator sees "Create event" CTA only when `data.canWrite` is true (server-evaluated); readers see neutral messages. 7. **Dark-mode grep gate** passes: `grep -rE 'bg-\[#|text-\[#|#[0-9a-fA-F]{3,6}' frontend/src/lib/timeline/` → zero hits. 8. **i18n completeness** — key-parity unit test passes: all `zeitstrahl_*` keys present in de/en/es with matching key sets. 9. **`TimelineDTO`** serializes `years` and `undated` as empty arrays (never `null`) for an empty archive. 10. **No new behavior** introduced — any gap requiring new endpoints or entity fields is filed as a separate issue. 11. **Curator forms excluded from mobile sweep** — documented constraint, not an oversight. --- ## Tests ### E2E (Playwright) — `frontend/e2e/timeline-a11y.spec.ts` - Template: `dashboard-enrichment-block.spec.ts` (NOT `accessibility.spec.ts` — the latter is fixed viewport). - Loop: `VIEWPORTS = [320, 375, 768, 1440]` × `SCHEMES = ['light', 'dark']` × `PAGES = ['/zeitstrahl', person-detail Lebensweg]`. - axe: `new AxeBuilder({ page }).withTags(['wcag2a','wcag2aa']).analyze()` — assert `violations` is empty. - Dark mode: use both `browser.newContext({ colorScheme: 'dark' })` AND `document.documentElement.setAttribute('data-theme', 'dark')`. - Fixture requirement: at least one curated event, one person with birth/death dates, and one dated letter seeded in the DB — prevents false-green sweeps against the empty state. - Note: runs locally only until CI E2E job is wired (companion `devops` issue). ### Unit/Component — `frontend/src/lib/timeline/` - `dateLabel.spec.ts` — 7 tests, one per `DatePrecision` variant. 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. - Run with `--project=client`; land in existing `unit-tests` CI 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 | Decision | Resolution | Rationale | |----------|------------|-----------| | Breakpoint floor: 375px vs 320px | **320px floor + keep 375px** | Matches house standard in `dashboard-enrichment-block.spec.ts`; protects against small Android overflow at zero extra design cost. | | Empty-state CTA: role-aware vs neutral | **Role-aware** — curator sees CTA, reader sees neutral | Cleaner UX (no dangling 403 buttons); security is correct (server-evaluated); cost is one `data.canWrite` prop — worth it. |
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-07 19:29:42 +02:00
marcel added the P3-laterui labels 2026-06-07 19:30:08 +02:00
Sign in to join this conversation.
No Label P3-later ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#783