Timeline (/zeitstrahl) Datum mode diverges from the canonical Concept-A visual spec — restore layout fidelity #833
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
/zeitstrahlDatum 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.Context & Why
#779 shipped the global
/zeitstrahlin "Datum" mode and claimed the "full Concept-A visual layout". A side-by-side review of the live page againstdocs/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 onTimelineDTOor derivable client-side from it.Constitution principles this touches (see
.specify/constitution.md):frontend/src/lib/timeline/(+ the route); cross-domain reuse via$lib/shared/only (theSparklineprimitive andmonthBucketshelpers already live there).title/senderName/receiverNamerender 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-canvaspanel) 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/zeitstrahlmatchesdocs/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 newtimeline_*i18n keys infrontend/messages/{de,en,es}.json.Out of Scope (explicit boundaries)
Familie/Weihnachten/Kriegchips on §3 letter cards).TimelineEntryDTOcarries 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..lcard.evevent-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 onTimelineEntryDTOand 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.evsemantics.)· persönlichand· SEASONsubtitle tokens (e.g. §3 line 296 "Sommer 1915 · abgeleitet · SEASON", line 312 "Frühjahr 1924 · persönlich · kuratiert").· SEASONis aDatePrecision-enum annotation the spec author printed for the reader of the spec sheet, never intended as production UI.· persönlichis 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.1899–1908/1925–1950outer spans) → #828.font-serif). Spec §3 uses sans for plain.lcard .t. Kept serif (OQ-1, resolved → keep).Data flow
Unchanged.
/zeitstrahl/+page.server.tsSSR-loadsGET /api/timeline→{ timeline }. All new values (year-range, letter count, event count) are derived client-side fromtimeline.years+timeline.undated. No client fetch, no new request, no PII logged. (Feasibility confirmed againstfrontend/src/lib/generated/api.tsTimelineEntryDTO— every count is reachable from the loaded DTO.)Requirements (EARS)
Frame & header
/zeitstrahlcontent shall wrap the timeline in a canvas frame matching.tl-canvas: aborder border-line,rounded-[10px],bg-canvas, with internal padding, using semantic tokens only (no raw hex)..dh-sub, composed of: the populated year-range ({firstYear}–{lastYear}, taken from the first/last entry intimeline.years), the letter count (every entry withkind === 'LETTER'across all year bands plustimeline.undated), the event count (every entry withkind === 'EVENT'across all year bands plusundated— this includes derived life-events, curated PERSONAL events, and HISTORICAL world-bands), and the static text "Gruppierung: Datum". Whentimeline.yearsis empty the range segment is omitted; when bothyearsandundatedare empty the entire sub-line is absent. The sub-line is renderedfont-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 onbg-canvas).Axis & year badge (desktop AND phone behavior is mandatory)
.ybadge{text-align:center}); while the viewport is <1024px it shall sit at the left spine. The existing sticky-at-top-16behavior (#779 REQ-006) shall be preserved in both..ybadge span{z-index:2}). On desktop (≥1024px) the marker is centered on theleft:50%spine; on phone (<1024px) the marker sits on theleft:0.5remspine and the badge clears it — the marker must never overlap the badge text on either axis.LetterCardrow shall render a connector dot on the axis matching.lrow .dot(white fill, mint ring). On desktop the dot is centered on theleft:50%spine between the alternating card and the axis; on phone the entry column is indented clear of theleft:0.5remspine (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 ofLetterCardread as two distinct marks at the spine, not one merged blob.)linear-gradient(#a1dcd8,#012851,#607080)) using existing semantic tokens:var(--palette-mint),var(--palette-navy), andvar(--c-tag-slate)for the slate stop (note: there is no--palette-slatetoken; slate lives only as--c-tag-slate, the same tokenWorldBand.sveltealready uses). No raw hex — this stays consistent with REQ-013.Pills, bands & letter cards
EventPillrenders a derived or curated PERSONAL pill, its subtitle shall be{dateLabel} · {provenance}, where{provenance}resolves to the Paraglide keytimeline_provenance_derived("abgeleitet") whenderived === trueandtimeline_provenance_curated("kuratiert") when curated PERSONAL (derived === false) — keyed offentry.derived, notgetAccentConfig's accent. Only this single provenance token is appended — the mockup's extra· persönlich/· SEASONtokens are out of scope (see Out of Scope).LetterCardhas a non-emptytitle, the title shall be prefixed with an envelope glyph wrapped<span aria-hidden="true">✉</span>plus ansr-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.WorldBandrenders 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 itsZeitraum: …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 -26pxin the mockup); at phone width use the §3.nwbleft-border accent treatment instead of recreating the full-bleed negative margin, so the band does not clip the left spine.Dense strip
YearLetterStripshall prefix its count with thearia-hidden✉ glyph +sr-onlylabel, 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.YearLetterStripshall 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
timeline.undatedis non-empty, the "Ohne Datum" section shall render inside a dashed frame matching.undatedand 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
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#eacsubstring 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 newci.ymlstep.<ol>/<section>/<h2>semantic structure, or any existing #779 REQ-001..027 behavior; all existing timeline tests shall stay green.frontend/messages/{de,en,es}.jsonwith matching key sets across locales.LetterCardentry has notitle(null/empty), then no ✉ glyph and nosr-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)
/zeitstrahlrenders a single bordered, roundedbg-canvascontainer wrapping the bands; verified in a*.svelte.spec.tsand visually at 1440px.years), the range segment is absent. Given an empty DTO, the sub-line is absent from the DOM.<h2>retainsposition: sticky; top: 4remat both widths.<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).::beforebackground is a three-stop gradient referencing--palette-mint,--palette-navy, and--c-tag-slate; the grep in REQ-013 still returns zero hits.BIRTHpill 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.LetterCardwith a title renders anaria-hidden✉ and ansr-only"Brief"; the document href is still exactly/documents/{documentId}.1914–1918pill with theZeitraumaria-label (#779 REQ-009) AND the inline "· historisch" text; "historisch" appears as text, not a separate pill element.YearLetterStripshows anaria-hidden✉ + the "Monats-Dichte" descriptor; the expand toggle is still present, ≥44px, keyboard-focusable, retains its "Briefe anzeigen" label, and reveals 30LetterCards.YearLetterStripfor 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" (theshortmonth carries a trailing period) — via the formatter orstartsWith, never a hardcoded "Jan 1915".frontend/src/lib/timeline/*.spec.ts+zeitstrahl/page.server.test.tssuite passes unchanged; DTO order, threshold-12, and gap-fold assertions remain green.timeline_*key exists in de/en/es.LetterCardwithtitle: ''/null renders no ✉ and no "Brief" sr-only label, but still renders sender → receiver and the date.Component touch-points
routes/zeitstrahl/+page.svelte<TimelineView>in the.tl-canvasframe (REQ-001); render the.dh-submeta line from derived counts (REQ-002).lib/timeline/TimelineView.svelte::before(REQ-006); markers themselves live inYearBand(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<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.sveltelib/timeline/WorldBand.sveltelib/timeline/LetterCard.sveltelib/timeline/YearLetterStrip.sveltefrontend/messages/{de,en,es}.jsonMarker-ownership rule (architect): the spine and the REQ-006 gradient belong to the container (
TimelineView::before); the year-badge node marker belongs toYearBand's<h2>; the per-letter connector dot belongs toYearBand's.letter-row. Dot/spine CSS is defined once, not duplicated across components. The spine geometry (left:50%desktop /left:0.5remphone) is unavoidably referenced by bothTimelineView(the spine) andYearBand(markers aligning to it); carry a one-line code comment at theYearBandmarker offset pointing at theTimelineViewspineleft, 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.tsload) — and never also recomputed insideTimelineView.i18n keys (draft)
Path:
frontend/messages/{de,en,es}.json.timeline_grouping_datetimeline_meta_summarytimeline_provenance_derivedtimeline_provenance_curatedtimeline_letter_glyph_labeltimeline_layer_historical_suffixtimeline_strip_density_captionMonth-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 existingtimeline_layer_world("Weltgeschehen", the sr-only layer label) — same layer, two registers (visible adjective vs. screen-reader noun).Tests (TDD)
*.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).LetterCard*.svelte.spec.tsthat renders atitlecontaining<script>/HTML and asserts it appears verbatim as text (escaped, not executed) — turns the no-{@html}contract into a permanent guard.timeline_*keys.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/authzIfclause 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
font-serif); consistency over pixel-matching §3's sans.lcard .t..evevent-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.No open questions remain.
Traceability
To be mirrored into
.specify/rtm.md(featurezeitstrahl-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
#eacin{#each}); fixed REQ-006 tovar(--c-tag-slate)(no--palette-slateexists) and reconciled it with REQ-013; scoped out the mockup's· persönlich/· SEASONtokens 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 includeundated[]and defined the event count; clarified REQ-009 (inline historisch) and REQ-010 (toggle preserved); added the## User Journeysection; added the no-titleIfrequirement (REQ-016); fixed the i18n path tofrontend/messages/; added the XSS-escaping regression test; resolved all three open questions.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}-12anchor input; keyed REQ-007 provenance offentry.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.LetterCard's mint left-border stay distinct at 375px) and REQ-009 (use the §3.nwbleft-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.TimelineView) and added an i18n note distinguishingtimeline_layer_historical_suffixfromtimeline_layer_world. Spec held stable across three consecutive all-APPROVE rounds; cleared for implementation.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
ErrorCodechange.REQ coverage (all
Donein.specify/rtm.md):timelineMeta.tsderives counts in the route only) —e4da28d7,a1e57ff818934413--c-tag-slate) —bfe66569· abgeleitet/· kuratiert(keyed offentry.derived) —08d8896cfc67dfc3· historisch(RANGE pill intact) —144719726382efa6e0b096f1timeline_*keys in de/en/es —808d6efa217508dd— 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 checkclean for changed files; visually confirmed at 1440px and 375px againstdocs/specs/zeitstrahl-final-spec.html§3.Out-of-scope items remain tracked: #835 (root-tag chips), #827 (
.evvariant + grouping toggle), #828/#829/#830.Ready for review.
marcel referenced this issue2026-06-14 11:33:48 +02:00
marcel referenced this issue2026-06-14 11:34:13 +02:00
marcel referenced this issue2026-06-14 11:42:37 +02:00
marcel referenced this issue2026-06-14 12:00:31 +02:00