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
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
/zeitstrahlcarrying 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, becauseTimelineEntryDTOcarries 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 onLetterCard.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):TimelineServiceresolves the root tag through the tag domain's service (TagService), neverTagRepositorydirectly.Tag/Documententity graph is serialized — id + name + color token only.{...}escaping; never{@html}.@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)Taghas acolorfield holding a token name (one of the 10 inTagService.ALLOWED_TAG_COLORS:sage, sienna, amber, slate, violet, rose, cobalt, moss, sand, coral), not a hex value.TagService.resolveEffectiveColors(...).style="background-color: var(--c-tag-{token})"(seeTagInput.svelte:135); the--c-tag-*tokens are defined inlayout.csswith 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 tovar(--c-tag-{token}). Since the primary tag we render is the root tag, itscoloris 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
TimelineEntryDTO(LETTER entries) with the primary root tag —rootTagId,rootTagName,rootTagColor(token) — assembled in-transaction inTimelineService, resolved viaTagService, batched (no N+1). No new table, no migration.LetterCard(so it appears in the global timeline, the dense-strip expansion, and — when it exists — the per-person Lebensweg rail), matching the §3.chipstyling, colored via--c-tag-{token}, with an sr-only theme label.api.tsafter the DTO change.Out of Scope (explicit boundaries)
.lcard.evevent-letter variant (mint-bordered serif card) → #827.LetterCardonly.Relationship to #827 (read before implementing)
#827 (Ereignis/Thema regroup) currently plans to add
rootTagId+rootTagNametoTimelineEntryDTOitself (its REQ-005) for Thema bucketing. To avoid two issues adding the same fields:rootTagId,rootTagName,rootTagColor) and the batched resolution inTimelineService. It is the smaller, lower-risk slice and ships first.TagServiceedge, keeping onlylinkedEventIdfor 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.rootTagColorand 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)
TimelineEntryDTOgains three nullable fields onLETTERentries (record grows 13 → 16):No new endpoint, no mutating path.
GET /api/timelinestays@RequirePermission(READ_ALL). Runnpm run generate:apiand commit the regeneratedapi.tsin the same PR.Requirements (EARS)
Backend — DTO & resolution
TimelineEntryDTOshall carry, forLETTERentries,rootTagId(UUID),rootTagName(String), androotTagColor(String token), assembled in-transaction inTimelineService(ADR-036) — id + name + token only, never a serializedTagentity.@Schema(requiredMode = REQUIRED)(they are null for non-letter entries and for letters with no tag).LETTERentry,TimelineServiceshall resolve its primary tag as the root ancestor of the letter's alphabetically-first tag name (reusing #827's Resolved Decision 3 rule), viaTagService(constitution §1.3), neverTagRepositorydirectly.TagRepository.findAncestorIdsthrough aTagServicemethod;rootTagColoris read from the resolved root tag's storedcolor.Backend — degenerate cases
rootTagId,rootTagName, androotTagColorshall all be null (the chip is then absent on render — never a placeholder).color, thenrootTagColorshall be null and the frontend shall render a neutral (uncolored) chip carrying the name, never a brokenvar(--c-tag-)reference.Frontend — chip render
LetterCardentry has a non-nullrootTagName, 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.title.rootTagColoris non-null, the chip's color shall be applied viavar(--c-tag-{rootTagColor})(matchingTagInput.svelte), inheriting the existing light/dark tag palette; no raw hex in the component.rootTagNamevia Svelte{...}escaping (curator/import-derived text);{@html}shall never appear in the chip.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).LetterCard, it shall render wherever aLetterCardrenders: the global timeline, the expanded view of a denseYearLetterStrip, and (when it exists) the per-person Lebensweg rail — with no per-surface special-casing.i18n & security
frontend/messages/{de,en,es}.jsonwith matching key sets; the tag name itself is rendered as data, not translated.GET /api/timelineremains read-only (READ_ALL); no new endpoint, mutation, orErrorCode; the enriched DTO exposes only ids/name/token aREAD_ALLreader can already see (no new IDOR surface), and the assembly path logs UUIDs only, never tag names (§2.7).Acceptance Criteria (measurable)
api.tsshowsrootTagId?,rootTagName?,rootTagColor?on the entry type (all optional); a non-letter entry serializes them null/absent; a@DataJpaTest/service test asserts noTagentity graph is serialized.Krieg/Briefe von der Front(rootKrieg) andAllgemeinresolvesrootTagName="Krieg"when "Briefe von der Front"-rooted-Kriegsorts before/after per the alphabetically-first-tag rule — test pins the deterministic choice for a known multi-tag set.rootTagColorequals the root tag's stored token.LetterCardrenders no chip element.rootTagId; the card renders exactly one chip.color == nullrenders a neutral chip with the name and no--c-tag-style binding (novar(--c-tag-)with an empty token in the DOM).LetterCardwithrootTagName="Familie"renders one chip containing "Familie" beneath the meta line (*.svelte.spec.ts).LetterCardrendered at a 320px-wide viewport with a longrootTagNameshows no horizontal overflow (the card'sscrollWidth≤ itsclientWidth); the chip's full name is reachable via its accessible name /title.rootTagColor="sienna"has an element whose inline style referencesvar(--c-tag-sienna); the REQ-013-style hex grep over the component is clean.rootTagNameof<img src=x onerror=alert(1)>renders as inert text;grep -rn '@html' frontend/src/lib/timeline/returns zero.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.LetterCardrendered standalone, inside an expandedYearLetterStrip, and (smoke) does not error whenpersonIdis set.timeline_tag_chip_labelexists in de/en/es.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./zeitstrahlin Datum mode against seed data and asserts at least one letter card renders a.chipwith 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
TimelineService(+ its entry-mapping)TagService(REQ-001/003/004).TagServiceresolveEffectiveColors(...)+TagRepository.findAncestorIdsexist, but nothing returns root(id, name, color)for a set of tags) wrappingTagRepository.findAncestorIds, returning root id+name+color (shared with #827). This method is the first red test of the slice (REQ-004).TimelineEntryDTOlib/timeline/LetterCard.svelteLetterCard(✉ glyph, connector dot) — coordinate / rebase (see Coordination).lib/timeline/TagChipor reuselib/tagchipfrontend/eslint.config.js,lib/timelinemay import{shared, person, document}but notlib/tag— so reuse means promoting a chip to$lib/shared, otherwise a small timeline-local chip.frontend/messages/{de,en,es}.jsontimeline_tag_chip_label(de "Thema" / en "Topic" / es "Tema").i18n keys (draft)
timeline_tag_chip_labelCoordination
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).var(--c-tag-*), no hex).Data Model Changes
None.
Tag.coloralready 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_ALLarchive. 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/authzIfclause; 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)
.specify/rtm.md(featurezeitstrahl-tag-chips, this issue #, Status=Planned) in the first implementation commit.api.tscommitted in the implementation PR.TimelineService → TagServiceedge is constitution-§1.3-compliant cross-domain access, not a new pattern). If #827's ADR-044 lands first and already records theTimelineService → TagServiceedge, reference it.Open Decisions
Concrete calls the spec has made so it stays buildable — confirm or override before/at implementation.
lib/tagto$lib/shared; importlib/tagdirectly — not allowed: perfrontend/eslint.config.js,lib/timelinemay import{shared, person, document}but notlib/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 iflib/tag's chip is trivially extractable to$lib/sharedwithout dragging tag-domain deps.Follow-up to #779/#833; foundation for #827. Hardened via
/review-issue(round 1: all six personas APPROVE, no blocking FAILs) — see## Open Decisionsfor the calls to confirm at implementation.marcel referenced this issue2026-06-14 11:32:55 +02:00
marcel referenced this issue2026-06-14 11:33:17 +02:00
marcel referenced this issue2026-06-14 11:33:48 +02:00
marcel referenced this issue2026-06-14 11:34:13 +02:00
marcel referenced this issue2026-06-14 11:34:36 +02:00
marcel referenced this issue2026-06-14 11:41:19 +02:00
marcel referenced this issue2026-06-14 11:41:39 +02:00
marcel referenced this issue2026-06-14 11:41:58 +02:00
marcel referenced this issue2026-06-14 11:42:59 +02:00
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
Donein.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
0be0a524feat(tag)— batchedTagService.resolveRootTags+RootTagrecord1114676afeat(timeline)—TimelineEntryDTOroot-tag fields + batched wiringd33c1e52chore(api)— regenerateapi.ts(optionalrootTag*?)90e2b4d6feat(i18n)—timeline_tag_chip_label(de/en/es)c19d4be3feat(timeline)—TagChip.sveltecomponent8376a520feat(timeline)— render chip onLetterCardbbf2f96edocs(timeline)— TagChip comment clears the raw-HTML grep gate4859c779docs(rtm)— trace REQ-001..014REQ → test
TimelineServiceTest(letter_with_tags_carries_its_primary_root_tag,untagged_letter_has_no_root_tag_fields)TagServiceTest(resolveRootTags_walksChildToRoot_withRootColor,…_memoizesPerDistinctTag_noNPlusOne,…_returnsNullColor_…) +TagServiceIntegrationTest(real-Postgres CTE walk) +TimelineServiceTest#root_tags_resolved_in_a_single_batched_passTagChip.svelte.spec.ts+LetterCard.svelte.spec.ts(incl. 320px no-overflow)LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip toomessages.spec.ts(de/en/es key parity)READ_ALLpath (TimelineControllerTest); no new endpoint/ErrorCode; logs UUIDs onlyGates
Backend tests green (TagService 52 + integration 2, Timeline 62);
mvnw clean package -DskipTestsOK. Frontend specs green (TagChip 6, LetterCard 14, messages 7, + TimelineView/YearBand/YearLetterStrip 32);svelte-checkadds no errors in touched files;@html+ dark-mode hex grep gates clean.No migration, no new endpoint, no ADR (the
TimelineService → TagServiceedge is constitution-§1.3-compliant cross-domain access). Follow-up: #827 can now descope its REQ-005 to consume these fields and add onlylinkedEventId.Review with
/review-pr 838when ready.marcel referenced this issue2026-06-14 18:19:09 +02:00
marcel referenced this issue2026-06-14 18:19:37 +02:00
marcel referenced this issue2026-06-14 18:20:13 +02:00
marcel referenced this issue2026-06-14 18:20:37 +02:00
marcel referenced this issue2026-06-14 18:20:58 +02:00
marcel referenced this issue2026-06-14 18:27:35 +02:00
marcel referenced this issue2026-06-14 18:27:57 +02:00
marcel referenced this issue2026-06-14 18:29:17 +02:00