Timeline: global /zeitstrahl view #779
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Milestone: Zeitstrahl — Family Timeline
Spec:
docs/superpowers/specs/2026-06-07-family-timeline-design.md§ "Concept & UX" / "Frontend"Depends on (hard, must be merged first):
GET /api/timeline→TimelineDTOassembly endpoint (year-bucketing, precision sort, undated bucket).dateLabel.tsshared precision→label helper + regenerated API types (TimelineDTO/TimelineYearDTO/TimelineEntryDTOin$lib/generated/api).This issue is blocked until both 5 and 6 are merged — neither the route, the
frontend/src/lib/timeline/dir, nor the generated DTO types exist yet.Context
The primary reader surface: a year-banded vertical timeline, phone-first for the younger audience. A long-lived navigable spine that weaves three layers (derived person life-events, hand-curated events, letters) into one chronological view.
Scope
Route
/zeitstrahlrendering the three layers per year band, with an "Ohne Datum" section at the end. Global view only (personIdundefined). Issue 10 reuses the sameTimelineViewwithpersonIdset; issue 8 adds filters; issue 9 adds curator forms — all out of scope here.Non-goals (this issue)
EventCarddoes not render childLetterCards here.TimelineFilters.svelte— issue 8.dateLabel.tsfrom issue 6; never add a secondformatPrecision-style function intimeline/.timeline/domain ADR belongs with the backend domain introduction). This issue references the ADR but does not write it.Data flow (mandatory)
/zeitstrahl/+page.server.tsloads/api/timelineserver-side viacreateApiClient(fetch)so the auth cookie is forwarded. No client-sidefetch/onMount.geschichten/+page.server.ts:GET /api/timelineis session-authenticated (any authenticated user). This issue touches no write endpoint, no@RequirePermission, no newErrorCode.getErrorMessage(extractErrorCode(result.error))— never render raw backend JSON.TimelineViewis presentation-only. All merge/sort/bucket logic lives in the issue-5 backend; the client never re-sorts or re-buckets. If it has to, that's a sign the issue-5 DTO contract is wrong — fix it there.geschichte/imports — keep the domain boundary clean. Reuse fromdocument/(thelib/document/timeline.tstick helpers, links to/documents/[id]) is fine.Component design
Four components under
frontend/src/lib/timeline/(one nameable region each):TimelineView.svelte— orchestrator. Holds the data and an optionalpersonIdprop (build it in now, undefined for global; issue 10 is then a no-op wire-up). Declare aslet { personId = undefined }: { personId?: string } = $props(). Do not passpersonIddown toYearBandorEventCard— they are leaf cards with no scope awareness. Renders year bands newest/oldest per the issue-5 DTO order, then the undated section. Use$derived(never$effect) for any view-derived state. The empty-state check is the first thing in the template:{#if yearBands.length === 0 && undated.length === 0}.YearBand.svelte— one<section>with the year as a real<h2>heading and its entries. The year heading is sticky (position: sticky/sticky top-…) so the spine stays oriented on a long phone scroll (see Decisions).EventCard.svelte— a leaf card for a PERSONAL/HISTORICAL/derived event. Do not branch internally into three giant blocks; use a$derivedaccent-class map keyed on(type, derived)so the template stays declarative. Extract the accent-class derivation as a module-level const map or a co-locatedeventCardConfig.tshelper — keepEventCard.svelteunder 60 lines; if it exceeds 60 lines with the glyph/icon/label matrix, split into agetAccentConfig(entry)helper imported fromeventCardConfig.ts.LetterCard.svelte— compact letter row: sender → receiver, snippet/title, precision date. Links to/documents/[id]via an internal<a href="/documents/{documentId}">built from the DTO'sdocumentId(UUID) — never from any free-text field; notarget="_blank". Addmin-h-[44px] flex items-centerto the<a>for 44px WCAG 2.2 touch target compliance on small screens.Font split within cards
font-serif(Tinos).font-sans.Apply this split at the element level within
LetterCardandEventCard, not just at the card level.Keying
(year.year).(entry.kind + ':' + (entry.eventId ?? entry.documentId))to avoid UUID-space collisions.Honest precision rendering
Every dated item (events and letters) renders through the shared
dateLabel.ts(issue 6):28. Juli 1914(DAY),Juli 1914(MONTH),Sommer 1914(SEASON),1914(YEAR),ca. 1914(APPROX),1914–1918(RANGE),Ohne Datum(UNKNOWN). Never fabricate a day. ARANGEshows a visible pill marker (e.g.1914–1918pill witharia-label="Zeitraum: 1914 bis 1918") — not a vertical rule (a rule is visual-only; a pill with text is both visible and readable by screen readers). Whenprecision === 'RANGE'buteventDateEndis null (data integrity edge case), render without the span marker — no crash, no pill, just the start year label.Visual-accent matrix (redundant cues — WCAG 1.4.1, never color-alone)
Each layer carries an icon/glyph and a text label in addition to the accent color:
Every accent is a semantic token remapped per theme; verify AA contrast in both light and dark mode (muted "historical" gray is the likeliest dark-mode AA failure — verify computed
coloron theEventCardHISTORICAL accent against its background: 3:1 for large text, 4.5:1 for normal text).Accessible glyph markup
Unicode glyphs (✳ † ⚭) are not self-announcing to screen readers. Wrap each as:
Never render a bare Unicode glyph without adjacent
sr-onlytext. Thesr-onlytext uses the German label (see i18n decision below).Styling & layout
rounded-sm border border-line bg-surface shadow-sm p-6.text-xs font-bold uppercase tracking-widest text-ink-3.font-serif(Tinos) for names and event/letter titles;font-sansfor date labels and metadata chrome. Body text ≥ 16px (text-base) for senior readers — prefertext-lg(18px) for description/snippet text. Nevertext-smfor body content.flex-wrapwithmin-w-0so long names wrap instead of pushing the card past the viewport — no horizontal overflow.LetterCardlink: visiblefocus-visible:ring-2 focus-visible:ring-brand-navy,min-h-[44px] flex items-center(≥44px touch target).<BackButton>from$lib/shared/primitives/BackButton.svelte.Mandatory states (reliability)
timeline.empty_statei18n key, German: "Noch keine Ereignisse"), never a blank page. Placed inside a meaningful landmark (inside<main>) with no nested conditions.{#if undated.length > 0}— removes the section from the DOM entirely when empty (notaria-hidden). A<section>with<h2>Ohne Datum</h2>when present.{@html}anywhere in this component tree — render free text (description,title, names) with plain{...}interpolation; for line breaks usewhitespace-pre-lineCSS, not{@html}.i18n
Add keys to
messages/{de,en,es}.json. German is primary; labels for derived events are German-only across all locales for MVP (see Decisions). The full key list:timeline.headingtimeline.empty_statetimeline.undated_sectiontimeline.layer.worldtimeline.layer.familytimeline.derived.birthtimeline.derived.deathtimeline.derived.marriageThe EN/ES columns intentionally carry the German value for the derived-event and layer labels — this is a documented MVP decision, not an oversight.
Required doc updates (blockers at PR time)
CLAUDE.mdroute table — add/zeitstrahl.docs/architecture/c4/l3-frontend-*.puml— add the/zeitstrahlroute +lib/timeline/dir.docs/architecture/db/db-orm.pumlanddb-relationships.puml— the newtimeline_events,timeline_event_persons,timeline_event_documentstables must appear (these tables are introduced in issue 2/5; confirm they are present before this PR merges).frontend/src/lib/timeline/domain dir.GLOSSARY.md— add entries for: "Zeitstrahl", "TimelineEvent", "EventType (PERSONAL/HISTORICAL)", "derived event", "Lebensweg".Acceptance criteria
<section>+<h2>per band); each band shows its events + letters; the undated section is present when there are undated items.1923-04-12) and a YEAR-precision letter (1923), the DAY item appears above the YEAR item. (Ordering comes from the issue-5 DTO; the component renders in DTO order — no client-side re-sorting.)RANGEevent1914–1918, it appears once, in the 1914 band, with a visible pill span marker — not repeated in 1915–1918.precision === 'RANGE'witheventDateEndnull,EventCardrenders without a span marker and does not crash.UNKNOWNprecision, they appear only in the "Ohne Datum" section at the end, and that section is absent from the DOM when empty ({#if}block, notaria-hidden)./zeitstrahlrenders an empty-state message (timeline.empty_state), not a blank page.personIdis undefined,TimelineViewrenders the global timeline (all years and entries from the DTO) with no filtering — confirms the prop is a no-op when unset.aria-hidden="true"+ adjacentsr-onlylabel text.LetterCardrenders an internal link whose href is exactly/documents/{documentId}.LetterCardlink hasmin-h-[44px]applied (≥44px touch target).frontend/e2e/zeitstrahl.spec.ts, see Tests).<section>with a heading; the whole timeline forms a navigable heading list; year headings are sticky. Verified manually pre-merge; automated axe gate deferred to issue 11.Tests (TDD)
Component tests —
frontend/src/lib/timeline/TimelineView.svelte.spec.ts(
*.svelte.spec.ts, vitest-browser-svelte, real DOM, assert viagetByRole/getByText):EventCardand aLetterCardwithin the correct band;eventDateEndrenders without pill, no crash;timeline.empty_statetext visible);personIdundefined renders global timeline; no prop mutation;getByText); glyph wrapped withsr-onlysibling;/documents/{id};min-h-[44px]class.Use a
makeTimelineDTO(overrides)/makeEntry(overrides)factory — don't hand-build the nested DTO in each test. Place the factory infrontend/src/lib/timeline/test-factories.tsso it is importable from both this spec and future issue-10 tests. Minimum factory shape:Run targeted single-file locally (
--project=clientfor the.svelte.spec.ts); leave the full sweep to CI.Server load test —
frontend/src/routes/zeitstrahl/page.server.test.ts(node, plain TS, mock
createApiClient— filename:page.server.test.ts, not+page.server.test.ts, matching codebase convention instammbaum/page.server.test.ts):loadreturns{ timeline: data }on ok;error(404, mappedMessage)on 404 non-ok;error(500, mappedMessage)on 500 non-ok;stammbaum/page.server.test.tslines 63–69);error(403, mappedMessage)on 403 — ensuresREAD_ALL-less sessions produce a user-friendly message, not raw JSON.dateLabel.tsprecision rendering is tested in issue 6 — here, only assert the card renders the consumed label (e.g. render aLetterCardwithprecision: 'MONTH'and assert the label text contains the MONTH output fromdateLabel.ts).E2E test —
frontend/e2e/zeitstrahl.spec.ts(Playwright)page.setViewportSize({ width: 320, height: 812 }), navigate to/zeitstrahl, assertawait page.evaluate(() => document.body.scrollWidth) === 320(no horizontal overflow).Decisions resolved (Round 0 — from original spec)
EventCardrenders no child letters. Clustering is curator-driven (linking in issue 9) and most meaningful in the per-person view (issue 10) — shipping the simplest correct rendering first keepsEventCard/LetterCardas flat leaf cards and avoids speculative composition.position: stickyon the<h2>year heading is near-free and solves long-scroll orientation on phones; combined with semantic<section>/<h2>(screen-reader heading navigation) it covers the MVP.Decisions resolved (Round 1 — from review)
DatePrecisionpackage ownership → keep indocument/, import across domain boundary (Option A). The enum is shared vocabulary — importing it fromdocument/intimeline/is a bounded, read-only, non-cyclic dependency. Promoting toshared/would touchDocument,TranscriptionBlock, and all importers for a marginal structural benefit. KISS wins. Documented in ADR-035 (issue 2) so a future architect understands the deliberate trade-off.{#if undated.length > 0}(removes from DOM). Notaria-hidden. Removing from the DOM is cleaner for screen readers — hidden content behindaria-hiddenstill occupies the accessibility tree and can confuse navigation.{#if}is the idiomatic Svelte pattern.timeline.empty_state. Matches the scoped key naming convention used elsewhere (timeline.*namespace). German value: "Noch keine Ereignisse."RANGEevent with nulleventDateEnd→ render without span marker, no crash. Graceful degradation: the component silently omits the pill. Backend validation is not guaranteed to prevent this edge case; crashing here would produce a blank timeline page.page.server.test.ts(no+prefix). Matches the established codebase convention (stammbaum/page.server.test.ts).flex-wrap/min-w-0class presence as a surrogate; the real overflow assertion lives infrontend/e2e/zeitstrahl.spec.tswithpage.setViewportSize.🏗️ Markus Keller — Senior Application Architect
Observations
The spec is solid — these are refinements, not reversals.
Round 1 resolved the two biggest open questions (DatePrecision package ownership and ADR-035 scope). What remains are structural gaps that could cause friction during implementation.
ADR-035 numbering is confirmed. The latest ADR is 034 (Ollama deployment). ADR-035 is correct for the timeline domain. The blocker "ADR-035 must exist before this PR merges" is appropriate — what's missing from the issue is a pointer to which issue writes it (mentioned as "issue 2" but the issue number is not linked in this ticket). A developer picking up this ticket cannot easily navigate to it.
TimelineViewvsYearBandresponsibility split. The spec says "Do not passpersonIddown toYearBandorEventCard." This is correct. But it impliesTimelineViewmust split the incoming DTO intoyearBandsandundated— those are$derivedvalues in the component, not structural components of the DTO. The issue should confirm:TimelineDTO.yearsis already year-bucketed (oneTimelineYearDTOper year), soTimelineViewjust iterates it — no re-bucketing needed. This is stated ("the component renders entries in DTO order") but the exact DTO shape could be clearer for implementors. The spec saysTimelineDTO = { years: TimelineYearDTO[], undated: TimelineEntryDTO[] }which is clear — good.Cross-domain import
DatePrecisionfromdocument/. The decision to keep it indocument/(Option A) is documented in the Decisions section. However, the issue says this is documented in ADR-035 (issue 2). For the implementor of this issue (issue 7), the import path$lib/document/...will feel like a boundary violation unless they've read ADR-035 first. Recommend: add a one-line code comment indateLabel.ts(or a note in this issue) referencing the decision —// DatePrecision imported from document/: see ADR-035.C4 diagram updates are a PR blocker. The issue lists
docs/architecture/c4/l3-frontend-*.pumlbut does not specify which file. Looking at the existing frontend C4 files:l3-frontend-3c-people-stories.pumlis the most likely home for the/zeitstrahlroute given it already tracks stories and persons. The implementor should not have to guess — naming the exact file in the doc-update checklist reduces friction.lib/document/timeline.tsnaming collision risk. The existingfrontend/src/lib/document/timeline.tsis a document density / timeline widget helper (unrelated to the Zeitstrahl feature). The newfrontend/src/lib/timeline/dateLabel.tsbelongs to the new domain. These do not conflict, but a developer grepping for "timeline" will find two files with unrelated purposes. This is cosmetic but worth noting — both names are correct within their respective domains.Recommendations
l3-frontend-3c-people-stories.pumlexplicitly as the diagram to update (or create a newl3-frontend-3e-timeline.pumlif the timeline domain is large enough to warrant its own diagram).GLOSSARY.mdentries (Zeitstrahl, TimelineEvent, EventType, derived event, Lebensweg) is the right level of documentation discipline.Open Decisions (none)
All architectural decisions from this angle are resolved.
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
The spec has matured well from Round 0. Decisions are resolved, the load pattern is explicit, and the test factory shape is defined. These are implementation-level gaps that will cause friction or bugs if not addressed now.
makeEntryfactory shape is underspecified forRANGEevents. The factory signature showsmakeEntry({ kind: 'EVENT', type: 'HISTORICAL', precision: 'RANGE', eventDateEnd: 1918 })— buteventDateEndin the DTO isLocalDate(serialized as a string like"1918-01-01"), not a rawnumber. The factory must produce what the backend actually emits, or the AC-RANGE test will test the wrong shape. Clarify:eventDateEndis eitherstring | null(ISO date) ornumber | null(year integer) — the DTO shape from issue 5 will define this. The factory should mirror it exactly.The
{#each}keying spec is correct but the composite key expression needs attention. The issue specifies(entry.kind + ':' + (entry.eventId ?? entry.documentId)). In Svelte 5,{#each entries as entry (entry.kind + ':' + (entry.eventId ?? entry.documentId))}works, but the key is a string concatenation that silently collapses if botheventIdanddocumentIdare null (producingkind + ':' + 'undefined'). The spec notes "avoid UUID-space collisions" but doesn't address the null case. Recommend: the factory should always produce either aneventIdor adocumentId(never both null), and this constraint should be enforced in the DTO — a test should assert that no entry has both null.EventCard60-line limit witheventCardConfig.ts. The spec says: "if it exceeds 60 lines with the glyph/icon/label matrix, split into agetAccentConfig(entry)helper imported fromeventCardConfig.ts." This is a conditional — which means the implementor may skip the split. Given that the matrix covers 3 types × (color + icon + label + derived variants), 60 lines will almost certainly be exceeded. Make the split unconditional: always extracteventCardConfig.ts. The component will be cleaner and the config will be importable from tests.$derivedvs$derived.by()choice. The spec says "Use$derived(never$effect) for any view-derived state." ForyearBandsandundatedthese will require multi-step computation from the DTO (timeline.years→ rendered array,timeline.undated→ rendered array). Use$derived.by()for both —$derivedis for single expressions,$derived.by()for multi-step.font-serif/font-sanssplit at element level. The spec is explicit: "Apply this split at the element level withinLetterCardandEventCard, not just at the card level." This is easy to miss during implementation. The test suite has no assertion for font classes — which is fine (testing CSS classes viagetByRoleis brittle), but a code review checklist item noting "verify font-serif on names, font-sans on dates" would prevent this slipping through.Test file naming conventions confirmed.
TimelineView.svelte.spec.ts(component,*.svelte.spec.ts) andpage.server.test.ts(load,*.test.ts) — both match the codebase convention verified againststammbaum/. Good. Thetest-factories.tsinfrontend/src/lib/timeline/is the right location for cross-issue reuse.loadpattern matchesstammbaum/+page.server.tsexactly — the 401 → redirect to/loginpattern is present in stammbaum and the spec correctly calls it out as the pattern to match. The server load test spec (page.server.test.ts) matches the stammbaum test structure.Recommendations
eventDateEndtype in the factory spec: string (ISO date) or number (year), matching whatTimelineEntryDTOactually emits from issue 5.eventCardConfig.tsunconditional — always extract it; do not leave it as a conditional "if 60 lines."eventId: nullanddocumentId: nullshould either be disallowed (preferred) or produce a unique key without crashing.Open Decisions (none)
The implementation path is clear.
🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
This issue is purely a read-only frontend route with no write endpoints, no file uploads, and no user-generated content rendered as HTML. The security profile is correspondingly narrow. Round 1 resolved no security decisions — this area was clean from the start. Confirming what I verified and surfacing two items worth noting.
No
{@html}— spec is explicit. The spec bans{@html}anywhere in the component tree and requires plain{...}interpolation fordescription,title, and names. This is the right call: any{@html}here would be an XSS vector given thatdescriptionandtitleare user-curated free text. The spec's alternative (CSSwhitespace-pre-linefor line breaks) is correct. Verify the ban is enforced at code review time — it is not currently in the test suite (ESLint could catch this withno-svelte-htmlrules, but that's out of scope for this issue).URL parameter handling. The
/zeitstrahlroute has no query parameters in this issue (filters are issue 8). The+page.server.tsload function callsapi.GET('/api/timeline')with no user-supplied input — no injection surface. Clean.LetterCardlink is constructed fromdocumentId(UUID), not from free text. The spec is explicit: "never from any free-text field." UUIDs are schema-validated by the backend before reaching this DTO. An XSS attack viahrefinjection is not possible here. The prohibition ontarget="_blank"(no orphaned opener) is correctly stated and prevents window.opener attacks.parsePanZoomParamsequivalent. Stammbaum (#692) had a validated URL parameter attack surface (Nora-referenced in the stammbaum load file). This issue has no query params — no equivalent risk exists here. The issue 8 filter params will introduce this surface and should be reviewed at that point.Session auth — no new permissions needed. The spec confirms
GET /api/timelinerequiresREAD_ALL(standard authenticated session). No newErrorCodeorPermissionvalues are introduced. The security boundary is correctly described.Dark-mode contrast for HISTORICAL gray accent. The spec explicitly flags this as the likeliest dark-mode AA failure: "muted 'historical' gray is the likeliest dark-mode AA failure — verify computed
coloron theEventCardHISTORICAL accent against its background." This is a UI concern but has a security dimension: color-contrast failure is an accessibility barrier (WCAG 1.4.3), and accessibility barriers can constitute legal exposure in some jurisdictions. Flag it as a PR-time verification requirement, not just a nice-to-have.Recommendations
{@html}ban by grep —grep -r '@html' frontend/src/lib/timeline/should return zero results.Open Decisions (none)
Security surface is well-constrained for a read-only SSR route.
🧪 Sara Holt — QA Engineer & Test Strategist
Observations
The test strategy is well thought out for Round 2 — the factory shape, test file naming, and pyramid split are all correct. These are gaps and ambiguities that will cause test failures or missing coverage if not addressed.
AC-TOUCH:
min-h-[44px]class presence is a structural assertion, not a behavior assertion. The issue says to assert the class is present on the<a>element. In vitest-browser-svelte this meansexpect(element).toHaveClass('min-h-[44px]'). This is fragile if the implementation achieves 44px via a different mechanism (e.g.,py-3on a flex container). A more robust assertion:getComputedStyle(element).minHeight === '44px'or simply ensuring the element's bounding box height is ≥ 44px viagetBoundingClientRect(). The class-presence check is a reasonable proxy but should be documented as such.AC-RANGE test setup requires a
RANGEentry witheventDateStart = 1914andeventDateEnd = 1918. The component test must verify the entry appears in the 1914 band only — not in 1915–1918. But the DTO yields a flat list of entries per year; the backend (issue 5) places the RANGE item in the 1914 bucket only. So the test needs to construct a DTO with a year band for 1914 (containing the RANGE entry) and year bands for 1915, 1916, 1917, 1918 (each containing zero RANGE entries). The factory must support building multi-year DTOs for this. The current factory spec (makeTimelineDTO({ years: [...], undated: [...] })) supports this but the test specification doesn't describe this multi-band setup explicitly. Recommend: add a note in the test spec for AC-RANGE confirming that 1915–1918 bands are included in the test DTO with no RANGE entry in them.AC-RESPONSIVE is the only E2E spec. The
zeitstrahl.spec.tsPlaywright file has a single test. This is intentional per the issue ("AC-RESPONSIVE ... verified via Playwright E2E"). However,zeitstrahl.spec.tswill be the entry point for future axe tests (issue 11) and filter tests (issue 8). Recommend setting up the file with adescribe('zeitstrahl')block and abeforeEachnavigation so future tests can be added without restructuring.Server load test — 401 redirect pattern must match
stammbaum/page.server.test.tslines 63–69. I verified the stammbaum test:await expect(load(...)).rejects.toMatchObject({ status: 302, location: '/login' }). The spec calls this out correctly. The 403 case is new in this spec (not present in stammbaum) — ensure the mock returns{ response: { ok: false, status: 403 }, error: { code: 'X' } }and thatgetErrorMessagemaps code 'X' to a string (or use a knownErrorCodevalue in the mock).dateLabel.tscoverage. The spec states "precision rendering is tested in issue 6 — here, only assert the card renders the consumed label." This is the right call — don't duplicate test coverage. But the component test forMONTHprecision must importdateLabel.tsto compute the expected string, not hardcode it, so the test stays correct if the formatting changes.Missing: test for the empty-state heading's landmark. AC-EMPTY says "empty-state message visible" but doesn't assert the message is inside
<main>. The spec says "Placed inside a meaningful landmark (inside<main>) with no nested conditions." A test usinggetByRole('main')should contain the empty-state text — add this assertion to the AC-EMPTY test.Recommendations
getBoundingClientRect().height >= 44or computed style check — more robust than class-presence.beforeEachpage navigation stub tozeitstrahl.spec.tsso future tests (issue 8, 11) slot in cleanly.<main>landmark.Open Decisions (none)
All test strategy questions are answered by the spec.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
Round 1 absorbed many of my concerns — the font split, touch targets, sticky headings, semantic section/h2 structure, and the glyph sr-only pattern are all now explicit in the spec. These are the remaining gaps from a UX and accessibility standpoint.
Sticky year heading:
position: stickyvalue is unspecified. The spec says "sticky (position: sticky/sticky top-…)" but does not specify thetopvalue. On mobile with a sticky nav bar, the wrong value will cause year headings to overlap with the navigation. Thetopvalue must account for the global nav height. Looking at the project: the global layout has a sticky header — thetopoffset for year headings must be exactly the header height (or zero if the header is not sticky on this page). This needs to be specified or verified at implementation time; if it's wrong, the sticky affordance actively hurts orientation rather than helping.Empty state message font. The spec says
text-baseortext-lgfor body content, nevertext-sm. The empty state message ("Noch keine Ereignisse") is body content and must follow this rule — but the spec's empty-state section doesn't mention the font class for this message. Recommend:class="text-lg font-serif text-ink-2 text-center py-12"or equivalent — consistent with how other empty states render in the archive."Ohne Datum" section heading. The
<h2>Ohne Datum</h2>heading will appear below all the year headings in the heading outline. Screen reader users navigating by headings will encounter "1899 … 1900 … 1901 … Ohne Datum." This is semantically correct — the section exists, has a heading, and its position in the outline is natural. No change needed; confirming this is intentional.RANGE pill accessibility. The spec mandates a pill marker for RANGE events with
aria-label="Zeitraum: 1914 bis 1918". Thearia-labelreplaces the visual1914–1918text for screen readers. Verify the pill element also hasrole="img"or is a<span>(not interactive). A<span aria-label="...">withoutrolewill not be announced as a landmark; it will be read as part of the text flow, which is acceptable. But if the pill is a<span>with content1914–1918, thearia-labeloverrides that — confirm the pill shows the text visually and the aria-label provides the expanded description, not a replacement.Dark mode: HISTORICAL gray accent contrast. The spec flags this explicitly. I want to reinforce it with a concrete test. The HISTORICAL accent color should be verified at the point of implementation — compute its contrast against
bg-surfacein dark mode using the Tailwind CSS 4 token values fromlayout.css. If the ratio falls below 4.5:1 for normal-size text, the color must be adjusted before merge. The spec requires this check but does not specify a fallback value if the check fails. Recommend: default totext-ink-2for HISTORICAL label text in dark mode if the dedicated token fails contrast, rather than discovering this in production.flex-wrap+min-w-0on the sender→receiver row. The spec mandates this for 320px rendering. This is the single most common mobile overflow cause for letter cards with long German names (e.g., "Wilhelmine Raddatz-Brauer → Friedrich-Wilhelm Raddatz"). Verify at exactly 320px with a name that is 25+ characters on each side. The Playwright test asserts no horizontal overflow at the viewport level — also recommend a manual visual check with a long-name fixture before PR approval.Body text ≥ 16px (
text-base), prefertext-lg(18px) for description/snippet. The spec states this. ForLetterCardsnippet text, confirmtext-base(16px) minimum is applied — this is the reading size for the 60+ transcriber audience. The card title can usetext-base font-serif; the snippet/description should betext-lg font-serif.Recommendations
topvalue for sticky year headings in relation to the global nav height (or note "verify against actual header height").text-lg font-serif text-ink-2.aria-labelstrategy: label overrides content (acceptable) vs. both visual text and expanded aria-label (ideal).text-ink-2as fallback.Open Decisions (none)
📋 Elicit — Requirements Engineer
Observations
Round 1 folded in substantial decisions and the spec is now dense and precise. These are residual ambiguities and a missing NFR that could produce incorrect behavior or implementation confusion.
AC-ORDER and AC-SAME-PRECISION-ORDER together establish a contract but leave the "why" implicit. Both criteria assert "renders in DTO order." This is correct per the design. But the issue text says "the component is a pass-through, proven by the AC-ORDER test" — yet AC-ORDER verifies that DAY comes before YEAR, which could be interpreted as a client-side sort. A developer unfamiliar with the design principle might implement client-side sorting to make the test pass, which would violate the constraint. Recommend adding a comment to the test:
// DTO delivers entries pre-sorted by issue-5 backend; this test verifies the component preserves, not produces, that order.This is a documentation gap, not a code gap, but it protects against future regressions when a new developer "fixes" sorting.AC-PERSON-ID-PROP is underspecified. The criterion says "When
personIdis undefined,TimelineViewrenders the global timeline (all years and entries from the DTO) with no filtering." But it doesn't specify what the test DTO contains — if it's an empty DTO, the test passes vacuously. The test should use a DTO with at least one year band and one undated entry to prove the data flows through.No NFR for initial load time. The page loads a potentially large DTO (all timeline events + derived person events + all letters). For an archive with 1,000+ documents spanning 50 years, the DTO could be substantial. No latency NFR is specified. Even a permissive one ("p95 < 2s on a 4G connection") would alert the team if the issue-5 assembly is slow. This should be raised at the issue 5 level but flagged here as a dependency risk.
timeline.empty_statekey in the i18n table. The German value is listed as "Noch keine Ereignisse" in the i18n table but the spec elsewhere says "Noch keine Ereignisse." (with a period). The period presence/absence is a consistency question — German convention for this type of UI message typically includes a period. Clarify once and apply consistently across all three locales.The
personIdprop onTimelineViewis typed asstringbut the Person domain usesUUID. The spec sayslet { personId = undefined }: { personId?: string } = $props(). This is correct (TypeScript has no UUID type;stringis the right type). However, the AC-PERSON-ID-PROP test should pass a valid UUID-shaped string (not just any string) to reflect realistic usage from issue 10.Deferred: letter-cluster-under-event. The decision to defer clustering (letters appearing under a linked event) is documented. However, the issue does not specify what happens when a letter has a linked event — will it appear in both its year band and under the event in issue 9's implementation? Or only in its year band? The current spec says "every letter renders once, in its own year band" — this implies that in the global view (issue 7), a letter linked to an event still appears in its own year band only. This is a boundary that issue 9 must respect. The constraint should be in the issue 9 spec, not here, but it should be explicitly documented somewhere.
Recommendations
timeline.empty_stateGerman value.Open Decisions (none)
The remaining items above are specification clarifications, not genuine tradeoffs requiring human input.
🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
This issue is a pure frontend route addition. No new Docker services, no new infrastructure, no new environment variables. From an infrastructure standpoint, the surface is minimal. Confirming what I checked and noting one build-time concern.
No new infrastructure. The
/zeitstrahlroute is served by the existing SvelteKit Node adapter. No additional Docker Compose service, no new port, no new volume. The existing Caddy reverse proxy routes/to the SvelteKit container — the new route is automatically covered. No infra changes needed.CI:
npm run generate:apimust run after issue 5 and 6 merge. The spec correctly states that the frontend blocked on issues 5 and 6. The generated types (TimelineDTO,TimelineYearDTO,TimelineEntryDTO) will not exist in$lib/generated/apiuntil the backend is running with--spring.profiles.active=devandnpm run generate:apihas been run. In CI this means: the CI pipeline for issue 7's branch must either (a) depend on a backend build artifact from issue 5/6's merge, or (b) have the generated types committed. Looking at how the project handles this: the generatedapitypes are committed to git (standard pattern in this repo). Issue 6 must commit the generated types before issue 7's branch is cut. The implementor of issue 7 must pull issue 6's merge before starting.E2E test
frontend/e2e/zeitstrahl.spec.ts. Playwright E2E tests run against the full Docker Compose stack in CI. The new spec will run with the existing E2E infrastructure — no changes todocker-compose.ci.ymlneeded. Thepage.setViewportSize({ width: 320, height: 812 })call is supported by the existing Playwright Chromium configuration.No new env vars. The issue introduces no configuration —
GET /api/timelineis a standard authenticated endpoint on the existing backend URL. Nothing to add to.env.example.Pre-commit hook note. A fresh worktree for this issue will need
npm installinfrontend/before the first commit (pre-commit hook runscd frontend && npm run lint). This is documented in project memory but worth noting: if the implementor creates a worktree, they mustnpm installbefore committing.Recommendations
npm run generate:apihas been committed before cutting the issue 7 branch.Open Decisions (none)
Infrastructure is a no-op for this issue.
Visual specs (on
main, commitddb1ec4d— HTML, open in a browser):docs/specs/zeitstrahl-final-spec.html— anatomy of the vertical axis, the three grouping modes (Datum / Ereignis / Thema), and the full all-cases preview (empty years → ≤3 letters → hundreds via year-strip + sparkline → undated bucket), responsive (desktop centred axis / phone left axis / 35 % Lebensweg rail), tokens, impl-ref.docs/specs/zeitstrahl-global-concepts.html— incl. the §5 letter-grouping model (date default + curated-event clusters + tag colour chips) and the §5 curation entry points.Complements the text design spec already referenced in the body (
docs/superpowers/specs/2026-06-07-family-timeline-design.md).