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
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:
@@ -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 */
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
41
frontend/src/lib/timeline/TagChip.svelte
Normal file
41
frontend/src/lib/timeline/TagChip.svelte
Normal 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>
|
||||
46
frontend/src/lib/timeline/TagChip.svelte.spec.ts
Normal file
46
frontend/src/lib/timeline/TagChip.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user