Restore /zeitstrahl Datum-mode visual fidelity to the Concept-A spec (#833) #836

Merged
marcel merged 23 commits from feat/issue-833-zeitstrahl-fidelity into main 2026-06-14 14:12:42 +02:00
Owner

Closes #833.

Presentation-only pass bringing the live /zeitstrahl (Datum mode) up to the canonical
docs/specs/zeitstrahl-final-spec.html §3 render. No backend change, no new endpoint, no
generate:api, no entity field, no new ErrorCode
— every value is already on TimelineDTO
or derived client-side. Data flow, ordering, density logic and a11y from #779 are unchanged
(REQ-014).

REQ → commit → test

REQ What Commit Test
001 .tl-canvas frame around the timeline e4da28d7 routes/zeitstrahl/page.svelte.spec.ts#wraps … canvas frame
002 meta sub-line: range · N Briefe · N Ereignisse · Gruppierung: Datum (range/line omitted when empty) a1e57ff8, e4da28d7 timelineMeta.spec.ts (4), page.svelte.spec.ts (meta / range-absent / empty-absent)
003 year badge centered ≥1024px, left spine <1024px, sticky top:4rem 18934413 YearBand.svelte.spec.ts#centers …, #left-aligns …, #sticky …
004 navy node marker on the spine, never occluding the badge text 18934413, 217508dd YearBand.svelte.spec.ts#node marker … clears the badge text (+ z-index guard)
005 per-letter connector dot (white fill, mint ring) on the spine 18934413 YearBand.svelte.spec.ts#one connector dot per letter row …
006 3-stop mint→navy→slate axis gradient bfe66569 TimelineView.svelte.spec.ts#three-stop gradient (+ REQ-013 grep)
007 event-pill {date} · abgeleitet/· kuratiert provenance 08d8896c EventPill.svelte.spec.ts (abgeleitet / kuratiert / no persönlich-SEASON)
008/016 ✉ glyph + sr-only "Brief" on letter titles, guarded by title fc67dfc3 LetterCard.svelte.spec.ts (✉ present / no-title / XSS verbatim)
009 WorldBand inline "· historisch" (non-RANGE & RANGE; pill intact) 14471972 WorldBand.svelte.spec.ts (non-RANGE / RANGE + pill)
010/011 strip ✉ count + "Monats-Dichte" caption + Jan/Dez endpoint labels; toggle preserved 6382efa6 YearLetterStrip.svelte.spec.ts (✉/caption / toggle / two labels ≥10px)
012 dashed-framed "Ohne Datum · {count}" e0b096f1 TimelineView.svelte.spec.ts#dashed border + count
013 semantic tokens only — zero raw-hex corrected REQ-013 grep → 0 hits
014 no DTO order / threshold / structure regressions existing timeline + page.server suites green
015 new timeline_* keys in de/en/es 808d6efa messages.spec.ts (parity + present)

RTM rows for #833 land in the first commit (b372b90e) and are flipped to Done (0a4f5c0a).

Verification

  • 147 timeline tests green (component + pure + route), full timeline+zeitstrahl+messages
    regression: 142 unchanged tests stay green (REQ-014).
  • REQ-013 hex grep and @html grep on lib/timeline/zero hits.
  • npm run check: my files are svelte-check-clean (total errors within the pre-existing baseline).
  • Visual proof at 1440px and 375px against §3: canvas frame + meta line, centered navy badges
    interrupting the 3-stop gradient spine, mint-ringed connector dots, ✉ titles, · abgeleitet
    pills, dashed "Ohne Datum · N", and dense-year strips with "✉ N Briefe", the "Monats-Dichte"
    caption and "Jan. {y} … Dez. {y}" endpoints. (A stale local backend container missing
    /api/timeline initially blocked the screenshot — rebuilt to verify; unrelated to this diff.)

Out of scope (tracked elsewhere)

Root-tag chips (#835), .lcard.ev variant & grouping toggle (#827), · persönlich/· SEASON
tokens, world-band in-time letter count, empty decades (#828), quiet-span labels (#829),
per-month drill-down (#830). Letter titles stay serif (OQ-1); plain-card mint border is a house
accent (OQ-2).

🤖 Generated with Claude Code

Closes #833. Presentation-only pass bringing the live `/zeitstrahl` (Datum mode) up to the canonical `docs/specs/zeitstrahl-final-spec.html` §3 render. **No backend change, no new endpoint, no `generate:api`, no entity field, no new `ErrorCode`** — every value is already on `TimelineDTO` or derived client-side. Data flow, ordering, density logic and a11y from #779 are unchanged (REQ-014). ## REQ → commit → test | REQ | What | Commit | Test | |---|---|---|---| | 001 | `.tl-canvas` frame around the timeline | `e4da28d7` | `routes/zeitstrahl/page.svelte.spec.ts#wraps … canvas frame` | | 002 | meta sub-line: range · N Briefe · N Ereignisse · Gruppierung: Datum (range/line omitted when empty) | `a1e57ff8`, `e4da28d7` | `timelineMeta.spec.ts` (4), `page.svelte.spec.ts` (meta / range-absent / empty-absent) | | 003 | year badge centered ≥1024px, left spine <1024px, sticky top:4rem | `18934413` | `YearBand.svelte.spec.ts#centers …`, `#left-aligns …`, `#sticky …` | | 004 | navy node marker on the spine, never occluding the badge text | `18934413`, `217508dd` | `YearBand.svelte.spec.ts#node marker … clears the badge text` (+ z-index guard) | | 005 | per-letter connector dot (white fill, mint ring) on the spine | `18934413` | `YearBand.svelte.spec.ts#one connector dot per letter row …` | | 006 | 3-stop mint→navy→slate axis gradient | `bfe66569` | `TimelineView.svelte.spec.ts#three-stop gradient` (+ REQ-013 grep) | | 007 | event-pill `{date} · abgeleitet`/`· kuratiert` provenance | `08d8896c` | `EventPill.svelte.spec.ts` (abgeleitet / kuratiert / no persönlich-SEASON) | | 008/016 | ✉ glyph + sr-only "Brief" on letter titles, guarded by title | `fc67dfc3` | `LetterCard.svelte.spec.ts` (✉ present / no-title / XSS verbatim) | | 009 | WorldBand inline "· historisch" (non-RANGE & RANGE; pill intact) | `14471972` | `WorldBand.svelte.spec.ts` (non-RANGE / RANGE + pill) | | 010/011 | strip ✉ count + "Monats-Dichte" caption + Jan/Dez endpoint labels; toggle preserved | `6382efa6` | `YearLetterStrip.svelte.spec.ts` (✉/caption / toggle / two labels ≥10px) | | 012 | dashed-framed "Ohne Datum · {count}" | `e0b096f1` | `TimelineView.svelte.spec.ts#dashed border + count` | | 013 | semantic tokens only — zero raw-hex | — | corrected REQ-013 grep → 0 hits | | 014 | no DTO order / threshold / structure regressions | — | existing timeline + page.server suites green | | 015 | new `timeline_*` keys in de/en/es | `808d6efa` | `messages.spec.ts` (parity + present) | RTM rows for #833 land in the first commit (`b372b90e`) and are flipped to Done (`0a4f5c0a`). ## Verification - **147 timeline tests green** (component + pure + route), full timeline+zeitstrahl+messages regression: 142 unchanged tests stay green (REQ-014). - REQ-013 hex grep and `@html` grep on `lib/timeline/` → **zero hits**. - `npm run check`: my files are svelte-check-clean (total errors within the pre-existing baseline). - **Visual proof** at 1440px and 375px against §3: canvas frame + meta line, centered navy badges interrupting the 3-stop gradient spine, mint-ringed connector dots, ✉ titles, `· abgeleitet` pills, dashed "Ohne Datum · N", and dense-year strips with "✉ N Briefe", the "Monats-Dichte" caption and "Jan. {y} … Dez. {y}" endpoints. (A stale local backend container missing `/api/timeline` initially blocked the screenshot — rebuilt to verify; unrelated to this diff.) ## Out of scope (tracked elsewhere) Root-tag chips (#835), `.lcard.ev` variant & grouping toggle (#827), `· persönlich`/`· SEASON` tokens, world-band in-time letter count, empty decades (#828), quiet-span labels (#829), per-month drill-down (#830). Letter titles stay serif (OQ-1); plain-card mint border is a house accent (OQ-2). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 14 commits 2026-06-14 11:32:27 +02:00
Trace the visual-fidelity requirements end to end before implementation,
matching the timeline-feature convention of landing RTM rows in the first
commit of the branch. Status: Planned; flipped to Done as each task lands.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seven new timeline_* keys feeding the upcoming chrome: the header meta
line (grouping label + events count), the event-pill provenance token,
the ✉ sr-only label, the world-band "historisch" suffix, and the strip
density caption — present in de/en/es with matching key sets, pinned by a
new parity test.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The derived/curated pill subtitle now reads "{date} · abgeleitet" or
"{date} · kuratiert", keyed off entry.derived so a reader sees both the
date and whether the event was derived from Person data or curated. Only
the single provenance token ships; the spec sheet's "· persönlich" /
"· SEASON" annotations stay out (already covered by the ★ glyph + mint
border and not production UI).

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A present LetterCard title now reads "✉ {title}" with an aria-hidden glyph
and an sr-only "Brief" label rendered as sibling nodes — never interpolated
into the escaped user title, which keeps its own pre-line span for
multi-line OCR text. No title → no glyph, no label (the row still shows
sender → receiver and the date). An XSS regression pins the no-{@html}
contract: an HTML-bearing title renders verbatim as text.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A HISTORICAL band's subtitle now carries the visible "historisch" register
inline as plain text: "{date} · historisch", or — for a RANGE — after the
existing 1914–1918 span pill (whose Zeitraum aria-label is unchanged). The
descriptor is a text node, never a second pill. Every WorldBand is
historical, so the suffix also trails an undated band on its own.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dense-year strip count now carries the ✉ glyph (aria-hidden + sr-only
"Brief"), and beneath the sparkline a "Monats-Dichte" caption sits between
the two endpoint month labels (Jan/Dez {year}) at the ≥10px micro-axis
floor, localized via the shared month formatter. The ≥44px keyboard-
focusable "Briefe anzeigen" expand toggle is preserved unchanged.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Ohne Datum" section now renders inside a dashed-bordered surface box
whose heading reads "Ohne Datum · {count}", matching the spec's .undated
treatment. The kind/type dispatch (events as pills/bands, letters as cards)
is unchanged; the section stays absent when there are no undated entries.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pure timelineMeta() returns the year range (first/last band, null when
there are no bands) and the letter/event totals across all year bands plus
the undated bucket — the single place these counts are computed.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline now sits inside a bordered, rounded bg-canvas sheet. Below the
heading a sub-line composes the year range, the letter and event counts
(from timelineMeta), and the static "Gruppierung: Datum" — joined by " · "
so the range drops out when there are no year bands and the whole line is
absent for an empty timeline. Semantic tokens only; AA-legible text-xs.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The year badge now centers on the axis at ≥1024px and hugs the left spine
below that (sticky top:4rem preserved), with a navy node marker so it
visibly interrupts the spine. Each letter row gains a connector dot (white
fill, mint ring) on the spine: centered between card and axis on desktop,
on the left spine clear of the indented card on phone. Spine geometry is
commented to track TimelineView's spine so the markers can't silently desync.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spine now runs mint → navy → slate, matching the spec's life-thread,
using --palette-mint / --palette-navy / --c-tag-slate (no --palette-slate
token exists). Semantic tokens only — no raw hex.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pass the full PageData shape (layout auth fields + timeline) through a
pageData() helper so the route spec is svelte-check-clean; the component
still only reads data.timeline.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All sixteen visual-fidelity requirements have a green test (142 timeline +
zeitstrahl + messages tests pass); record the implementation files and the
test that proves each.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(timeline): keep the year badge above its node marker (REQ-004)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m23s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 5m37s
CI / fail2ban Regex (pull_request) Successful in 53s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
217508ddb2
The node marker carried a higher stacking order than the (non-positioned)
badge, so on the centered desktop axis the navy node painted over the white
year digits. Make the badge positioned with the higher z-index; the node now
sits behind the centered pill (which is itself the axis interruption) and
shows only to the badge's left on phone. Guarded by a z-index assertion.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel added 2 commits 2026-06-14 11:40:36 +02:00
The absolutely-positioned spine ::before painted above the in-flow centered
content (density strips, event pills), drawing the line through them. Give
.timeline-axis a stacking context and the spine z-index:-1 so the line is
always background; cards, pills, strips, dots and badges ride on top.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
refactor(timeline): drop the canvas outer border (REQ-001)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m1s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 23s
SDD Gate / RTM Check (pull_request) Successful in 16s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
15836ea9ca
The page is already bg-canvas, so the frame's border was the only thing
making it visible; per review it reads cleaner without it. Keep the padded
bg-canvas surface; the timeline sits on the page without a frame line.

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

Two fixes from live visual review (1440px/375px), pushed:

  • 8029bdecspine stacking (REQ-006): the absolutely-positioned axis ::before was painting over the in-flow centered content (density strips, event pills), drawing the line through them. Gave .timeline-axis a stacking context and the spine z-index: -1 so the line is always background; cards/pills/strips/dots/badges ride on top. Guarded by a ::before z-index assertion.
  • 15836ea9dropped the canvas outer border (REQ-001): the page is already bg-canvas, so the border was the only thing making the frame visible; it reads cleaner without it. Kept the padded surface. RTM REQ-001 + the route test updated to match.

(Earlier 217508dd also fixed the year badge painting under its node marker on the centered desktop axis.)

Re-verified visually: no outer border, the gradient spine sits behind all content, badges/dots/✉/provenance/strip-axis all correct.

Two fixes from live visual review (1440px/375px), pushed: - `8029bdec` — **spine stacking (REQ-006):** the absolutely-positioned axis `::before` was painting *over* the in-flow centered content (density strips, event pills), drawing the line through them. Gave `.timeline-axis` a stacking context and the spine `z-index: -1` so the line is always background; cards/pills/strips/dots/badges ride on top. Guarded by a `::before` z-index assertion. - `15836ea9` — **dropped the canvas outer border (REQ-001):** the page is already `bg-canvas`, so the border was the only thing making the frame visible; it reads cleaner without it. Kept the padded surface. RTM REQ-001 + the route test updated to match. (Earlier `217508dd` also fixed the year badge painting under its node marker on the centered desktop axis.) Re-verified visually: no outer border, the gradient spine sits behind all content, badges/dots/✉/provenance/strip-axis all correct.
marcel added 7 commits 2026-06-14 13:53:10 +02:00
The provenance token (abgeleitet/kuratiert) was nested inside the
{#if dateLabel} block, so an undated or UNKNOWN-precision event — e.g.
one in the undated bucket — rendered no provenance at all. Compose the
subtitle as an optional "{date} · " prefix in front of the always-present
provenance instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A timeline with content in only one category rendered a misleading
"0 Briefe" / "0 Ereignisse" segment. Only push a count segment when its
count is greater than zero; the grouping label and (when present) the
range are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A count of one rendered "1 Briefe" / "1 Ereignisse". Add _singular
companion keys (de/en/es) and select them when the count is exactly one,
following the project's _singular/_plural convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "· historisch" register was emitted in all three date branches, with
the dateless branch dropping the leading separator. Render the span pill
or date as a conditional prefix, then a single trailing "· historisch"
span — one render site, consistent separator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flatMap intermediate array plus two filter passes with one
walk over the year bands and the undated bucket. Same counts, no
throwaway allocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The aria-hidden glyph + sr-only label markup was hand-copied in LetterCard
and YearLetterStrip. Extract a small GlyphLabel component and use it at
both sites so the accessibility idiom has a single owner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
refactor(timeline): single-source the spine X position via --spine-x
All checks were successful
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 25s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 4m33s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m19s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 26s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / Unit & Component Tests (pull_request) Successful in 4m24s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m21s
239565ea20
The spine offset (0.5rem phone / 50% desktop) was hard-coded in both
TimelineView's .timeline-axis::before and YearBand's .year-node/.letter-dot,
kept in sync only by a comment. Declare --spine-x once on .timeline-axis
and have the markers consume it by inheritance, so a change to the spine
position moves the markers with it. Add a test that the year-node tracks
the token.

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

📋 Requirements Engineer — PR Review

Verdict: ⚠️ Approved with concerns

Traceability against the #833 spec (issue body is the contract). Every REQ-NNN is implemented, tested, and has an RTM row flipped to Done:

REQ Implemented Test RTM
001 canvas frame ⚠️ borderless (deviates) page.svelte.spec.ts Done (notes drop)
002 meta line timelineMeta.spec.ts + page.svelte.spec.ts Done
003 badge centering YearBand.svelte.spec.ts Done
004 node marker YearBand.svelte.spec.ts Done
005 connector dots YearBand.svelte.spec.ts Done
006 3-stop gradient TimelineView.svelte.spec.ts Done
007 provenance EventPill.svelte.spec.ts Done
008 ✉ glyph LetterCard.svelte.spec.ts Done
009 · historisch WorldBand.svelte.spec.ts Done
010 strip ✉ + caption YearLetterStrip.svelte.spec.ts Done
011 two endpoint labels YearLetterStrip.svelte.spec.ts Done
012 dashed undated + count TimelineView.svelte.spec.ts Done
013 hex grep zero ✓ (verified by inspection) grep gate Done
014 no regression existing suites Done
015 i18n parity messages.spec.ts Done
016 no-title → no ✉ LetterCard.svelte.spec.ts Done

Concerns (no code blocker, but one spec reconciliation):

  • REQ-001 / AC-001 contradict the shipped code. The requirement normatively says "a border border-line … single bordered … container", but +page.svelte ships rounded-[10px] bg-canvas p-6 with no border, and page.svelte.spec.ts asserts border === false. The drop is deliberate (PR comment 15836ea9) and the RTM row notes it — but the issue body REQ-001 + AC-001 text was not updated. Under SDD the issue is the source of truth, so it now disagrees with main. Fold the borderless decision into REQ-001/AC-001 (as the timeline issues' own changelog process does) so spec and code agree.
  • i18n draft divergence (informational). The draft timeline_meta_summary template key (issue §i18n) was not used; the meta line is composed from per-segment keys with a singular/plural split. This is an improvement (avoids the grammatically wrong "1 Briefe"), and the table was labeled "draft" — recording it, no action.

Scope creep: none — every shipped behavior maps to a REQ; the new singular keys serve REQ-002/015.

### 📋 Requirements Engineer — PR Review **Verdict: ⚠️ Approved with concerns** Traceability against the #833 spec (issue body is the contract). Every `REQ-NNN` is implemented, tested, and has an RTM row flipped to `Done`: | REQ | Implemented | Test | RTM | |---|---|---|---| | 001 canvas frame | ⚠️ borderless (deviates) | `page.svelte.spec.ts` | Done (notes drop) | | 002 meta line | ✓ | `timelineMeta.spec.ts` + `page.svelte.spec.ts` | Done | | 003 badge centering | ✓ | `YearBand.svelte.spec.ts` | Done | | 004 node marker | ✓ | `YearBand.svelte.spec.ts` | Done | | 005 connector dots | ✓ | `YearBand.svelte.spec.ts` | Done | | 006 3-stop gradient | ✓ | `TimelineView.svelte.spec.ts` | Done | | 007 provenance | ✓ | `EventPill.svelte.spec.ts` | Done | | 008 ✉ glyph | ✓ | `LetterCard.svelte.spec.ts` | Done | | 009 · historisch | ✓ | `WorldBand.svelte.spec.ts` | Done | | 010 strip ✉ + caption | ✓ | `YearLetterStrip.svelte.spec.ts` | Done | | 011 two endpoint labels | ✓ | `YearLetterStrip.svelte.spec.ts` | Done | | 012 dashed undated + count | ✓ | `TimelineView.svelte.spec.ts` | Done | | 013 hex grep zero | ✓ (verified by inspection) | grep gate | Done | | 014 no regression | ✓ | existing suites | Done | | 015 i18n parity | ✓ | `messages.spec.ts` | Done | | 016 no-title → no ✉ | ✓ | `LetterCard.svelte.spec.ts` | Done | **Concerns (no code blocker, but one spec reconciliation):** - **REQ-001 / AC-001 contradict the shipped code.** The requirement normatively says "a `border border-line` … single bordered … container", but `+page.svelte` ships `rounded-[10px] bg-canvas p-6` with **no** border, and `page.svelte.spec.ts` asserts `border === false`. The drop is deliberate (PR comment `15836ea9`) and the RTM row notes it — but the **issue body REQ-001 + AC-001 text was not updated**. Under SDD the issue is the source of truth, so it now disagrees with `main`. Fold the borderless decision into REQ-001/AC-001 (as the timeline issues' own changelog process does) so spec and code agree. - **i18n draft divergence (informational).** The draft `timeline_meta_summary` template key (issue §i18n) was not used; the meta line is composed from per-segment keys with a singular/plural split. This is an *improvement* (avoids the grammatically wrong "1 Briefe"), and the table was labeled "draft" — recording it, no action. **Scope creep:** none — every shipped behavior maps to a REQ; the new singular keys serve REQ-002/015.
Author
Owner

🛠️ Developer (Felix Brandt) — PR Review

Verdict: ⚠️ Approved with concerns

Checked: clean code, layering, generate:api, ErrorCode four-site, single-source-of-truth.

Good:

  • Presentation-only, correctly handled: no generate:api, no new ErrorCode, no entity/DTO field (constitution §3.5/§3.6 N/A — matches the claim; verified the diff touches only lib/timeline/, the route, messages/*.json, rtm.md).
  • Layering clean (§1.4): all changes in lib/timeline/ + the route; reuse of $lib/shared (formatTickLabel/monthBuckets). No cross-domain import.
  • timelineMeta.ts is a pure function and the single home for the counts; the route renders them, TimelineView never recomputes (marker-ownership rule honored).
  • Correctness win: the meta line uses singular keys for count === 1 ("1 Brief"), avoiding the grammatically wrong "1 Briefe" the draft timeline_meta_summary template would have produced. Nice.

Concern (suggestion):

  • GlyphLabel.svelte is a new abstraction with two callers (LetterCard, YearLetterStrip). Constitution §3.2: "an abstraction is introduced only on the third real caller." It's borderline — the a11y pattern (aria-hidden glyph + sr-only label) genuinely benefits from one definition + the GlyphLabel.spec.ts guard, so I'd accept it — but note the §3.2 tension. If no third caller is imminent, inlining is the by-the-book choice.

Nit: EventPill's new provenance tests are tagged (REQ-007), colliding with the pre-existing #779 (REQ-007) tests in the same file. Different features, same tag — harmless but consider #833 REQ-007 to disambiguate.

### 🛠️ Developer (Felix Brandt) — PR Review **Verdict: ⚠️ Approved with concerns** Checked: clean code, layering, `generate:api`, `ErrorCode` four-site, single-source-of-truth. **Good:** - Presentation-only, correctly handled: **no** `generate:api`, **no** new `ErrorCode`, **no** entity/DTO field (constitution §3.5/§3.6 N/A — matches the claim; verified the diff touches only `lib/timeline/`, the route, `messages/*.json`, `rtm.md`). - Layering clean (§1.4): all changes in `lib/timeline/` + the route; reuse of `$lib/shared` (`formatTickLabel`/`monthBuckets`). No cross-domain import. - `timelineMeta.ts` is a pure function and the **single** home for the counts; the route renders them, `TimelineView` never recomputes (marker-ownership rule honored). - Correctness win: the meta line uses singular keys for `count === 1` ("1 Brief"), avoiding the grammatically wrong "1 Briefe" the draft `timeline_meta_summary` template would have produced. Nice. **Concern (suggestion):** - `GlyphLabel.svelte` is a new abstraction with **two** callers (`LetterCard`, `YearLetterStrip`). Constitution §3.2: "an abstraction is introduced only on the third real caller." It's borderline — the a11y pattern (aria-hidden glyph + sr-only label) genuinely benefits from one definition + the `GlyphLabel.spec.ts` guard, so I'd accept it — but note the §3.2 tension. If no third caller is imminent, inlining is the by-the-book choice. **Nit:** EventPill's new provenance tests are tagged `(REQ-007)`, colliding with the pre-existing #779 `(REQ-007)` tests in the same file. Different features, same tag — harmless but consider `#833 REQ-007` to disambiguate.
Author
Owner

🧪 Tester — PR Review

Verdict: Approved

Checked: every REQ has a real test, regression safety of the touched existing files, edge-case coverage, test levels.

  • Each REQ-001..016 has a component/pure test (table in the Requirements comment). The CSS-heavy REQs are tested properly, not waved through: REQ-003 centering, REQ-006 gradient, and REQ-004/005 marker offsets assert getComputedStyle/bounding-box geometry at real viewports (375 / 1440 / 1280-reset) — e.g. expect(n.right).toBeLessThanOrEqual(l.left + 0.5) for the phone node clearance and the ::before zIndex === '-1' spine guard. Strong.
  • REQ-016 + the XSS-verbatim test (<script>alert(1)</script> title rendered as text, a script null) turn the no-{@html} contract into a permanent guard.
  • Regression (REQ-014) verified on the riskiest edit: EventPill now always renders the subtitle (provenance) instead of gating on {#if dateLabel}. I read the pre-existing EventPill.svelte.spec.ts on main — no test asserts an exact subtitle or the absence of a trailing token, so the change is regression-safe; the existing aria-hidden/sr-only assertions still hit the config glyph (EventPill doesn't route through GlyphLabel).

Suggestions (non-blocking):

  • timelineMeta.spec.ts exercises eventCount only with derived events. AC-002 says the count also includes curated PERSONAL and HISTORICAL world-bands. The logic counts by kind === 'EVENT' so it's structurally covered, but adding one HISTORICAL entry to the count fixture would lock the AC literally.
  • Keep the "142 unchanged tests green" claim a CI result — per project rule, run only the touched spec files locally (the full suite crashes the box).
### 🧪 Tester — PR Review **Verdict: ✅ Approved** Checked: every REQ has a real test, regression safety of the touched existing files, edge-case coverage, test levels. - **Each REQ-001..016 has a component/pure test** (table in the Requirements comment). The CSS-heavy REQs are tested properly, not waved through: REQ-003 centering, REQ-006 gradient, and REQ-004/005 marker offsets assert `getComputedStyle`/bounding-box geometry at **real** viewports (375 / 1440 / 1280-reset) — e.g. `expect(n.right).toBeLessThanOrEqual(l.left + 0.5)` for the phone node clearance and the `::before` `zIndex === '-1'` spine guard. Strong. - REQ-016 + the XSS-verbatim test (`<script>alert(1)</script>` title rendered as text, `a script` null) turn the no-`{@html}` contract into a permanent guard. - **Regression (REQ-014) verified on the riskiest edit:** EventPill now always renders the subtitle (provenance) instead of gating on `{#if dateLabel}`. I read the pre-existing `EventPill.svelte.spec.ts` on `main` — no test asserts an exact subtitle or the *absence* of a trailing token, so the change is regression-safe; the existing aria-hidden/sr-only assertions still hit the config glyph (EventPill doesn't route through `GlyphLabel`). **Suggestions (non-blocking):** - `timelineMeta.spec.ts` exercises `eventCount` only with **derived** events. AC-002 says the count also includes curated PERSONAL and HISTORICAL world-bands. The logic counts by `kind === 'EVENT'` so it's structurally covered, but adding one HISTORICAL entry to the count fixture would lock the AC literally. - Keep the "142 unchanged tests green" claim a **CI** result — per project rule, run only the touched spec files locally (the full suite crashes the box).
Author
Owner

🔐 Security (Nora "NullX") — PR Review

Verdict: Approved

Checked: {@html} / XSS, untrusted-text escaping, new endpoints/mutation, secrets/PII in logs.

  • No new endpoint, no mutation, no ErrorCode, no auth surface — read-only presentation pass. constitution §2.1/§2.8 not applicable.
  • Every new glyph/label (✉, node/connector markers, "· historisch", provenance token, meta line) is a static literal or Paraglide key — never interpolated user text. GlyphLabel renders the glyph aria-hidden and the label as separate escaped nodes.
  • LetterCard keeps the user title in its own escaped {entry.title} sibling span; the ✉ is a sibling node, not concatenated into the title (the inline comment makes this contract explicit). The added regression renders <script>alert(1)</script> as a title and asserts it appears verbatim as text with no a script node — exactly the permanent guard I want, re-pinning #779 REQ-021. constitution §2.5 ✓.
  • EventPill subtitle is ${dateLabel} · ${provenance} — a formatted date + a Paraglide word, rendered via {subtitle} (Svelte-escaped); no user-HTML path. Meta line is numbers + keys, escaped.
  • No logging added → no PII (§2.7); no secrets/DSN (§2.6).

Nothing to fix.

### 🔐 Security (Nora "NullX") — PR Review **Verdict: ✅ Approved** Checked: `{@html}` / XSS, untrusted-text escaping, new endpoints/mutation, secrets/PII in logs. - No new endpoint, no mutation, no `ErrorCode`, no auth surface — read-only presentation pass. constitution §2.1/§2.8 not applicable. - Every new glyph/label (✉, node/connector markers, "· historisch", provenance token, meta line) is a **static literal or Paraglide key** — never interpolated user text. `GlyphLabel` renders the glyph `aria-hidden` and the label as separate escaped nodes. - `LetterCard` keeps the user `title` in its own escaped `{entry.title}` sibling span; the ✉ is a *sibling* node, not concatenated into the title (the inline comment makes this contract explicit). The added regression renders `<script>alert(1)</script>` as a title and asserts it appears **verbatim as text** with no `a script` node — exactly the permanent guard I want, re-pinning #779 REQ-021. constitution §2.5 ✓. - `EventPill` subtitle is `${dateLabel} · ${provenance}` — a formatted date + a Paraglide word, rendered via `{subtitle}` (Svelte-escaped); no user-HTML path. Meta line is numbers + keys, escaped. - No logging added → no PII (§2.7); no secrets/DSN (§2.6). Nothing to fix.
Author
Owner

⚙️ DevOps — PR Review

Verdict: Approved

Checked: migrations, env vars, CI guards, generated artifacts, artifact-action pin.

  • No Flyway migration, no env var, no .gitea/workflows/ change, no runtime dependency added — constitution §4/§5 untouched.
  • No generated artifact edited (generated/api.ts, paraglide/ untouched). New i18n strings are added to messages/*.json, the source — correct (the Vite plugin recompiles paraglide/).
  • actions/(upload|download)-artifact@v3 pin (ADR-014) and all CI guard steps untouched.
  • .specify/rtm.md edited — expected and correct: it's the committed spec matrix, not a do-not-touch file; 16 rows added for #833 with implementation + test refs, flipped to Done.

LGTM — nothing to action.

### ⚙️ DevOps — PR Review **Verdict: ✅ Approved** Checked: migrations, env vars, CI guards, generated artifacts, artifact-action pin. - No Flyway migration, no env var, no `.gitea/workflows/` change, no runtime dependency added — constitution §4/§5 untouched. - No generated artifact edited (`generated/api.ts`, `paraglide/` untouched). New i18n strings are added to `messages/*.json`, the **source** — correct (the Vite plugin recompiles `paraglide/`). - `actions/(upload|download)-artifact@v3` pin (ADR-014) and all CI guard steps untouched. - `.specify/rtm.md` edited — expected and correct: it's the committed spec matrix, not a do-not-touch file; 16 rows added for #833 with implementation + test refs, flipped to `Done`. LGTM — nothing to action.
Author
Owner

🎨 UI/UX — PR Review

Verdict: ⚠️ Approved with concerns

Checked: states, i18n, a11y, design tokens, touch targets, legibility floor.

Good:

  • a11y: decorative markers (year-node, letter-dot) and glyphs are aria-hidden; meaning is carried by sr-only labels via GlyphLabel. The strip toggle keeps its ≥44px target and visible "Briefe anzeigen" label.
  • i18n: every new string is a Paraglide key with de/en/es parity (messages.spec.ts), including the deliberate split of timeline_layer_historical_suffix ("historisch", visible) from timeline_layer_world ("Weltgeschehen", sr-only).
  • Tokens only: the gradient uses --palette-mint / --palette-navy / --c-tag-slate; no raw hex in a color context (REQ-013 holds — I confirmed the '#a1dcd8' literals in TimelineView.svelte.spec.ts are not in a :/-[# context, so the corrected grep does not trip on them).
  • Legibility: meta line pinned to text-xs (12px) and strip axis labels to text-[10px], both deliberately above the mockup's 9.5/6px sub-floor for the 60+ transcriber audience. Right call.

Concern:

  • The central "framed sheet" promise is now visually a no-op. REQ-001's whole point is the timeline reading as a framed life-thread; the border was dropped, leaving bg-canvas p-6 inside a bg-canvas page — same fill, no border, so the "sheet" is invisible except for padding. The decision is documented and screenshot-verified and I won't block on taste, but pick one: (a) restore a subtle frame (bg-surface for the sheet, or a border-line) so the canvas reads as a sheet, or (b) accept padding-only and update REQ-001/AC-001 so the visual goal matches what ships. As-is, the issue still promises a frame the page doesn't render. (Ties to the Requirements Engineer's REQ-001 point.)
### 🎨 UI/UX — PR Review **Verdict: ⚠️ Approved with concerns** Checked: states, i18n, a11y, design tokens, touch targets, legibility floor. **Good:** - **a11y:** decorative markers (`year-node`, `letter-dot`) and glyphs are `aria-hidden`; meaning is carried by `sr-only` labels via `GlyphLabel`. The strip toggle keeps its ≥44px target and visible "Briefe anzeigen" label. - **i18n:** every new string is a Paraglide key with de/en/es parity (`messages.spec.ts`), including the deliberate split of `timeline_layer_historical_suffix` ("historisch", visible) from `timeline_layer_world` ("Weltgeschehen", sr-only). - **Tokens only:** the gradient uses `--palette-mint` / `--palette-navy` / `--c-tag-slate`; no raw hex in a color context (REQ-013 holds — I confirmed the `'#a1dcd8'` literals in `TimelineView.svelte.spec.ts` are not in a `:`/`-[#` context, so the corrected grep does not trip on them). - **Legibility:** meta line pinned to `text-xs` (12px) and strip axis labels to `text-[10px]`, both deliberately above the mockup's 9.5/6px sub-floor for the 60+ transcriber audience. Right call. **Concern:** - **The central "framed sheet" promise is now visually a no-op.** REQ-001's whole point is the timeline reading as a *framed* life-thread; the border was dropped, leaving `bg-canvas p-6` **inside a `bg-canvas` page** — same fill, no border, so the "sheet" is invisible except for padding. The decision is documented and screenshot-verified and I won't block on taste, but pick one: (a) restore a subtle frame (`bg-surface` for the sheet, or a `border-line`) so the canvas reads as a sheet, or (b) accept padding-only and update REQ-001/AC-001 so the visual goal matches what ships. As-is, the issue still promises a frame the page doesn't render. (Ties to the Requirements Engineer's REQ-001 point.)
Author
Owner

🏛️ Architect — PR Review

Verdict: Approved

Checked: domain boundaries, the issue's marker-ownership rule, single-source-of-truth, ADR need.

  • Marker-ownership rule followed precisely. The spine + 3-stop gradient live on TimelineView's .timeline-axis::before; the year-node and per-letter dot live on YearBand. The spine X is a genuine single source of truth — --spine-x declared once on .timeline-axis (0.5rem phone / 50% desktop) and inherited by the YearBand markers, with the required one-line comment pointing back at the axis so a future spine move can't silently desync the markers. The positions the year-node from the inherited --spine-x token test locks exactly that contract.
  • Counts derived once. REQ-002 totals live in timelineMeta.ts, consumed by the route, never recomputed in TimelineView — the ownership the spec mandated.
  • No boundary coupling. lib/timeline → lib/shared only (the #779 REQ-026 boundary holds); no new backend domain, so no ArchitectureTest allow-list change is required (constitution §1.7 N/A).
  • No irreversible decision → no ADR required. Presentation-only, no new dependency, no superseded ADR.
  • GlyphLabel is a reasonable local presentational primitive; the caller-count question (§3.2) is Felix's to weigh, not an architecture concern.

Clean — no architecture action.

### 🏛️ Architect — PR Review **Verdict: ✅ Approved** Checked: domain boundaries, the issue's marker-ownership rule, single-source-of-truth, ADR need. - **Marker-ownership rule followed precisely.** The spine + 3-stop gradient live on `TimelineView`'s `.timeline-axis::before`; the year-node and per-letter dot live on `YearBand`. The spine X is a genuine single source of truth — `--spine-x` declared once on `.timeline-axis` (0.5rem phone / 50% desktop) and **inherited** by the YearBand markers, with the required one-line comment pointing back at the axis so a future spine move can't silently desync the markers. The `positions the year-node from the inherited --spine-x token` test locks exactly that contract. - **Counts derived once.** REQ-002 totals live in `timelineMeta.ts`, consumed by the route, never recomputed in `TimelineView` — the ownership the spec mandated. - **No boundary coupling.** `lib/timeline → lib/shared` only (the #779 REQ-026 boundary holds); no new backend domain, so no `ArchitectureTest` allow-list change is required (constitution §1.7 N/A). - **No irreversible decision → no ADR required.** Presentation-only, no new dependency, no superseded ADR. - `GlyphLabel` is a reasonable local presentational primitive; the caller-count question (§3.2) is Felix's to weigh, not an architecture concern. Clean — no architecture action.
marcel merged commit 239565ea20 into main 2026-06-14 14:12:42 +02:00
marcel deleted branch feat/issue-833-zeitstrahl-fidelity 2026-06-14 14:12:43 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#836