Commit Graph

1282 Commits

Author SHA1 Message Date
Marcel
bd78f34f09 feat(timeline): render a same-year curated event as its cluster card header
A curated event with letters in its own band now becomes the contained card header
(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —
the title reads once. Derived life-events, world-bands, and letterless event pills
are unchanged (REQ-001 amended for curated-with-letters; the identity fixture now
links its letter to the curated event so the letterless world band stays a band).

Refs #827
2026-06-15 14:56:57 +02:00
Marcel
9ad18f92d9 feat(timeline): make a grouped cluster one contained card
Wraps each cluster in a bordered, rounded surface card (keeping the colour rail)
so the header and its letters read as a single unit.

Refs #827
2026-06-15 14:52:28 +02:00
Marcel
b08f86f76d feat(timeline): collapse the leftover Weitere-Briefe/Ohne-Thema bin to a drawer
The catch-all bucket renders count-only by default behind a reveal control, then
expands to the first-5 + show-more body. Keeps the junk drawer quiet instead of
flooding the timeline.

Refs #827
2026-06-15 14:50:35 +02:00
Marcel
c1dc58c24f feat(timeline): cap grouped clusters at 5 letters with a show-more toggle
Replaces the in-bucket month-density sparkline with a first-5 preview + show-more
/ show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12
YearLetterStrip.

Refs #827
2026-06-15 14:48:10 +02:00
Marcel
ea1034f9ce feat(timeline): nest Ereignis letters under their event pill, no duplicate title
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m47s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m56s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 34s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
In Ereignis mode the curated event showed twice — once as its axis pill and again
as a repeated "✉ <event>" bucket header below. Letters that link to a curated event
whose pill is in the same year band now nest directly under that pill (headerless),
so the title reads once. A cluster whose pill lives in another band keeps its header,
and unlinked letters still fall to the single "Weitere Briefe" bucket. Thema mode is
unchanged (tags have no axis pill). REQ-001 holds — the pills render identically.

Refs #827
2026-06-15 12:07:39 +02:00
Marcel
23534fb077 feat(timeline): contain buckets with a colour rail and collapse oversized ones
The grouped view flooded: buckets had no visual containment (a tiny floating pill
over cards identical to the ungrouped view) and the >12-letter density collapse was
gone, so "Weitere Briefe · 325" / "Sonstiges · 10" dumped every card.

LetterBucket now binds each cluster with a coloured left rail (tag colour in Thema,
mint for an Ereignis cluster, neutral for the fallback), renders compact cards, and
— above BUCKET_DENSE_THRESHOLD (6) — collapses to the existing month-density
YearLetterStrip instead of a flood. Adds a `nested` mode (no header) for letters that
sit under their event pill, and shares the tag-colour token allow-list via tagColorVar.

Refs #827
2026-06-15 12:01:47 +02:00
Marcel
ca06293dc5 feat(timeline): add a compact LetterCard variant for in-bucket letters
Grouped-mode buckets stack many letters; the full two-line card with its own date
chip floods the view. The compact variant tightens the padding and, when the
letter has a title, drops the redundant date chip (the per-year bucket already
frames the time and these archive titles embed the date). Datum mode is untouched
— compact defaults to false.

Refs #827
2026-06-15 11:57:20 +02:00
Marcel
dd97418e24 fix(timeline): keep the Thema bucket-header label in a fixed ink, not the tag token
The tinted bucket-header chip painted the saturated --c-tag-* token AS its label
text over a 18% wash of the same token. For the light tokens that fails WCAG AA:
amber ≈3.0:1, sand ≈3.2:1, sage ≈3.4:1 (only sienna, the one the test used,
passed). Move the tint to the chip fill + dot and render the label in a fixed
dark ink so every token clears 4.5:1 while the chip still reads as tinted.

Refs #827
2026-06-15 11:54:19 +02:00
Marcel
9551bbd1ca test(i18n): assert the #827 grouping + bucket keys exist in every locale
REQ-012 coverage: the new grouping/segment/bucket keys are present in de/en/es
and the pre-existing timeline_grouping_date / timeline_tag_chip_label are reused,
not re-declared.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:07:43 +02:00
Marcel
6c85f47794 test(timeline): gate the event layer identity and the {@html} ban
Add the REQ-001 structural-identity check (the event pills/world-bands render
identically across all three grouping modes — the no-VRT-harness equivalent of
the pixel-diff) and the REQ-009 grep gate (no lib/timeline component reaches for
the raw-HTML directive). Reword the BucketHeaderChip doc to describe the
directive by name so the gate stays literal.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:00:16 +02:00
Marcel
8be4b40e54 feat(timeline): render letter buckets in TimelineView/YearBand
Thread groupingMode through TimelineView → YearBand. TimelineView resolves the
event lookup once over the filtered view (so Ereignis clusters never reference a
filtered-out event). In non-Datum modes YearBand keeps its event pills/world-bands
identical (REQ-001) and replaces the loose letters with per-year LetterBuckets
(REQ-002/003/004); Datum keeps the original card/strip path. The undated bucket is
unchanged in every mode.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:52:48 +02:00
Marcel
bc22b2d4c9 feat(timeline): add the Datum·Ereignis·Thema grouping control
An ARIA radiogroup with roving tabindex (#827, REQ-010/011/018): three
arrow-key-navigable ≥44px segments with text labels, full-word aria-labels and
≤360px abbreviations, semantic-token colours that hold contrast in dark mode,
defaulting to Datum. When disabled it stays in place, retains its selection, and
announces a screen-reader reason — deliberately distinct from #780's
aria-pressed layer toggles.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:48:36 +02:00
Marcel
f3c2465465 feat(timeline): add the LetterBucket cluster component
Renders one loose-letter cluster for Ereignis/Thema mode (#827): an
"✉ <event> · <n>" header over .lcard.ev cards in Ereignis, a tinted
BucketHeaderChip over chip-suppressed cards in Thema, and a localized
"Weitere Briefe"/"Ohne Thema" header with plain cards for the fallback bucket.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:42:45 +02:00
Marcel
0ae4e9a311 feat(timeline): give LetterCard an event variant and chip suppression
Add a `variant="event"` that marks the card `.lcard.ev` for Ereignis-mode event
clusters (#827, REQ-014) and a `suppressTagChip` that hides the per-letter
TagChip inside its own Thema bucket where the header already conveys the topic
(REQ-017). Datum/Ereignis keep the #838 per-letter chip behaviour.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:37:54 +02:00
Marcel
99528e6bea feat(timeline): add the tinted Thema bucket-header chip
A fully-tinted root-tag chip for Thema-mode bucket headers (#827, REQ-015):
fill and label both derive from the tag's --c-tag-* token via a color-mix wash
so the label keeps ≥4.5:1 contrast in light and dark mode. A null or unknown
token falls back to a neutral chip with no broken colour. Curator text is
{...}-escaped (REQ-009). Distinct from the neutral per-letter TagChip.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:34:29 +02:00
Marcel
4b11d66ca5 feat(timeline): add the client-side letter regroup transform
Pure module powering the #827 Datum·Ereignis·Thema toggle: buildEventLookup
(curated events that survived the #780 layer filter), hasLooseLetters (the
control's enabled state), and bucketLetters (cluster loose letters by
linkedEventId or primary root tag, with a "Weitere Briefe"/"Ohne Thema"
fallback). Filter-then-group, no refetch.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:31:03 +02:00
Marcel
0726226c95 chore(api): regenerate types with TimelineEntryDTO.linkedEventId
Regenerated frontend/src/lib/generated/api.ts from the live OpenAPI spec after
adding the nullable linkedEventId field — keeps the CI type-check green for the
#827 grouping UI that consumes it.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:27:57 +02:00
Marcel
4d4266ba99 docs(rtm): trace #842 timeline-curator-affordances + i18n parity guard
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m38s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 6m35s
CI / fail2ban Regex (pull_request) Successful in 52s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 30s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
Add REQ-001..011 rows for the timeline-curator-affordances feature (all
Done) and a messages.spec parity guard pinning timeline_add_event +
person_add_event across de/en/es. REQ-010/011 cite the existing #781
new-event route + its 403 guard test (no new production code).

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:19:28 +02:00
Marcel
11bcaf7cdb feat(timeline): add a WRITE_ALL-gated edit pencil to WorldBand
A curated HISTORICAL band had no edit affordance at all. Mirror the
EventPill pencil inline at the end of the band (data-testid="event-edit",
/edit href, aria-hidden glyph + sr-only Bearbeiten), gated on
canWrite && eventId != null. Thread canWrite to WorldBand through both
the year-band path and the undated bucket.

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:08:14 +02:00
Marcel
cd238285ae feat(timeline): gate the EventPill edit pencil behind canWrite
Thread a gate-closed canWrite prop through TimelineView -> YearBand ->
EventPill and the undated bucket so a Reader never sees a dead-end edit
link. canEdit now also requires canWrite; the default false keeps an
un-threaded caller closed. The real boundary stays the #781 route guard
plus the backend permission -- this only hides the link.

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:03:53 +02:00
Marcel
33aff36867 test(timeline): guard the layer filter against navigation and fetch
A static boundary gate (mirroring the project's no-`{@html}` greps) that reads
TimelineFilters.svelte and /zeitstrahl/+page.svelte and fails if either ever
reintroduces goto(, url.searchParams, api.GET, or fetch( — the filter must stay
presentation-only and fully client-side.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
c6fe61f06b feat(timeline): add TimelineFilters presentation component
A dumb, client-side layer-filter bar for /zeitstrahl: three $bindable layer
toggles (Personal/Historical/Letters) in a fieldset/legend, a sticky
"Filter (N aktiv)" trigger driven by hiddenLayerCount, and a reset text button
shown only when a layer is off. Toggles mirror the SearchFilterBar undated-toggle
markup (aria-pressed, ✓ glyph, 44px touch target, semantic tokens). The
collapsible slide honours prefers-reduced-motion by zeroing its duration. No
goto, no fetch — the route owns the state and the filtered view.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
182d014971 feat(timeline): add layer-filter i18n keys (de/en/es)
Eight Paraglide keys for the /zeitstrahl layer filter — layer labels, the
fieldset legend, the sticky trigger (distinct timeline_filter_trigger and
timeline_filter_trigger_active({count}) so it never reads "Filter (0 aktiv)"),
the reset button, and the filtered-empty message — added to all three locales.
messages.spec asserts their presence and the {count} signature.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
dc9d1d52b3 feat(timeline): add client-side layer-filter helpers
Pure helpers for the /zeitstrahl layer filter: isDefaultState and
hiddenLayerCount drive the "Filter (N active)" trigger, and filterTimeline
derives a client-side view that hides personal/historical/letter layers and
drops year bands left empty. Letters ride the Letters layer, HISTORICAL events
the Historical layer, and curated PERSONAL plus derived life-events the
Personal layer.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
8558567688 feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
Closes #837

Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free.

Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`).

## What's in it
- **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped.
- **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint.
- New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6).
- `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision.
- Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated.
- Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019.

## Requirements
All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`.

## Test plan
- **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds.
- **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean.

## Notes for reviewers
- **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`.
- `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned).
- **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #841
2026-06-14 21:17:36 +02:00
Marcel
1cd6ffd5ca refactor(timeline): de-duplicate the TagChip markup
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m4s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m26s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
CI / Unit & Component Tests (push) Successful in 4m21s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 4m57s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 30s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
Two cleanups flagged in review, both behaviour-preserving:
- collapse the {#if color}/{:else} square-marker branches (identical but for the
  neutral fill) into one element via class:bg-ink-3={!color}; squareStyle is
  already empty when color is null, so no var(--c-tag-) leaks into the neutral
  chip.
- drop the redundant `truncate` class from the name span — the inline
  overflow/ellipsis trio (kept so it applies before the stylesheet loads,
  REQ-008a) already expresses exactly what `truncate` would.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:59:10 +02:00
Marcel
bbf2f96e28 docs(timeline): reword TagChip comment to clear the raw-HTML grep gate
The doc comment described escaping by naming the raw-HTML directive literally,
which trips the lib/timeline grep gate that forbids that token. Reword it the
way LetterCard already does — behavior unchanged.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:16:51 +02:00
Marcel
8376a520c5 feat(timeline): show the root-tag chip on the letter card
LetterCard now renders a TagChip beneath the sender→receiver/date line
whenever the entry carries a rootTagName, mapping rootTagColor to the chip
(neutral when null). Because the chip lives on LetterCard it shows up wherever
a LetterCard does — the global timeline and the expanded YearLetterStrip — with
no per-surface special-casing; a tagless letter shows no chip. A long name
truncates inline so the card never overflows at 320px.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:13:21 +02:00
Marcel
c19d4be3fe feat(timeline): add the root-tag chip component
TagChip renders a letter's primary root tag as a small rounded pill — a
decorative aria-hidden colored square (var(--c-tag-{token}), neutral when the
color is null) plus the escaped tag name, prefixed by the sr-only theme label
so color is never the only cue. Truncation is set inline so a long name
ellipsizes without forcing the card into horizontal scroll, and the full name
stays reachable via the chip title. Timeline-local by design — lib/timeline may
not import lib/tag (eslint boundary).

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:08:51 +02:00
Marcel
90e2b4d6c2 feat(i18n): add the timeline tag-chip theme label
timeline_tag_chip_label (de "Thema" / en "Topic" / es "Tema") is the sr-only
prefix the /zeitstrahl letter tag chip reads out so color is never the only
cue. Pinned per locale in messages.spec.ts; the tag name itself is rendered as
data, never translated.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:08:21 +02:00
Marcel
d33c1e5249 chore(api): regenerate api.ts with the timeline root-tag fields
openapi-typescript pickup of TimelineEntryDTO.rootTagId/rootTagName/
rootTagColor (all optional), so the SvelteKit timeline can read the new
letter chip fields. Regenerated from the live dev spec; only the additive
fields differ from the committed baseline.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:03:16 +02:00
Marcel
239565ea20 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
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>
2026-06-14 13:51:46 +02:00
Marcel
0a235dc911 refactor(timeline): extract a shared GlyphLabel primitive
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>
2026-06-14 13:48:25 +02:00
Marcel
0bd6790b1f refactor(timeline): count timelineMeta totals in a single pass
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>
2026-06-14 12:19:40 +02:00
Marcel
84938e1bf3 refactor(timeline): render the WorldBand historical suffix once
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>
2026-06-14 12:17:39 +02:00
Marcel
398babe584 fix(timeline): pluralize the zeitstrahl meta-line counts
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>
2026-06-14 12:15:11 +02:00
Marcel
f715f9ce9c fix(timeline): show EventPill provenance even when the event has no date
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>
2026-06-14 12:08:13 +02:00
Marcel
8029bdec92 fix(timeline): keep the axis spine behind the in-flow content (REQ-006)
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>
2026-06-14 11:38:32 +02:00
Marcel
217508ddb2 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
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>
2026-06-14 11:30:59 +02:00
Marcel
bfe66569d7 feat(timeline): paint the axis with a three-stop gradient (REQ-006)
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>
2026-06-14 11:10:49 +02:00
Marcel
18934413bb feat(timeline): center the year badge and add spine markers (REQ-003/004/005)
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>
2026-06-14 11:10:05 +02:00
Marcel
a1e57ff8cf feat(timeline): derive header meta figures from the DTO (REQ-002)
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>
2026-06-14 10:58:25 +02:00
Marcel
e0b096f12c feat(timeline): frame the undated bucket with a dashed border + count (REQ-012)
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>
2026-06-14 10:55:37 +02:00
Marcel
6382efa65a feat(timeline): caption the density strip with ✉ + month axis (REQ-010/011)
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>
2026-06-14 10:52:55 +02:00
Marcel
144719720f feat(timeline): add the "· historisch" descriptor to world bands (REQ-009)
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>
2026-06-14 10:49:19 +02:00
Marcel
fc67dfc3d5 feat(timeline): prefix letter titles with the ✉ glyph (REQ-008/016)
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>
2026-06-14 10:45:47 +02:00
Marcel
08d8896cd1 feat(timeline): show provenance token on the event pill (REQ-007)
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>
2026-06-14 10:42:33 +02:00
Marcel
808d6efa1a i18n(timeline): add zeitstrahl visual-fidelity strings (REQ-015)
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>
2026-06-14 10:39:58 +02:00
Marcel
bc02d22270 refactor(timeline): drop EventForm's redundant type state
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m13s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 4m33s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m11s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m12s
The submitted type comes from EventTypeSelect's own hidden input; EventForm's
`type` $state was only read to seed `value=` and the onchange reassignment
had no downstream reader. Inline the seed expression and pass markDirty
directly, removing the second source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:13:17 +02:00
Marcel
23f6bc284d fix(timeline): validate RANGE end-date client-side with a field-level error
A RANGE event with a blank end date passed validateEventForm and reached
the backend, which 400s with a generic INVALID_DATE_RANGE mapped to "end
must not be before start" — wrong for a missing end date, and shown only as
a top-of-form alert. Validate it before the API call and surface a dedicated
event_editor_end_date_required message on the end-date field via a new
DatePrecisionField endDateError prop (defaults '', so the document form is
unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:11:20 +02:00