Compare commits

..

3 Commits

Author SHA1 Message Date
Marcel
9f887f12f5 feat(i18n): add person_meta_doc_count + person_meta_rel_count keys (de/en/es) (§7)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m59s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m52s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
Refs #859
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:16:49 +02:00
Marcel
33a1db5d77 refactor(persons): adopt MetaLine primitive on PersonDetail (§7)
Refs #859
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:16:22 +02:00
Marcel
649b6b447c feat(shared): add MetaLine primitive — · -separated meta, optional icon (§7)
Refs #859
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:15:48 +02:00
23 changed files with 293 additions and 390 deletions

View File

@@ -193,6 +193,8 @@
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
"person_received_docs_heading": "Empfangene Dokumente",
"person_no_received_docs": "Diese Person ist noch nicht als Empfänger verknüpft.",
"person_meta_doc_count": "{count} Dokumente",
"person_meta_rel_count": "{count} Beziehungen",
"person_role_sender": "Gesendet",
"person_role_receiver": "Empfangen",
"person_co_correspondents_heading": "Häufige Korrespondenten",

View File

@@ -193,6 +193,8 @@
"person_no_docs": "This person has not yet been linked as a sender.",
"person_received_docs_heading": "Received documents",
"person_no_received_docs": "This person has not yet been linked as a receiver.",
"person_meta_doc_count": "{count} documents",
"person_meta_rel_count": "{count} relationships",
"person_role_sender": "Sent",
"person_role_receiver": "Received",
"person_co_correspondents_heading": "Frequent correspondents",

View File

@@ -193,6 +193,8 @@
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
"person_received_docs_heading": "Documentos recibidos",
"person_no_received_docs": "Esta persona aún no está vinculada como receptor.",
"person_meta_doc_count": "{count} documentos",
"person_meta_rel_count": "{count} relaciones",
"person_role_sender": "Enviado",
"person_role_receiver": "Recibido",
"person_co_correspondents_heading": "Corresponsales frecuentes",

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 Avatar from '$lib/shared/primitives/Avatar.svelte';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import RelationshipPill from '$lib/person/relationship/RelationshipPill.svelte';
import DocumentDate from './DocumentDate.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
@@ -88,7 +88,13 @@ 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"
>
<Avatar name={person.displayName} size={28} decorative={true} />
<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>
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
{#if relationLabel}
<RelationshipPill label={relationLabel} />

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import { avatarFor } from '$lib/shared/avatar';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import { formatAuthorName, formatPublishedAt } from './utils';
import type { components } from '$lib/generated/api';
@@ -27,7 +26,13 @@ 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">
<Avatar name={authorName} size={28} decorative={true} />
<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>
<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>
@@ -58,7 +63,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: {avatarFor(authorName).bg}"
style="background-color: {personAvatarColor(authorName)}"
></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 Avatar from '$lib/shared/primitives/Avatar.svelte';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import { formatDocumentMetaLine } from './utils';
import type { components } from '$lib/generated/api';
@@ -51,7 +51,13 @@ 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"
>
<Avatar name={personName(p)} size={26} decorative={true} />
<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>
{personName(p)}
</a>
</li>

View File

@@ -1,7 +1,6 @@
<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'];
@@ -26,6 +25,12 @@ 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>
@@ -33,13 +38,15 @@ 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 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"
>
<!-- 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}
{#if person.personType === 'INSTITUTION'}
<svg
class="h-5 w-5"
@@ -77,11 +84,10 @@ const documentCount = $derived(person.documentCount ?? 0);
class="h-6 w-6 opacity-70"
/>
{/if}
</div>
{:else}
<!-- Confirmed person — deterministic name-hash Avatar (DESIGN_RULES §5) -->
<Avatar name={person.displayName} size={48} decorative={true} />
{/if}
{:else}
{initials}
{/if}
</div>
<!-- Name -->
<p class="font-serif text-sm font-bold text-ink group-hover:underline">

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { abbreviateName } from '$lib/person/personFormat';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
import { abbreviateName, getInitials, personAvatarColor } from '$lib/person/personFormat';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
@@ -12,12 +11,20 @@ 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-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"
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"
>
<Avatar name={person.displayName} size={26} decorative={true} />
<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>
<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 } from './personFormat';
import { getInitials, abbreviateName, formatXsMeta, personAvatarColor } from './personFormat';
import { formatDate } from '$lib/shared/utils/date';
// ─── getInitials ─────────────────────────────────────────────────────────────
@@ -97,6 +97,29 @@ 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,6 +8,16 @@ 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();
@@ -73,3 +83,7 @@ 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

