Merge remote-tracking branch 'origin/main' into feat/issue-837-relationship-edit-dates
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m2s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m8s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
SDD Gate / RTM Check (pull_request) Successful in 16s

# Conflicts:
#	.specify/rtm.md
This commit is contained in:
Marcel
2026-06-14 19:47:26 +02:00
18 changed files with 534 additions and 12 deletions

View File

@@ -2463,6 +2463,10 @@ export interface components {
linkedPersonIds?: string[];
/** @enum {string} */
derivedType?: "BIRTH" | "DEATH" | "MARRIAGE";
/** Format: uuid */
rootTagId?: string;
rootTagName?: string;
rootTagColor?: string;
};
TimelineYearDTO: {
/** Format: int32 */

View File

@@ -90,4 +90,12 @@ describe('message key parity', () => {
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
});
// #835 REQ-013: the letter chip's sr-only theme label is a Paraglide key in every
// locale so color is never the only cue; the tag NAME is rendered as data, not translated.
it('zeitstrahl tag-chip label key is present in all locales (#835 REQ-013)', () => {
expect(de).toMatchObject({ timeline_tag_chip_label: 'Thema' });
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
});
});

View File

@@ -2,6 +2,7 @@
import * as m from '$lib/paraglide/messages.js';
import { timelineDateLabel } from './dateLabel';
import GlyphLabel from './GlyphLabel.svelte';
import TagChip from './TagChip.svelte';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
@@ -46,4 +47,9 @@ const receiver = $derived(
<span data-testid="letter-date"> · {dateLabel}</span>
{/if}
</span>
{#if entry.rootTagName}
<!-- The primary root-tag chip sits on its own line beneath the meta line
(#835 §3); absent when the letter has no tag (REQ-005). -->
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
{/if}
</a>

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import { timelineDateLabel } from './dateLabel';
import { makeEntry } from './test-factories';
@@ -86,4 +88,42 @@ describe('LetterCard', () => {
expect(document.body.textContent).toContain(evil);
expect(document.querySelector('a script')).toBeNull();
});
it('renders one root-tag chip beneath the meta line when rootTagName is present (REQ-008)', () => {
render(LetterCard, { entry: makeEntry({ rootTagName: 'Familie', rootTagColor: 'sage' }) });
const chips = document.querySelectorAll('[data-testid="tag-chip"]');
expect(chips).toHaveLength(1);
expect(chips[0].textContent).toContain('Familie');
});
it('renders no chip when the letter has no root tag (REQ-005/006)', () => {
render(LetterCard, { entry: makeEntry({ rootTagName: undefined, rootTagColor: undefined }) });
expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
});
it('keeps a long tag name from overflowing the card at 320px, full name in the title (REQ-008a)', () => {
document.body.style.width = '320px';
render(LetterCard, {
entry: makeEntry({
rootTagName: 'Briefe von der Front und aus der Heimat',
rootTagColor: 'sienna'
})
});
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.scrollWidth).toBeLessThanOrEqual(link.clientWidth);
const chip = document.querySelector('[data-testid="tag-chip"]') as HTMLElement;
expect(chip.getAttribute('title')).toBe('Briefe von der Front und aus der Heimat');
document.body.style.width = '';
});
it('renders the chip inside an expanded YearLetterStrip too (REQ-012)', async () => {
render(YearLetterStrip, {
letters: [makeEntry({ rootTagName: 'Familie', rootTagColor: 'sage', documentId: 'doc-1' })],
year: 1909
});
(document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement).click();
await tick();
const chip = document.querySelector('[data-testid="tag-chip"]');
expect(chip?.textContent).toContain('Familie');
});
});

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
/**
* A single root-tag chip on a timeline letter card (§3 of the Zeitstrahl spec): a
* decorative colored square marker plus the tag name, prefixed for screen readers by
* an sr-only theme label so color is never the only cue (WCAG 1.4.1, REQ-011). The
* name is curator/import-derived and rendered via default `{...}` escaping — never the
* raw-HTML directive (REQ-010). `color` is a `--c-tag-*` token or null; a null color
* renders a neutral marker with no `var(--c-tag-)` reference (REQ-007). Truncation is set inline
* (not via a utility class) so a long name ellipsizes even before the stylesheet loads,
* keeping the card free of horizontal overflow at 320px (REQ-008a).
*/
let { name, color }: { name: string; color: string | null } = $props();
const squareStyle = $derived(color ? `background-color: var(--c-tag-${color})` : '');
</script>
<span
data-testid="tag-chip"
title={name}
style="display: inline-flex; align-items: center; gap: 4px; max-width: 100%; min-width: 0"
class="mt-1 self-start rounded-full border border-line bg-surface px-2 py-0.5"
>
<span class="sr-only">{m.timeline_tag_chip_label()}: </span>
<!-- One marker for both states: a set color paints it via squareStyle; a null color leaves
squareStyle empty and falls back to the neutral bg-ink-3 (no var(--c-tag-) reference). -->
<span
data-testid="tag-chip-square"
aria-hidden="true"
style={squareStyle}
class="inline-block h-2 w-2 flex-shrink-0 rounded-sm"
class:bg-ink-3={!color}
></span>
<!-- Overflow/ellipsis set inline (not via Tailwind's `truncate`) so the name ellipsizes even
before the stylesheet loads, keeping the card overflow-free at 320px (REQ-008a). -->
<span
style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0"
class="font-sans text-[11px] text-ink-2">{name}</span
>
</span>

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import TagChip from './TagChip.svelte';
afterEach(() => cleanup());
describe('TagChip', () => {
it('renders the tag name (REQ-008)', () => {
render(TagChip, { name: 'Familie', color: 'sage' });
expect(document.body.textContent).toContain('Familie');
});
it('prefixes the name with an sr-only theme label and a decorative square (REQ-011)', () => {
render(TagChip, { name: 'Krieg', color: 'sienna' });
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toContain(m.timeline_tag_chip_label());
const square = document.querySelector('[data-testid="tag-chip-square"]');
expect(square?.getAttribute('aria-hidden')).toBe('true');
});
it('applies the color via var(--c-tag-{token}), never raw hex (REQ-009)', () => {
render(TagChip, { name: 'Krieg', color: 'sienna' });
const square = document.querySelector('[data-testid="tag-chip-square"]') as HTMLElement;
expect(square.getAttribute('style')).toContain('var(--c-tag-sienna)');
});
it('renders a neutral chip with no --c-tag- binding when color is null (REQ-007)', () => {
render(TagChip, { name: 'Allgemein', color: null });
expect(document.body.textContent).toContain('Allgemein');
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
});
it('exposes the full name as the chip title so a truncated name stays reachable (REQ-008a)', () => {
render(TagChip, { name: 'Briefe von der Front', color: 'sienna' });
const chip = document.querySelector('[data-testid="tag-chip"]') as HTMLElement;
expect(chip.getAttribute('title')).toBe('Briefe von der Front');
});
it('renders an HTML-bearing name as inert text, never markup (REQ-010)', () => {
const evil = '<img src=x onerror="alert(1)">';
render(TagChip, { name: evil, color: null });
expect(document.body.textContent).toContain(evil);
expect(document.querySelector('img')).toBeNull();
});
});