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
23 changed files with 455 additions and 416 deletions

View File

@@ -130,8 +130,6 @@
"doc_no_scan": "Kein Scan vorhanden",
"persons_heading": "Personenverzeichnis",
"persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.",
"persons_eyebrow": "Verzeichnis",
"persons_lede": "Jede Hand, die schrieb oder genannt wurde — Absender, Empfänger, Erwähnte.",
"persons_btn_new": "Neue Person",
"persons_search_placeholder": "Namen suchen...",
"persons_empty_heading": "Keine Personen gefunden.",

View File

@@ -130,8 +130,6 @@
"doc_no_scan": "No scan available",
"persons_heading": "Person directory",
"persons_subtitle": "Browse the index of all recorded persons in the family archive.",
"persons_eyebrow": "Directory",
"persons_lede": "Every hand that wrote or was named — senders, recipients, those mentioned.",
"persons_btn_new": "New person",
"persons_search_placeholder": "Search names...",
"persons_empty_heading": "No persons found.",

View File

@@ -130,8 +130,6 @@
"doc_no_scan": "No hay escaneo disponible",
"persons_heading": "Directorio de personas",
"persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.",
"persons_eyebrow": "Directorio",
"persons_lede": "Cada mano que escribió o fue nombrada — remitentes, destinatarios, mencionados.",
"persons_btn_new": "Nueva persona",
"persons_search_placeholder": "Buscar nombres...",
"persons_empty_heading": "No se encontraron personas.",

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,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,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

