Restore /zeitstrahl Datum-mode visual fidelity to the Concept-A spec (#833) #836
Reference in New Issue
Block a user
Delete Branch "feat/issue-833-zeitstrahl-fidelity"
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?
Closes #833.
Presentation-only pass bringing the live
/zeitstrahl(Datum mode) up to the canonicaldocs/specs/zeitstrahl-final-spec.html§3 render. No backend change, no new endpoint, nogenerate:api, no entity field, no newErrorCode— every value is already onTimelineDTOor derived client-side. Data flow, ordering, density logic and a11y from #779 are unchanged
(REQ-014).
REQ → commit → test
.tl-canvasframe around the timelinee4da28d7routes/zeitstrahl/page.svelte.spec.ts#wraps … canvas framea1e57ff8,e4da28d7timelineMeta.spec.ts(4),page.svelte.spec.ts(meta / range-absent / empty-absent)18934413YearBand.svelte.spec.ts#centers …,#left-aligns …,#sticky …18934413,217508ddYearBand.svelte.spec.ts#node marker … clears the badge text(+ z-index guard)18934413YearBand.svelte.spec.ts#one connector dot per letter row …bfe66569TimelineView.svelte.spec.ts#three-stop gradient(+ REQ-013 grep){date} · abgeleitet/· kuratiertprovenance08d8896cEventPill.svelte.spec.ts(abgeleitet / kuratiert / no persönlich-SEASON)fc67dfc3LetterCard.svelte.spec.ts(✉ present / no-title / XSS verbatim)14471972WorldBand.svelte.spec.ts(non-RANGE / RANGE + pill)6382efa6YearLetterStrip.svelte.spec.ts(✉/caption / toggle / two labels ≥10px)e0b096f1TimelineView.svelte.spec.ts#dashed border + counttimeline_*keys in de/en/es808d6efamessages.spec.ts(parity + present)RTM rows for #833 land in the first commit (
b372b90e) and are flipped to Done (0a4f5c0a).Verification
regression: 142 unchanged tests stay green (REQ-014).
@htmlgrep onlib/timeline/→ zero hits.npm run check: my files are svelte-check-clean (total errors within the pre-existing baseline).interrupting the 3-stop gradient spine, mint-ringed connector dots, ✉ titles,
· abgeleitetpills, 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/timelineinitially blocked the screenshot — rebuilt to verify; unrelated to this diff.)Out of scope (tracked elsewhere)
Root-tag chips (#835),
.lcard.evvariant & grouping toggle (#827),· persönlich/· SEASONtokens, 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
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>Two fixes from live visual review (1440px/375px), pushed:
8029bdec— spine stacking (REQ-006): the absolutely-positioned axis::beforewas painting over the in-flow centered content (density strips, event pills), drawing the line through them. Gave.timeline-axisa stacking context and the spinez-index: -1so the line is always background; cards/pills/strips/dots/badges ride on top. Guarded by a::beforez-index assertion.15836ea9— dropped the canvas outer border (REQ-001): the page is alreadybg-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
217508ddalso 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.
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>📋 Requirements Engineer — PR Review
Verdict: ⚠️ Approved with concerns
Traceability against the #833 spec (issue body is the contract). Every
REQ-NNNis implemented, tested, and has an RTM row flipped toDone:page.svelte.spec.tstimelineMeta.spec.ts+page.svelte.spec.tsYearBand.svelte.spec.tsYearBand.svelte.spec.tsYearBand.svelte.spec.tsTimelineView.svelte.spec.tsEventPill.svelte.spec.tsLetterCard.svelte.spec.tsWorldBand.svelte.spec.tsYearLetterStrip.svelte.spec.tsYearLetterStrip.svelte.spec.tsTimelineView.svelte.spec.tsmessages.spec.tsLetterCard.svelte.spec.tsConcerns (no code blocker, but one spec reconciliation):
border border-line… single bordered … container", but+page.svelteshipsrounded-[10px] bg-canvas p-6with no border, andpage.svelte.spec.tsassertsborder === false. The drop is deliberate (PR comment15836ea9) 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 withmain. Fold the borderless decision into REQ-001/AC-001 (as the timeline issues' own changelog process does) so spec and code agree.timeline_meta_summarytemplate 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.
🛠️ Developer (Felix Brandt) — PR Review
Verdict: ⚠️ Approved with concerns
Checked: clean code, layering,
generate:api,ErrorCodefour-site, single-source-of-truth.Good:
generate:api, no newErrorCode, no entity/DTO field (constitution §3.5/§3.6 N/A — matches the claim; verified the diff touches onlylib/timeline/, the route,messages/*.json,rtm.md).lib/timeline/+ the route; reuse of$lib/shared(formatTickLabel/monthBuckets). No cross-domain import.timelineMeta.tsis a pure function and the single home for the counts; the route renders them,TimelineViewnever recomputes (marker-ownership rule honored).count === 1("1 Brief"), avoiding the grammatically wrong "1 Briefe" the drafttimeline_meta_summarytemplate would have produced. Nice.Concern (suggestion):
GlyphLabel.svelteis 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 + theGlyphLabel.spec.tsguard, 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-007to disambiguate.🧪 Tester — PR Review
Verdict: ✅ Approved
Checked: every REQ has a real test, regression safety of the touched existing files, edge-case coverage, test levels.
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::beforezIndex === '-1'spine guard. Strong.<script>alert(1)</script>title rendered as text,a scriptnull) turn the no-{@html}contract into a permanent guard.{#if dateLabel}. I read the pre-existingEventPill.svelte.spec.tsonmain— 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 throughGlyphLabel).Suggestions (non-blocking):
timelineMeta.spec.tsexerciseseventCountonly with derived events. AC-002 says the count also includes curated PERSONAL and HISTORICAL world-bands. The logic counts bykind === 'EVENT'so it's structurally covered, but adding one HISTORICAL entry to the count fixture would lock the AC literally.🔐 Security (Nora "NullX") — PR Review
Verdict: ✅ Approved
Checked:
{@html}/ XSS, untrusted-text escaping, new endpoints/mutation, secrets/PII in logs.ErrorCode, no auth surface — read-only presentation pass. constitution §2.1/§2.8 not applicable.GlyphLabelrenders the glypharia-hiddenand the label as separate escaped nodes.LetterCardkeeps the usertitlein 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 noa scriptnode — exactly the permanent guard I want, re-pinning #779 REQ-021. constitution §2.5 ✓.EventPillsubtitle is${dateLabel} · ${provenance}— a formatted date + a Paraglide word, rendered via{subtitle}(Svelte-escaped); no user-HTML path. Meta line is numbers + keys, escaped.Nothing to fix.
⚙️ DevOps — PR Review
Verdict: ✅ Approved
Checked: migrations, env vars, CI guards, generated artifacts, artifact-action pin.
.gitea/workflows/change, no runtime dependency added — constitution §4/§5 untouched.generated/api.ts,paraglide/untouched). New i18n strings are added tomessages/*.json, the source — correct (the Vite plugin recompilesparaglide/).actions/(upload|download)-artifact@v3pin (ADR-014) and all CI guard steps untouched..specify/rtm.mdedited — 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 toDone.LGTM — nothing to action.
🎨 UI/UX — PR Review
Verdict: ⚠️ Approved with concerns
Checked: states, i18n, a11y, design tokens, touch targets, legibility floor.
Good:
year-node,letter-dot) and glyphs arearia-hidden; meaning is carried bysr-onlylabels viaGlyphLabel. The strip toggle keeps its ≥44px target and visible "Briefe anzeigen" label.messages.spec.ts), including the deliberate split oftimeline_layer_historical_suffix("historisch", visible) fromtimeline_layer_world("Weltgeschehen", sr-only).--palette-mint/--palette-navy/--c-tag-slate; no raw hex in a color context (REQ-013 holds — I confirmed the'#a1dcd8'literals inTimelineView.svelte.spec.tsare not in a:/-[#context, so the corrected grep does not trip on them).text-xs(12px) and strip axis labels totext-[10px], both deliberately above the mockup's 9.5/6px sub-floor for the 60+ transcriber audience. Right call.Concern:
bg-canvas p-6inside abg-canvaspage — 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-surfacefor the sheet, or aborder-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.)🏛️ Architect — PR Review
Verdict: ✅ Approved
Checked: domain boundaries, the issue's marker-ownership rule, single-source-of-truth, ADR need.
TimelineView's.timeline-axis::before; the year-node and per-letter dot live onYearBand. The spine X is a genuine single source of truth —--spine-xdeclared 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. Thepositions the year-node from the inherited --spine-x tokentest locks exactly that contract.timelineMeta.ts, consumed by the route, never recomputed inTimelineView— the ownership the spec mandated.lib/timeline → lib/sharedonly (the #779 REQ-026 boundary holds); no new backend domain, so noArchitectureTestallow-list change is required (constitution §1.7 N/A).GlyphLabelis a reasonable local presentational primitive; the caller-count question (§3.2) is Felix's to weigh, not an architecture concern.Clean — no architecture action.