Compare commits

..

4 Commits

Author SHA1 Message Date
Marcel
61116916a8 refactor(person): delete personAvatarColor + djb2 from personFormat.ts
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m43s
CI / OCR Service Tests (pull_request) Successful in 29s
CI / Backend Unit Tests (pull_request) Successful in 6m12s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
Dead code now that all callers use Avatar / avatarFor. Removes the old
5-color id-hashed palette (AVATAR_PALETTE) and its test suite.

Refs #855
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:04:11 +02:00
Marcel
f4d8442760 refactor(shared): migrate all avatar call sites to Avatar primitive (§5)
Replaces ad-hoc color hashing in PersonCard, PersonChip, ContributorStack,
ReaderPersonChips, CoCorrespondentsList, CommentMessage, GeschichteListRow,
StoryReader, DocumentMetadataDrawer, and the Geschichte detail page.

Refs #855
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:03:44 +02:00
Marcel
fe8868d143 feat(shared): add Avatar primitive — sizes 26/28/40/48, stacked, overflow chip (§5)
Refs #855
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:03:07 +02:00
Marcel
9ae84828ea feat(shared): add avatarFor(name) util — deterministic 10-color name-hash (§5)
Refs #855
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:02:38 +02:00
21 changed files with 399 additions and 469 deletions

View File

