feat(timeline): global /zeitstrahl timeline (Concept A) — #779 #831

Merged
marcel merged 22 commits from feat/issue-779-zeitstrahl into main 2026-06-13 21:56:46 +02:00
Owner

Global /zeitstrahl timeline — Concept A "Der Lebensfaden"

Closes #779. Builds the frontend route + component tree that renders the existing GET /api/timelineTimelineDTO, in "Datum" (chronological) mode, covering the full Concept-A layout from docs/specs/zeitstrahl-final-spec.html. Presentation-only: entries render in DTO order, never re-sorted/re-bucketed. No backend change, no generate:api, no migration, no new ErrorCode.

What's here

  • Refactor-first (REQ-026): the pure month-bucket math moved document/timeline.ts$lib/shared/utils/monthBuckets.ts so lib/timeline/ never imports lib/document/. The /api/documents/density glue (fetchDensity/buildDensityUrl) stays put; the 3 density components + the relocated spec are re-pointed. grep -rn lib/document frontend/src/lib/timeline/ → zero.
  • Timeline domain frontend/src/lib/timeline/: TimelineView (orchestrator, <ol> + gap-fold + undated + empty-state, derived-safe {#each} keys, personId seam), YearBand (sticky <h2>, cards-vs-strip, DTO order, desktop alternating axis), EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan, eventCardConfig, timelineDensity, test-factories.
  • Shared Sparkline primitive; route /zeitstrahl/+page.{server.ts,svelte} (SSR-first, mirrors stammbaum); nav link + 14 i18n keys (de/en/es).
  • E2E e2e/zeitstrahl.spec.ts (nav smoke, <main> containment, 320px no-overflow on a seeded long-named letter).
  • Docs: CLAUDE.md + frontend/CLAUDE.md route tables, document/shared READMEs (monthBuckets move), GLOSSARY "Lebensweg", c4 l3-frontend diagram, RTM REQ-001..027 → Done.

Requirement coverage

All REQ-001..027 implemented and tested (see .specify/rtm.md, feature zeitstrahl-global-view). 18 commits, atomic, red/green TDD.

  • Node/server tests: 63 passing (monthBuckets, timelineDensity, eventCardConfig, document/timeline, zeitstrahl/page.server).
  • Browser/component + E2E tests run in CI (*.svelte.spec.ts × 7, e2e/zeitstrahl.spec.ts) — not run locally by policy.

REQ-019 — contrast (manual, recorded)

The HISTORICAL band label renders in text-ink-2 (the spec's documented fallback), not raw slate:

  • Light: #4b5563 on canvas #f0efe96.6:1 (AA ✓)
  • Dark: #9ca3af on canvas #010e1e≈7.6:1 (AAA ✓)

The raw tag-slate (#607080) measures ≈4.4:1 on canvas in light — below 4.5:1 — which is exactly why the band text falls back to text-ink-2 per REQ-019. Slate is used only for the decorative glyph, which carries an sr-only "Weltgeschehen" label (color never the sole cue, WCAG 1.4.1).

REQ-021 — escaping guard

grep -r '@html' frontend/src/lib/timeline/zero. All OCR/import-derived text (title, senderName, receiverName) renders via default {...} escaping + whitespace-pre-line.

Out of scope (follow-ups filed under milestone #14)

Grouping toggle (#827), leading/trailing archive-range decades (#828), curator quiet-span labels (#829), per-month strip drill-down (#830).

🤖 Generated with Claude Code

## Global `/zeitstrahl` timeline — Concept A "Der Lebensfaden" Closes #779. Builds the **frontend** route + component tree that renders the existing `GET /api/timeline` → `TimelineDTO`, in **"Datum" (chronological) mode**, covering the full Concept-A layout from `docs/specs/zeitstrahl-final-spec.html`. Presentation-only: entries render in DTO order, never re-sorted/re-bucketed. **No backend change, no `generate:api`, no migration, no new `ErrorCode`.** ### What's here - **Refactor-first (REQ-026):** the pure month-bucket math moved `document/timeline.ts` → `$lib/shared/utils/monthBuckets.ts` so `lib/timeline/` never imports `lib/document/`. The `/api/documents/density` glue (`fetchDensity`/`buildDensityUrl`) stays put; the 3 density components + the relocated spec are re-pointed. `grep -rn lib/document frontend/src/lib/timeline/` → zero. - **Timeline domain** `frontend/src/lib/timeline/`: `TimelineView` (orchestrator, `<ol>` + gap-fold + undated + empty-state, derived-safe `{#each}` keys, `personId` seam), `YearBand` (sticky `<h2>`, cards-vs-strip, DTO order, desktop alternating axis), `EventPill`, `WorldBand`, `LetterCard`, `YearLetterStrip`, `GapSpan`, `eventCardConfig`, `timelineDensity`, `test-factories`. - **Shared** `Sparkline` primitive; **route** `/zeitstrahl/+page.{server.ts,svelte}` (SSR-first, mirrors `stammbaum`); **nav link** + 14 i18n keys (de/en/es). - **E2E** `e2e/zeitstrahl.spec.ts` (nav smoke, `<main>` containment, 320px no-overflow on a seeded long-named letter). - **Docs**: CLAUDE.md + frontend/CLAUDE.md route tables, document/shared READMEs (monthBuckets move), GLOSSARY "Lebensweg", c4 l3-frontend diagram, RTM REQ-001..027 → Done. ### Requirement coverage All **REQ-001..027** implemented and tested (see `.specify/rtm.md`, feature `zeitstrahl-global-view`). 18 commits, atomic, red/green TDD. - Node/server tests: **63 passing** (`monthBuckets`, `timelineDensity`, `eventCardConfig`, `document/timeline`, `zeitstrahl/page.server`). - Browser/component + E2E tests run in **CI** (`*.svelte.spec.ts` × 7, `e2e/zeitstrahl.spec.ts`) — not run locally by policy. ### REQ-019 — contrast (manual, recorded) The HISTORICAL band label renders in **`text-ink-2`** (the spec's documented fallback), not raw slate: - **Light:** `#4b5563` on canvas `#f0efe9` → **6.6:1** (AA ✓) - **Dark:** `#9ca3af` on canvas `#010e1e` → **≈7.6:1** (AAA ✓) The raw `tag-slate` (#607080) measures ≈**4.4:1** on canvas in light — below 4.5:1 — which is exactly why the band text falls back to `text-ink-2` per REQ-019. Slate is used only for the decorative `◍` glyph, which carries an `sr-only` "Weltgeschehen" label (color never the sole cue, WCAG 1.4.1). ### REQ-021 — escaping guard `grep -r '@html' frontend/src/lib/timeline/` → **zero**. All OCR/import-derived text (`title`, `senderName`, `receiverName`) renders via default `{...}` escaping + `whitespace-pre-line`. ### Out of scope (follow-ups filed under milestone #14) Grouping toggle (#827), leading/trailing archive-range decades (#828), curator quiet-span labels (#829), per-month strip drill-down (#830). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 17 commits 2026-06-13 20:07:36 +02:00
Relocate the 10 pure helpers (monthBoundaryFrom/To, buildMonthSequence,
fillDensityGaps, clipBucketsToRange, aggregateToYears, selectionBoundaryFrom/To,
tickIndicesFor, formatTickLabel) and their unit tests out of document/timeline.ts
into a shared module so lib/timeline/ can consume them without importing
lib/document/. The /api/documents/density glue (buildDensityUrl, fetchDensity,
DensityState, DensityFilters) stays in document/timeline.ts. Re-point the three
density components and the density-filter spec at the shared module.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isDense(count) thresholds dense year bands at >12 letters (REQ-012);
monthHistogram(letters, year) buckets a band's letters into exactly 12 month
buckets via the shared fillDensityGaps, counting each letter on its eventDate
anchor month and ignoring undated entries (REQ-027). Imports shared only.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 Paraglide keys for the /zeitstrahl view: nav link, heading, empty/undated/
gap/unknown-person chrome, letters count, strip expand, range aria, and the
layer/derived labels. The layer (Weltgeschehen/Familie) and derived (Geburt/
Tod/Heirat) labels carry the German term across all locales by design
(documented MVP decision). REQ-024.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A minimal presentational bar series (one bar per value, heights scaled to the
max, faint floor for empty buckets). Lives in shared so both the timeline
density strip and the document chart can use it. REQ-012 (supports).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getAccentConfig(entry) maps each EVENT to its glyph (* / † / ⚭ / ★ / ◍), German
redundant-cue label, and accent kind (REQ-007/008/018). test-factories build
TimelineEntryDTO/TimelineDTO mirroring the real wire shape for component specs.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Single archive letter: sender → receiver (Unbekannt fallback for empty names,
REQ-014), title, precision date chip via timelineDateLabel (omitted when null,
REQ-013), linking to exactly /documents/{documentId} with no target (REQ-023).
44px touch target enforced inline + focus-visible ring (REQ-020). OCR/import
text via {...} escaping + whitespace-pre-line, no {@html} (REQ-021).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Centered axis pill: derived life-events (* Geburt / † Tod / ⚭ Heirat) and curated
PERSONAL events (★, mint border) via getAccentConfig. Glyph wrapped aria-hidden +
sr-only label (REQ-018). Edit affordance only for a curated event with eventId,
never derived/null (REQ-008). REQ-007.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full-width muted band; RANGE renders a span pill (1914–1918) with a Zeitraum
aria-label (REQ-009); a RANGE with no end degrades to the start year, no pill,
no crash (REQ-010). World glyph is a redundant non-color cue with sr-only label
(REQ-018); text uses text-ink-2 to hold AA in both themes (REQ-019).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A thin dashed span rendering '{from}–{to} · keine Einträge', collapsing to a
single year when the run has length 1 (REQ-015).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Letter count + 12-month density sparkline + a >=44px keyboard-focusable expand
toggle that reveals that year's LetterCards (REQ-012). Sparkline values from the
shared monthHistogram.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One <section> per year with a sticky <h2> at top:4rem (REQ-006). Events render in
DTO order as pills/bands; letters render as individual cards while <= 12 (REQ-011)
or collapse to one density strip above that (REQ-012); DTO order is never re-sorted
(REQ-003). Letters carry an alternating data-side for the centered desktop axis
(REQ-004); single left column on phone (REQ-005). Derived-safe {#each} key.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renders year bands in DTO order with interior empty-year runs folded into one
GapSpan (REQ-015), a single <ol> in chronological DOM order (REQ-006), the undated
bucket via {#if} (REQ-016), and a calm empty state (REQ-017). personId is a
declared seam (issue #10), undefined here, never passed to leaf cards (REQ-025).
Centered desktop spine / left phone spine via scoped CSS. Owns no <main>.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
break-words on sender/receiver/title so a 25+char correspondent name cannot
force horizontal overflow on a 320px phone (REQ-005).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SSR-first load fetches GET /api/timeline via createApiClient (auth cookie
forwarded), no query params for the global view (REQ-001), returns { timeline }
with no client-side fetch (REQ-002); 401 -> /login, any other non-ok ->
error(status, getErrorMessage(...)), never raw JSON, no PII logged (REQ-022).
The page renders <TimelineView> under the layout's <main>. Adds the Zeitstrahl
nav link (desktop + mobile) and 'timeline' to the eslint routes boundary
allow-list so the route may import the domain.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Nav-link smoke + timeline-in-<main> (empty-or-populated), and the 320px
no-overflow guarantee on a timeline seeded with 25+char correspondent names
(REQ-005). Runs against the real stack via the seeded admin session.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(timeline): document /zeitstrahl, lib/timeline, monthBuckets move; RTM #779
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m28s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 6m25s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
fee519b8a9
Route tables (CLAUDE.md + frontend/CLAUDE.md), the document/timeline.ts ->
$lib/shared/utils/monthBuckets move (document + shared READMEs), GLOSSARY
Lebensweg entry, the c4 l3-frontend people-stories diagram, and the RTM rows
REQ-001..027 for feature zeitstrahl-global-view (#779), all marked Done.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel added the featureui labels 2026-06-13 20:07:45 +02:00
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-13 20:07:46 +02:00
marcel added 3 commits 2026-06-13 20:35:44 +02:00
Move the per-entry {#each} key logic into a shared entryKey.ts so the
undated bucket in TimelineView can reuse it. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The undated bucket is assembled from all entries, so it can contain
events as well as letters. Rendering every undated entry with LetterCard
produced a dead /documents/undefined link and "Unknown -> Unknown" for
events. Dispatch on kind/type like YearBand does (WorldBand/EventPill/
LetterCard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(i18n): translate timeline sr-only labels in en/es locales
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m18s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m17s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
4a6fd770d7
timeline_layer_* and timeline_derived_* shipped German values in the
English and Spanish catalogs, so EN/ES screen-reader users heard German
for the world/family layer and birth/death/marriage cues. Translate them;
de.json stays canonical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

📋 Requirements Engineer — PR Review

Verdict: 🚫 Changes requested

One requirement is contradicted by the code (REQ-024). Everything else traces end-to-end. RTM rows were flipped to Done, but the issue body's Traceability still says Planned (RTM is the committed source of truth, so that's fine — noting for the record).

Per-REQ traceability

REQ Implemented? Tested? RTM Done? Note
001 route renders, personId undefined +page.server.ts, TimelineView page.server.test.ts, TimelineView…#person-id-noop
002 SSR fetch via createApiClient +page.server.ts page.server.test.ts (asserts GET('/api/timeline'), no client fetch)
003 DTO-order, no re-sort TimelineView/YearBand pass-through YearBand…#DTO order, TimelineView…#ol-order
004 ≥1024px centered alternating YearBand CSS + data-side surrogate #alternating sides + e2e
005 <1024px left axis, 320 floor scoped CSS e2e#no-overflow-320 (seeded long names)
006 single <ol>, sticky <h2> top:4rem TimelineView…#ol, YearBand…#sticky
007 derived pill glyph+German label eventCardConfig EventPill…, eventCardConfig.spec
008 curated pill, edit iff eventId EventPill 3 cases href target unspecified by spec — see Developer/UX
009 HISTORICAL band once + span pill aria WorldBand WorldBand…#range pill "once per year / not repeated" is the backend's job; component is correct
010 RANGE null end degrade WorldBand…#degrades
011 ≤12 → cards YearBand
012 >12 → strip+sparkline+expand YearLetterStrip 3 cases
013 date via timelineDateLabel LetterCard…#precision, #null→no chip
014 empty name → "Unbekannt" LetterCard sender + receiver cases
015 interior gap fold TimelineView/GapSpan multi + single-year
016 undated section iff non-empty present + absent cases
017 empty state #empty-state
018 redundant non-color cue aria-hidden glyph + sr-only across EventPill/WorldBand/TimelineView
019 AA contrast both themes text-ink-2 fallback manual, both ratios in PR body satisfied
020 ≥44px + focus ring LetterCard #touch-target
021 no {@html} grep → zero (verified)
022 non-ok → mapped error, 401→login +page.server.ts 401/404/500/403
023 href exactly /documents/{id} #href
024 strings via Paraglide; derived/layer labels German-only across locales 🚫 CONTRADICTED ⚠️ tests pass only because they run in de (RTM claims Done) see blocker
025 personId prop seam, not passed down #person-id-noop
026 monthBuckets in shared, no timeline→document import relocated spec + grep → zero
027 monthHistogram → 12 buckets timelineDensity timelineDensity.spec

Blockers

  • frontend/messages/en.json:1044-1048 and es.json:1044-1048 contradict REQ-024. REQ-024 and the issue's i18n table state, verbatim: "The EN/ES timeline.derived.* and timeline.layer.* values intentionally carry the German term (documented MVP decision, not an oversight)." The table mandates de = en = es = Weltgeschehen / Familie / Geburt / Tod / Heirat. The code ships en = World events / Family / Birth / Death / Marriage and es = Acontecimientos mundiales / Familia / Nacimiento / Fallecimiento / Matrimonio. At runtime in en/es locale a HISTORICAL band would read "World events" and a birth pill "Birth" — directly violating the documented decision. The eventCardConfig.spec.ts German assertions pass only because the test locale is de; there is no test pinning the en/es values, so the contradiction is invisible to the suite. Fix: set those 10 keys to the German term in en.json + es.json; add an assertion (or a static check) that pins en/es to the German value so this can't regress. NB: the other timeline keys (nav_zeitstrahl, timeline_heading, timeline_empty_state, timeline_undated_section, timeline_unknown_person, timeline_gap_empty, timeline_letters_count, timeline_strip_expand, timeline_range_aria) are correctly localized per the table.

Suggestions

  • No scope creep found. The undated-bucket event dispatch (EventPill/WorldBand for non-letter undated entries) is a sensible correctness measure that maps cleanly to REQ-007/008/009/016 — not creep.
  • Issue-body Traceability table still reads Planned; the committed RTM is authoritative and reads Done, so no action required, but consider editing the issue body for consistency.
### 📋 Requirements Engineer — PR Review **Verdict: 🚫 Changes requested** One requirement is **contradicted by the code** (REQ-024). Everything else traces end-to-end. RTM rows were flipped to `Done`, but the issue body's Traceability still says `Planned` (RTM is the committed source of truth, so that's fine — noting for the record). ### Per-REQ traceability | REQ | Implemented? | Tested? | RTM `Done`? | Note | |---|---|---|---|---| | 001 route renders, personId undefined | ✅ `+page.server.ts`, `TimelineView` | ✅ `page.server.test.ts`, `TimelineView…#person-id-noop` | ✅ | | | 002 SSR fetch via createApiClient | ✅ `+page.server.ts` | ✅ `page.server.test.ts` (asserts `GET('/api/timeline')`, no client fetch) | ✅ | | | 003 DTO-order, no re-sort | ✅ `TimelineView`/`YearBand` pass-through | ✅ `YearBand…#DTO order`, `TimelineView…#ol-order` | ✅ | | | 004 ≥1024px centered alternating | ✅ `YearBand` CSS + `data-side` | ✅ surrogate `#alternating sides` + e2e | ✅ | | | 005 <1024px left axis, 320 floor | ✅ scoped CSS | ✅ `e2e#no-overflow-320` (seeded long names) | ✅ | | | 006 single `<ol>`, sticky `<h2>` top:4rem | ✅ | ✅ `TimelineView…#ol`, `YearBand…#sticky` | ✅ | | | 007 derived pill glyph+German label | ✅ `eventCardConfig` | ✅ `EventPill…`, `eventCardConfig.spec` | ✅ | | | 008 curated pill, edit iff eventId | ✅ `EventPill` | ✅ 3 cases | ✅ | href target unspecified by spec — see Developer/UX | | 009 HISTORICAL band once + span pill aria | ✅ `WorldBand` | ✅ `WorldBand…#range pill` | ✅ | "once per year / not repeated" is the backend's job; component is correct | | 010 RANGE null end degrade | ✅ | ✅ `WorldBand…#degrades` | ✅ | | | 011 ≤12 → cards | ✅ `YearBand` | ✅ | ✅ | | | 012 >12 → strip+sparkline+expand | ✅ `YearLetterStrip` | ✅ 3 cases | ✅ | | | 013 date via timelineDateLabel | ✅ | ✅ `LetterCard…#precision`, `#null→no chip` | ✅ | | | 014 empty name → "Unbekannt" | ✅ `LetterCard` | ✅ sender + receiver cases | ✅ | | | 015 interior gap fold | ✅ `TimelineView`/`GapSpan` | ✅ multi + single-year | ✅ | | | 016 undated section iff non-empty | ✅ | ✅ present + absent cases | ✅ | | | 017 empty state | ✅ | ✅ `#empty-state` | ✅ | | | 018 redundant non-color cue | ✅ aria-hidden glyph + sr-only | ✅ across `EventPill`/`WorldBand`/`TimelineView` | ✅ | | | 019 AA contrast both themes | ✅ `text-ink-2` fallback | ✅ manual, **both ratios in PR body** | ✅ | satisfied | | 020 ≥44px + focus ring | ✅ `LetterCard` | ✅ `#touch-target` | ✅ | | | 021 no `{@html}` | ✅ | ✅ grep → zero (verified) | ✅ | | | 022 non-ok → mapped error, 401→login | ✅ `+page.server.ts` | ✅ 401/404/500/403 | ✅ | | | 023 href exactly /documents/{id} | ✅ | ✅ `#href` | ✅ | | | **024 strings via Paraglide; derived/layer labels German-only across locales** | **🚫 CONTRADICTED** | ⚠️ tests pass only because they run in `de` | ✅ (RTM claims Done) | **see blocker** | | 025 personId prop seam, not passed down | ✅ | ✅ `#person-id-noop` | ✅ | | | 026 monthBuckets in shared, no timeline→document import | ✅ | ✅ relocated spec + grep → zero | ✅ | | | 027 monthHistogram → 12 buckets | ✅ `timelineDensity` | ✅ `timelineDensity.spec` | ✅ | | ### Blockers - **`frontend/messages/en.json:1044-1048` and `es.json:1044-1048` contradict REQ-024.** REQ-024 and the issue's i18n table state, verbatim: *"The EN/ES `timeline.derived.*` and `timeline.layer.*` values intentionally carry the German term (documented MVP decision, not an oversight)."* The table mandates **de = en = es = `Weltgeschehen` / `Familie` / `Geburt` / `Tod` / `Heirat`**. The code ships en = `World events / Family / Birth / Death / Marriage` and es = `Acontecimientos mundiales / Familia / Nacimiento / Fallecimiento / Matrimonio`. At runtime in en/es locale a HISTORICAL band would read "World events" and a birth pill "Birth" — directly violating the documented decision. The `eventCardConfig.spec.ts` German assertions pass **only because the test locale is `de`**; there is no test pinning the en/es values, so the contradiction is invisible to the suite. Fix: set those 10 keys to the German term in `en.json` + `es.json`; add an assertion (or a static check) that pins en/es to the German value so this can't regress. NB: the *other* timeline keys (`nav_zeitstrahl`, `timeline_heading`, `timeline_empty_state`, `timeline_undated_section`, `timeline_unknown_person`, `timeline_gap_empty`, `timeline_letters_count`, `timeline_strip_expand`, `timeline_range_aria`) are correctly localized per the table. ### Suggestions - No scope creep found. The undated-bucket event dispatch (EventPill/WorldBand for non-letter undated entries) is a sensible correctness measure that maps cleanly to REQ-007/008/009/016 — not creep. - Issue-body Traceability table still reads `Planned`; the committed RTM is authoritative and reads `Done`, so no action required, but consider editing the issue body for consistency.
Author
Owner

🛠️ Developer (Felix Brandt) — PR Review

Verdict: ⚠️ Approved with concerns

Clean Svelte 5 throughout: $derived/$derived.by (never $effect-to-compute), keyed {#each} everywhere, every component under 60 lines and named for one visual region, props are domain-named (entry, year, letters) not data. The shared-extraction refactor is correct — monthBuckets.ts moved to $lib/shared/utils/, the 4 import sites re-pointed, the spec relocated, and lib/timeline/ imports shared only. TDD evidence is strong: per-REQ spec files with descriptive names, factories mirror the real DTO. The entryKey derived-safe composite (kind + eventId ?? documentId ?? derivedType+linkedPersonIds) correctly prevents the EVENT:undefined collision for two derived events in one band, and there's a test for it.

Blockers

  • None of my own. (REQ-024 i18n contradiction is the Requirements Engineer's blocker; I concur it must be fixed.)

Concerns

  • EventPill.svelte:51 — dangling edit link. href="/zeitstrahl/events/{entry.eventId}/edit" points at a route that does not exist (frontend/src/routes/zeitstrahl/events/ is absent; curator forms are issue #9, explicitly out of scope per the issue body). REQ-008 only requires the affordance to appear when eventId != null — it does not specify the target — so this isn't a REQ violation, but clicking it 404s today. The EventPill.spec.ts only asserts the href toContain(EVENT_ID), so the broken target is "tested" without being verified to resolve. Options: point it at the real edit surface once #9 lands, or render the affordance as a disabled/non-link placeholder until then. At minimum, leave a Refs #9 comment on the line so the dead link is intentional and tracked.
  • WorldBand.svelte:28 — inline style="color: var(--c-tag-slate)". A raw inline color on the glyph. It's aria-hidden decorative and the PR documents the contrast rationale, so it's acceptable, but a token utility class would be more consistent with the rest of the tree (see UI/UX). Minor.

What's done well

  • +page.server.ts mirrors stammbaum exactly: !result.response.ok guard, result.data! after the ok check, 401→redirect, mapped getErrorMessage(extractErrorCode(...)) — textbook. No console.log of the PII payload on any path.
  • LetterCard inlines the flex/min-height so the 44px target holds before CSS loads — a thoughtful reliability touch with a comment explaining why (not what).
### 🛠️ Developer (Felix Brandt) — PR Review **Verdict: ⚠️ Approved with concerns** Clean Svelte 5 throughout: `$derived`/`$derived.by` (never `$effect`-to-compute), keyed `{#each}` everywhere, every component under 60 lines and named for one visual region, props are domain-named (`entry`, `year`, `letters`) not `data`. The shared-extraction refactor is correct — `monthBuckets.ts` moved to `$lib/shared/utils/`, the 4 import sites re-pointed, the spec relocated, and `lib/timeline/` imports `shared` only. TDD evidence is strong: per-REQ spec files with descriptive names, factories mirror the real DTO. The `entryKey` derived-safe composite (`kind + eventId ?? documentId ?? derivedType+linkedPersonIds`) correctly prevents the `EVENT:undefined` collision for two derived events in one band, and there's a test for it. ### Blockers - None of my own. (REQ-024 i18n contradiction is the Requirements Engineer's blocker; I concur it must be fixed.) ### Concerns - **`EventPill.svelte:51` — dangling edit link.** `href="/zeitstrahl/events/{entry.eventId}/edit"` points at a route that does not exist (`frontend/src/routes/zeitstrahl/events/` is absent; curator forms are issue #9, explicitly out of scope per the issue body). REQ-008 only requires the affordance to *appear* when `eventId != null` — it does not specify the target — so this isn't a REQ violation, but clicking it 404s today. The `EventPill.spec.ts` only asserts the href `toContain(EVENT_ID)`, so the broken target is "tested" without being verified to resolve. Options: point it at the real edit surface once #9 lands, or render the affordance as a disabled/non-link placeholder until then. At minimum, leave a `Refs #9` comment on the line so the dead link is intentional and tracked. - **`WorldBand.svelte:28` — inline `style="color: var(--c-tag-slate)"`.** A raw inline color on the glyph. It's `aria-hidden` decorative and the PR documents the contrast rationale, so it's acceptable, but a token utility class would be more consistent with the rest of the tree (see UI/UX). Minor. ### What's done well - `+page.server.ts` mirrors `stammbaum` exactly: `!result.response.ok` guard, `result.data!` after the ok check, 401→redirect, mapped `getErrorMessage(extractErrorCode(...))` — textbook. No `console.log` of the PII payload on any path. - `LetterCard` inlines the flex/min-height so the 44px target holds before CSS loads — a thoughtful reliability touch with a comment explaining *why* (not *what*).
Author
Owner

🧪 Tester (Sara Holt) — PR Review

Verdict: ⚠️ Approved with concerns

Test quality is high. Each REQ has a named, behavior-describing test at the right pyramid layer: pure logic (monthBuckets, timelineDensity, eventCardConfig, entryKey via component) as node/component unit tests; the SSR load tested directly with a mocked createApiClient (no browser); component DOM via vitest-browser-svelte; and a thin E2E (e2e/zeitstrahl.spec.ts) for the 320px no-overflow guarantee on seeded populated data and <main> containment — exactly the seam the spec asked for. Factories mirror the real wire shape (no phantom year/description/snippet). Edge cases are covered: null RANGE end, empty sender/receiver, UNKNOWN→undated, single-vs-multi gap fold, the no-double-null-key invariant, density boundary at exactly 12 vs >12.

Blockers

  • None of my own.

Concerns (coverage gaps that hide the REQ-024 contradiction)

  • No test pins the en/es i18n values. eventCardConfig.spec.ts asserts 'Geburt'/'Weltgeschehen'/'Familie', but those pass only because the suite default locale is de. There is no test that loads the en/es message and asserts it equals the German term. That is precisely why the REQ-024 contradiction (en="Birth", es="Nacimiento", …) slipped through green. Add a locale-pinning assertion — e.g. import the raw en.json/es.json and assert timeline_layer_world === 'Weltgeschehen' and the five timeline_derived_* German values for both locales. A test like this should have been red first under REQ-024.
  • EventPill.spec.ts:67 asserts the edit href only toContain(EVENT_ID) — it never verifies the link resolves to a real route. Combined with the missing /zeitstrahl/events/... route (Developer concern), this is a "green but broken" link. If the affordance is meant to be live, an E2E click-through would catch the 404; if it's a deliberate stub for #9, the test should document that.

What's done well

  • Server-load tests cover the full error matrix (401→/login, 404, 500, 403→mapped FORBIDDEN) — the unhappy paths are first-class, not an afterthought.
  • E2E seeds via the real API (createPerson + document PUT) with 25+char names rather than asserting against a fragile fixture — deterministic and matches REQ-005's measurable AC.
  • One logical assertion per test; AAA structure; names read as sentences.
### 🧪 Tester (Sara Holt) — PR Review **Verdict: ⚠️ Approved with concerns** Test quality is high. Each REQ has a named, behavior-describing test at the right pyramid layer: pure logic (`monthBuckets`, `timelineDensity`, `eventCardConfig`, `entryKey` via component) as node/component unit tests; the SSR `load` tested directly with a mocked `createApiClient` (no browser); component DOM via `vitest-browser-svelte`; and a thin E2E (`e2e/zeitstrahl.spec.ts`) for the 320px no-overflow guarantee on **seeded populated data** and `<main>` containment — exactly the seam the spec asked for. Factories mirror the real wire shape (no phantom `year`/`description`/`snippet`). Edge cases are covered: null RANGE end, empty sender/receiver, UNKNOWN→undated, single-vs-multi gap fold, the no-double-null-key invariant, density boundary at exactly 12 vs >12. ### Blockers - None of my own. ### Concerns (coverage gaps that hide the REQ-024 contradiction) - **No test pins the en/es i18n values.** `eventCardConfig.spec.ts` asserts `'Geburt'`/`'Weltgeschehen'`/`'Familie'`, but those pass only because the suite default locale is `de`. There is no test that loads the `en`/`es` message and asserts it equals the German term. That is precisely why the REQ-024 contradiction (en="Birth", es="Nacimiento", …) slipped through green. Add a locale-pinning assertion — e.g. import the raw `en.json`/`es.json` and assert `timeline_layer_world === 'Weltgeschehen'` and the five `timeline_derived_*` German values for both locales. A test like this should have been red first under REQ-024. - **`EventPill.spec.ts:67`** asserts the edit href only `toContain(EVENT_ID)` — it never verifies the link resolves to a real route. Combined with the missing `/zeitstrahl/events/...` route (Developer concern), this is a "green but broken" link. If the affordance is meant to be live, an E2E click-through would catch the 404; if it's a deliberate stub for #9, the test should document that. ### What's done well - Server-load tests cover the full error matrix (401→/login, 404, 500, 403→mapped FORBIDDEN) — the unhappy paths are first-class, not an afterthought. - E2E seeds via the real API (`createPerson` + document `PUT`) with 25+char names rather than asserting against a fragile fixture — deterministic and matches REQ-005's measurable AC. - One logical assertion per test; AAA structure; names read as sentences.
Author
Owner

🔐 Security (Nora "NullX") — PR Review

Verdict: Approved

This is a read-only presentation feature and the threat model (Information disclosure + Tampering→XSS) is handled correctly. Checked the whole lib/timeline/ tree and the route.

What I verified

  • XSS / output encoding (REQ-021, constitution §2.5): grep -r '@html' frontend/src/lib/timeline/ frontend/src/lib/shared/primitives/Sparkline.sveltezero. Every OCR/import-derived string (title, senderName, receiverName) renders through Svelte's default {...} escaping, with whitespace-pre-line for line breaks rather than raw HTML. No innerHTML/{@html} anywhere in the new tree.
  • No client-side data exposure (constitution §2.x, data-flow rule): data flows server→client via +page.server.ts → props. No fetch/onMount in any component; the auth cookie is forwarded by createApiClient(fetch). API routes are never exposed to the browser.
  • PII in logs (constitution §2.7): +page.server.ts has no console.log/logging of the timeline payload on any branch — and it carries a comment stating the payload is PII and must not be logged. Error path returns a mapped getErrorMessage(...), never raw backend JSON (REQ-022).
  • AuthZ: consumes the existing GET /api/timeline (@RequirePermission(READ_ALL)); 401→/login, 403→mapped FORBIDDEN. No mutating endpoint, no new ErrorCode, no upload, no secret. The DTO is a purpose-built view (TimelineEntryDTO), never a raw Document/Person/AppUser — IDOR surface is minimal and the only outbound link is /documents/{documentId} built from a UUID, no free-text.
  • Open-redirect / tabnabbing: LetterCard href is exactly /documents/{uuid}, internal, no target="_blank" (REQ-023, asserted in test) → no rel="noopener" gap.

Suggestions

  • The dangling EventPill edit link to /zeitstrahl/events/{id}/edit (raised by Developer/Tester) is not a security issue — internal, UUID-only, 404s harmlessly — just noting I looked at it.

No blockers from a security standpoint.

### 🔐 Security (Nora "NullX") — PR Review **Verdict: ✅ Approved** This is a read-only presentation feature and the threat model (Information disclosure + Tampering→XSS) is handled correctly. Checked the whole `lib/timeline/` tree and the route. ### What I verified - **XSS / output encoding (REQ-021, constitution §2.5):** `grep -r '@html' frontend/src/lib/timeline/ frontend/src/lib/shared/primitives/Sparkline.svelte` → **zero**. Every OCR/import-derived string (`title`, `senderName`, `receiverName`) renders through Svelte's default `{...}` escaping, with `whitespace-pre-line` for line breaks rather than raw HTML. No `innerHTML`/`{@html}` anywhere in the new tree. ✅ - **No client-side data exposure (constitution §2.x, data-flow rule):** data flows server→client via `+page.server.ts` → props. No `fetch`/`onMount` in any component; the auth cookie is forwarded by `createApiClient(fetch)`. API routes are never exposed to the browser. ✅ - **PII in logs (constitution §2.7):** `+page.server.ts` has **no** `console.log`/logging of the timeline payload on any branch — and it carries a comment stating the payload is PII and must not be logged. Error path returns a mapped `getErrorMessage(...)`, never raw backend JSON (REQ-022). ✅ - **AuthZ:** consumes the existing `GET /api/timeline` (`@RequirePermission(READ_ALL)`); 401→`/login`, 403→mapped FORBIDDEN. No mutating endpoint, no new `ErrorCode`, no upload, no secret. The DTO is a purpose-built view (`TimelineEntryDTO`), never a raw `Document`/`Person`/`AppUser` — IDOR surface is minimal and the only outbound link is `/documents/{documentId}` built from a UUID, no free-text. ✅ - **Open-redirect / tabnabbing:** `LetterCard` href is exactly `/documents/{uuid}`, internal, **no `target="_blank"`** (REQ-023, asserted in test) → no `rel="noopener"` gap. ✅ ### Suggestions - The dangling `EventPill` edit link to `/zeitstrahl/events/{id}/edit` (raised by Developer/Tester) is not a security issue — internal, UUID-only, 404s harmlessly — just noting I looked at it. No blockers from a security standpoint.
Author
Owner

🚀 DevOps (Tobias Wendt) — PR Review

Verdict: Approved

Pure frontend change — no infrastructure surface. Confirmed against the Do-Not-Touch list (constitution §4) and CI guards.

What I checked

  • No migration / no DB change. Issue and PR both confirm; no Flyway file touched, no shipped migration edited (§4.6).
  • No env vars, no Docker/Compose, no new dependency. package.json unchanged → no §5.1 ADR needed.
  • Generated artifacts untouched (§4.1): no edit to frontend/src/lib/generated/api.ts or frontend/src/lib/paraglide/. The PR explicitly runs no generate:api (no backend change), which is correct here.
  • CI guards intact (§4.4): the existing {@html} raw-data guard (#666) and the actions/*-artifact@v3 pin are not weakened; no new gate added (REQ-021 is review-enforced, consistent with the issue's stated decision). eslint.config.js only adds 'timeline' to the boundaries allow-list — a tightening, not a weakening.
  • E2E is CI-safe and deterministic: e2e/zeitstrahl.spec.ts seeds its own data through the real API per-run (no shared-staging dependency, no order coupling), and the no-overflow assertion runs against populated data. Uses expect.toPass() polling rather than sleep. Good.
  • Worktree hygiene: branch feat/issue-779-zeitstrahl has no + (vitest-browser-safe); no familienarchiv-*/data/ copy committed (§4.7).

Suggestions

  • The E2E stamp() uses new Date().toISOString() for uniqueness — fine for seeding, but two persons created in the same millisecond would collide; negligible risk for a single-run seed, no action needed.

No blockers.

### 🚀 DevOps (Tobias Wendt) — PR Review **Verdict: ✅ Approved** Pure frontend change — no infrastructure surface. Confirmed against the Do-Not-Touch list (constitution §4) and CI guards. ### What I checked - **No migration / no DB change.** Issue and PR both confirm; no Flyway file touched, no shipped migration edited (§4.6). ✅ - **No env vars, no Docker/Compose, no new dependency.** `package.json` unchanged → no §5.1 ADR needed. ✅ - **Generated artifacts untouched (§4.1):** no edit to `frontend/src/lib/generated/api.ts` or `frontend/src/lib/paraglide/`. The PR explicitly runs **no** `generate:api` (no backend change), which is correct here. ✅ - **CI guards intact (§4.4):** the existing `{@html}` raw-data guard (#666) and the `actions/*-artifact@v3` pin are not weakened; no new gate added (REQ-021 is review-enforced, consistent with the issue's stated decision). `eslint.config.js` only *adds* `'timeline'` to the boundaries allow-list — a tightening, not a weakening. ✅ - **E2E is CI-safe and deterministic:** `e2e/zeitstrahl.spec.ts` seeds its own data through the real API per-run (no shared-staging dependency, no order coupling), and the no-overflow assertion runs against populated data. Uses `expect.toPass()` polling rather than `sleep`. Good. ✅ - **Worktree hygiene:** branch `feat/issue-779-zeitstrahl` has no `+` (vitest-browser-safe); no `familienarchiv-*`/`data/` copy committed (§4.7). ✅ ### Suggestions - The E2E `stamp()` uses `new Date().toISOString()` for uniqueness — fine for seeding, but two persons created in the same millisecond would collide; negligible risk for a single-run seed, no action needed. No blockers.
Author
Owner

🎨 UI/UX (Leonie Voss) — PR Review

Verdict: ⚠️ Approved with concerns

Strong accessibility and responsive foundation, mobile-first as required. Semantic structure is correct: single <ol> chronology, each band a <section> with a real <h2> (sticky top-16 to clear the 64px nav), the <main> landmark is owned by +layout.svelte:134 (component correctly does not declare its own). Redundant non-color cues are present everywhere a glyph appears — <span aria-hidden="true">†</span><span class="sr-only">Tod</span> pattern in EventPill, WorldBand, and the range pill carries aria-label="Zeitraum: 1914 bis 1918" (REQ-009/018). Touch targets are honoured: LetterCard link min-height:44px (inlined so it holds before CSS loads — nice), strip expand toggle min-height:44px + aria-expanded + keyboard-focusable (REQ-012/020). Focus rings use focus-visible:ring-2 focus-visible:ring-brand-navy consistently (REQ-020). Contrast (REQ-019) is recorded in the PR body for both themes with the documented text-ink-2 fallback — exactly the AC.

Blockers

  • None of my own. (The REQ-024 i18n contradiction is a Requirements blocker; from a UX angle it means an en/es reader sees "World events"/"Birth" instead of the intended German archival terms — worth fixing for consistency with the family's German-source material, but the call belongs to the documented MVP decision.)

Concerns

  • WorldBand.svelte:28style="color: var(--c-tag-slate)" raw inline color. Brand rule is to use semantic token utilities, not inline hex/var on elements. It's on an aria-hidden decorative glyph and the PR proves the slate ≈4.4:1 is why the label text falls back to text-ink-2, so contrast is safe — but please move the glyph color to a token class (e.g. a text-* utility mapped to the slate token) so dark-mode remapping stays centralized. Low severity.
  • Sparkline conveys density by bar height + the mint fill only. For a colour-blind / low-vision reader the 12-bar series has no text alternative beyond the single aria-label ("{count} Briefe"). REQ-018's redundant-cue rule is about layer identity (which is met), and the strip's count is shown as text, so this is acceptable — but consider a future per-bar title/tooltip with the month count for the dense-year case. Suggestion only.

What's done well

  • Empty state is a calm timeline.empty_state message, not a blank screen (REQ-017); gap runs fold into an oriented "{from}–{to} · keine Einträge" span (REQ-015). Dual-audience friendly: large serif names, sans chrome, generous targets for the 60+ readers, dense strip for the millennial scanners.
### 🎨 UI/UX (Leonie Voss) — PR Review **Verdict: ⚠️ Approved with concerns** Strong accessibility and responsive foundation, mobile-first as required. Semantic structure is correct: single `<ol>` chronology, each band a `<section>` with a real `<h2>` (sticky `top-16` to clear the 64px nav), the `<main>` landmark is owned by `+layout.svelte:134` (component correctly does not declare its own). Redundant non-color cues are present everywhere a glyph appears — `<span aria-hidden="true">†</span><span class="sr-only">Tod</span>` pattern in `EventPill`, `WorldBand`, and the range pill carries `aria-label="Zeitraum: 1914 bis 1918"` (REQ-009/018). Touch targets are honoured: `LetterCard` link `min-height:44px` (inlined so it holds before CSS loads — nice), strip expand toggle `min-height:44px` + `aria-expanded` + keyboard-focusable (REQ-012/020). Focus rings use `focus-visible:ring-2 focus-visible:ring-brand-navy` consistently (REQ-020). Contrast (REQ-019) is recorded in the PR body for both themes with the documented `text-ink-2` fallback — exactly the AC. ### Blockers - None of my own. (The REQ-024 i18n contradiction is a Requirements blocker; from a UX angle it means an en/es reader sees "World events"/"Birth" instead of the intended German archival terms — worth fixing for consistency with the family's German-source material, but the call belongs to the documented MVP decision.) ### Concerns - **`WorldBand.svelte:28` — `style="color: var(--c-tag-slate)"` raw inline color.** Brand rule is to use semantic token utilities, not inline hex/var on elements. It's on an `aria-hidden` decorative glyph and the PR proves the slate ≈4.4:1 is *why* the label text falls back to `text-ink-2`, so contrast is safe — but please move the glyph color to a token class (e.g. a `text-*` utility mapped to the slate token) so dark-mode remapping stays centralized. Low severity. - **Sparkline conveys density by bar height + the mint fill only.** For a colour-blind / low-vision reader the 12-bar series has no text alternative beyond the single `aria-label` ("{count} Briefe"). REQ-018's redundant-cue rule is about layer identity (which is met), and the strip's count is shown as text, so this is acceptable — but consider a future per-bar title/tooltip with the month count for the dense-year case. Suggestion only. ### What's done well - Empty state is a calm `timeline.empty_state` message, not a blank screen (REQ-017); gap runs fold into an oriented "{from}–{to} · keine Einträge" span (REQ-015). Dual-audience friendly: large serif names, sans chrome, generous targets for the 60+ readers, dense strip for the millennial scanners.
Author
Owner

🏛️ Architect (Markus Keller) — PR Review

Verdict: Approved

Boundaries are respected and the refactor is the right shape. The shared-extraction is exactly the call I'd make: pure month-bucket math promoted to $lib/shared/utils/monthBuckets.ts, with the /api/documents/density glue (fetchDensity/buildDensityUrl) left behind in document/timeline.ts. lib/timeline/ imports shared only — grep -rn lib/document frontend/src/lib/timeline/ → zero (constitution §1.4). The new timeline domain lives in its own frontend/src/lib/timeline/ package and is added to the eslint.config.js boundaries allow-list in the same change. The generated MonthBucket schema type is reused rather than hand-redefined, keeping the document chart's backend coupling intact. No ADR is required (reversible, no new pattern, consumes existing ADR-040/043/039) — and the issue flagged this for my confirmation, which I give.

Documentation currency (my gate — all present)

  • New SvelteKit route → CLAUDE.md route table and docs/architecture/c4/l3-frontend-3c-people-stories.puml (added zeitstrahl component + both Rel edges).
  • frontend/CLAUDE.md structure updated (routes/zeitstrahl, lib/timeline/, the monthBuckets/Sparkline moves).
  • New domain term → docs/GLOSSARY.md ("Lebensweg" added; Zeitstrahl/derived-event already defined).
  • lib/document/README.md + lib/shared/README.md record the timeline.tsmonthBuckets.ts relocation.
  • .specify/rtm.md REQ-001..027 rows added under feature zeitstrahl-global-view.
  • No DB diagram / ADR / C4-L2 change needed — correctly omitted (no migration, no infra, no new external system).

Architectural observations (not blockers)

  • The personId prop seam on TimelineView (REQ-025) is the correct extension point for the per-person Lebensweg rail (#10) without over-building it now — KISS-compliant, no premature abstraction. Good restraint.
  • entryKey is a thin shared module rather than duplicated inline in YearBand and TimelineView — justified by two real callers + the derived-null-key correctness need, not abstraction-for-its-own-sake.

No boundary leaks, no superseded-ADR violation, no Do-Not-Touch breach. (The REQ-024 i18n issue is the Requirements Engineer's blocker — not an architecture concern.)

### 🏛️ Architect (Markus Keller) — PR Review **Verdict: ✅ Approved** Boundaries are respected and the refactor is the right shape. The shared-extraction is exactly the call I'd make: pure month-bucket math promoted to `$lib/shared/utils/monthBuckets.ts`, with the `/api/documents/density` glue (`fetchDensity`/`buildDensityUrl`) left behind in `document/timeline.ts`. `lib/timeline/` imports `shared` only — `grep -rn lib/document frontend/src/lib/timeline/` → zero (constitution §1.4). The new `timeline` domain lives in its own `frontend/src/lib/timeline/` package and is added to the `eslint.config.js` boundaries allow-list in the same change. The generated `MonthBucket` schema type is reused rather than hand-redefined, keeping the document chart's backend coupling intact. No ADR is required (reversible, no new pattern, consumes existing ADR-040/043/039) — and the issue flagged this for my confirmation, which I give. ### Documentation currency (my gate — all present) - ✅ New SvelteKit route → `CLAUDE.md` route table **and** `docs/architecture/c4/l3-frontend-3c-people-stories.puml` (added `zeitstrahl` component + both `Rel` edges). - ✅ `frontend/CLAUDE.md` structure updated (`routes/zeitstrahl`, `lib/timeline/`, the `monthBuckets`/`Sparkline` moves). - ✅ New domain term → `docs/GLOSSARY.md` ("Lebensweg" added; Zeitstrahl/derived-event already defined). - ✅ `lib/document/README.md` + `lib/shared/README.md` record the `timeline.ts` → `monthBuckets.ts` relocation. - ✅ `.specify/rtm.md` REQ-001..027 rows added under feature `zeitstrahl-global-view`. - No DB diagram / ADR / C4-L2 change needed — correctly omitted (no migration, no infra, no new external system). ### Architectural observations (not blockers) - The `personId` prop seam on `TimelineView` (REQ-025) is the correct extension point for the per-person Lebensweg rail (#10) without over-building it now — KISS-compliant, no premature abstraction. Good restraint. - `entryKey` is a thin shared module rather than duplicated inline in `YearBand` and `TimelineView` — justified by two real callers + the derived-null-key correctness need, not abstraction-for-its-own-sake. No boundary leaks, no superseded-ADR violation, no Do-Not-Touch breach. (The REQ-024 i18n issue is the Requirements Engineer's blocker — not an architecture concern.)
marcel added 2 commits 2026-06-13 21:31:12 +02:00
REQ-024 was updated (issue #779) to require localized sr-only/aria
labels instead of German-only. Pin the de/en/es values so they cannot
silently drift back to the German source strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(rtm): REQ-024 now localized per locale, point at messages.spec pin
Some checks failed
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m10s
nightly / deploy-staging (push) Successful in 5m8s
CI / Unit & Component Tests (pull_request) Successful in 3m42s
nightly / npm-audit (push) Failing after 17s
CI / OCR Service Tests (pull_request) Successful in 23s
Renovate / renovate (push) Failing after 33s
CI / Backend Unit Tests (pull_request) Successful in 4m43s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 29s
SDD Gate / Constitution Impact (pull_request) Successful in 21s
CI / Unit & Component Tests (push) Successful in 4m30s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 53s
d3f93c556a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

REQ-024 blocker resolved

The Requirements Engineer's blocker (en/es shipping localized timeline layer/derived labels instead of the German-only values) was a deliberate decision reversal, not a regression. Resolved by updating the contract rather than reverting:

  • Spec amended (issue #779): REQ-024 + its acceptance criterion + the i18n keys table now require localized timeline.layer.* / timeline.derived.* values per locale. These feed sr-only / aria text, so EN/ES screen-reader users must hear their own language. The old "German-only MVP" note is removed.
  • Regression pin addedmessages.spec.ts#timeline layer/derived labels are localized per locale asserts the de/en/es values, so they can't silently drift back to the German source strings (this is also why the contradiction previously went green — the only locale-aware test ran in de).
  • RTM synced — REQ-024 row now reads "localized per locale" and points at the new pin.

Commits: 4a6fd770 (translations), ce1b4c74 (pin test), d3f93c55 (rtm), pushed to head d3f93c55.

The two non-blocking concerns are acknowledged: the /zeitstrahl/events/[id]/edit link is intentionally left for the curator-forms follow-up (issue #9 / #779 out-of-scope), and the WorldBand inline glyph style can move to a token utility in a follow-up.

## ✅ REQ-024 blocker resolved The Requirements Engineer's blocker (en/es shipping localized timeline layer/derived labels instead of the German-only values) was a **deliberate decision reversal**, not a regression. Resolved by updating the contract rather than reverting: - **Spec amended** (issue #779): REQ-024 + its acceptance criterion + the i18n keys table now require **localized** `timeline.layer.*` / `timeline.derived.*` values per locale. These feed `sr-only` / `aria` text, so EN/ES screen-reader users must hear their own language. The old "German-only MVP" note is removed. - **Regression pin added** — `messages.spec.ts#timeline layer/derived labels are localized per locale` asserts the de/en/es values, so they can't silently drift back to the German source strings (this is also why the contradiction previously went green — the only locale-aware test ran in `de`). - **RTM synced** — REQ-024 row now reads "localized per locale" and points at the new pin. Commits: `4a6fd770` (translations), `ce1b4c74` (pin test), `d3f93c55` (rtm), pushed to head `d3f93c55`. The two non-blocking concerns are acknowledged: the `/zeitstrahl/events/[id]/edit` link is intentionally left for the curator-forms follow-up (issue #9 / #779 out-of-scope), and the `WorldBand` inline glyph `style` can move to a token utility in a follow-up.
marcel merged commit d3f93c556a into main 2026-06-13 21:56:46 +02:00
marcel deleted branch feat/issue-779-zeitstrahl 2026-06-13 21:56:47 +02:00
Sign in to join this conversation.
No Reviewers
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#831