@@ -1,92 +0,0 @@
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

@@ -1,43 +0,0 @@
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,22 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import * as m from '$lib/paraglide/messages.js';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
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();
}
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
@@ -22,12 +37,11 @@ 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 items-center justify-center rounded-full shadow-sm dark:shadow-none dark:ring-1 dark:ring-white/10"
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 ?? '')}"
>
<Avatar name={p.displayName ?? p.lastName ?? ''} size={48} decorative={true} />
{getInitials(p.displayName ?? p.lastName ?? '')}
</span>
<span class="truncate font-serif text-sm font-bold text-ink group-hover:underline"
>{p.displayName ?? p.lastName}</span

View File

@@ -2,7 +2,8 @@
import { m } from '$lib/paraglide/messages.js';
import type { FlatMessage } from '$lib/shared/types';
import { extractQuote } from '$lib/shared/discussion/comment';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
// 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 { relativeTime } from '$lib/shared/utils/time';
import { renderBody } from '$lib/shared/discussion/mention';
@@ -38,8 +39,12 @@ 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 — name-keyed deterministic color (DESIGN_RULES §5) -->
<Avatar name={message.authorName} size={26} decorative={true} />
<!-- 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>
<!-- Content -->
<div class="min-w-0 flex-1">

View File

@@ -1,94 +0,0 @@
<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

