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>
This commit is contained in:
59
frontend/src/lib/timeline/BucketHeaderChip.svelte
Normal file
59
frontend/src/lib/timeline/BucketHeaderChip.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header chip of a Thema-mode root-tag bucket (#827, REQ-015): a *fully-tinted* chip whose
|
||||||
|
* fill and label both derive from the root tag's `--c-tag-*` colour token — distinct from the
|
||||||
|
* neutral per-letter {@link TagChip} (a surface pill with a tiny colour square). The label uses
|
||||||
|
* the saturated token as text over a subtle `color-mix` wash of the same token, so the ≥4.5:1
|
||||||
|
* label contrast holds in both light and dark themes. A `null` colour — or any value outside the
|
||||||
|
* known token set (the §2 `krieg`/`weih`/`fam` are demo class names, not tokens) — falls back to a
|
||||||
|
* neutral chip with no `var(--c-tag-)` reference, never a broken colour. The name is
|
||||||
|
* curator/import-derived and rendered through default `{...}` escaping, never `{@html}` (REQ-009).
|
||||||
|
*/
|
||||||
|
const TAG_COLORS = new Set([
|
||||||
|
'sage',
|
||||||
|
'sienna',
|
||||||
|
'amber',
|
||||||
|
'slate',
|
||||||
|
'violet',
|
||||||
|
'rose',
|
||||||
|
'cobalt',
|
||||||
|
'moss',
|
||||||
|
'sand',
|
||||||
|
'coral'
|
||||||
|
]);
|
||||||
|
|
||||||
|
let { name, color }: { name: string; color: string | null } = $props();
|
||||||
|
|
||||||
|
const token = $derived(color && TAG_COLORS.has(color) ? color : null);
|
||||||
|
const chipStyle = $derived(
|
||||||
|
token
|
||||||
|
? `background-color: color-mix(in srgb, var(--c-tag-${token}) 14%, transparent); color: var(--c-tag-${token})`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
const dotStyle = $derived(token ? `background-color: var(--c-tag-${token})` : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-testid="bucket-header-chip"
|
||||||
|
title={name}
|
||||||
|
style={chipStyle}
|
||||||
|
class="inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-0.5 font-sans text-xs font-semibold"
|
||||||
|
class:border={!token}
|
||||||
|
class:border-line={!token}
|
||||||
|
class:bg-surface={!token}
|
||||||
|
class:text-ink-3={!token}
|
||||||
|
>
|
||||||
|
<span class="sr-only">{m.timeline_tag_chip_label()}: </span>
|
||||||
|
<span
|
||||||
|
data-testid="bucket-header-chip-dot"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={dotStyle}
|
||||||
|
class="inline-block h-2 w-2 flex-shrink-0 rounded-sm"
|
||||||
|
class:bg-ink-3={!token}
|
||||||
|
></span>
|
||||||
|
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0"
|
||||||
|
>{name}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
44
frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts
Normal file
44
frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('BucketHeaderChip (REQ-015/009)', () => {
|
||||||
|
it('renders the root-tag name', () => {
|
||||||
|
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
|
||||||
|
expect(document.body.textContent).toContain('Krieg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tints the chip with var(--c-tag-{token}) for a known colour token (REQ-015)', () => {
|
||||||
|
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
|
||||||
|
const chip = document.querySelector('[data-testid="bucket-header-chip"]') as HTMLElement;
|
||||||
|
expect(chip.getAttribute('style')).toContain('var(--c-tag-sienna)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a neutral chip with no --c-tag- binding when colour is null (REQ-015)', () => {
|
||||||
|
render(BucketHeaderChip, { name: 'Ohne Thema', color: null });
|
||||||
|
expect(document.body.textContent).toContain('Ohne Thema');
|
||||||
|
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to neutral for an unknown colour token, never a broken var (REQ-015)', () => {
|
||||||
|
// "krieg" is a §2 demo class name, not a real --c-tag-* token.
|
||||||
|
render(BucketHeaderChip, { name: 'Krieg', color: 'krieg' });
|
||||||
|
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefixes the name with an sr-only theme label so colour is never the only cue', () => {
|
||||||
|
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
|
||||||
|
const srOnly = document.querySelector('.sr-only');
|
||||||
|
expect(srOnly?.textContent).toContain(m.timeline_tag_chip_label());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an HTML-bearing name as inert text, never markup (REQ-009)', () => {
|
||||||
|
const evil = '<img src=x onerror="alert(1)">';
|
||||||
|
render(BucketHeaderChip, { name: evil, color: null });
|
||||||
|
expect(document.body.textContent).toContain(evil);
|
||||||
|
expect(document.querySelector('img')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user