@@ -1,48 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
eyebrow,
title,
lede,
right
}: {
eyebrow: string;
title: string;
lede?: string;
right?: Snippet;
} = $props();
</script>
<div
data-testid="page-header-wrapper"
style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap"
>
<div
data-testid="page-header-inner"
style="border-left:4px solid var(--c-accent); padding-left:18px"
>
<div
data-testid="page-header-eyebrow"
style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px"
>
{eyebrow}
</div>
<h1
style="font-family:var(--font-serif); font-weight:700; font-size:clamp(28px,8vw,46px); line-height:1.06; color:var(--c-ink); margin:0"
>
{title}
</h1>
{#if lede}
<p
data-testid="page-header-lede"
style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px"
>
{lede}
</p>
{/if}
</div>
{#if right}
{@render right()}
{/if}
</div>

View File

@@ -1,135 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PageHeader from './PageHeader.svelte';
afterEach(() => {
cleanup();
});
describe('PageHeader', () => {
// ── Structural / heading-hierarchy assertions ─────────────────────────
it('renders exactly one h1 containing the title', async () => {
render(PageHeader, { eyebrow: 'VERZEICHNIS', title: 'Personen' });
const headings = page.getByRole('heading', { level: 1 });
await expect.element(headings).toBeInTheDocument();
await expect.element(headings).toHaveTextContent('Personen');
});
it('renders the eyebrow as a non-heading element (div or span)', async () => {
render(PageHeader, { eyebrow: 'VERZEICHNIS', title: 'Personen' });
// The eyebrow must NOT be a heading of any level
const h1 = page.getByRole('heading', { level: 1 });
await expect.element(h1).toBeInTheDocument();
// No h2-h6 headings should be present
const h2 = page.getByRole('heading', { level: 2 });
await expect.element(h2).not.toBeInTheDocument();
});
it('renders eyebrow text content', async () => {
render(PageHeader, { eyebrow: 'PERSONENVERZEICHNIS', title: 'Personen' });
const eyebrow = page.getByTestId('page-header-eyebrow');
await expect.element(eyebrow).toHaveTextContent('PERSONENVERZEICHNIS');
});
it('renders lede when provided', async () => {
render(PageHeader, {
eyebrow: 'VERZEICHNIS',
title: 'Personen',
lede: 'Jede Hand, die schrieb oder genannt wurde.'
});
const lede = page.getByTestId('page-header-lede');
await expect.element(lede).toHaveTextContent('Jede Hand, die schrieb oder genannt wurde.');
});
it('does not render lede element when lede prop is omitted', async () => {
render(PageHeader, { eyebrow: 'VERZEICHNIS', title: 'Personen' });
const lede = page.getByTestId('page-header-lede');
await expect.element(lede).not.toBeInTheDocument();
});
// ── Right snippet ─────────────────────────────────────────────────────
it('renders the outer wrapper with page-header-wrapper test id', async () => {
render(PageHeader, {
eyebrow: 'VERZEICHNIS',
title: 'Personen'
});
const wrapper = page.getByTestId('page-header-wrapper');
await expect.element(wrapper).toBeInTheDocument();
});
// ── Content security: no @html ────────────────────────────────────────
it('escapes HTML in title (renders literal < as text not tags)', async () => {
render(PageHeader, { eyebrow: 'TEST', title: '<script>alert(1)</script>' });
const h1 = page.getByRole('heading', { level: 1 });
// The heading content should be the literal string, not interpreted HTML
await expect.element(h1).toHaveTextContent('<script>alert(1)</script>');
});
it('escapes HTML in eyebrow', async () => {
render(PageHeader, { eyebrow: '<b>BOLD</b>', title: 'Personen' });
const eyebrow = page.getByTestId('page-header-eyebrow');
await expect.element(eyebrow).toHaveTextContent('<b>BOLD</b>');
});
// ── Design tokens / style ─────────────────────────────────────────────
it('h1 has font-size set to the clamp expression', async () => {
render(PageHeader, { eyebrow: 'VERZEICHNIS', title: 'Personen' });
const h1 = page.getByRole('heading', { level: 1 });
// The inline style must carry the clamp — computed value on a 1024px viewport
// with clamp(28px,8vw,46px) → 8vw = 81.92px > 46px → capped at 46px
const el = h1.element() as HTMLElement;
const fs = el.style.fontSize || getComputedStyle(el).fontSize;
expect(fs).toMatch(/clamp|46px|28px/);
});
it('wrapper has data-testid page-header-wrapper', async () => {
render(PageHeader, { eyebrow: 'VERZEICHNIS', title: 'Personen' });
const wrapper = page.getByTestId('page-header-wrapper');
await expect.element(wrapper).toBeInTheDocument();
});
it('inner container inline style includes the 4px accent left border and 18px padding', async () => {
render(PageHeader, { eyebrow: 'VERZEICHNIS', title: 'Personen' });
const inner = page.getByTestId('page-header-inner');
const el = inner.element() as HTMLElement;
const styleAttr = el.getAttribute('style') ?? '';
expect(styleAttr).toContain('border-left:4px solid var(--c-accent)');
expect(styleAttr).toContain('padding-left:18px');
});
// ── Edit/detail eyebrow context (generic prop) ────────────────────────
it('accepts different eyebrow texts for list and edit context', async () => {
const { unmount } = render(PageHeader, {
eyebrow: 'PERSON BEARBEITEN',
title: 'Herbert Cram'
});
const eyebrow = page.getByTestId('page-header-eyebrow');
await expect.element(eyebrow).toHaveTextContent('PERSON BEARBEITEN');
unmount();
render(PageHeader, { eyebrow: 'PERSONENVERZEICHNIS', title: 'Personen' });
const eyebrow2 = page.getByTestId('page-header-eyebrow');
await expect.element(eyebrow2).toHaveTextContent('PERSONENVERZEICHNIS');
});
});

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

@@ -7,7 +7,7 @@ import { m } from '$lib/paraglide/messages.js';
import PersonCard from '$lib/person/PersonCard.svelte';
import PersonFilterBar from '$lib/person/PersonFilterBar.svelte';
import Pagination from '$lib/shared/primitives/Pagination.svelte';
import PageHeader from '$lib/shared/primitives/PageHeader.svelte';
import PersonsStatsBar from './PersonsStatsBar.svelte';
import PersonsEmptyState from './PersonsEmptyState.svelte';
let { data } = $props();
@@ -53,62 +53,71 @@ const noFiltersActive = $derived(
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<PageHeader eyebrow={m.persons_eyebrow()} title={m.page_title_persons()} lede={m.persons_lede()}>
{#snippet right()}
<div class="flex shrink-0 items-center gap-3">
<!-- Search -->
<div class="relative">
<label for="search" class="sr-only">{m.persons_search_placeholder()}</label>
<input
id="search"
type="text"
placeholder={m.persons_search_placeholder()}
bind:value={q}
oninput={handleSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-ink-3"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-40"
/>
</div>
</div>
<!-- Triage link (transcriber only) -->
{#if data.canWrite}
<a
href="/persons/review"
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm border border-line bg-surface px-4 py-2 font-sans text-sm font-semibold text-ink transition-colors hover:bg-muted"
>
{m.persons_toggle_needs_review({ count: data.needsReviewCount })}
</a>
{/if}
<!-- New person CTA -->
{#if data.canWrite}
<a
href="/persons/new"
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm bg-primary px-4 py-2.5 font-sans text-sm font-bold tracking-wide text-primary-fg transition-colors hover:bg-primary/80"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 invert dark:invert-0"
/>
{m.persons_btn_new()}
</a>
{/if}
<!-- Header: title+stats on left, search+CTA on right -->
<div class="mb-6 flex flex-wrap items-end justify-between gap-4 border-b border-ink/10 pb-6">
<div>
<h1 class="font-serif text-3xl font-medium text-ink">{m.page_title_persons()}</h1>
<div class="mt-2">
<PersonsStatsBar
totalPersons={data.stats.totalPersons ?? 0}
totalDocuments={data.stats.totalDocuments ?? 0}
/>
</div>
{/snippet}
</PageHeader>
</div>
<div class="flex items-center gap-3">
<!-- Search -->
<div class="relative">
<label for="search" class="sr-only">{m.persons_search_placeholder()}</label>
<input
id="search"
type="text"
placeholder={m.persons_search_placeholder()}
bind:value={q}
oninput={handleSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-ink-3"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-40"
/>
</div>
</div>
<!-- Triage link (transcriber only) -->
{#if data.canWrite}
<a
href="/persons/review"
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm border border-line bg-surface px-4 py-2 font-sans text-sm font-semibold text-ink transition-colors hover:bg-muted"
>
{m.persons_toggle_needs_review({ count: data.needsReviewCount })}
</a>
{/if}
<!-- New person CTA -->
{#if data.canWrite}
<a
href="/persons/new"
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm bg-primary px-4 py-2.5 font-sans text-sm font-bold tracking-wide text-primary-fg transition-colors hover:bg-primary/80"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 invert dark:invert-0"
/>
{m.persons_btn_new()}
</a>
{/if}
</div>
</div>
<!-- Filter chips + show-all toggle -->
<div class="mb-8">

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}