As a family member I want each /zeitstrahl letter to show its root-tag color chip so I can read the archive's themes at a glance (Datum mode) #835

Closed
opened 2026-06-14 10:11:43 +02:00 by marcel · 1 comment
Owner

As a family member I want each /zeitstrahl letter to carry its root-tag color chip so the timeline's themes (Krieg/Weihnachten/Familie …) are legible at a glance

Milestone: Zeitstrahl — Family Timeline (#14)
Canonical visual spec: docs/specs/zeitstrahl-final-spec.html — the §3 letter cards carry colored root-tag chips (.chip.fam "Familie", .chip.weih "Weihnachten", .chip.krieg" "Krieg"), and the §1 legend names this the "Wurzel-Tag-Farbchip". Token table: chip colors come from --c-tag-* (root).
Split from: #833 (visual-fidelity follow-up), which fenced this out as "needs a backend DTO change — file/track as a separate full-stack issue". Coordinates with: #827 (Ereignis/Thema regroup) — see Relationship to #827.

Context & Why

The Concept-A mockup shows every dated letter on /zeitstrahl carrying a small colored root-tag chip beneath its sender→receiver line — the single strongest at-a-glance signal of what a stretch of the archive is about (war correspondence, Christmas letters, family news). #779 shipped the timeline without it and #833 (the pure-frontend fidelity pass) could not add it, because TimelineEntryDTO carries no tag data. This issue closes that gap for the default Datum mode: it adds the letter's primary root tag (id + name + color token) to the DTO and renders the chip on LetterCard.

This is a thin, independently-shippable vertical slice (one DTO enrichment + one chip), deliberately scoped below the full #827 "Thema" regroup. It is the foundation #827's Thema bucketing then reuses (see Relationship to #827).

Constitution principles this depends on (see .specify/constitution.md):

  • §1.3 — TimelineService resolves the root tag through the tag domain's service (TagService), never TagRepository directly.
  • §1.6 / ADR-036 — the new fields are assembled in-transaction in the timeline view; no lazy Tag/Document entity graph is serialized — id + name + color token only.
  • §2.5 — the curator/import-derived tag name renders via Svelte {...} escaping; never {@html}.
  • §3.5 — the new always-present-for-some-rows fields are correctly modelled (nullable → not @Schema(requiredMode = REQUIRED)).

Related: ADR-040 (timeline data model), follow-up to #779/#833.

How tag color works today (verified against main — build to this)

  • Tag has a color field holding a token name (one of the 10 in TagService.ALLOWED_TAG_COLORS: sage, sienna, amber, slate, violet, rose, cobalt, moss, sand, coral), not a hex value.
  • Color is stored only on root tags; children inherit the parent's color via TagService.resolveEffectiveColors(...).
  • The frontend renders a tag color as style="background-color: var(--c-tag-{token})" (see TagInput.svelte:135); the --c-tag-* tokens are defined in layout.css with dark-mode overrides, so a token-based chip is automatically theme-correct.

⇒ The DTO carries the color token string (e.g. "sienna"), and the chip maps it to var(--c-tag-{token}). Since the primary tag we render is the root tag, its color is set directly (roots carry their own color), so no inheritance walk is needed once the root is resolved.

User Journey

A family reader opens /zeitstrahl (Datum mode). A 1909 letter card now shows, under "Elfriede → Karl · Mai 1909", a small rounded chip — a colored square plus the word "Familie" in the tag's color. A December letter shows an amber "Weihnachten" chip; a wartime letter a sienna "Krieg" chip. A letter with no tag shows no chip (no empty placeholder). A multi-tagged letter shows exactly one chip — its primary root tag. A screen-reader user hears "Thema: Familie", not a bare color. The chips render identically in the expanded view of a dense year's strip. Nothing about ordering or which letters appear changes.

Scope

  • Backend: enrich TimelineEntryDTO (LETTER entries) with the primary root tag — rootTagId, rootTagName, rootTagColor (token) — assembled in-transaction in TimelineService, resolved via TagService, batched (no N+1). No new table, no migration.
  • Frontend: render the chip on LetterCard (so it appears in the global timeline, the dense-strip expansion, and — when it exists — the per-person Lebensweg rail), matching the §3 .chip styling, colored via --c-tag-{token}, with an sr-only theme label.
  • Regenerate + commit api.ts after the DTO change.

Out of Scope (explicit boundaries)

  • Thema-mode regrouping / bucketing and the grouping toggle#827. This issue only renders a per-letter chip in the existing Datum layout; it does not bucket letters by tag.
  • Multiple chips per letter. Only the single primary root tag chip renders (deterministic selection below) — never one chip per tag.
  • The .lcard.ev event-letter variant (mint-bordered serif card) → #827.
  • Filtering the timeline by tag / clicking a chip to filter → out of scope (a chip is a read-only signal here; tag filtering is the filters issue #8).
  • Per-letter chips on the document search / other surfaces — this issue is the timeline LetterCard only.

Relationship to #827 (read before implementing)

#827 (Ereignis/Thema regroup) currently plans to add rootTagId + rootTagName to TimelineEntryDTO itself (its REQ-005) for Thema bucketing. To avoid two issues adding the same fields:

  • This issue owns the shared root-tag DTO enrichment (rootTagId, rootTagName, rootTagColor) and the batched resolution in TimelineService. It is the smaller, lower-risk slice and ships first.
  • #827 should be descoped to consume these fields (its REQ-005 drops the field addition and the TagService edge, keeping only linkedEventId for Ereignis and the Thema bucketing logic on top of the fields this issue delivers). #827 additionally needs the color for its colored Thema buckets — which this issue already provides.
  • If #827 lands first for any reason, this issue instead extends #827's fields with rootTagColor and adds only the chip. Either order works; the fields are defined once.

(When this issue is picked up, update #827's REQ-005 + its "Visual fidelity" note accordingly.)

DTO contract (target)

TimelineEntryDTO gains three nullable fields on LETTER entries (record grows 13 → 16):

TimelineEntryDTO:
  ...existing 13 fields...
  rootTagId:    UUID   | null   # primary root tag id; null if the letter has no tags
  rootTagName:  string | null   # primary root tag display name (escaped on render)
  rootTagColor: string | null   # primary root tag color TOKEN (one of the 10 allowed), null if the root has no color

No new endpoint, no mutating path. GET /api/timeline stays @RequirePermission(READ_ALL). Run npm run generate:api and commit the regenerated api.ts in the same PR.

Requirements (EARS)

Backend — DTO & resolution

  • REQ-001 (Ubiquitous) — TimelineEntryDTO shall carry, for LETTER entries, rootTagId (UUID), rootTagName (String), and rootTagColor (String token), assembled in-transaction in TimelineService (ADR-036) — id + name + token only, never a serialized Tag entity.
  • REQ-002 (Ubiquitous) — The three new fields shall be nullable and shall not carry @Schema(requiredMode = REQUIRED) (they are null for non-letter entries and for letters with no tag).
  • REQ-003 (Event-driven) — When assembling a LETTER entry, TimelineService shall resolve its primary tag as the root ancestor of the letter's alphabetically-first tag name (reusing #827's Resolved Decision 3 rule), via TagService (constitution §1.3), never TagRepository directly.
  • REQ-004 (Ubiquitous, NFR) — Root-tag resolution shall run as a single batched/memoized pass per distinct tag within the assembly transaction (no per-letter N+1), reusing TagRepository.findAncestorIds through a TagService method; rootTagColor is read from the resolved root tag's stored color.

Backend — degenerate cases

  • REQ-005 (Unwanted-behavior) — If a letter has no tags, then rootTagId, rootTagName, and rootTagColor shall all be null (the chip is then absent on render — never a placeholder).
  • REQ-006 (Unwanted-behavior) — If a letter has multiple tags, then exactly one primary root tag shall be resolved (deterministic per REQ-003); the DTO shall never carry a list or multiple chips' worth of data.
  • REQ-007 (Unwanted-behavior) — If the resolved root tag has no color, then rootTagColor shall be null and the frontend shall render a neutral (uncolored) chip carrying the name, never a broken var(--c-tag-) reference.

Frontend — chip render

  • REQ-008 (Event-driven) — When a LetterCard entry has a non-null rootTagName, the card shall render a single tag chip matching §3 .chip (a small rounded pill: a colored square marker + the tag name) beneath the sender→receiver/date line.
  • REQ-008a (State-driven, responsive) — While the viewport is narrow (down to 320px), the chip shall wrap onto its own line beneath the meta line rather than overflow the card, and a long tag name (e.g. "Briefe von der Front") shall truncate with an ellipsis past a sensible max width — so the chip never forces horizontal scroll alongside #833's ✉/connector chrome. The full name remains available as the chip's accessible name / title.
  • REQ-009 (State-driven) — While rootTagColor is non-null, the chip's color shall be applied via var(--c-tag-{rootTagColor}) (matching TagInput.svelte), inheriting the existing light/dark tag palette; no raw hex in the component.
  • REQ-010 (Ubiquitous) — The chip shall render rootTagName via Svelte {...} escaping (curator/import-derived text); {@html} shall never appear in the chip.
  • REQ-011 (Ubiquitous, a11y) — The colored square shall be decorative (aria-hidden="true"); the tag name is the visible+accessible label, prefixed for screen readers by an sr-only theme label (timeline_tag_chip_label, e.g. "Thema") so color is never the only cue (WCAG 1.4.1) and contrast holds in both themes (WCAG 1.4.3).
  • REQ-012 (Ubiquitous) — Because the chip lives on LetterCard, it shall render wherever a LetterCard renders: the global timeline, the expanded view of a dense YearLetterStrip, and (when it exists) the per-person Lebensweg rail — with no per-surface special-casing.

i18n & security

  • REQ-013 (Ubiquitous) — The sr-only theme label shall be a Paraglide key present in frontend/messages/{de,en,es}.json with matching key sets; the tag name itself is rendered as data, not translated.
  • REQ-014 (Ubiquitous) — GET /api/timeline remains read-only (READ_ALL); no new endpoint, mutation, or ErrorCode; the enriched DTO exposes only ids/name/token a READ_ALL reader can already see (no new IDOR surface), and the assembly path logs UUIDs only, never tag names (§2.7).

Acceptance Criteria (measurable)

  • REQ-001/002 — Regenerated api.ts shows rootTagId?, rootTagName?, rootTagColor? on the entry type (all optional); a non-letter entry serializes them null/absent; a @DataJpaTest/service test asserts no Tag entity graph is serialized.
  • REQ-003 — A letter tagged Krieg/Briefe von der Front (root Krieg) and Allgemein resolves rootTagName="Krieg" when "Briefe von der Front"-rooted-Krieg sorts before/after per the alphabetically-first-tag rule — test pins the deterministic choice for a known multi-tag set.
  • REQ-004 — A timeline with N letters across M distinct tags resolves roots in ≤ (M + constant) tag queries, not N (asserted via repository call count / a batched-pass test); rootTagColor equals the root tag's stored token.
  • REQ-005 — An untagged letter has all three fields null; its LetterCard renders no chip element.
  • REQ-006 — A letter with ≥2 tags yields exactly one rootTagId; the card renders exactly one chip.
  • REQ-007 — A letter whose root tag has color == null renders a neutral chip with the name and no --c-tag- style binding (no var(--c-tag-) with an empty token in the DOM).
  • REQ-008 — A LetterCard with rootTagName="Familie" renders one chip containing "Familie" beneath the meta line (*.svelte.spec.ts).
  • REQ-008a — A LetterCard rendered at a 320px-wide viewport with a long rootTagName shows no horizontal overflow (the card's scrollWidth ≤ its clientWidth); the chip's full name is reachable via its accessible name / title.
  • REQ-009 — A chip with rootTagColor="sienna" has an element whose inline style references var(--c-tag-sienna); the REQ-013-style hex grep over the component is clean.
  • REQ-010 — A rootTagName of <img src=x onerror=alert(1)> renders as inert text; grep -rn '@html' frontend/src/lib/timeline/ returns zero.
  • REQ-011 — The color square has aria-hidden="true"; the chip exposes an accessible name containing the sr-only label + tag name; axe reports zero violations on a card-with-chip in light and dark.
  • REQ-012 — The chip appears on a LetterCard rendered standalone, inside an expanded YearLetterStrip, and (smoke) does not error when personId is set.
  • REQ-013 — A key-parity test confirms timeline_tag_chip_label exists in de/en/es.
  • REQ-014page.server.test.ts / endpoint test confirms no new permission or error path; a log-capture test (or review) confirms no tag name is logged.
  • E2E smoke (non-blocking for this thin slice) — A Playwright step loads /zeitstrahl in Datum mode against seed data and asserts at least one letter card renders a .chip with a tag name; treated as a smoke check, not a gate, given the component/axe/strip tests above carry the behavioral coverage.

Component touch-points

Layer File Change
Backend TimelineService (+ its entry-mapping) Resolve primary root tag (id/name/color) per letter, batched, via TagService (REQ-001/003/004).
Backend TagService Add a new public batched root-resolution method (none exists today — resolveEffectiveColors(...) + TagRepository.findAncestorIds exist, but nothing returns root (id, name, color) for a set of tags) wrapping TagRepository.findAncestorIds, returning root id+name+color (shared with #827). This method is the first red test of the slice (REQ-004).
Backend TimelineEntryDTO Add 3 nullable fields (REQ-001/002).
Frontend lib/timeline/LetterCard.svelte Render the chip (REQ-008..011). Note: #833 also edits LetterCard (✉ glyph, connector dot) — coordinate / rebase (see Coordination).
Frontend (maybe) lib/timeline/TagChip or reuse lib/tag chip Evaluate reusing an existing tag-chip primitive vs. a small timeline-local chip; per frontend/eslint.config.js, lib/timeline may import {shared, person, document} but not lib/tag — so reuse means promoting a chip to $lib/shared, otherwise a small timeline-local chip.
i18n frontend/messages/{de,en,es}.json timeline_tag_chip_label (de "Thema" / en "Topic" / es "Tema").

i18n keys (draft)

Key de en es
timeline_tag_chip_label Thema Topic Tema

Coordination

  • #833 edits the same LetterCard.svelte (✉ title glyph, connector dot, sr-only label). Land #833 first and rebase this chip onto it, or sequence them deliberately, so the chip sits correctly relative to #833's new card chrome (the §3 chip sits beneath the sender→receiver/date line).
  • #827 consumes these DTO fields for Thema bucketing — descope #827's REQ-005 to depend on this issue (see Relationship to #827).
  • #783 (a11y pass) owns the dark-mode token grep gate; the chip must pass it (REQ-009 uses var(--c-tag-*), no hex).

Data Model Changes

None. Tag.color already exists; the primary root tag is resolved at read time. No Flyway migration (next free number not consumed).

Security Considerations

Read-only on the family-wide READ_ALL archive. STRIDE: only Information disclosure (DTO exposes a tag id/name/token the reader already sees — no new scoping/IDOR) and Tampering→XSS (curator/import tag name rendered via {...}, never {@html} — REQ-010), both mitigated. No mutating endpoint ⇒ no new authn/authz If clause; the Unwanted-behavior branches (REQ-005/006/007) are render/resolution guards, not access guards. No PII (tag names) in logs (REQ-014).

Pre-Implementation Artifacts (commit with the code)

  • RTM rows — mirror REQ-001..014 into .specify/rtm.md (feature zeitstrahl-tag-chips, this issue #, Status=Planned) in the first implementation commit.
  • Regenerated api.ts committed in the implementation PR.
  • No ADR (additive DTO fields; the TimelineService → TagService edge is constitution-§1.3-compliant cross-domain access, not a new pattern). If #827's ADR-044 lands first and already records the TimelineService → TagService edge, reference it.

Open Decisions

Concrete calls the spec has made so it stays buildable — confirm or override before/at implementation.

  • Chip component placement — Decided: a small timeline-local chip (alternatives: promote a shared chip from lib/tag to $lib/shared; import lib/tag directly — not allowed: per frontend/eslint.config.js, lib/timeline may import {shared, person, document} but not lib/tag). Rationale: KISS / constitution §3.2 — a one-off colored pill doesn't justify promoting a shared primitive. Override to the shared-chip route only if lib/tag's chip is trivially extractable to $lib/shared without dragging tag-domain deps.
  • Primary-tag rule — Decided: the primary tag is the root ancestor of the letter's alphabetically-first tag name, verbatim with #827's Resolved Decision 3, so Datum chips and Thema buckets agree (REQ-003). Confirm this still matches #827's wording when this issue is picked up; if #827 has since revised the rule, update REQ-003 to track it.

Follow-up to #779/#833; foundation for #827. Hardened via /review-issue (round 1: all six personas APPROVE, no blocking FAILs) — see ## Open Decisions for the calls to confirm at implementation.

# As a family member I want each /zeitstrahl letter to carry its root-tag color chip so the timeline's themes (Krieg/Weihnachten/Familie …) are legible at a glance **Milestone:** Zeitstrahl — Family Timeline (#14) **Canonical visual spec:** `docs/specs/zeitstrahl-final-spec.html` — the §3 letter cards carry colored root-tag chips (`.chip.fam` "Familie", `.chip.weih` "Weihnachten", `.chip.krieg" "Krieg"`), and the §1 legend names this the **"Wurzel-Tag-Farbchip"**. Token table: chip colors come from `--c-tag-*` (root). **Split from:** #833 (visual-fidelity follow-up), which fenced this out as "needs a backend DTO change — file/track as a separate full-stack issue". **Coordinates with:** #827 (Ereignis/Thema regroup) — see *Relationship to #827*. ## Context & Why The Concept-A mockup shows every dated letter on `/zeitstrahl` carrying a small **colored root-tag chip** beneath its sender→receiver line — the single strongest at-a-glance signal of what a stretch of the archive is *about* (war correspondence, Christmas letters, family news). #779 shipped the timeline without it and #833 (the pure-frontend fidelity pass) could not add it, because `TimelineEntryDTO` carries no tag data. This issue closes that gap **for the default Datum mode**: it adds the letter's primary root tag (id + name + color token) to the DTO and renders the chip on `LetterCard`. This is a thin, independently-shippable vertical slice (one DTO enrichment + one chip), deliberately scoped **below** the full #827 "Thema" regroup. It is the **foundation** #827's Thema bucketing then reuses (see *Relationship to #827*). Constitution principles this depends on (see `.specify/constitution.md`): - §1.3 — `TimelineService` resolves the root tag through the **tag domain's service** (`TagService`), never `TagRepository` directly. - §1.6 / ADR-036 — the new fields are assembled in-transaction in the timeline view; no lazy `Tag`/`Document` entity graph is serialized — id + name + color token only. - §2.5 — the curator/import-derived tag name renders via Svelte `{...}` escaping; never `{@html}`. - §3.5 — the new always-present-for-some-rows fields are correctly modelled (nullable → **not** `@Schema(requiredMode = REQUIRED)`). Related: ADR-040 (timeline data model), follow-up to #779/#833. ## How tag color works today (verified against `main` — build to this) - `Tag` has a `color` field holding a **token name** (one of the 10 in `TagService.ALLOWED_TAG_COLORS`: `sage, sienna, amber, slate, violet, rose, cobalt, moss, sand, coral`), **not** a hex value. - Color is **stored only on root tags**; children inherit the parent's color via `TagService.resolveEffectiveColors(...)`. - The frontend renders a tag color as `style="background-color: var(--c-tag-{token})"` (see `TagInput.svelte:135`); the `--c-tag-*` tokens are defined in `layout.css` **with dark-mode overrides**, so a token-based chip is automatically theme-correct. ⇒ The DTO carries the **color token string** (e.g. `"sienna"`), and the chip maps it to `var(--c-tag-{token})`. Since the *primary* tag we render is the **root** tag, its `color` is set directly (roots carry their own color), so no inheritance walk is needed once the root is resolved. ## User Journey A family reader opens `/zeitstrahl` (Datum mode). A 1909 letter card now shows, under "Elfriede → Karl · Mai 1909", a small rounded chip — a colored square plus the word "Familie" in the tag's color. A December letter shows an amber "Weihnachten" chip; a wartime letter a sienna "Krieg" chip. A letter with no tag shows no chip (no empty placeholder). A multi-tagged letter shows exactly one chip — its primary root tag. A screen-reader user hears "Thema: Familie", not a bare color. The chips render identically in the expanded view of a dense year's strip. Nothing about ordering or which letters appear changes. ## Scope - **Backend:** enrich `TimelineEntryDTO` (LETTER entries) with the primary root tag — `rootTagId`, `rootTagName`, `rootTagColor` (token) — assembled in-transaction in `TimelineService`, resolved via `TagService`, batched (no N+1). No new table, no migration. - **Frontend:** render the chip on `LetterCard` (so it appears in the global timeline, the dense-strip expansion, and — when it exists — the per-person Lebensweg rail), matching the §3 `.chip` styling, colored via `--c-tag-{token}`, with an sr-only theme label. - Regenerate + commit `api.ts` after the DTO change. ## Out of Scope (explicit boundaries) - **Thema-mode regrouping / bucketing and the grouping toggle** → #827. This issue only renders a per-letter chip in the existing Datum layout; it does not bucket letters by tag. - **Multiple chips per letter.** Only the single **primary root tag** chip renders (deterministic selection below) — never one chip per tag. - **The `.lcard.ev` event-letter variant** (mint-bordered serif card) → #827. - **Filtering the timeline by tag / clicking a chip to filter** → out of scope (a chip is a read-only signal here; tag filtering is the filters issue #8). - **Per-letter chips on the document search / other surfaces** — this issue is the timeline `LetterCard` only. ## Relationship to #827 (read before implementing) #827 (Ereignis/Thema regroup) currently plans to add `rootTagId` + `rootTagName` to `TimelineEntryDTO` itself (its REQ-005) for Thema bucketing. To avoid two issues adding the same fields: - **This issue owns the shared root-tag DTO enrichment** (`rootTagId`, `rootTagName`, **`rootTagColor`**) and the batched resolution in `TimelineService`. It is the smaller, lower-risk slice and ships first. - **#827 should be descoped to *consume* these fields** (its REQ-005 drops the field addition and the `TagService` edge, keeping only `linkedEventId` for Ereignis and the Thema bucketing logic on top of the fields this issue delivers). #827 additionally needs the color for its colored Thema buckets — which this issue already provides. - If #827 lands first for any reason, this issue instead **extends** #827's fields with `rootTagColor` and adds only the chip. Either order works; the fields are defined once. (When this issue is picked up, update #827's REQ-005 + its "Visual fidelity" note accordingly.) ## DTO contract (target) `TimelineEntryDTO` gains three nullable fields on `LETTER` entries (record grows 13 → 16): ``` TimelineEntryDTO: ...existing 13 fields... rootTagId: UUID | null # primary root tag id; null if the letter has no tags rootTagName: string | null # primary root tag display name (escaped on render) rootTagColor: string | null # primary root tag color TOKEN (one of the 10 allowed), null if the root has no color ``` No new endpoint, no mutating path. `GET /api/timeline` stays `@RequirePermission(READ_ALL)`. Run `npm run generate:api` and **commit the regenerated `api.ts` in the same PR**. ## Requirements (EARS) ### Backend — DTO & resolution - **REQ-001** (Ubiquitous) — `TimelineEntryDTO` shall carry, for `LETTER` entries, `rootTagId` (UUID), `rootTagName` (String), and `rootTagColor` (String token), assembled in-transaction in `TimelineService` (ADR-036) — id + name + token only, never a serialized `Tag` entity. - **REQ-002** (Ubiquitous) — The three new fields shall be nullable and shall **not** carry `@Schema(requiredMode = REQUIRED)` (they are null for non-letter entries and for letters with no tag). - **REQ-003** (Event-driven) — When assembling a `LETTER` entry, `TimelineService` shall resolve its primary tag as the **root ancestor of the letter's alphabetically-first tag name** (reusing #827's Resolved Decision 3 rule), via `TagService` (constitution §1.3), never `TagRepository` directly. - **REQ-004** (Ubiquitous, NFR) — Root-tag resolution shall run as a single batched/memoized pass per distinct tag within the assembly transaction (no per-letter N+1), reusing `TagRepository.findAncestorIds` through a `TagService` method; `rootTagColor` is read from the resolved root tag's stored `color`. ### Backend — degenerate cases - **REQ-005** (Unwanted-behavior) — If a letter has no tags, then `rootTagId`, `rootTagName`, and `rootTagColor` shall all be null (the chip is then absent on render — never a placeholder). - **REQ-006** (Unwanted-behavior) — If a letter has multiple tags, then exactly one primary root tag shall be resolved (deterministic per REQ-003); the DTO shall never carry a list or multiple chips' worth of data. - **REQ-007** (Unwanted-behavior) — If the resolved root tag has no `color`, then `rootTagColor` shall be null and the frontend shall render a neutral (uncolored) chip carrying the name, never a broken `var(--c-tag-)` reference. ### Frontend — chip render - **REQ-008** (Event-driven) — When a `LetterCard` entry has a non-null `rootTagName`, the card shall render a single tag chip matching §3 `.chip` (a small rounded pill: a colored square marker + the tag name) beneath the sender→receiver/date line. - **REQ-008a** (State-driven, responsive) — While the viewport is narrow (down to 320px), the chip shall wrap onto its own line beneath the meta line rather than overflow the card, and a long tag name (e.g. "Briefe von der Front") shall truncate with an ellipsis past a sensible max width — so the chip never forces horizontal scroll alongside #833's ✉/connector chrome. The full name remains available as the chip's accessible name / `title`. - **REQ-009** (State-driven) — While `rootTagColor` is non-null, the chip's color shall be applied via `var(--c-tag-{rootTagColor})` (matching `TagInput.svelte`), inheriting the existing light/dark tag palette; no raw hex in the component. - **REQ-010** (Ubiquitous) — The chip shall render `rootTagName` via Svelte `{...}` escaping (curator/import-derived text); `{@html}` shall never appear in the chip. - **REQ-011** (Ubiquitous, a11y) — The colored square shall be decorative (`aria-hidden="true"`); the tag name is the visible+accessible label, prefixed for screen readers by an sr-only theme label (`timeline_tag_chip_label`, e.g. "Thema") so color is never the only cue (WCAG 1.4.1) and contrast holds in both themes (WCAG 1.4.3). - **REQ-012** (Ubiquitous) — Because the chip lives on `LetterCard`, it shall render wherever a `LetterCard` renders: the global timeline, the expanded view of a dense `YearLetterStrip`, and (when it exists) the per-person Lebensweg rail — with no per-surface special-casing. ### i18n & security - **REQ-013** (Ubiquitous) — The sr-only theme label shall be a Paraglide key present in `frontend/messages/{de,en,es}.json` with matching key sets; the tag name itself is rendered as data, not translated. - **REQ-014** (Ubiquitous) — `GET /api/timeline` remains read-only (`READ_ALL`); no new endpoint, mutation, or `ErrorCode`; the enriched DTO exposes only ids/name/token a `READ_ALL` reader can already see (no new IDOR surface), and the assembly path logs UUIDs only, never tag names (§2.7). ## Acceptance Criteria (measurable) - **REQ-001/002** — Regenerated `api.ts` shows `rootTagId?`, `rootTagName?`, `rootTagColor?` on the entry type (all optional); a non-letter entry serializes them null/absent; a `@DataJpaTest`/service test asserts no `Tag` entity graph is serialized. - **REQ-003** — A letter tagged `Krieg/Briefe von der Front` (root `Krieg`) and `Allgemein` resolves `rootTagName="Krieg"` when "Briefe von der Front"-rooted-`Krieg` sorts before/after per the alphabetically-first-tag rule — test pins the deterministic choice for a known multi-tag set. - **REQ-004** — A timeline with N letters across M distinct tags resolves roots in ≤ (M + constant) tag queries, not N (asserted via repository call count / a batched-pass test); `rootTagColor` equals the root tag's stored token. - **REQ-005** — An untagged letter has all three fields null; its `LetterCard` renders **no** chip element. - **REQ-006** — A letter with ≥2 tags yields exactly one `rootTagId`; the card renders exactly one chip. - **REQ-007** — A letter whose root tag has `color == null` renders a neutral chip with the name and no `--c-tag-` style binding (no `var(--c-tag-)` with an empty token in the DOM). - **REQ-008** — A `LetterCard` with `rootTagName="Familie"` renders one chip containing "Familie" beneath the meta line (`*.svelte.spec.ts`). - **REQ-008a** — A `LetterCard` rendered at a 320px-wide viewport with a long `rootTagName` shows no horizontal overflow (the card's `scrollWidth` ≤ its `clientWidth`); the chip's full name is reachable via its accessible name / `title`. - **REQ-009** — A chip with `rootTagColor="sienna"` has an element whose inline style references `var(--c-tag-sienna)`; the REQ-013-style hex grep over the component is clean. - **REQ-010** — A `rootTagName` of `<img src=x onerror=alert(1)>` renders as inert text; `grep -rn '@html' frontend/src/lib/timeline/` returns zero. - **REQ-011** — The color square has `aria-hidden="true"`; the chip exposes an accessible name containing the sr-only label + tag name; axe reports zero violations on a card-with-chip in light and dark. - **REQ-012** — The chip appears on a `LetterCard` rendered standalone, inside an expanded `YearLetterStrip`, and (smoke) does not error when `personId` is set. - **REQ-013** — A key-parity test confirms `timeline_tag_chip_label` exists in de/en/es. - **REQ-014** — `page.server.test.ts` / endpoint test confirms no new permission or error path; a log-capture test (or review) confirms no tag name is logged. - **E2E smoke (non-blocking for this thin slice)** — A Playwright step loads `/zeitstrahl` in Datum mode against seed data and asserts at least one letter card renders a `.chip` with a tag name; treated as a smoke check, not a gate, given the component/axe/strip tests above carry the behavioral coverage. ## Component touch-points | Layer | File | Change | |---|---|---| | Backend | `TimelineService` (+ its entry-mapping) | Resolve primary root tag (id/name/color) per letter, batched, via `TagService` (REQ-001/003/004). | | Backend | `TagService` | Add a **new** public batched root-resolution method (none exists today — `resolveEffectiveColors(...)` + `TagRepository.findAncestorIds` exist, but nothing returns root `(id, name, color)` for a set of tags) wrapping `TagRepository.findAncestorIds`, returning root id+name+color (shared with #827). This method is the **first red test** of the slice (REQ-004). | | Backend | `TimelineEntryDTO` | Add 3 nullable fields (REQ-001/002). | | Frontend | `lib/timeline/LetterCard.svelte` | Render the chip (REQ-008..011). **Note:** #833 also edits `LetterCard` (✉ glyph, connector dot) — coordinate / rebase (see Coordination). | | Frontend (maybe) | `lib/timeline/TagChip` or reuse `lib/tag` chip | Evaluate reusing an existing tag-chip primitive vs. a small timeline-local chip; per `frontend/eslint.config.js`, `lib/timeline` may import `{shared, person, document}` but **not** `lib/tag` — so reuse means promoting a chip to `$lib/shared`, otherwise a small timeline-local chip. | | i18n | `frontend/messages/{de,en,es}.json` | `timeline_tag_chip_label` (de "Thema" / en "Topic" / es "Tema"). | ## i18n keys (draft) | Key | de | en | es | |---|---|---|---| | `timeline_tag_chip_label` | Thema | Topic | Tema | ## Coordination - **#833** edits the same `LetterCard.svelte` (✉ title glyph, connector dot, sr-only label). Land #833 first and rebase this chip onto it, or sequence them deliberately, so the chip sits correctly relative to #833's new card chrome (the §3 chip sits **beneath** the sender→receiver/date line). - **#827** consumes these DTO fields for Thema bucketing — descope #827's REQ-005 to depend on this issue (see *Relationship to #827*). - **#783** (a11y pass) owns the dark-mode token grep gate; the chip must pass it (REQ-009 uses `var(--c-tag-*)`, no hex). ## Data Model Changes **None.** `Tag.color` already exists; the primary root tag is resolved at read time. No Flyway migration (next free number not consumed). ## Security Considerations Read-only on the family-wide `READ_ALL` archive. STRIDE: only **Information disclosure** (DTO exposes a tag id/name/token the reader already sees — no new scoping/IDOR) and **Tampering→XSS** (curator/import tag name rendered via `{...}`, never `{@html}` — REQ-010), both mitigated. No mutating endpoint ⇒ no new authn/authz `If` clause; the Unwanted-behavior branches (REQ-005/006/007) are render/resolution guards, not access guards. No PII (tag names) in logs (REQ-014). ## Pre-Implementation Artifacts (commit with the code) - [ ] **RTM rows** — mirror REQ-001..014 into `.specify/rtm.md` (feature `zeitstrahl-tag-chips`, this issue #, Status=Planned) in the first implementation commit. - [ ] **Regenerated `api.ts`** committed in the implementation PR. - [ ] No ADR (additive DTO fields; the `TimelineService → TagService` edge is constitution-§1.3-compliant cross-domain access, not a new pattern). If #827's ADR-044 lands first and already records the `TimelineService → TagService` edge, reference it. ## Open Decisions Concrete calls the spec has made so it stays buildable — confirm or override before/at implementation. - **Chip component placement — Decided: a small timeline-local chip** (alternatives: promote a shared chip from `lib/tag` to `$lib/shared`; import `lib/tag` directly — **not** allowed: per `frontend/eslint.config.js`, `lib/timeline` may import `{shared, person, document}` but **not** `lib/tag`). Rationale: KISS / constitution §3.2 — a one-off colored pill doesn't justify promoting a shared primitive. Override to the shared-chip route only if `lib/tag`'s chip is trivially extractable to `$lib/shared` without dragging tag-domain deps. - **Primary-tag rule — Decided: the primary tag is the root ancestor of the letter's alphabetically-first tag name**, verbatim with #827's Resolved Decision 3, so Datum chips and Thema buckets agree (REQ-003). Confirm this still matches #827's wording when this issue is picked up; if #827 has since revised the rule, update REQ-003 to track it. _Follow-up to #779/#833; foundation for #827. Hardened via `/review-issue` (round 1: all six personas APPROVE, no blocking FAILs) — see `## Open Decisions` for the calls to confirm at implementation._
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-14 10:11:43 +02:00
marcel added the P2-mediumfeatureui labels 2026-06-14 10:12:01 +02:00
Author
Owner

Implemented — PR #838 (branch feat/issue-835-zeitstrahl-tag-chip)

Built via red→green TDD, one atomic commit per logical change. All 14 requirements have a passing test and are marked Done in .specify/rtm.md.

Open Decision confirmed with maintainer: primary tag = root ancestor of the letter's alphabetically-first assigned tag (matches #827 Resolved Decision 3 verbatim). Chip placement = a small timeline-local chip (as decided).

Commits

Commit Scope
0be0a524 feat(tag) — batched TagService.resolveRootTags + RootTag record
1114676a feat(timeline)TimelineEntryDTO root-tag fields + batched wiring
d33c1e52 chore(api) — regenerate api.ts (optional rootTag*?)
90e2b4d6 feat(i18n)timeline_tag_chip_label (de/en/es)
c19d4be3 feat(timeline)TagChip.svelte component
8376a520 feat(timeline) — render chip on LetterCard
bbf2f96e docs(timeline) — TagChip comment clears the raw-HTML grep gate
4859c779 docs(rtm) — trace REQ-001..014

REQ → test

  • REQ-001/002/005/006TimelineServiceTest (letter_with_tags_carries_its_primary_root_tag, untagged_letter_has_no_root_tag_fields)
  • REQ-003/004/007TagServiceTest (resolveRootTags_walksChildToRoot_withRootColor, …_memoizesPerDistinctTag_noNPlusOne, …_returnsNullColor_…) + TagServiceIntegrationTest (real-Postgres CTE walk) + TimelineServiceTest#root_tags_resolved_in_a_single_batched_pass
  • REQ-008/008a/009/010/011TagChip.svelte.spec.ts + LetterCard.svelte.spec.ts (incl. 320px no-overflow)
  • REQ-012LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too
  • REQ-013messages.spec.ts (de/en/es key parity)
  • REQ-014 → unchanged READ_ALL path (TimelineControllerTest); no new endpoint/ErrorCode; logs UUIDs only

Gates

Backend tests green (TagService 52 + integration 2, Timeline 62); mvnw clean package -DskipTests OK. Frontend specs green (TagChip 6, LetterCard 14, messages 7, + TimelineView/YearBand/YearLetterStrip 32); svelte-check adds no errors in touched files; @html + dark-mode hex grep gates clean.

No migration, no new endpoint, no ADR (the TimelineService → TagService edge is constitution-§1.3-compliant cross-domain access). Follow-up: #827 can now descope its REQ-005 to consume these fields and add only linkedEventId.

Review with /review-pr 838 when ready.

## Implemented — PR #838 (branch `feat/issue-835-zeitstrahl-tag-chip`) Built via red→green TDD, one atomic commit per logical change. All 14 requirements have a passing test and are marked `Done` in `.specify/rtm.md`. **Open Decision confirmed with maintainer:** primary tag = root ancestor of the letter's **alphabetically-first assigned tag** (matches #827 Resolved Decision 3 verbatim). Chip placement = a small **timeline-local** chip (as decided). ### Commits | Commit | Scope | |---|---| | `0be0a524` | `feat(tag)` — batched `TagService.resolveRootTags` + `RootTag` record | | `1114676a` | `feat(timeline)` — `TimelineEntryDTO` root-tag fields + batched wiring | | `d33c1e52` | `chore(api)` — regenerate `api.ts` (optional `rootTag*?`) | | `90e2b4d6` | `feat(i18n)` — `timeline_tag_chip_label` (de/en/es) | | `c19d4be3` | `feat(timeline)` — `TagChip.svelte` component | | `8376a520` | `feat(timeline)` — render chip on `LetterCard` | | `bbf2f96e` | `docs(timeline)` — TagChip comment clears the raw-HTML grep gate | | `4859c779` | `docs(rtm)` — trace REQ-001..014 | ### REQ → test - **REQ-001/002/005/006** → `TimelineServiceTest` (`letter_with_tags_carries_its_primary_root_tag`, `untagged_letter_has_no_root_tag_fields`) - **REQ-003/004/007** → `TagServiceTest` (`resolveRootTags_walksChildToRoot_withRootColor`, `…_memoizesPerDistinctTag_noNPlusOne`, `…_returnsNullColor_…`) + `TagServiceIntegrationTest` (real-Postgres CTE walk) + `TimelineServiceTest#root_tags_resolved_in_a_single_batched_pass` - **REQ-008/008a/009/010/011** → `TagChip.svelte.spec.ts` + `LetterCard.svelte.spec.ts` (incl. 320px no-overflow) - **REQ-012** → `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` - **REQ-013** → `messages.spec.ts` (de/en/es key parity) - **REQ-014** → unchanged `READ_ALL` path (`TimelineControllerTest`); no new endpoint/ErrorCode; logs UUIDs only ### Gates Backend tests green (TagService 52 + integration 2, Timeline 62); `mvnw clean package -DskipTests` OK. Frontend specs green (TagChip 6, LetterCard 14, messages 7, + TimelineView/YearBand/YearLetterStrip 32); `svelte-check` adds no errors in touched files; `@html` + dark-mode hex grep gates clean. No migration, no new endpoint, no ADR (the `TimelineService → TagService` edge is constitution-§1.3-compliant cross-domain access). Follow-up: **#827** can now descope its REQ-005 to *consume* these fields and add only `linkedEventId`. Review with `/review-pr 838` when ready.
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#835