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