@@ -2,7 +2,7 @@
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/shared/utils/date';
import { formatDocumentStatus } from '$lib/document/documentStatusLabel';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import RelationshipPill from '$lib/person/relationship/RelationshipPill.svelte';
import DocumentDate from './DocumentDate.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
@@ -88,13 +88,7 @@ function getFullName(person: Person): string {
href="/persons/{person.id}"
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style="background-color: {personAvatarColor(person.id)}"
aria-hidden="true"
>
{getInitials(person.displayName)}
</span>
<Avatar name={person.displayName} size={28} decorative={true} />
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
{#if relationLabel}
<RelationshipPill label={relationLabel} />

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import { avatarFor } from '$lib/shared/avatar';
import { formatAuthorName, formatPublishedAt } from './utils';
import type { components } from '$lib/generated/api';
@@ -26,13 +27,7 @@ const authorName = $derived(formatAuthorName(geschichte.author));
>
<!-- Meta column (desktop) -->
<div class="hidden w-40 shrink-0 flex-col items-start gap-1 border-r border-line-2 p-3 sm:flex">
<span
aria-hidden="true"
class="flex h-7 w-7 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white"
style="background-color: {personAvatarColor(authorName)}"
>
{getInitials(authorName)}
</span>
<Avatar name={authorName} size={28} decorative={true} />
<span class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</span>
{#if publishedAt}
<span class="font-sans text-sm text-ink-3">{publishedAt}</span>
@@ -63,7 +58,7 @@ const authorName = $derived(formatAuthorName(geschichte.author));
<span
aria-hidden="true"
class="h-2.5 w-2.5 shrink-0 rounded-full"
style="background-color: {personAvatarColor(authorName)}"
style="background-color: {avatarFor(authorName).bg}"
></span>
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
{#if isDraft}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { safeHtml } from '$lib/shared/utils/sanitize';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import { formatDocumentMetaLine } from './utils';
import type { components } from '$lib/generated/api';
@@ -51,13 +51,7 @@ function personName(p: { firstName?: string; lastName?: string }): string {
style="display: inline-flex; min-height: 44px"
class="inline-flex min-h-[44px] items-center gap-2 rounded-full border border-line bg-surface px-3 py-1.5 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<span
aria-hidden="true"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-sans text-[8px] font-bold text-white"
style="background-color: {personAvatarColor(p.id)}"
>
{getInitials(personName(p))}
</span>
<Avatar name={personName(p)} size={26} decorative={true} />
{personName(p)}
</a>
</li>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PersonTypeBadge from '$lib/person/PersonTypeBadge.svelte';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['PersonSummaryDTO'];
@@ -25,12 +26,6 @@ const showGlyph = $derived(
isUnconfirmed || hasNoName || (person.personType != null && person.personType !== 'PERSON')
);
const initials = $derived.by(() => {
const first = person.firstName?.[0] ?? '';
const last = person.lastName?.[0] ?? '';
return first ? first + last : last;
});
const documentCount = $derived(person.documentCount ?? 0);
</script>
@@ -38,15 +33,13 @@ const documentCount = $derived(person.documentCount ?? 0);
<div
class="flex h-full flex-col items-center gap-2 rounded border border-line bg-surface px-4 py-6 text-center shadow-sm transition-all duration-200 hover:border-l-4 hover:border-accent hover:shadow-md"
>
<!-- Avatar: confirmed persons get a primary-coloured initials disc; institutions/groups
and unconfirmed entries get a neutral, muted glyph so "unverified" is pre-attentive. -->
<div
class={[
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full font-serif text-base font-bold transition-colors',
isUnconfirmed || hasNoName ? 'bg-muted text-ink-2' : 'bg-primary text-primary-fg'
]}
>
{#if showGlyph}
<!-- Avatar: confirmed persons get a deterministic name-hashed Avatar disc;
institutions/groups and unconfirmed entries get a neutral, muted glyph
so "unverified" is pre-attentive. -->
{#if showGlyph}
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-muted text-ink-2 transition-colors"
>
{#if person.personType === 'INSTITUTION'}
<svg
class="h-5 w-5"
@@ -84,10 +77,11 @@ const documentCount = $derived(person.documentCount ?? 0);
class="h-6 w-6 opacity-70"
/>
{/if}
{:else}
{initials}
{/if}
</div>
</div>
{:else}
<!-- Confirmed person — deterministic name-hash Avatar (DESIGN_RULES §5) -->
<Avatar name={person.displayName} size={48} decorative={true} />
{/if}
<!-- Name -->
<p class="font-serif text-sm font-bold text-ink group-hover:underline">

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { abbreviateName, getInitials, personAvatarColor } from '$lib/person/personFormat';
import { abbreviateName } from '$lib/person/personFormat';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
@@ -11,20 +12,12 @@ type Props = {
let { person, abbreviated }: Props = $props();
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
const avatarColor = $derived(personAvatarColor(person.id));
const initials = $derived(getInitials(person.displayName));
</script>
<a
href="/persons/{person.id}"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-muted px-2 py-0.5 hover:border-primary hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm border border-line bg-muted px-2 py-0.5 hover:border-primary hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
>
<span
class="flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full text-[13px] font-bold text-white"
style="background-color: {avatarColor}"
aria-hidden="true"
>
{initials}
</span>
<Avatar name={person.displayName} size={26} decorative={true} />
<span class="text-[14px] font-semibold text-ink">{name}</span>
</a>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getInitials, abbreviateName, formatXsMeta, personAvatarColor } from './personFormat';
import { getInitials, abbreviateName, formatXsMeta } from './personFormat';
import { formatDate } from '$lib/shared/utils/date';
// ─── getInitials ─────────────────────────────────────────────────────────────
@@ -97,29 +97,6 @@ describe('formatXsMeta', () => {
});
});
// ─── personAvatarColor ───────────────────────────────────────────────────────
const PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'];
describe('personAvatarColor', () => {
it('returns a value from the palette', () => {
expect(PALETTE).toContain(personAvatarColor('abc'));
});
it('is deterministic — same id always returns same color', () => {
const id = '550e8400-e29b-41d4-a716-446655440000';
expect(personAvatarColor(id)).toBe(personAvatarColor(id));
});
it('all 5 palette entries are reachable across 1000 random UUIDs', () => {
const seen = new Set<string>();
for (let i = 0; i < 1000; i++) {
seen.add(personAvatarColor(crypto.randomUUID()));
}
expect(seen.size).toBe(5);
});
});
// ─── formatDate ──────────────────────────────────────────────────────────────
describe('formatDate', () => {

View File

@@ -8,16 +8,6 @@ type DocForMeta = {
documentDate?: string | null;
};
const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const;
function djb2(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return Math.abs(hash);
}
/** Localized fallback when a person has no name parts. */
export function unknownPersonName(): string {
return m.person_unknown();
@@ -83,7 +73,3 @@ export function formatXsMeta(doc: DocForMeta): string {
return parts.join(' · ');
}
export function personAvatarColor(personId: string): string {
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
}

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { avatarFor } from './avatar';
import { AVATAR_PALETTE } from './avatarPalette';
// ─── palette ─────────────────────────────────────────────────────────────────
describe('AVATAR_PALETTE via avatarFor', () => {
it('palette has exactly 10 colors', () => {
expect(AVATAR_PALETTE).toHaveLength(10);
});
});
// ─── avatarFor ────────────────────────────────────────────────────────────────
describe('avatarFor', () => {
it('returns a bg color from the 10-color palette', () => {
const result = avatarFor('Marcel Raddatz');
expect(AVATAR_PALETTE as readonly string[]).toContain(result.bg);
});
it('is deterministic — same name always returns same color', () => {
expect(avatarFor('Karl Müller').bg).toBe(avatarFor('Karl Müller').bg);
expect(avatarFor('Karl Müller').initials).toBe(avatarFor('Karl Müller').initials);
});
it('returns first + last initial for a two-word name', () => {
expect(avatarFor('Anna Raddatz').initials).toBe('AR');
});
it('returns first + last initial for a three-word name (middle name ignored)', () => {
expect(avatarFor('Anna Maria Raddatz').initials).toBe('AR');
});
it('returns single initial for a single-word name', () => {
expect(avatarFor('Raddatz').initials).toBe('R');
});
it('returns empty initials and palette[0] for an empty string (guard)', () => {
const result = avatarFor('');
expect(result.initials).toBe('');
expect(result.bg).toBe(AVATAR_PALETTE[0]);
});
it('does not throw on whitespace-only name', () => {
expect(() => avatarFor(' ')).not.toThrow();
const result = avatarFor(' ');
expect(result.initials).toBe('');
});
it('initials are uppercased', () => {
expect(avatarFor('anna raddatz').initials).toBe('AR');
});
it('all 10 palette entries are reachable across varied names', () => {
const names = [
'Alpha Beta',
'Gamma Delta',
'Epsilon Zeta',
'Eta Theta',
'Iota Kappa',
'Lambda Mu',
'Nu Xi',
'Omicron Pi',
'Rho Sigma',
'Tau Upsilon',
'Phi Chi',
'Psi Omega',
'Anna Schmidt',
'Karl Weber',
'Maria Müller',
'Heinrich Braun',
'Clara Raddatz',
'Ernst Wagner',
'Helene Fischer',
'Otto Klein'
];
const seen = new Set<string>();
for (const n of names) seen.add(avatarFor(n).bg);
// We should hit more than 1 color across 20 varied names
expect(seen.size).toBeGreaterThan(1);
});
it('uses h * 31 hash (name-keyed, not djb2)', () => {
// Verify that the same name produces stable output across calls
// (implementation-agnostic determinism test)
const name = 'Herbert Cram';
const r1 = avatarFor(name);
const r2 = avatarFor(name);
expect(r1.bg).toBe(r2.bg);
expect(r1.initials).toBe(r2.initials);
});
});

View File

@@ -0,0 +1,43 @@
import { AVATAR_PALETTE } from './avatarPalette';
export type AvatarResult = {
bg: string;
initials: string;
};
/**
* Deterministic name-hashed avatar util (DESIGN_RULES §5).
*
* - Hash: `h = (h * 31 + charCode) >>> 0` (unsigned 32-bit, name-keyed)
* - Initials: first char of first token + first char of last token, uppercased
* - Empty name → safe fallback: empty initials, palette[0]
* - Single-token name → one initial
*
* Lives in `$lib/shared/` so Avatar.svelte does not import from any domain.
* Do NOT use this for id-hashing — the deliberate trade-off (stable across
* screens, shifts on rename) is documented in issue #855.
*/
export function avatarFor(name: string): AvatarResult {
const trimmed = name.trim();
// Empty / whitespace-only guard
if (!trimmed) {
return { bg: AVATAR_PALETTE[0], initials: '' };
}
// Hash: h = (h * 31 + charCode) >>> 0
let h = 0;
for (let i = 0; i < trimmed.length; i++) {
h = (h * 31 + trimmed.charCodeAt(i)) >>> 0;
}
const bg = AVATAR_PALETTE[h % AVATAR_PALETTE.length];
// Initials: first + last token initial
const parts = trimmed.split(/\s+/);
const first = parts[0][0] ?? '';
const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? '') : '';
const initials = (first + last).toUpperCase();
return { bg, initials };
}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { DashboardPulseDTO } from '$lib/generated/api';
import Card from '$lib/shared/primitives/Card.svelte';
interface Props {
pulse: DashboardPulseDTO | null;
@@ -11,13 +10,11 @@ const { pulse }: Props = $props();
</script>
{#if pulse !== null}
<!--
Card adoption (issue #858): DashboardFamilyPulse is now rendered inside the
shared Card primitive so it inherits the 3px mint top accent, semantic tokens,
and the section-caption helper. The caption text is the Paraglide key
m.pulse_eyebrow() — adopter responsibility per the safe-rendering contract.
-->
<Card padding="sm" accent="top" caption={m.pulse_eyebrow()}>
<section class="rounded-sm border border-line bg-surface p-5">
<p class="font-sans text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.pulse_eyebrow()}
</p>
{#if pulse.pages > 0}
<h2 class="mt-1 font-serif text-[1.375rem] leading-snug text-ink">
{m.pulse_headline({ pages: pulse.pages })}
@@ -69,5 +66,5 @@ const { pulse }: Props = $props();
</span>
</div>
</div>
</Card>
</section>
{/if}

View File

@@ -20,8 +20,7 @@ describe('DashboardFamilyPulse', () => {
it('renders nothing when pulse is null', async () => {
render(DashboardFamilyPulse, { props: { pulse: null } });
// Component now renders via Card primitive (div, not section)
expect(document.querySelector('[data-testid="card"]')).toBeNull();
expect(document.querySelector('section')).toBeNull();
});
it('renders the eyebrow when pulse is not null', async () => {
@@ -30,12 +29,10 @@ describe('DashboardFamilyPulse', () => {
await expect.element(page.getByText('Diese Woche')).toBeVisible();
});
it('hides the pulse headline when pages is 0', async () => {
it('hides the headline when pages is 0', async () => {
render(DashboardFamilyPulse, { props: { pulse: basePulse({ pages: 0 }) } });
// The Card caption is always rendered as an h2; check the pulse headline (h2 inside Card children)
// specifically by its text content — it should not appear when pages is 0
await expect.element(page.getByText(/Seiten bearbeitet/)).not.toBeInTheDocument();
await expect.element(page.getByRole('heading')).not.toBeInTheDocument();
});
it('renders the headline when pages > 0', async () => {

View File

@@ -1,22 +1,7 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import * as m from '$lib/paraglide/messages.js';
const AVATAR_PALETTE = ['#012851', '#5A3080', '#005F74', '#2A6040', '#803020'] as const;
function djb2(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) hash = (hash * 33) ^ str.charCodeAt(i);
return Math.abs(hash);
}
function personAvatarColor(id: string): string {
return AVATAR_PALETTE[djb2(id) % AVATAR_PALETTE.length];
}
function getInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '';
if (words.length === 1) return words[0].charAt(0).toUpperCase();
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
}
import Avatar from '$lib/shared/primitives/Avatar.svelte';
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
@@ -37,11 +22,12 @@ const { persons }: Props = $props();
href="/persons/{p.id}"
class="group flex min-h-[44px] flex-col items-center gap-2 rounded border border-line bg-surface px-4 py-6 text-center no-underline shadow-sm transition-all duration-200 hover:border-l-4 hover:border-accent hover:shadow-md focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<!-- Avatar: name-keyed deterministic color; dark:ring-1 dark:ring-white/10
keeps the disc edge visible against --c-surface #011526 in dark mode -->
<span
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-base font-bold text-white shadow-sm dark:shadow-none dark:ring-1 dark:ring-white/10"
style="background-color: {personAvatarColor(p.id ?? '')}"
class="flex h-12 w-12 items-center justify-center rounded-full shadow-sm dark:shadow-none dark:ring-1 dark:ring-white/10"
>
{getInitials(p.displayName ?? p.lastName ?? '')}
<Avatar name={p.displayName ?? p.lastName ?? ''} size={48} decorative={true} />
</span>
<span class="truncate font-serif text-sm font-bold text-ink group-hover:underline"
>{p.displayName ?? p.lastName}</span

View File

@@ -2,8 +2,7 @@
import { m } from '$lib/paraglide/messages.js';
import type { FlatMessage } from '$lib/shared/types';
import { extractQuote } from '$lib/shared/discussion/comment';
// eslint-disable-next-line boundaries/dependencies -- discussion UI needs person initials for avatars; move to shared if getInitials becomes generic
import { getInitials } from '$lib/person/personFormat';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import { relativeTime } from '$lib/shared/utils/time';
import { renderBody } from '$lib/shared/discussion/mention';
@@ -39,12 +38,8 @@ const parsed = $derived(extractQuote(message.content));
tabindex="-1"
class="flex gap-2 rounded outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2"
>
<!-- Avatar circle with initials -->
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
>
{getInitials(message.authorName)}
</div>
<!-- Avatar circle with initials — name-keyed deterministic color (DESIGN_RULES §5) -->
<Avatar name={message.authorName} size={26} decorative={true} />
<!-- Content -->
<div class="min-w-0 flex-1">

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import { avatarFor } from '$lib/shared/avatar';
type AvatarSize = 26 | 28 | 40 | 48;
interface Props {
/** Person name — plain string, never a domain entity */
name?: string;
/** Visual size in px */
size?: AvatarSize;
/** Stacked variant: negative margin + ring-surface ring */
stacked?: boolean;
/**
* a11y mode:
* - true (default when name is adjacent in layout) → aria-hidden
* - false → role="img" + aria-label=name
*/
decorative?: boolean;
/**
* Overflow chip mode — renders "+N" instead of initials.
* When set, name/initials are ignored.
*/
overflow?: number;
/**
* Placeholder mode — dashed border empty circle (for ContributorStack empty state).
*/
placeholder?: boolean;
}
let {
name = '',
size = 40,
stacked = false,
decorative = true,
overflow,
placeholder = false
}: Props = $props();
const avatar = $derived(avatarFor(name));
const sizeClass: Record<AvatarSize, string> = {
26: 'h-[26px] w-[26px] text-[10px]',
28: 'h-[28px] w-[28px] text-[11px]',
40: 'h-[40px] w-[40px] text-[14px]',
48: 'h-[48px] w-[48px] text-[16px]'
};
const classes = $derived(
[
'inline-flex shrink-0 items-center justify-center rounded-full font-sans font-bold text-white transition-colors',
sizeClass[size] ?? sizeClass[40],
stacked ? '-ml-1.5 ring-2 ring-surface dark:ring-surface' : '',
placeholder ? 'border border-dashed border-line bg-transparent text-transparent' : ''
]
.filter(Boolean)
.join(' ')
);
</script>
{#if overflow !== undefined}
<!-- Overflow "+N" chip -->
<span
data-testid="avatar"
class="{classes} bg-muted text-ink-3"
role="img"
aria-label="Weitere Personen"
>
+{overflow}
</span>
{:else if placeholder}
<!-- Empty placeholder (dashed border, no background) -->
<span data-testid="avatar" class={classes} role="img" aria-label="Noch niemand angefangen"></span>
{:else if decorative}
<!-- Decorative: name is adjacent in layout — aria-hidden="true" -->
<span
data-testid="avatar"
class={classes}
style="background-color: {avatar.bg}"
aria-hidden="true"
>
{avatar.initials}
</span>
{:else}
<!-- Standalone: expose name to screen readers via role="img" + aria-label -->
<span
data-testid="avatar"
class={classes}
style="background-color: {avatar.bg}"
role="img"
aria-label={name}
>
{avatar.initials}
</span>
{/if}

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Avatar from './Avatar.svelte';
afterEach(() => cleanup());
function getAvatar(): HTMLElement | null {
return document.querySelector('[data-testid="avatar"]');
}
describe('Avatar', () => {
it('renders initials from name', async () => {
render(Avatar, { name: 'Anna Raddatz' });
await expect.element(page.getByText('AR')).toBeInTheDocument();
});
it('applies size-26 class for size=26', async () => {
render(Avatar, { name: 'Anna Raddatz', size: 26 });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.className).toMatch(/h-\[26px\]/);
expect(el!.className).toMatch(/w-\[26px\]/);
});
it('applies size-28 class for size=28', async () => {
render(Avatar, { name: 'Karl Müller', size: 28 });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.className).toMatch(/h-\[28px\]/);
expect(el!.className).toMatch(/w-\[28px\]/);
});
it('applies size-40 class for size=40', async () => {
render(Avatar, { name: 'Karl Müller', size: 40 });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.className).toMatch(/h-\[40px\]/);
expect(el!.className).toMatch(/w-\[40px\]/);
});
it('applies size-48 class for size=48', async () => {
render(Avatar, { name: 'Herbert Cram', size: 48 });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.className).toMatch(/h-\[48px\]/);
expect(el!.className).toMatch(/w-\[48px\]/);
});
it('stacked variant adds -ml-1.5 and ring-2 ring-surface', async () => {
render(Avatar, { name: 'Anna Raddatz', size: 26, stacked: true });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.className).toMatch(/-ml-1\.5/);
expect(el!.className).toMatch(/ring-2/);
expect(el!.className).toMatch(/ring-surface/);
});
it('non-stacked variant does not have -ml-1.5', async () => {
render(Avatar, { name: 'Anna Raddatz', size: 26, stacked: false });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.className).not.toMatch(/-ml-1\.5/);
});
it('decorative mode: aria-hidden=true when decorative prop is true', async () => {
render(Avatar, { name: 'Anna Raddatz', decorative: true });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.getAttribute('aria-hidden')).toBe('true');
});
it('standalone mode: role=img and aria-label=name when decorative is false', async () => {
render(Avatar, { name: 'Herbert Cram', decorative: false });
await expect.element(page.getByRole('img', { name: 'Herbert Cram' })).toBeInTheDocument();
});
it('renders overflow +N chip when overflow prop is set', async () => {
render(Avatar, { overflow: 3 });
await expect.element(page.getByText('+3')).toBeInTheDocument();
});
it('renders placeholder (dashed border) when placeholder prop is true', async () => {
render(Avatar, { placeholder: true, size: 26 });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.className).toMatch(/border-dashed/);
});
it('has rounded-full class', async () => {
render(Avatar, { name: 'Karl Müller', size: 40 });
const el = getAvatar();
expect(el).not.toBeNull();
expect(el!.className).toMatch(/rounded-full/);
});
it('stacked variant carries ring classes (dark mode edge on dark surface)', async () => {
render(Avatar, { name: 'Karl Müller', size: 26, stacked: true });
const el = getAvatar();
expect(el).not.toBeNull();
// ring-2 ring-surface keeps avatar edges visible on dark surface (#011526)
expect(el!.className).toMatch(/ring/);
});
});

View File

@@ -1,84 +0,0 @@
<script lang="ts">
/**
* Card — shared archival card primitive (Mappe redesign §7).
*
* Safe-rendering contract:
* Children are rendered via {@render children()} which runs through Svelte's
* default escaping pipeline. {@html} is NEVER used in this component. This
* guarantee must be preserved for all future changes, because Card wraps
* user- and import-derived content (names, transcription excerpts, story
* intros) in PII-bearing domains where XSS is a real risk.
*
* Accent is decorative only (WCAG 1.4.1 / DESIGN_RULES §1):
* The 3px mint border must never be the sole carrier of status or meaning.
* Any status meaning must come from a StatusDot + label, not the border color.
*
* Dark-mode:
* Accent is driven exclusively by var(--c-accent). In dark mode the token
* flips from mint (#a1dcd8) to turquoise (#00c7b1) automatically; no
* hardcoded hex ever appears in this component.
*/
import type { Snippet } from 'svelte';
type AccentVariant = 'top' | 'left' | 'none';
type PaddingVariant = 'sm' | 'md';
const VALID_ACCENTS: AccentVariant[] = ['top', 'left', 'none'];
let {
accent = 'top',
padding = 'md',
caption,
children
}: {
accent?: AccentVariant;
padding?: PaddingVariant;
caption?: string;
children?: Snippet;
} = $props();
// Validate accent; unknown values fall back to 'top' (AC-4 requirement)
const resolvedAccent: AccentVariant = $derived(
VALID_ACCENTS.includes(accent as AccentVariant) ? (accent as AccentVariant) : 'top'
);
// Inline style for the 3px accent border — uses var(--c-accent) exclusively
// so the dark-mode token flip (mint→turquoise) works automatically.
const accentStyle: string = $derived(
resolvedAccent === 'top'
? 'border-top: 3px solid var(--c-accent);'
: resolvedAccent === 'left'
? 'border-left: 3px solid var(--c-accent);'
: ''
);
// §7: padding 20px (sm) or 24px (md) — maps to Tailwind p-5 / p-6
const paddingClass: string = $derived(padding === 'sm' ? 'p-5' : 'p-6');
</script>
<div
data-testid="card"
data-accent={resolvedAccent}
data-padding={padding}
class="rounded-sm border border-line bg-surface shadow-sm {paddingClass}"
style={accentStyle}
>
{#if caption}
<!--
Section-caption helper: Montserrat 12px / 700 / .12em / UPPERCASE / text-ink-3.
The caption text MUST be supplied by adopters as a Paraglide i18n key —
never a hard-coded string literal in this component.
-->
<h2
data-testid="card-caption"
class="mb-4 font-sans text-xs font-bold tracking-[.12em] text-ink-3 uppercase"
>
{caption}
</h2>
{/if}
{#if children}
{@render children()}
{/if}
</div>

View File

@@ -1,199 +0,0 @@
/**
* Card.svelte.spec.ts
*
* RED-first: written before Card.svelte exists.
* Tests all three accent variants, padding values, radius, section-caption helper,
* fallback for invalid accent props, and dark-mode token correctness.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { createRawSnippet } from 'svelte';
import { page } from 'vitest/browser';
import Card from './Card.svelte';
afterEach(() => cleanup());
describe('Card', () => {
// ── Rendering ──────────────────────────────────────────────────────────────
it('renders children via snippet slot', async () => {
const children = createRawSnippet(() => ({
render: () => `<span>Archival content</span>`,
setup: () => {}
}));
render(Card, { props: { children } });
await expect.element(page.getByText('Archival content')).toBeInTheDocument();
});
it('has data-testid="card" on the root element', async () => {
render(Card);
await expect.element(page.getByTestId('card')).toBeInTheDocument();
});
// ── Base classes ───────────────────────────────────────────────────────────
it('has bg-surface token class', async () => {
render(Card);
const el = document.querySelector('[data-testid="card"]');
expect(el?.className).toContain('bg-surface');
});
it('has border-line token class', async () => {
render(Card);
const el = document.querySelector('[data-testid="card"]');
expect(el?.className).toContain('border-line');
});
it('has shadow-sm class', async () => {
render(Card);
const el = document.querySelector('[data-testid="card"]');
expect(el?.className).toContain('shadow-sm');
});
it('has rounded-sm class (2px radius)', async () => {
render(Card);
const el = document.querySelector('[data-testid="card"]');
expect(el?.className).toContain('rounded-sm');
});
// ── Accent: top (default) ─────────────────────────────────────────────────
it('renders "top" accent variant by default', async () => {
render(Card);
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
expect(el?.dataset.accent).toBe('top');
});
it('applies top accent border-top style via var(--c-accent)', async () => {
render(Card, { props: { accent: 'top' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
const style = el?.getAttribute('style') ?? '';
// The top accent is delivered as an inline style using var(--c-accent)
expect(style).toContain('var(--c-accent)');
expect(style).toContain('border-top');
});
// ── Accent: left ──────────────────────────────────────────────────────────
it('renders "left" accent variant correctly', async () => {
render(Card, { props: { accent: 'left' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
expect(el?.dataset.accent).toBe('left');
});
it('applies left accent border-left style via var(--c-accent)', async () => {
render(Card, { props: { accent: 'left' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
const style = el?.getAttribute('style') ?? '';
expect(style).toContain('var(--c-accent)');
expect(style).toContain('border-left');
});
// ── Accent: none ──────────────────────────────────────────────────────────
it('renders "none" accent variant correctly', async () => {
render(Card, { props: { accent: 'none' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
expect(el?.dataset.accent).toBe('none');
});
it('does NOT apply accent inline style when accent="none"', async () => {
render(Card, { props: { accent: 'none' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
const style = el?.getAttribute('style') ?? '';
// No border-top or border-left with var(--c-accent) when accent is none
expect(style).not.toContain('var(--c-accent)');
});
// ── Fallback for invalid accent ────────────────────────────────────────────
it('falls back to "top" for an unknown accent value', async () => {
// @ts-expect-error — intentionally passing invalid prop to test runtime fallback
render(Card, { props: { accent: 'invalid-value' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
expect(el?.dataset.accent).toBe('top');
});
// ── Padding ───────────────────────────────────────────────────────────────
it('defaults to padding="md" (24px)', async () => {
render(Card);
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
expect(el?.dataset.padding).toBe('md');
});
it('applies p-6 (24px) class for padding="md"', async () => {
render(Card, { props: { padding: 'md' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
expect(el?.className).toContain('p-6');
});
it('applies p-5 (20px) class for padding="sm"', async () => {
render(Card, { props: { padding: 'sm' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
expect(el?.className).toContain('p-5');
});
// ── Section-caption helper ─────────────────────────────────────────────────
it('does NOT render a caption element when caption prop is absent', async () => {
render(Card);
const caption = document.querySelector('[data-testid="card-caption"]');
expect(caption).toBeNull();
});
it('renders the section-caption helper when caption text is provided', async () => {
render(Card, { props: { caption: 'Briefkorrespondenz' } });
await expect.element(page.getByTestId('card-caption')).toBeInTheDocument();
});
it('caption has font-sans Montserrat token class', async () => {
render(Card, { props: { caption: 'Dokumente' } });
const el = document.querySelector('[data-testid="card-caption"]') as HTMLElement;
expect(el?.className).toContain('font-sans');
});
it('caption has text-ink-3 token class', async () => {
render(Card, { props: { caption: 'Personen' } });
const el = document.querySelector('[data-testid="card-caption"]') as HTMLElement;
expect(el?.className).toContain('text-ink-3');
});
it('caption has uppercase class', async () => {
render(Card, { props: { caption: 'Übersicht' } });
const el = document.querySelector('[data-testid="card-caption"]') as HTMLElement;
expect(el?.className).toContain('uppercase');
});
it('caption has font-bold class (700 weight)', async () => {
render(Card, { props: { caption: 'Briefwechsel' } });
const el = document.querySelector('[data-testid="card-caption"]') as HTMLElement;
expect(el?.className).toContain('font-bold');
});
it('renders caption text content', async () => {
render(Card, { props: { caption: 'Zeitstrahl' } });
await expect.element(page.getByText('Zeitstrahl')).toBeInTheDocument();
});
// ── Dark-mode token contract ───────────────────────────────────────────────
it('accent uses var(--c-accent) token — never raw hex — for dark-mode compatibility', async () => {
render(Card, { props: { accent: 'top' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
const style = el?.getAttribute('style') ?? '';
// Must use the CSS variable, not any hardcoded hex color
expect(style).toContain('var(--c-accent)');
expect(style).not.toMatch(/#[0-9a-fA-F]{3,6}/);
});
it('no raw Tailwind color class (e.g. green-*, blue-*) on card element', async () => {
render(Card, { props: { accent: 'top' } });
const el = document.querySelector('[data-testid="card"]') as HTMLElement;
const cls = el?.className ?? '';
// Check for raw Tailwind palette colors (bg-green-*, border-blue-*, etc.)
expect(cls).not.toMatch(
/\b(bg|border|text)-(red|green|blue|yellow|purple|pink|indigo|gray|slate|zinc|stone|orange|amber|lime|emerald|teal|cyan|sky|violet|fuchsia|rose)-\d+/
);
});
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
type ActivityActorDTO = components['schemas']['ActivityActorDTO'];
@@ -11,36 +12,24 @@ interface Props {
let { contributors, hasMore }: Props = $props();
const safeContributors = $derived(contributors ?? []);
function safeColor(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3';
}
</script>
{#if safeContributors.length === 0}
<span
role="img"
aria-label="Noch niemand angefangen"
class="inline-block h-[22px] w-[22px] flex-shrink-0 rounded-full border-[1.5px] border-dashed border-[#cdcbbf]"
></span>
<!-- Empty placeholder: dashed border circle (DESIGN_RULES §5) -->
<Avatar placeholder={true} size={26} />
{:else}
<span class="inline-flex items-center">
{#each safeContributors as actor, i (actor.initials + '-' + actor.color)}
<span
role="img"
aria-label={actor.name ?? actor.initials}
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
style="background-color: {safeColor(actor.color)};"
title={actor.name ?? actor.initials}
>
{actor.initials}
</span>
<!-- Stacked avatar: ring-surface ring instead of legacy ring-white so edge
works in both light and dark themes. First avatar has no negative margin. -->
<Avatar name={actor.name ?? actor.initials} size={26} stacked={i > 0} decorative={false} />
{/each}
{#if hasMore}
<!-- Overflow indicator — uses muted bg + ink-3 text, stacked -->
<span
role="img"
aria-label="Weitere Mitwirkende"
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[10px] font-bold text-ink-3 ring-2 ring-white"
class="-ml-1.5 inline-flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full bg-muted font-sans text-[10px] font-bold text-ink-3 ring-2 ring-surface"
>
</span>

View File

@@ -3,8 +3,8 @@ import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/shared/utils/date';
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import { csrfFetch } from '$lib/shared/cookies';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
@@ -71,13 +71,7 @@ async function handleDelete() {
</h1>
<div class="mb-4 flex items-center gap-3 border-b border-line-2 pb-4">
{#if authorName}
<span
aria-hidden="true"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white"
style="background-color: {personAvatarColor(authorName)}"
>
{getInitials(authorName)}
</span>
<Avatar name={authorName} size={28} decorative={true} />
{/if}
<div>
{#if authorName}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
let {
coCorrespondents,
@@ -10,15 +11,6 @@ let {
personId: string;
personName: string;
} = $props();
function initials(name: string): string {
return name
.split(' ')
.map((n) => n[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase();
}
</script>
{#if coCorrespondents.length > 0}
@@ -38,12 +30,8 @@ function initials(name: string): string {
title={m.person_correspondents_search_title({ A: personName, B: c.name })}
class="inline-flex items-center gap-1.5 rounded-full border border-line bg-muted px-3 py-1.5 font-sans text-xs font-bold text-ink transition-colors hover:border-primary hover:bg-surface"
>
<!-- Initials circle -->
<span
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-primary font-serif text-[9px] font-bold text-primary-fg"
>
{initials(c.name)}
</span>
<!-- Avatar — name-keyed deterministic color (DESIGN_RULES §5) -->
<Avatar name={c.name} size={26} decorative={true} />
{c.name}
<span
class="text-[10px] font-normal text-ink-3"

View File

@@ -2,6 +2,7 @@
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDate } from '$lib/person/personLifeDates';
import PersonTypeBadge from '$lib/person/PersonTypeBadge.svelte';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
let {
@@ -34,12 +35,11 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
<div class="h-1.5 w-full bg-primary"></div>
<div class="p-6">
<!-- Avatar — navy circle, centered -->
<!-- Avatar — deterministic name-hash (DESIGN_RULES §5); type-glyph fallback for
non-PERSON types; centered -->
<div class="mb-4 flex justify-center">
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary font-serif text-xl font-bold text-primary-fg"
>
{#if person.personType && person.personType !== 'PERSON'}
{#if person.personType && person.personType !== 'PERSON'}
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-ink-2">
<svg
class="h-7 w-7"
fill="none"
@@ -67,10 +67,10 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
/>
{/if}
</svg>
{:else}
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
{/if}
</div>
</div>
{:else}
<Avatar name={person.displayName} size={48} decorative={true} />
{/if}
</div>
{#if person.personType === 'PERSON' && person.title}