@@ -1,105 +0,0 @@
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,6 +1,5 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
type ActivityActorDTO = components['schemas']['ActivityActorDTO'];
@@ -12,24 +11,36 @@ 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}
<!-- Empty placeholder: dashed border circle (DESIGN_RULES §5) -->
<Avatar placeholder={true} size={26} />
<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>
{:else}
<span class="inline-flex items-center">
{#each safeContributors as actor, i (actor.initials + '-' + actor.color)}
<!-- 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} />
<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>
{/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-[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"
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"
>
</span>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
let {
items,
iconSrc
}: {
items: string[];
iconSrc?: string;
} = $props();
</script>
{#if items.length > 0}
<div
data-testid="meta-line"
style="display:flex; align-items:center; flex-wrap:wrap; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2);"
>
{#if iconSrc}
<img src={iconSrc} alt="" style="width:14px; height:14px; opacity:0.5; flex-shrink:0;" />
{/if}
{#each items as item, i (i)}
{#if i > 0}
<span data-testid="meta-sep" aria-hidden="true">·</span>
{/if}
<span data-testid="meta-item">{item}</span>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import MetaLine from './MetaLine.svelte';
afterEach(() => cleanup());
describe('MetaLine', () => {
it('renders N item spans when given N items', async () => {
render(MetaLine, { items: ['14. März 1923', '14 Dokumente', '4 Personen'] });
const spans = document.querySelectorAll('[data-testid="meta-item"]');
expect(spans).toHaveLength(3);
expect(spans[0].textContent).toBe('14. März 1923');
expect(spans[1].textContent).toBe('14 Dokumente');
expect(spans[2].textContent).toBe('4 Personen');
});
it('renders separator spans between items', async () => {
render(MetaLine, { items: ['A', 'B', 'C'] });
const seps = document.querySelectorAll('[data-testid="meta-sep"]');
// N items → N-1 separators
expect(seps).toHaveLength(2);
expect(seps[0].textContent).toBe('·');
});
it('renders nothing when items is empty', async () => {
const { container } = render(MetaLine, { items: [] });
// No element children — Svelte may leave an empty comment node but no DOM elements
expect(container.querySelectorAll('[data-testid]')).toHaveLength(0);
expect(container.querySelectorAll('div, span, img')).toHaveLength(0);
});
it('renders nothing when items has one element (no separator)', async () => {
render(MetaLine, { items: ['Nur eines'] });
const seps = document.querySelectorAll('[data-testid="meta-sep"]');
expect(seps).toHaveLength(0);
const spans = document.querySelectorAll('[data-testid="meta-item"]');
expect(spans).toHaveLength(1);
});
it('shows the leading img when iconSrc is supplied', async () => {
render(MetaLine, {
items: ['Datum'],
iconSrc: '/degruyter-icons/Simple/Small-16px/SVG/Action/Calendar-Add-SM.svg'
});
const img = document.querySelector('img');
expect(img).not.toBeNull();
});
it('does NOT render an img when iconSrc is omitted', async () => {
render(MetaLine, { items: ['Datum'] });
const img = document.querySelector('img');
expect(img).toBeNull();
});
it('icon has width 14px, height 14px, opacity 0.5, and alt=""', async () => {
render(MetaLine, {
items: ['Datum'],
iconSrc: '/degruyter-icons/Simple/Small-16px/SVG/Action/Calendar-Add-SM.svg'
});
const img = document.querySelector('img') as HTMLImageElement;
expect(img.alt).toBe('');
// Inline style values (set directly on the element, not via getComputedStyle)
expect(img.style.width).toBe('14px');
expect(img.style.height).toBe('14px');
expect(img.style.opacity).toBe('0.5');
});
it('applies font-size 12px to the wrapper', async () => {
render(MetaLine, { items: ['Test'] });
const wrapper = document.querySelector('[data-testid="meta-line"]') as HTMLElement;
expect(wrapper).not.toBeNull();
expect(wrapper.style.fontSize).toBe('12px');
});
});

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,7 +71,13 @@ async function handleDelete() {
</h1>
<div class="mb-4 flex items-center gap-3 border-b border-line-2 pb-4">
{#if authorName}
<Avatar name={authorName} size={28} decorative={true} />
<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>
{/if}
<div>
{#if authorName}

View File

@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import PersonCard from './PersonCard.svelte';
import MetaLine from '$lib/shared/primitives/MetaLine.svelte';
import NameHistoryCard from './NameHistoryCard.svelte';
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
import PersonDocumentList from './PersonDocumentList.svelte';
@@ -15,6 +16,16 @@ const person = $derived(data.person);
const sentDocuments = $derived(data.sentDocuments);
const receivedDocuments = $derived(data.receivedDocuments);
const totalDocCount = $derived(sentDocuments.length + receivedDocuments.length);
const relCount = $derived(data.relationships.length + data.inferredRelationships.length);
const personMetaItems = $derived.by(() => {
const items: string[] = [];
if (totalDocCount > 0) items.push(m.person_meta_doc_count({ count: totalDocCount }));
if (relCount > 0) items.push(m.person_meta_rel_count({ count: relCount }));
return items;
});
const coCorrespondents = $derived.by(() => {
const freq = new SvelteMap<string, { id: string; name: string; count: number }>();
@@ -61,6 +72,11 @@ const coCorrespondents = $derived.by(() => {
<!-- Left column: Person card + name history -->
<div>
<PersonCard person={person} canWrite={data.canWrite} />
{#if personMetaItems.length > 0}
<div class="mt-3">
<MetaLine items={personMetaItems} />
</div>
{/if}
<div class="mt-6">
<NameHistoryCard aliases={data.aliases} personFirstName={person.firstName} />
</div>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import Avatar from '$lib/shared/primitives/Avatar.svelte';
let {
coCorrespondents,
@@ -11,6 +10,15 @@ 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}
@@ -30,8 +38,12 @@ let {
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"
>
<!-- Avatar — name-keyed deterministic color (DESIGN_RULES §5) -->
<Avatar name={c.name} size={26} decorative={true} />
<!-- 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>
{c.name}
<span
class="text-[10px] font-normal text-ink-3"

View File

@@ -2,7 +2,6 @@
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 {
@@ -35,11 +34,12 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
<div class="h-1.5 w-full bg-primary"></div>
<div class="p-6">
<!-- Avatar — deterministic name-hash (DESIGN_RULES §5); type-glyph fallback for
non-PERSON types; centered -->
<!-- Avatar — navy circle, centered -->
<div class="mb-4 flex justify-center">
{#if person.personType && person.personType !== 'PERSON'}
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-ink-2">
<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'}
<svg
class="h-7 w-7"
fill="none"
@@ -67,10 +67,10 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
/>
{/if}
</svg>
</div>
{:else}
<Avatar name={person.displayName} size={48} decorative={true} />
{/if}
{:else}
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
{/if}
</div>
</div>
{#if person.personType === 'PERSON' && person.title}