Timeline (/zeitstrahl) Datum mode diverges from the canonical Concept-A visual spec — restore layout fidelity #833

Closed
opened 2026-06-14 08:47:44 +02:00 by marcel · 1 comment
Owner

As a family member I want /zeitstrahl to render exactly like the canonical "Lebensfaden" mockup so the timeline reads as the finished, framed life-thread the design promises

Milestone: Zeitstrahl — Family Timeline (#14)
Canonical visual spec (on main, open in a browser): docs/specs/zeitstrahl-final-spec.html — Concept A "Der Lebensfaden", §3 "Vollständige Vorschau" is the canonical desktop render.
Builds on: #779 / PR #831 (global /zeitstrahl Datum mode, merged). This issue is visual-fidelity follow-up only — the data flow, ordering, a11y semantics, and density logic from #779 are correct and unchanged.

Review status: Rounds 1–4 of /review-issue complete (all six personas APPROVE in Rounds 2–4); all persona comments folded into this body and removed (the issue body is the source of truth). Changelog at the bottom.

Context & Why

#779 shipped the global /zeitstrahl in "Datum" mode and claimed the "full Concept-A visual layout". A side-by-side review of the live page against docs/specs/zeitstrahl-final-spec.html §3 shows the structure is right but the chrome is not: the timeline renders bare on the page background (no canvas frame), the year badges sit hard against the left edge instead of centered on the axis, letter cards float beside the spine with no connector dot, and several small spec affordances (✉ glyphs, month-axis labels, the event-pill provenance line, the header meta line) are missing. The result reads as an unfinished wireframe rather than the framed "life-thread" the spec depicts.

This is a presentation-only pass. No backend change, no new endpoint, no generate:api, no entity field. Every value the spec shows is either already on TimelineDTO or derivable client-side from it.

Constitution principles this touches (see .specify/constitution.md):

  • §1.4 — all work stays in frontend/src/lib/timeline/ (+ the route); cross-domain reuse via $lib/shared/ only (the Sparkline primitive and monthBuckets helpers already live there).
  • §2.5 — untrusted (OCR/import-derived) title/senderName/receiverName render via Svelte {...} escaping; never {@html}. New glyph prefixes (✉, node dots) are static Paraglide/literal markup, never interpolated user text.

Related: ADR-040 (timeline domain), #783 (a11y/polish pass — overlaps on the dark-mode token rule; see Coordination).

User Journey

A logged-in family member opens Zeitstrahl. The page now presents the timeline inside a framed "sheet" (the .tl-canvas panel) with a title and a meta sub-line ("1909–1924 · 187 Briefe · 38 Ereignisse · Gruppierung: Datum"). Scrolling the centered desktop axis, each year is announced by a navy badge centered on the spine, visibly interrupting the life-thread. Life-event pills read "Geburt: … · abgeleitet" so the reader sees both the date and that the event was derived. Letters hang off the axis on alternating sides, each tethered to the spine by a small mint-ringed dot, each titled with a ✉. Dense years collapse to a strip whose count carries a ✉ and whose sparkline is labelled "Jan {year} … Dez {year}". Undated letters sit in a clearly framed "Ohne Datum · N" box at the end. On a phone the same content stacks on a single left spine — the badges, dots, and node markers sit on that left spine without ever overlapping the cards. Nothing about ordering, density thresholds, or which entries appear changes — only the chrome catches up to the spec. (Degenerate journey: nothing the family member does changes; this is a rendering-fidelity story, journey included per template requirement.)

Scope

Visual-fidelity changes to the existing Datum-mode component tree so the live /zeitstrahl matches docs/specs/zeitstrahl-final-spec.html §3 on a centered desktop axis (≥1024px) and a left phone axis (<1024px). Files: frontend/src/routes/zeitstrahl/+page.svelte, frontend/src/lib/timeline/{TimelineView,YearBand,EventPill,WorldBand,LetterCard,YearLetterStrip}.svelte, plus new timeline_* i18n keys in frontend/messages/{de,en,es}.json.

Out of Scope (explicit boundaries)

  • Per-letter root-tag color chips (spec legend "Wurzel-Tag-Farbchip", and the Familie/Weihnachten/Krieg chips on §3 letter cards). TimelineEntryDTO carries no tag field — needs a backend DTO change (same data gap as "Thema" mode). Now tracked as #835 (full-stack: DTO root-tag enrichment + the per-letter chip); not built here.
  • The .lcard.ev event-letter variant (mint-bordered, serif letter card used in the §2 "Ereignis" mode to show letters bundled under a curated event). It depends on a letter→event link that does not exist on TimelineEntryDTO and only appears in non-Datum modes. Deferred with the grouping toggle (#827); not rendered here. (This is why OQ-2's "keep the mint left-border on every plain card" is a deliberate house accent, not the spec's .ev semantics.)
  • The mockup's · persönlich and · SEASON subtitle tokens (e.g. §3 line 296 "Sommer 1915 · abgeleitet · SEASON", line 312 "Frühjahr 1924 · persönlich · kuratiert"). · SEASON is a DatePrecision-enum annotation the spec author printed for the reader of the spec sheet, never intended as production UI. · persönlich is the PERSONAL-layer name, already conveyed redundantly by the ★ glyph + mint border. Only the single provenance token ships (REQ-007). Documented exclusion, not an oversight.
  • World-band in-time letter count ("· 187 Briefe in dieser Zeit"). The "· historisch" label is added (REQ-009); the count requires summing letters across the band's span — deferred unless trivially derivable from the loaded DTO, in which case it is additive.
  • Grouping toggle (Datum/Ereignis/Thema)#827. The header meta line shows a static "Gruppierung: Datum" string only; no toggle control.
  • Leading/trailing empty decades (§3 1899–1908 / 1925–1950 outer spans) → #828.
  • Editorial quiet-span labels (§3 "Nachkriegsjahre") → #829.
  • Per-month bar drill-down inside the strip → #830. (This is why REQ-010 ships only "Monats-Dichte" and not the mockup's longer "· antippen → Monate → Briefe ▾" hint, which advertises that deferred interaction.)
  • Serif letter titles — deliberate from #779 (font split: titles → font-serif). Spec §3 uses sans for plain .lcard .t. Kept serif (OQ-1, resolved → keep).

Data flow

Unchanged. /zeitstrahl/+page.server.ts SSR-loads GET /api/timeline{ timeline }. All new values (year-range, letter count, event count) are derived client-side from timeline.years + timeline.undated. No client fetch, no new request, no PII logged. (Feasibility confirmed against frontend/src/lib/generated/api.ts TimelineEntryDTO — every count is reachable from the loaded DTO.)

Requirements (EARS)

Frame & header

  • REQ-001 (Ubiquitous) — The /zeitstrahl content shall wrap the timeline in a canvas frame matching .tl-canvas: a border border-line, rounded-[10px], bg-canvas, with internal padding, using semantic tokens only (no raw hex).
  • REQ-002 (Ubiquitous) — Below the "Zeitstrahl" heading the view shall render a meta sub-line matching .dh-sub, composed of: the populated year-range ({firstYear}–{lastYear}, taken from the first/last entry in timeline.years), the letter count (every entry with kind === 'LETTER' across all year bands plus timeline.undated), the event count (every entry with kind === 'EVENT' across all year bands plus undated — this includes derived life-events, curated PERSONAL events, and HISTORICAL world-bands), and the static text "Gruppierung: Datum". When timeline.years is empty the range segment is omitted; when both years and undated are empty the entire sub-line is absent. The sub-line is rendered font-sans text-xs text-ink-3 (≥12px, AA-compliant on the canvas) — not the mockup's literal 9.5px/#7a7a76, which is below this project's 12px legibility floor and fails WCAG AA (≈4.0:1 on bg-canvas).

Axis & year badge (desktop AND phone behavior is mandatory)

  • REQ-003 (State-driven) — While the viewport is ≥1024px, the year badge shall be horizontally centered on the axis (matching .ybadge{text-align:center}); while the viewport is <1024px it shall sit at the left spine. The existing sticky-at-top-16 behavior (#779 REQ-006) shall be preserved in both.
  • REQ-004 (Ubiquitous) — The year badge shall sit on the spine with a navy node marker so the badge visibly interrupts the axis (matching .ybadge span{z-index:2}). On desktop (≥1024px) the marker is centered on the left:50% spine; on phone (<1024px) the marker sits on the left:0.5rem spine and the badge clears it — the marker must never overlap the badge text on either axis.
  • REQ-005 (Ubiquitous) — Each individual LetterCard row shall render a connector dot on the axis matching .lrow .dot (white fill, mint ring). On desktop the dot is centered on the left:50% spine between the alternating card and the axis; on phone the entry column is indented clear of the left:0.5rem spine (mirroring the mockup's .nsp{padding-left:21px}) and the dot sits on that spine to the left of the card content (negative offset relative to the indented column) — the dot must never overlay the card on either axis. (At 375px verify the new connector dot and the existing mint left-border of LetterCard read as two distinct marks at the spine, not one merged blob.)
  • REQ-006 (Ubiquitous) — The axis gradient shall run mint → navy → slate (three stops, matching linear-gradient(#a1dcd8,#012851,#607080)) using existing semantic tokens: var(--palette-mint), var(--palette-navy), and var(--c-tag-slate) for the slate stop (note: there is no --palette-slate token; slate lives only as --c-tag-slate, the same token WorldBand.svelte already uses). No raw hex — this stays consistent with REQ-013.

Pills, bands & letter cards

  • REQ-007 (Event-driven) — When EventPill renders a derived or curated PERSONAL pill, its subtitle shall be {dateLabel} · {provenance}, where {provenance} resolves to the Paraglide key timeline_provenance_derived ("abgeleitet") when derived === true and timeline_provenance_curated ("kuratiert") when curated PERSONAL (derived === false) — keyed off entry.derived, not getAccentConfig's accent. Only this single provenance token is appended — the mockup's extra · persönlich / · SEASON tokens are out of scope (see Out of Scope).
  • REQ-008 (Event-driven) — When LetterCard has a non-empty title, the title shall be prefixed with an envelope glyph wrapped <span aria-hidden="true">✉</span> plus an sr-only "Brief" label (timeline_letter_glyph_label), matching §3 "✉ Brief aus Stettin". The glyph shall be a static literal/Paraglide key concatenated as a sibling element — never interpolated into, or {@html}-rendered alongside, the escaped user title.
  • REQ-009 (Event-driven) — When WorldBand renders a HISTORICAL band, it shall include the layer descriptor "· historisch" (timeline_layer_historical_suffix) inline in the band subtitle, matching §3 "1914–1918 · historisch". For a non-RANGE band the subtitle reads {dateLabel} · historisch; for a RANGE band the existing visible span pill ("1914–1918" with its Zeitraum: … aria-label, #779 REQ-009, unchanged) is followed by the inline "· historisch" text. The descriptor is plain text, not a second detached pill. Implementer note: the band is full-bleed via a desktop negative margin (margin:0 -26px in the mockup); at phone width use the §3 .nwb left-border accent treatment instead of recreating the full-bleed negative margin, so the band does not clip the left spine.

Dense strip

  • REQ-010 (Ubiquitous) — YearLetterStrip shall prefix its count with the aria-hidden ✉ glyph + sr-only label, and shall render the static density descriptor "Monats-Dichte" (timeline_strip_density_caption) near the sparkline. The existing ≥44px keyboard-focusable expand toggle and its visible label ("Briefe anzeigen") shall be preserved — the density descriptor is additive context, not a replacement for the toggle.
  • REQ-011 (Ubiquitous) — YearLetterStrip shall render exactly the two endpoint month-axis labels beneath the sparkline, matching .strip .axl: "Jan {year}" at the left, "Dez {year}" at the right (not a label under every bar). Labels are localized via the existing locale month formatter (formatTickLabel / shared month-bucket helpers — no new per-month key set) and rendered at ≥10px (the mockup's 6px is below this project's legibility floor for the 60+ transcriber audience). Pass the January/December anchors ({year}-01 / {year}-12) to the formatter — formatTickLabel('{year}') alone returns only the year.

Undated bucket

  • REQ-012 (State-driven) — While timeline.undated is non-empty, the "Ohne Datum" section shall render inside a dashed frame matching .undated and its heading shall include the entry count ("Ohne Datum · {count}"), preserving the existing kind/type dispatch (events still render as pills/bands, not letter cards).

Cross-cutting

  • REQ-013 (Ubiquitous) — All new styles shall use semantic Tailwind tokens (bg-canvas, bg-surface, text-ink*, border-line, brand-navy, brand-mint, var(--palette-*), var(--c-tag-slate)); no raw hex in a color context. Verification grep (corrected): grep -rnE '(bg|text|border|from|via|to|ring|fill|stroke|outline|decoration)-\[#[0-9a-fA-F]{3,8}\]|:[[:space:]]*#[0-9a-fA-F]{3,8}' frontend/src/lib/timeline/ → zero hits. (The earlier bare #[0-9a-fA-F]{3,6} form was wrong — it matches the #eac substring inside every {#each} block and reports false positives on clean code.) This is the same local / PR-review gate #783 uses; it is not a new ci.yml step.
  • REQ-014 (Ubiquitous) — No change shall alter DTO ordering, the strip-vs-cards density threshold (12), gap-folding, the <ol>/<section>/<h2> semantic structure, or any existing #779 REQ-001..027 behavior; all existing timeline tests shall stay green.
  • REQ-015 (Ubiquitous) — All new user-facing strings shall be Paraglide keys present in frontend/messages/{de,en,es}.json with matching key sets across locales.
  • REQ-016 (Unwanted-behavior) — If a LetterCard entry has no title (null/empty), then no ✉ glyph and no sr-only "Brief" label shall render (the glyph attaches only to a present title — matches the existing {#if entry.title} guard); the row shall still render sender → receiver and the date.

Acceptance Criteria (measurable)

  • REQ-001/zeitstrahl renders a single bordered, rounded bg-canvas container wrapping the bands; verified in a *.svelte.spec.ts and visually at 1440px.
  • REQ-002 — Given a DTO spanning years 1909–1924 with 187 LETTER entries and 38 EVENT entries (across bands + undated), the sub-line reads "1909–1924 · 187 Briefe · 38 Ereignisse · Gruppierung: Datum". Given only undated entries (no years), the range segment is absent. Given an empty DTO, the sub-line is absent from the DOM.
  • REQ-003 — At ≥1024px the year badge's computed horizontal center equals the axis center (within tolerance); at <1024px it sits at the left spine; the <h2> retains position: sticky; top: 4rem at both widths.
  • REQ-004 — Each year <h2> has an associated node marker element; at 1440px it is centered on the spine, at 375px it is on the left spine and does not overlap the badge text (bounding boxes do not intersect the text run).
  • REQ-005 — A band with 3 letters renders 3 connector dots; at 1440px each dot is on the centered spine, at 375px each dot is left of its card (dot right edge ≤ card left edge) and on the left spine.
  • REQ-006 — The axis ::before background is a three-stop gradient referencing --palette-mint, --palette-navy, and --c-tag-slate; the grep in REQ-013 still returns zero hits.
  • REQ-007 — A derived BIRTH pill subtitle reads "{date} · abgeleitet"; a curated PERSONAL pill subtitle reads "{date} · kuratiert"; values from Paraglide keys (computed in the test, not hardcoded); no "persönlich"/"SEASON" text present.
  • REQ-008 — A LetterCard with a title renders an aria-hidden ✉ and an sr-only "Brief"; the document href is still exactly /documents/{documentId}.
  • REQ-009 — A non-RANGE HISTORICAL band subtitle contains "· historisch"; a RANGE band still renders its 1914–1918 pill with the Zeitraum aria-label (#779 REQ-009) AND the inline "· historisch" text; "historisch" appears as text, not a separate pill element.
  • REQ-010 — A 30-letter YearLetterStrip shows an aria-hidden ✉ + the "Monats-Dichte" descriptor; the expand toggle is still present, ≥44px, keyboard-focusable, retains its "Briefe anzeigen" label, and reveals 30 LetterCards.
  • REQ-011 — A YearLetterStrip for 1915 renders exactly two month-axis labels at the sparkline ends, each computed-font-size ≥10px. The test asserts the locale formatter output for the Jan/Dez anchors (formatTickLabel('1915-01', locale) / '1915-12') — e.g. de yields "Jan. 1915" / "Dez. 1915" (the short month carries a trailing period) — via the formatter or startsWith, never a hardcoded "Jan 1915".
  • REQ-012 — With 11 undated entries the "Ohne Datum" section is a dashed-framed box whose heading contains "11"; with zero undated entries the section is absent from the DOM.
  • REQ-013 — The corrected grep returns zero hits on the implemented tree.
  • REQ-014 — The full existing frontend/src/lib/timeline/*.spec.ts + zeitstrahl/page.server.test.ts suite passes unchanged; DTO order, threshold-12, and gap-fold assertions remain green.
  • REQ-015 — A key-parity unit test confirms every new timeline_* key exists in de/en/es.
  • REQ-016 — A LetterCard with title: ''/null renders no ✉ and no "Brief" sr-only label, but still renders sender → receiver and the date.

Component touch-points

File Change
routes/zeitstrahl/+page.svelte Wrap <TimelineView> in the .tl-canvas frame (REQ-001); render the .dh-sub meta line from derived counts (REQ-002).
lib/timeline/TimelineView.svelte Owns the spine + its 3-stop gradient on the container ::before (REQ-006); markers themselves live in YearBand (see ownership rule). The REQ-002 totals are not computed here — they are derived in the route (see the ownership note below).
lib/timeline/YearBand.svelte Center the year <h2> on the axis at ≥1024px, left at <1024px, keep sticky (REQ-003); the year-badge node marker on the <h2> (REQ-004); the connector dot per .letter-row (REQ-005), with the phone column indent.
lib/timeline/EventPill.svelte Provenance suffix in the subtitle (REQ-007).
lib/timeline/WorldBand.svelte Inline "· historisch" descriptor (REQ-009).
lib/timeline/LetterCard.svelte ✉ title glyph + sr-only label, guarded by title presence (REQ-008/016).
lib/timeline/YearLetterStrip.svelte ✉ count glyph + "Monats-Dichte" descriptor, toggle preserved (REQ-010); two endpoint month-axis labels (REQ-011).
frontend/messages/{de,en,es}.json New keys (see i18n table).

Marker-ownership rule (architect): the spine and the REQ-006 gradient belong to the container (TimelineView ::before); the year-badge node marker belongs to YearBand's <h2>; the per-letter connector dot belongs to YearBand's .letter-row. Dot/spine CSS is defined once, not duplicated across components. The spine geometry (left:50% desktop / left:0.5rem phone) is unavoidably referenced by both TimelineView (the spine) and YearBand (markers aligning to it); carry a one-line code comment at the YearBand marker offset pointing at the TimelineView spine left, so a future spine-position edit doesn't silently desync the markers. Likewise the REQ-002 meta counts are derived in exactly one place — the route (+page.svelte, or its +page.server.ts load) — and never also recomputed inside TimelineView.

i18n keys (draft)

Path: frontend/messages/{de,en,es}.json.

Key de en es
timeline_grouping_date Gruppierung: Datum Grouping: Date Agrupación: Fecha
timeline_meta_summary {range} · {letters} Briefe · {events} Ereignisse {range} · {letters} letters · {events} events {range} · {letters} cartas · {events} eventos
timeline_provenance_derived abgeleitet derived derivado
timeline_provenance_curated kuratiert curated curado
timeline_letter_glyph_label Brief Letter Carta
timeline_layer_historical_suffix historisch historical histórico
timeline_strip_density_caption Monats-Dichte Monthly density Densidad mensual

Month-axis endpoint labels (REQ-011) reuse the existing locale month formatter (formatTickLabel / shared month-bucket helpers) — no new per-month key set, to stay DRY with the document density chart. Note: timeline_layer_historical_suffix ("historisch", the inline visible band descriptor) intentionally differs from the existing timeline_layer_world ("Weltgeschehen", the sr-only layer label) — same layer, two registers (visible adjective vs. screen-reader noun).

Tests (TDD)

  • Component *.svelte.spec.ts (vitest-browser-svelte, --project=client, single-file local): REQ-001 (canvas frame), REQ-002 (meta line text incl. undated in counts + range-absent + empty-absent branches), REQ-004/005 (markers present; phone-offset assertion at a narrow viewport), REQ-007 (provenance text, no persönlich/SEASON), REQ-008 (✉ + sr-only + href intact), REQ-009 (inline historisch; range pill intact), REQ-010 (✉ + descriptor + toggle/label intact), REQ-011 (exactly two endpoint labels ≥10px), REQ-012 (dashed frame + count + empty absence), REQ-016 (no-title → no ✉, row still renders).
  • XSS-escaping regression (security): a LetterCard *.svelte.spec.ts that renders a title containing <script>/HTML and asserts it appears verbatim as text (escaped, not executed) — turns the no-{@html} contract into a permanent guard.
  • Regression REQ-014: run the existing timeline + page.server suites unchanged; they must stay green.
  • i18n parity REQ-015: key-parity unit test over the new timeline_* keys.
  • Centering (REQ-003) and the 3-stop gradient (REQ-006) are largely CSS; assert the structural hooks in unit tests and verify the visual result with a screenshot at 1440px / 375px before merge.

Security Considerations

Read-only, presentation-only. No new endpoint, no mutation, no ErrorCode. New glyphs/markers are static literals/Paraglide keys; all OCR/import-derived text continues through {...} escaping (no {@html} enters the tree — #779 REQ-021 unchanged, re-pinned by the XSS-escaping regression test above). STRIDE: only Information-disclosure/Tampering→XSS are in play, both already mitigated; no mutating endpoint exists, so no authn/authz If clause is applicable (REQ-016 is the only Unwanted-behavior branch and it is a render guard, not an access guard).

Coordination

#783 ("polish & accessibility pass") owns the a11y/dark-mode sweep and shares the dark-mode token grep gate (REQ-013, corrected here). This issue is the visual-layout half; work it first (it changes the markup #783 will then audit), or land them together. No requirement here weakens a #783 acceptance criterion. The corrected REQ-013 grep should be back-ported to #783's AC so both issues use the working form.

Decisions Resolved

# Question Resolution Rationale
OQ-1 Serif vs sans letter titles Keep serif #779's documented element-level font split (titles → font-serif); consistency over pixel-matching §3's sans .lcard .t.
OQ-2 Mint left-border on plain letter cards Keep as house accent §3 reserves the mint border for the .ev event-letter variant, which depends on a letter→event link not in the DTO and ships with #827; the plain-card mint accent is a deliberate, low-risk house choice. The §3 divergence is acknowledged, not accidental.
OQ-3 Header archive-name prefix ("Die Familie Raddatz") Counts only Sample data with no current source; the meta line ships range + counts + grouping only.

No open questions remain.

Traceability

To be mirrored into .specify/rtm.md (feature zeitstrahl-visual-fidelity, issue #833, REQ-001..016, Status: Planned) — these rows land in the first commit of the implementation branch (consistent with prior timeline issues #776/#777/#778), not at spec time.

Review changelog

  • Round 1 (6 personas; Security + Architect APPROVE, the other four CHANGES REQUESTED) — folded: corrected the broken REQ-013 grep gate (matched #eac in {#each}); fixed REQ-006 to var(--c-tag-slate) (no --palette-slate exists) and reconciled it with REQ-013; scoped out the mockup's · persönlich/· SEASON tokens and pinned REQ-007 to one provenance token; changed REQ-011 from 12 labels to 2 endpoint labels ≥10px; added the phone-axis offset + marker-ownership rule to REQ-004/005; pinned REQ-002 counts to include undated[] and defined the event count; clarified REQ-009 (inline historisch) and REQ-010 (toggle preserved); added the ## User Journey section; added the no-title If requirement (REQ-016); fixed the i18n path to frontend/messages/; added the XSS-escaping regression test; resolved all three open questions.
  • Round 2 (6 personas; all APPROVE, no blockers) — folded non-blocking refinements: pinned the REQ-002 meta sub-line to font-sans text-xs text-ink-3 (≥12px, AA) instead of the mockup's sub-floor 9.5px/#7a7a76; corrected the REQ-011 AC to assert the locale formatter output (de "Jan. 1915") and noted the {year}-01/{year}-12 anchor input; keyed REQ-007 provenance off entry.derived; added the spine-geometry desync code-comment note to the marker-ownership rule. REQ-002 atomicity (reads like 3 requirements) and the RTM-rows-at-first-commit deferral were raised as accepted non-blocking QUESTIONs — no body change.
  • Round 3 (6 personas; all APPROVE, no blockers) — folded two implementer notes: REQ-005 (verify the phone connector dot and LetterCard's mint left-border stay distinct at 375px) and REQ-009 (use the §3 .nwb left-border treatment at phone width rather than recreating the desktop full-bleed negative margin, to avoid clipping the left spine). No requirement changes; the spec held stable across two consecutive all-APPROVE rounds.
  • Round 4 (6 personas; all APPROVE, no blockers) — folded two clarity nits: pinned the REQ-002 count derivation to a single place (the route, not also TimelineView) and added an i18n note distinguishing timeline_layer_historical_suffix from timeline_layer_world. Spec held stable across three consecutive all-APPROVE rounds; cleared for implementation.
# As a family member I want /zeitstrahl to render exactly like the canonical "Lebensfaden" mockup so the timeline reads as the finished, framed life-thread the design promises **Milestone:** Zeitstrahl — Family Timeline (#14) **Canonical visual spec** (on `main`, open in a browser): `docs/specs/zeitstrahl-final-spec.html` — Concept A "Der Lebensfaden", §3 "Vollständige Vorschau" is the canonical desktop render. **Builds on:** #779 / PR #831 (global `/zeitstrahl` Datum mode, merged). This issue is **visual-fidelity follow-up only** — the data flow, ordering, a11y semantics, and density logic from #779 are correct and unchanged. > **Review status:** Rounds 1–4 of `/review-issue` complete (all six personas APPROVE in Rounds 2–4); all persona comments folded into this body and removed (the issue body is the source of truth). Changelog at the bottom. ## Context & Why #779 shipped the global `/zeitstrahl` in "Datum" mode and claimed the "full Concept-A visual layout". A side-by-side review of the live page against `docs/specs/zeitstrahl-final-spec.html` §3 shows the **structure is right but the chrome is not**: the timeline renders bare on the page background (no canvas frame), the year badges sit hard against the left edge instead of centered on the axis, letter cards float beside the spine with no connector dot, and several small spec affordances (✉ glyphs, month-axis labels, the event-pill provenance line, the header meta line) are missing. The result reads as an unfinished wireframe rather than the framed "life-thread" the spec depicts. This is a presentation-only pass. **No backend change, no new endpoint, no `generate:api`, no entity field.** Every value the spec shows is either already on `TimelineDTO` or derivable client-side from it. Constitution principles this touches (see `.specify/constitution.md`): - §1.4 — all work stays in `frontend/src/lib/timeline/` (+ the route); cross-domain reuse via `$lib/shared/` only (the `Sparkline` primitive and `monthBuckets` helpers already live there). - §2.5 — untrusted (OCR/import-derived) `title`/`senderName`/`receiverName` render via Svelte `{...}` escaping; never `{@html}`. New glyph prefixes (✉, node dots) are static Paraglide/literal markup, never interpolated user text. Related: ADR-040 (timeline domain), #783 (a11y/polish pass — overlaps on the dark-mode token rule; see Coordination). ## User Journey A logged-in family member opens **Zeitstrahl**. The page now presents the timeline inside a framed "sheet" (the `.tl-canvas` panel) with a title and a meta sub-line ("1909–1924 · 187 Briefe · 38 Ereignisse · Gruppierung: Datum"). Scrolling the centered desktop axis, each year is announced by a navy badge **centered on the spine**, visibly interrupting the life-thread. Life-event pills read "Geburt: … · abgeleitet" so the reader sees both the date and that the event was derived. Letters hang off the axis on alternating sides, each tethered to the spine by a small mint-ringed dot, each titled with a ✉. Dense years collapse to a strip whose count carries a ✉ and whose sparkline is labelled "Jan {year} … Dez {year}". Undated letters sit in a clearly framed "Ohne Datum · N" box at the end. On a phone the same content stacks on a single left spine — the badges, dots, and node markers sit on that left spine without ever overlapping the cards. Nothing about ordering, density thresholds, or which entries appear changes — only the chrome catches up to the spec. (Degenerate journey: nothing the family member *does* changes; this is a rendering-fidelity story, journey included per template requirement.) ## Scope Visual-fidelity changes to the existing `Datum`-mode component tree so the live `/zeitstrahl` matches `docs/specs/zeitstrahl-final-spec.html` §3 on a centered desktop axis (≥1024px) and a left phone axis (<1024px). Files: `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/{TimelineView,YearBand,EventPill,WorldBand,LetterCard,YearLetterStrip}.svelte`, plus new `timeline_*` i18n keys in `frontend/messages/{de,en,es}.json`. ## Out of Scope (explicit boundaries) - **Per-letter root-tag color chips** (spec legend "Wurzel-Tag-Farbchip", and the `Familie`/`Weihnachten`/`Krieg` chips on §3 letter cards). `TimelineEntryDTO` carries no tag field — needs a backend DTO change (same data gap as "Thema" mode). **Now tracked as #835** (full-stack: DTO root-tag enrichment + the per-letter chip); not built here. - **The `.lcard.ev` event-letter variant** (mint-bordered, serif letter card used in the §2 "Ereignis" mode to show letters bundled under a curated event). It depends on a letter→event link that does not exist on `TimelineEntryDTO` and only appears in non-Datum modes. Deferred with the grouping toggle (#827); not rendered here. (This is why OQ-2's "keep the mint left-border on every plain card" is a deliberate house accent, not the spec's `.ev` semantics.) - **The mockup's `· persönlich` and `· SEASON` subtitle tokens** (e.g. §3 line 296 "Sommer 1915 · abgeleitet · SEASON", line 312 "Frühjahr 1924 · persönlich · kuratiert"). `· SEASON` is a `DatePrecision`-enum annotation the spec author printed for the *reader of the spec sheet*, never intended as production UI. `· persönlich` is the PERSONAL-layer name, already conveyed redundantly by the ★ glyph + mint border. Only the single provenance token ships (REQ-007). Documented exclusion, not an oversight. - **World-band in-time letter count** ("· 187 Briefe in dieser Zeit"). The "· historisch" label is added (REQ-009); the count requires summing letters across the band's span — deferred unless trivially derivable from the loaded DTO, in which case it is additive. - **Grouping toggle (Datum/Ereignis/Thema)** → #827. The header meta line shows a static "Gruppierung: Datum" string only; no toggle control. - **Leading/trailing empty decades** (§3 `1899–1908` / `1925–1950` outer spans) → #828. - **Editorial quiet-span labels** (§3 "Nachkriegsjahre") → #829. - **Per-month bar drill-down** inside the strip → #830. (This is why REQ-010 ships only "Monats-Dichte" and not the mockup's longer "· antippen → Monate → Briefe ▾" hint, which advertises that deferred interaction.) - **Serif letter titles** — deliberate from #779 (font split: titles → `font-serif`). Spec §3 uses sans for plain `.lcard .t`. Kept serif (OQ-1, resolved → keep). ## Data flow Unchanged. `/zeitstrahl/+page.server.ts` SSR-loads `GET /api/timeline` → `{ timeline }`. All new values (year-range, letter count, event count) are derived client-side from `timeline.years` + `timeline.undated`. No client fetch, no new request, no PII logged. (Feasibility confirmed against `frontend/src/lib/generated/api.ts` `TimelineEntryDTO` — every count is reachable from the loaded DTO.) ## Requirements (EARS) ### Frame & header - **REQ-001** (Ubiquitous) — The `/zeitstrahl` content shall wrap the timeline in a canvas frame matching `.tl-canvas`: a `border border-line`, `rounded-[10px]`, `bg-canvas`, with internal padding, using semantic tokens only (no raw hex). - **REQ-002** (Ubiquitous) — Below the "Zeitstrahl" heading the view shall render a meta sub-line matching `.dh-sub`, composed of: the populated year-range (`{firstYear}–{lastYear}`, taken from the first/last entry in `timeline.years`), the **letter count** (every entry with `kind === 'LETTER'` across **all year bands plus `timeline.undated`**), the **event count** (every entry with `kind === 'EVENT'` across all year bands plus `undated` — this includes derived life-events, curated PERSONAL events, and HISTORICAL world-bands), and the static text "Gruppierung: Datum". When `timeline.years` is empty the range segment is omitted; when both `years` and `undated` are empty the entire sub-line is absent. The sub-line is rendered `font-sans text-xs text-ink-3` (≥12px, AA-compliant on the canvas) — **not** the mockup's literal 9.5px/`#7a7a76`, which is below this project's 12px legibility floor and fails WCAG AA (≈4.0:1 on `bg-canvas`). ### Axis & year badge (desktop AND phone behavior is mandatory) - **REQ-003** (State-driven) — While the viewport is ≥1024px, the year badge shall be horizontally centered on the axis (matching `.ybadge{text-align:center}`); while the viewport is <1024px it shall sit at the left spine. The existing sticky-at-`top-16` behavior (#779 REQ-006) shall be preserved in both. - **REQ-004** (Ubiquitous) — The year badge shall sit on the spine with a navy node marker so the badge visibly interrupts the axis (matching `.ybadge span{z-index:2}`). On desktop (≥1024px) the marker is centered on the `left:50%` spine; on phone (<1024px) the marker sits on the `left:0.5rem` spine and the badge clears it — the marker must never overlap the badge text on either axis. - **REQ-005** (Ubiquitous) — Each individual `LetterCard` row shall render a connector dot on the axis matching `.lrow .dot` (white fill, mint ring). On desktop the dot is centered on the `left:50%` spine between the alternating card and the axis; on phone the entry column is indented clear of the `left:0.5rem` spine (mirroring the mockup's `.nsp{padding-left:21px}`) and the dot sits on that spine to the **left** of the card content (negative offset relative to the indented column) — the dot must never overlay the card on either axis. (At 375px verify the new connector dot and the existing mint left-border of `LetterCard` read as two distinct marks at the spine, not one merged blob.) - **REQ-006** (Ubiquitous) — The axis gradient shall run mint → navy → slate (three stops, matching `linear-gradient(#a1dcd8,#012851,#607080)`) using existing semantic tokens: `var(--palette-mint)`, `var(--palette-navy)`, and `var(--c-tag-slate)` for the slate stop (note: there is **no** `--palette-slate` token; slate lives only as `--c-tag-slate`, the same token `WorldBand.svelte` already uses). No raw hex — this stays consistent with REQ-013. ### Pills, bands & letter cards - **REQ-007** (Event-driven) — When `EventPill` renders a derived or curated PERSONAL pill, its subtitle shall be `{dateLabel} · {provenance}`, where `{provenance}` resolves to the Paraglide key `timeline_provenance_derived` ("abgeleitet") when `derived === true` and `timeline_provenance_curated` ("kuratiert") when curated PERSONAL (`derived === false`) — keyed off `entry.derived`, not `getAccentConfig`'s accent. Only this single provenance token is appended — the mockup's extra `· persönlich` / `· SEASON` tokens are out of scope (see Out of Scope). - **REQ-008** (Event-driven) — When `LetterCard` has a non-empty `title`, the title shall be prefixed with an envelope glyph wrapped `<span aria-hidden="true">✉</span>` plus an `sr-only` "Brief" label (`timeline_letter_glyph_label`), matching §3 "✉ Brief aus Stettin". The glyph shall be a static literal/Paraglide key concatenated as a sibling element — never interpolated into, or `{@html}`-rendered alongside, the escaped user title. - **REQ-009** (Event-driven) — When `WorldBand` renders a HISTORICAL band, it shall include the layer descriptor "· historisch" (`timeline_layer_historical_suffix`) **inline in the band subtitle**, matching §3 "1914–1918 · historisch". For a non-RANGE band the subtitle reads `{dateLabel} · historisch`; for a RANGE band the existing visible span pill ("1914–1918" with its `Zeitraum: …` aria-label, #779 REQ-009, unchanged) is followed by the inline "· historisch" text. The descriptor is plain text, not a second detached pill. Implementer note: the band is full-bleed via a desktop negative margin (`margin:0 -26px` in the mockup); at phone width use the §3 `.nwb` left-border accent treatment instead of recreating the full-bleed negative margin, so the band does not clip the left spine. ### Dense strip - **REQ-010** (Ubiquitous) — `YearLetterStrip` shall prefix its count with the `aria-hidden` ✉ glyph + `sr-only` label, and shall render the static density descriptor "Monats-Dichte" (`timeline_strip_density_caption`) near the sparkline. The existing ≥44px keyboard-focusable expand toggle and its visible label ("Briefe anzeigen") shall be **preserved** — the density descriptor is additive context, not a replacement for the toggle. - **REQ-011** (Ubiquitous) — `YearLetterStrip` shall render exactly the two endpoint month-axis labels beneath the sparkline, matching `.strip .axl`: "Jan {year}" at the left, "Dez {year}" at the right (not a label under every bar). Labels are localized via the existing locale month formatter (`formatTickLabel` / shared month-bucket helpers — no new per-month key set) and rendered at ≥10px (the mockup's 6px is below this project's legibility floor for the 60+ transcriber audience). Pass the January/December anchors (`{year}-01` / `{year}-12`) to the formatter — `formatTickLabel('{year}')` alone returns only the year. ### Undated bucket - **REQ-012** (State-driven) — While `timeline.undated` is non-empty, the "Ohne Datum" section shall render inside a dashed frame matching `.undated` and its heading shall include the entry count ("Ohne Datum · {count}"), preserving the existing kind/type dispatch (events still render as pills/bands, not letter cards). ### Cross-cutting - **REQ-013** (Ubiquitous) — All new styles shall use semantic Tailwind tokens (`bg-canvas`, `bg-surface`, `text-ink*`, `border-line`, `brand-navy`, `brand-mint`, `var(--palette-*)`, `var(--c-tag-slate)`); no raw hex in a color context. **Verification grep (corrected):** `grep -rnE '(bg|text|border|from|via|to|ring|fill|stroke|outline|decoration)-\[#[0-9a-fA-F]{3,8}\]|:[[:space:]]*#[0-9a-fA-F]{3,8}' frontend/src/lib/timeline/` → zero hits. (The earlier bare `#[0-9a-fA-F]{3,6}` form was wrong — it matches the `#eac` substring inside every `{#each}` block and reports false positives on clean code.) This is the same **local / PR-review gate** #783 uses; it is not a new `ci.yml` step. - **REQ-014** (Ubiquitous) — No change shall alter DTO ordering, the strip-vs-cards density threshold (12), gap-folding, the `<ol>`/`<section>`/`<h2>` semantic structure, or any existing #779 REQ-001..027 behavior; all existing timeline tests shall stay green. - **REQ-015** (Ubiquitous) — All new user-facing strings shall be Paraglide keys present in `frontend/messages/{de,en,es}.json` with matching key sets across locales. - **REQ-016** (Unwanted-behavior) — If a `LetterCard` entry has no `title` (null/empty), then no ✉ glyph and no `sr-only` "Brief" label shall render (the glyph attaches only to a present title — matches the existing `{#if entry.title}` guard); the row shall still render sender → receiver and the date. ## Acceptance Criteria (measurable) - **REQ-001** — `/zeitstrahl` renders a single bordered, rounded `bg-canvas` container wrapping the bands; verified in a `*.svelte.spec.ts` and visually at 1440px. - **REQ-002** — Given a DTO spanning years 1909–1924 with 187 LETTER entries and 38 EVENT entries (across bands + undated), the sub-line reads "1909–1924 · 187 Briefe · 38 Ereignisse · Gruppierung: Datum". Given only undated entries (no `years`), the range segment is absent. Given an empty DTO, the sub-line is absent from the DOM. - **REQ-003** — At ≥1024px the year badge's computed horizontal center equals the axis center (within tolerance); at <1024px it sits at the left spine; the `<h2>` retains `position: sticky; top: 4rem` at both widths. - **REQ-004** — Each year `<h2>` has an associated node marker element; at 1440px it is centered on the spine, at 375px it is on the left spine and does not overlap the badge text (bounding boxes do not intersect the text run). - **REQ-005** — A band with 3 letters renders 3 connector dots; at 1440px each dot is on the centered spine, at 375px each dot is left of its card (dot right edge ≤ card left edge) and on the left spine. - **REQ-006** — The axis `::before` background is a three-stop gradient referencing `--palette-mint`, `--palette-navy`, and `--c-tag-slate`; the grep in REQ-013 still returns zero hits. - **REQ-007** — A derived `BIRTH` pill subtitle reads "{date} · abgeleitet"; a curated PERSONAL pill subtitle reads "{date} · kuratiert"; values from Paraglide keys (computed in the test, not hardcoded); no "persönlich"/"SEASON" text present. - **REQ-008** — A `LetterCard` with a title renders an `aria-hidden` ✉ and an `sr-only` "Brief"; the document href is still exactly `/documents/{documentId}`. - **REQ-009** — A non-RANGE HISTORICAL band subtitle contains "· historisch"; a RANGE band still renders its `1914–1918` pill with the `Zeitraum` aria-label (#779 REQ-009) AND the inline "· historisch" text; "historisch" appears as text, not a separate pill element. - **REQ-010** — A 30-letter `YearLetterStrip` shows an `aria-hidden` ✉ + the "Monats-Dichte" descriptor; the expand toggle is still present, ≥44px, keyboard-focusable, retains its "Briefe anzeigen" label, and reveals 30 `LetterCard`s. - **REQ-011** — A `YearLetterStrip` for 1915 renders exactly two month-axis labels at the sparkline ends, each computed-`font-size` ≥10px. The test asserts the **locale formatter output** for the Jan/Dez anchors (`formatTickLabel('1915-01', locale)` / `'1915-12'`) — e.g. de yields "Jan. 1915" / "Dez. 1915" (the `short` month carries a trailing period) — via the formatter or `startsWith`, never a hardcoded "Jan 1915". - **REQ-012** — With 11 undated entries the "Ohne Datum" section is a dashed-framed box whose heading contains "11"; with zero undated entries the section is absent from the DOM. - **REQ-013** — The corrected grep returns zero hits on the implemented tree. - **REQ-014** — The full existing `frontend/src/lib/timeline/*.spec.ts` + `zeitstrahl/page.server.test.ts` suite passes unchanged; DTO order, threshold-12, and gap-fold assertions remain green. - **REQ-015** — A key-parity unit test confirms every new `timeline_*` key exists in de/en/es. - **REQ-016** — A `LetterCard` with `title: ''`/null renders no ✉ and no "Brief" sr-only label, but still renders sender → receiver and the date. ## Component touch-points | File | Change | |---|---| | `routes/zeitstrahl/+page.svelte` | Wrap `<TimelineView>` in the `.tl-canvas` frame (REQ-001); render the `.dh-sub` meta line from derived counts (REQ-002). | | `lib/timeline/TimelineView.svelte` | **Owns the spine + its 3-stop gradient** on the container `::before` (REQ-006); markers themselves live in `YearBand` (see ownership rule). The REQ-002 totals are **not** computed here — they are derived in the route (see the ownership note below). | | `lib/timeline/YearBand.svelte` | Center the year `<h2>` on the axis at ≥1024px, left at <1024px, keep sticky (REQ-003); the year-badge **node marker** on the `<h2>` (REQ-004); the **connector dot** per `.letter-row` (REQ-005), with the phone column indent. | | `lib/timeline/EventPill.svelte` | Provenance suffix in the subtitle (REQ-007). | | `lib/timeline/WorldBand.svelte` | Inline "· historisch" descriptor (REQ-009). | | `lib/timeline/LetterCard.svelte` | ✉ title glyph + sr-only label, guarded by title presence (REQ-008/016). | | `lib/timeline/YearLetterStrip.svelte` | ✉ count glyph + "Monats-Dichte" descriptor, toggle preserved (REQ-010); two endpoint month-axis labels (REQ-011). | | `frontend/messages/{de,en,es}.json` | New keys (see i18n table). | **Marker-ownership rule (architect):** the spine and the REQ-006 gradient belong to the container (`TimelineView` `::before`); the year-badge node marker belongs to `YearBand`'s `<h2>`; the per-letter connector dot belongs to `YearBand`'s `.letter-row`. Dot/spine CSS is defined once, not duplicated across components. The spine *geometry* (`left:50%` desktop / `left:0.5rem` phone) is unavoidably referenced by both `TimelineView` (the spine) and `YearBand` (markers aligning to it); carry a one-line code comment at the `YearBand` marker offset pointing at the `TimelineView` spine `left`, so a future spine-position edit doesn't silently desync the markers. Likewise the REQ-002 meta counts are derived in **exactly one place** — the route (`+page.svelte`, or its `+page.server.ts` load) — and never also recomputed inside `TimelineView`. ## i18n keys (draft) Path: `frontend/messages/{de,en,es}.json`. | Key | de | en | es | |---|---|---|---| | `timeline_grouping_date` | Gruppierung: Datum | Grouping: Date | Agrupación: Fecha | | `timeline_meta_summary` | {range} · {letters} Briefe · {events} Ereignisse | {range} · {letters} letters · {events} events | {range} · {letters} cartas · {events} eventos | | `timeline_provenance_derived` | abgeleitet | derived | derivado | | `timeline_provenance_curated` | kuratiert | curated | curado | | `timeline_letter_glyph_label` | Brief | Letter | Carta | | `timeline_layer_historical_suffix` | historisch | historical | histórico | | `timeline_strip_density_caption` | Monats-Dichte | Monthly density | Densidad mensual | Month-axis endpoint labels (REQ-011) reuse the existing locale month formatter (`formatTickLabel` / shared month-bucket helpers) — no new per-month key set, to stay DRY with the document density chart. Note: `timeline_layer_historical_suffix` ("historisch", the inline visible band descriptor) intentionally differs from the existing `timeline_layer_world` ("Weltgeschehen", the sr-only layer label) — same layer, two registers (visible adjective vs. screen-reader noun). ## Tests (TDD) - **Component** `*.svelte.spec.ts` (vitest-browser-svelte, `--project=client`, single-file local): REQ-001 (canvas frame), REQ-002 (meta line text incl. undated in counts + range-absent + empty-absent branches), REQ-004/005 (markers present; phone-offset assertion at a narrow viewport), REQ-007 (provenance text, no persönlich/SEASON), REQ-008 (✉ + sr-only + href intact), REQ-009 (inline historisch; range pill intact), REQ-010 (✉ + descriptor + toggle/label intact), REQ-011 (exactly two endpoint labels ≥10px), REQ-012 (dashed frame + count + empty absence), REQ-016 (no-title → no ✉, row still renders). - **XSS-escaping regression** (security): a `LetterCard` `*.svelte.spec.ts` that renders a `title` containing `<script>`/HTML and asserts it appears **verbatim as text** (escaped, not executed) — turns the no-`{@html}` contract into a permanent guard. - **Regression** REQ-014: run the existing timeline + page.server suites unchanged; they must stay green. - **i18n parity** REQ-015: key-parity unit test over the new `timeline_*` keys. - Centering (REQ-003) and the 3-stop gradient (REQ-006) are largely CSS; assert the structural hooks in unit tests and verify the visual result with a screenshot at 1440px / 375px before merge. ## Security Considerations Read-only, presentation-only. No new endpoint, no mutation, no `ErrorCode`. New glyphs/markers are static literals/Paraglide keys; all OCR/import-derived text continues through `{...}` escaping (no `{@html}` enters the tree — #779 REQ-021 unchanged, re-pinned by the XSS-escaping regression test above). STRIDE: only Information-disclosure/Tampering→XSS are in play, both already mitigated; no mutating endpoint exists, so no authn/authz `If` clause is applicable (REQ-016 is the only Unwanted-behavior branch and it is a render guard, not an access guard). ## Coordination #783 ("polish & accessibility pass") owns the a11y/dark-mode sweep and shares the dark-mode token grep gate (REQ-013, corrected here). This issue is the **visual-layout** half; work it first (it changes the markup #783 will then audit), or land them together. No requirement here weakens a #783 acceptance criterion. The corrected REQ-013 grep should be back-ported to #783's AC so both issues use the working form. ## Decisions Resolved | # | Question | Resolution | Rationale | |---|----------|-----------|-----------| | OQ-1 | Serif vs sans letter titles | **Keep serif** | #779's documented element-level font split (titles → `font-serif`); consistency over pixel-matching §3's sans `.lcard .t`. | | OQ-2 | Mint left-border on plain letter cards | **Keep as house accent** | §3 reserves the mint border for the `.ev` event-letter variant, which depends on a letter→event link not in the DTO and ships with #827; the plain-card mint accent is a deliberate, low-risk house choice. The §3 divergence is acknowledged, not accidental. | | OQ-3 | Header archive-name prefix ("Die Familie Raddatz") | **Counts only** | Sample data with no current source; the meta line ships range + counts + grouping only. | No open questions remain. ## Traceability To be mirrored into `.specify/rtm.md` (feature `zeitstrahl-visual-fidelity`, issue #833, REQ-001..016, Status: Planned) — these rows land in the **first commit** of the implementation branch (consistent with prior timeline issues #776/#777/#778), not at spec time. ## Review changelog - **Round 1** (6 personas; Security + Architect APPROVE, the other four CHANGES REQUESTED) — folded: corrected the broken REQ-013 grep gate (matched `#eac` in `{#each}`); fixed REQ-006 to `var(--c-tag-slate)` (no `--palette-slate` exists) and reconciled it with REQ-013; scoped out the mockup's `· persönlich`/`· SEASON` tokens and pinned REQ-007 to one provenance token; changed REQ-011 from 12 labels to 2 endpoint labels ≥10px; added the phone-axis offset + marker-ownership rule to REQ-004/005; pinned REQ-002 counts to include `undated[]` and defined the event count; clarified REQ-009 (inline historisch) and REQ-010 (toggle preserved); added the `## User Journey` section; added the no-title `If` requirement (REQ-016); fixed the i18n path to `frontend/messages/`; added the XSS-escaping regression test; resolved all three open questions. - **Round 2** (6 personas; all APPROVE, no blockers) — folded non-blocking refinements: pinned the REQ-002 meta sub-line to `font-sans text-xs text-ink-3` (≥12px, AA) instead of the mockup's sub-floor 9.5px/`#7a7a76`; corrected the REQ-011 AC to assert the locale formatter output (de "Jan. 1915") and noted the `{year}-01`/`{year}-12` anchor input; keyed REQ-007 provenance off `entry.derived`; added the spine-geometry desync code-comment note to the marker-ownership rule. REQ-002 atomicity (reads like 3 requirements) and the RTM-rows-at-first-commit deferral were raised as accepted non-blocking QUESTIONs — no body change. - **Round 3** (6 personas; all APPROVE, no blockers) — folded two implementer notes: REQ-005 (verify the phone connector dot and `LetterCard`'s mint left-border stay distinct at 375px) and REQ-009 (use the §3 `.nwb` left-border treatment at phone width rather than recreating the desktop full-bleed negative margin, to avoid clipping the left spine). No requirement changes; the spec held stable across two consecutive all-APPROVE rounds. - **Round 4** (6 personas; all APPROVE, no blockers) — folded two clarity nits: pinned the REQ-002 count derivation to a single place (the route, not also `TimelineView`) and added an i18n note distinguishing `timeline_layer_historical_suffix` from `timeline_layer_world`. Spec held stable across **three** consecutive all-APPROVE rounds; cleared for implementation.
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-14 08:47:44 +02:00
marcel added the P2-mediumbugui labels 2026-06-14 08:47:53 +02:00
Author
Owner

Implemented → PR #836

All 16 requirements implemented TDD (red → green), one atomic commit each on
feat/issue-833-zeitstrahl-fidelity. Presentation-only — no backend, endpoint, generate:api,
entity, or ErrorCode change.

REQ coverage (all Done in .specify/rtm.md):

  • REQ-001/002 — canvas frame + meta sub-line (timelineMeta.ts derives counts in the route only) — e4da28d7, a1e57ff8
  • REQ-003/004/005 — centered/left year badge (sticky), navy node marker, per-letter connector dot — 18934413
  • REQ-006 — 3-stop mint→navy→slate axis gradient (--c-tag-slate) — bfe66569
  • REQ-007 — event-pill · abgeleitet / · kuratiert (keyed off entry.derived) — 08d8896c
  • REQ-008/016 — ✉ + sr-only "Brief" on titles, guarded; XSS-verbatim regression — fc67dfc3
  • REQ-009 — WorldBand inline · historisch (RANGE pill intact) — 14471972
  • REQ-010/011 — strip ✉ count + "Monats-Dichte" + Jan/Dez endpoint labels, toggle preserved — 6382efa6
  • REQ-012 — dashed-framed "Ohne Datum · {count}" — e0b096f1
  • REQ-013 — corrected hex grep → 0 hits; REQ-015 — new timeline_* keys in de/en/es — 808d6efa
  • REQ-014 — existing timeline + page.server suites stay green
  • Follow-up 217508dd — keep the badge above its node marker (z-index) so the centered desktop pill never occludes the year text (caught in visual review, now z-index-guarded).

Verification: 147 timeline tests green; full timeline+zeitstrahl+messages regression (142) unchanged; npm run check clean for changed files; visually confirmed at 1440px and 375px against docs/specs/zeitstrahl-final-spec.html §3.

Out-of-scope items remain tracked: #835 (root-tag chips), #827 (.ev variant + grouping toggle), #828/#829/#830.

Ready for review.

## Implemented → PR #836 All 16 requirements implemented TDD (red → green), one atomic commit each on `feat/issue-833-zeitstrahl-fidelity`. Presentation-only — no backend, endpoint, `generate:api`, entity, or `ErrorCode` change. **REQ coverage** (all `Done` in `.specify/rtm.md`): - REQ-001/002 — canvas frame + meta sub-line (`timelineMeta.ts` derives counts in the route only) — `e4da28d7`, `a1e57ff8` - REQ-003/004/005 — centered/left year badge (sticky), navy node marker, per-letter connector dot — `18934413` - REQ-006 — 3-stop mint→navy→slate axis gradient (`--c-tag-slate`) — `bfe66569` - REQ-007 — event-pill `· abgeleitet` / `· kuratiert` (keyed off `entry.derived`) — `08d8896c` - REQ-008/016 — ✉ + sr-only "Brief" on titles, guarded; XSS-verbatim regression — `fc67dfc3` - REQ-009 — WorldBand inline `· historisch` (RANGE pill intact) — `14471972` - REQ-010/011 — strip ✉ count + "Monats-Dichte" + Jan/Dez endpoint labels, toggle preserved — `6382efa6` - REQ-012 — dashed-framed "Ohne Datum · {count}" — `e0b096f1` - REQ-013 — corrected hex grep → **0 hits**; REQ-015 — new `timeline_*` keys in de/en/es — `808d6efa` - REQ-014 — existing timeline + page.server suites stay green - Follow-up `217508dd` — keep the badge above its node marker (z-index) so the centered desktop pill never occludes the year text (caught in visual review, now z-index-guarded). **Verification:** 147 timeline tests green; full timeline+zeitstrahl+messages regression (142) unchanged; `npm run check` clean for changed files; **visually confirmed at 1440px and 375px** against `docs/specs/zeitstrahl-final-spec.html` §3. Out-of-scope items remain tracked: #835 (root-tag chips), #827 (`.ev` variant + grouping toggle), #828/#829/#830. Ready for review.
Sign in to join this conversation.
No Label P2-medium bug ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#833