Compare commits

..

4 Commits

Author SHA1 Message Date
Marcel
3de3b2131f refactor(shared): re-skin PersonFilterBar + TimelineFilters to §6 tokens
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 6m7s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 6m58s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m12s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
Refs #857
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:03:11 +02:00
Marcel
94b8117c17 refactor(shared): re-skin ThemeToggle onto SegmentedControl (§6 tokens)
Adds theme_segment_light/dark/label i18n keys (de/en/es).

Refs #857
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:02:44 +02:00
Marcel
a3343f898f refactor(geschichten): migrate TypeSelector onto SegmentedControl primitive
Refs #857
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:02:17 +02:00
Marcel
533196dabb feat(shared): add SegmentedControl primitive — §6 inline-flex, active=navy, radiogroup
Refs #857
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:01:51 +02:00
28 changed files with 496 additions and 499 deletions

View File

@@ -32,6 +32,9 @@
"layout_menu_close": "Menü schließen",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"theme_toggle_label": "Farbschema",
"theme_segment_light": "Hell",
"theme_segment_dark": "Dunkel",
"btn_save": "Speichern",
"btn_cancel": "Abbrechen",
"btn_confirm": "Bestätigen",

View File

@@ -32,6 +32,9 @@
"layout_menu_close": "Close menu",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"theme_toggle_label": "Color scheme",
"theme_segment_light": "Light",
"theme_segment_dark": "Dark",
"btn_save": "Save",
"btn_cancel": "Cancel",
"btn_confirm": "Confirm",

View File

@@ -32,6 +32,9 @@
"layout_menu_close": "Cerrar menú",
"theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro",
"theme_toggle_label": "Esquema de color",
"theme_segment_light": "Claro",
"theme_segment_dark": "Oscuro",
"btn_save": "Guardar",
"btn_cancel": "Cancelar",
"btn_confirm": "Confirmar",

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

@@ -54,10 +54,11 @@ function setReview(next: boolean) {
});
}
// §6 tokens: Montserrat 12px/700 tracking-[.08em] UPPERCASE; 44px touch target
const chipBase =
'inline-flex min-h-[44px] min-w-[44px] items-center gap-1.5 rounded-sm border px-4 py-2 font-sans text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none';
const chipActive = 'border-brand-navy bg-brand-navy text-white';
const chipInactive = 'border-line bg-surface text-ink hover:bg-muted';
'inline-flex min-h-[44px] min-w-[44px] items-center gap-1.5 rounded-sm border px-4 py-[9px] font-sans text-xs font-bold tracking-[.08em] uppercase transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none';
const chipActive = 'border-primary bg-primary text-primary-fg';
const chipInactive = 'border-line bg-surface text-ink-2 hover:bg-surface-2';
</script>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">

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,55 @@
<script lang="ts">
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
interface Option {
value: string;
label: string;
}
interface Props {
options: Option[];
value: string;
onChange: (value: string) => void;
label?: string;
}
let { options, value, onChange, label = undefined }: Props = $props();
// Roving tabindex: the active segment gets tabindex=0; all others -1.
// If no segment is active yet, fall back to the first so keyboard nav
// can enter the control.
const rovingFocus = $derived(
options.some((o) => o.value === value) ? value : (options[0]?.value ?? '')
);
</script>
<!--
§6 Segmented Control — single-select, radiogroup + roving tabindex.
Callers supply pre-resolved Paraglide strings for all labels.
No {@html} is used on option labels (XSS-safe default escaping only).
-->
<div
role="radiogroup"
aria-label={label}
class="inline-flex flex-wrap rounded-sm border border-line md:flex-nowrap"
use:radioGroupNav={(v) => onChange(v)}
>
{#each options as option, i (option.value)}
<button
type="button"
role="radio"
value={option.value}
aria-checked={option.value === value}
tabindex={option.value === rovingFocus ? 0 : -1}
onclick={() => onChange(option.value)}
class="min-h-[44px] cursor-pointer px-4 py-[9px] font-sans text-xs font-bold tracking-[.08em]
uppercase transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{i > 0 ? 'border-l border-line' : ''}
{option.value === value
? 'bg-primary text-primary-fg'
: 'hover:bg-surface-2 bg-surface text-ink-2'}"
>{option.label}</button
>
{/each}
</div>

View File

@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
afterEach(cleanup);
const options = [
{ value: 'a', label: 'Option A' },
{ value: 'b', label: 'Option B' },
{ value: 'c', label: 'Option C' }
];
const { default: SegmentedControl } = await import('./SegmentedControl.svelte');
// ─── Structure & ARIA ─────────────────────────────────────────────────────────
describe('SegmentedControl — structure', () => {
it('renders a radiogroup wrapper', async () => {
render(SegmentedControl, { props: { options, value: 'a', onChange: vi.fn() } });
const group = document.querySelector('[role="radiogroup"]');
expect(group).not.toBeNull();
});
it('renders one radio per option with the correct label', async () => {
render(SegmentedControl, { props: { options, value: 'a', onChange: vi.fn() } });
await expect.element(page.getByRole('radio', { name: 'Option A' })).toBeVisible();
await expect.element(page.getByRole('radio', { name: 'Option B' })).toBeVisible();
await expect.element(page.getByRole('radio', { name: 'Option C' })).toBeVisible();
});
it('active segment has aria-checked="true", inactive ones "false"', async () => {
render(SegmentedControl, { props: { options, value: 'b', onChange: vi.fn() } });
await expect
.element(page.getByRole('radio', { name: 'Option B' }))
.toHaveAttribute('aria-checked', 'true');
await expect
.element(page.getByRole('radio', { name: 'Option A' }))
.toHaveAttribute('aria-checked', 'false');
await expect
.element(page.getByRole('radio', { name: 'Option C' }))
.toHaveAttribute('aria-checked', 'false');
});
it('active segment gets tabindex=0, inactive segments tabindex=-1', async () => {
render(SegmentedControl, { props: { options, value: 'a', onChange: vi.fn() } });
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
expect(radios[0]?.getAttribute('tabindex')).toBe('0');
expect(radios[1]?.getAttribute('tabindex')).toBe('-1');
expect(radios[2]?.getAttribute('tabindex')).toBe('-1');
});
it('applies §6 active token classes to the active segment', async () => {
render(SegmentedControl, { props: { options, value: 'a', onChange: vi.fn() } });
const activeRadio = page.getByRole('radio', { name: 'Option A' });
await expect.element(activeRadio).toHaveClass(/bg-primary/);
await expect.element(activeRadio).toHaveClass(/text-primary-fg/);
});
it('applies §6 inactive token classes to inactive segments', async () => {
render(SegmentedControl, { props: { options, value: 'a', onChange: vi.fn() } });
const inactiveRadio = page.getByRole('radio', { name: 'Option B' });
await expect.element(inactiveRadio).toHaveClass(/bg-surface/);
await expect.element(inactiveRadio).toHaveClass(/text-ink-2/);
});
it('segment labels are rendered as plain text — no innerHTML injection', async () => {
const xssOptions = [{ value: 'x', label: '<img src=x onerror=alert(1)>' }];
render(SegmentedControl, { props: { options: xssOptions, value: 'x', onChange: vi.fn() } });
// If the label were injected via {@html}, an <img> element would appear
const imgs = document.querySelectorAll('img');
expect(imgs.length).toBe(0);
// The raw text string should appear literally
const radio = document.querySelector('[role="radio"]');
expect(radio?.textContent).toContain('<img');
});
it('wrapper has border border-line rounded-sm tokens', async () => {
render(SegmentedControl, { props: { options, value: 'a', onChange: vi.fn() } });
const group = document.querySelector('[role="radiogroup"]');
expect(group?.className).toMatch(/border-line/);
expect(group?.className).toMatch(/rounded-sm/);
});
});
// ─── Interaction ──────────────────────────────────────────────────────────────
describe('SegmentedControl — click selection', () => {
it('clicking an inactive segment calls onChange with its value', async () => {
const onChange = vi.fn();
render(SegmentedControl, { props: { options, value: 'a', onChange } });
await page.getByRole('radio', { name: 'Option B' }).click();
expect(onChange).toHaveBeenCalledWith('b');
});
it('clicking the already-active segment still calls onChange', async () => {
const onChange = vi.fn();
render(SegmentedControl, { props: { options, value: 'a', onChange } });
await page.getByRole('radio', { name: 'Option A' }).click();
expect(onChange).toHaveBeenCalledWith('a');
});
});
// ─── Keyboard ─────────────────────────────────────────────────────────────────
describe('SegmentedControl — keyboard roving tabindex', () => {
it('ArrowRight moves focus and selection to the next segment', async () => {
const onChange = vi.fn();
render(SegmentedControl, { props: { options, value: 'a', onChange } });
await page.getByRole('radio', { name: 'Option A' }).click();
await userEvent.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenCalledWith('b');
});
it('ArrowLeft moves focus to the previous segment', async () => {
const onChange = vi.fn();
render(SegmentedControl, { props: { options, value: 'b', onChange } });
await page.getByRole('radio', { name: 'Option B' }).click();
await userEvent.keyboard('{ArrowLeft}');
expect(onChange).toHaveBeenCalledWith('a');
});
it('ArrowRight wraps from last to first', async () => {
const onChange = vi.fn();
render(SegmentedControl, { props: { options, value: 'c', onChange } });
await page.getByRole('radio', { name: 'Option C' }).click();
await userEvent.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenCalledWith('a');
});
it('ArrowLeft wraps from first to last', async () => {
const onChange = vi.fn();
render(SegmentedControl, { props: { options, value: 'a', onChange } });
await page.getByRole('radio', { name: 'Option A' }).click();
await userEvent.keyboard('{ArrowLeft}');
expect(onChange).toHaveBeenCalledWith('c');
});
});
// ─── Touch target ─────────────────────────────────────────────────────────────
describe('SegmentedControl — geometry', () => {
it('each segment meets the 44px min-height touch target', async () => {
render(SegmentedControl, { props: { options, value: 'a', onChange: vi.fn() } });
const radios = document.querySelectorAll('[role="radio"]');
for (const radio of Array.from(radios)) {
expect(radio.className).toMatch(/min-h-\[44px\]/);
}
});
it('each segment has the px-4 py-[9px] padding classes', async () => {
render(SegmentedControl, { props: { options, value: 'a', onChange: vi.fn() } });
const radios = document.querySelectorAll('[role="radio"]');
for (const radio of Array.from(radios)) {
expect(radio.className).toMatch(/px-4/);
expect(radio.className).toMatch(/py-\[9px\]/);
}
});
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import SegmentedControl from './SegmentedControl.svelte';
type Theme = 'light' | 'dark';
@@ -24,51 +25,31 @@ const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() {
theme = theme === 'dark' ? 'light' : 'dark';
const themeOptions = $derived([
{ value: 'light', label: m.theme_segment_light() },
{ value: 'dark', label: m.theme_segment_dark() }
]);
function handleChange(next: string) {
if (next !== 'light' && next !== 'dark') return;
theme = next as Theme;
localStorage.setItem('theme', theme);
document.documentElement.setAttribute('data-theme', theme);
}
</script>
<button
type="button"
onclick={toggle}
aria-label={themeLabel}
title={themeLabel}
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if theme === 'dark'}
<!-- Sun icon — click to go light -->
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
/>
</svg>
{:else}
<!-- Moon icon — click to go dark -->
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
{/if}
</button>
<!--
ThemeToggle — binary light/dark segmented control.
File kept at $lib/shared/primitives/ThemeToggle.svelte (required by #862).
Boot FOUC prevention: a tiny inline script in <head> reads localStorage['theme']
and sets data-theme before paint — that script is unchanged by this refactor.
The aria-label on the group communicates the toggle purpose to screen readers.
-->
<div aria-label={themeLabel} title={themeLabel}>
<SegmentedControl
options={themeOptions}
value={theme}
onChange={handleChange}
label={m.theme_toggle_label()}
/>
</div>

View File

@@ -8,38 +8,59 @@ afterEach(() => {
localStorage.removeItem('theme');
});
describe('ThemeToggle — label derivation (light mode)', () => {
describe('ThemeToggle — renders segments (light mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('aria-label invites switching to dark mode when theme is light', async () => {
it('renders a radiogroup with Hell and Dunkel segments', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
expect(document.querySelector('[role="radiogroup"]')).not.toBeNull();
await expect.element(page.getByRole('radio', { name: 'Hell' })).toBeVisible();
await expect.element(page.getByRole('radio', { name: 'Dunkel' })).toBeVisible();
});
it('title equals aria-label in light mode', async () => {
it('Hell segment is aria-checked="true" in light mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
await expect
.element(page.getByRole('radio', { name: 'Hell' }))
.toHaveAttribute('aria-checked', 'true');
await expect
.element(page.getByRole('radio', { name: 'Dunkel' }))
.toHaveAttribute('aria-checked', 'false');
});
});
describe('ThemeToggle — label derivation (dark mode)', () => {
describe('ThemeToggle — renders segments (dark mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'dark');
});
it('aria-label invites switching to light mode when theme is dark', async () => {
it('Dunkel segment is aria-checked="true" in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
});
it('title equals aria-label in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
await expect
.element(page.getByRole('radio', { name: 'Dunkel' }))
.toHaveAttribute('aria-checked', 'true');
await expect
.element(page.getByRole('radio', { name: 'Hell' }))
.toHaveAttribute('aria-checked', 'false');
});
});
describe('ThemeToggle — theme switching', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('clicking Dunkel sets data-theme=dark on documentElement', async () => {
render(ThemeToggle);
await page.getByRole('radio', { name: 'Dunkel' }).click();
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('clicking Dunkel persists theme in localStorage', async () => {
render(ThemeToggle);
await page.getByRole('radio', { name: 'Dunkel' }).click();
expect(localStorage.getItem('theme')).toBe('dark');
});
});

View File

@@ -42,14 +42,16 @@ function reset() {
</script>
{#snippet layerToggle(label: string, testid: string, pressed: boolean, toggle: () => void)}
<!-- §6 re-skin: bg-primary/text-primary-fg (active) · bg-surface/text-ink-2 (inactive)
Typography: Montserrat 12px/700 tracking-[.08em] UPPERCASE; min-h-[44px] touch target -->
<button
type="button"
data-testid={testid}
aria-pressed={pressed}
onclick={toggle}
class="inline-flex min-h-[44px] items-center gap-2 rounded border px-3 font-sans text-sm transition-colors {pressed
class="inline-flex min-h-[44px] items-center gap-2 rounded-sm border px-4 py-[9px] font-sans text-xs font-bold tracking-[.08em] uppercase transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {pressed
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-muted text-ink-2 hover:bg-line'}"
: 'hover:bg-surface-2 border-line bg-surface text-ink-2'}"
>
<span
aria-hidden="true"
@@ -73,7 +75,7 @@ function reset() {
aria-expanded={open}
aria-controls={open ? 'timeline-filter-panel' : undefined}
onclick={() => (open = !open)}
class="inline-flex min-h-[44px] items-center gap-2 rounded border border-line bg-surface px-4 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
class="hover:bg-surface-2 inline-flex min-h-[44px] items-center gap-2 rounded-sm border border-line bg-surface px-4 font-sans text-xs font-bold tracking-[.08em] text-ink-2 uppercase transition-colors"
>
{hiddenCount === 0
? m.timeline_filter_trigger()

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

@@ -1,11 +1,9 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
import SegmentedControl from '$lib/shared/primitives/SegmentedControl.svelte';
type GeschichteType = 'STORY' | 'JOURNEY';
const TYPES: GeschichteType[] = ['STORY', 'JOURNEY'];
interface Props {
onweiter: (type: GeschichteType) => void;
}
@@ -15,10 +13,6 @@ let { onweiter }: Props = $props();
let selected = $state<GeschichteType | null>(null);
let announcement = $state('');
// Roving-tabindex holder: falls back to the first card so keyboard nav can start
// even when nothing is selected (all cards at tabindex=-1 would be a keyboard dead-spot).
const rovingFocusType = $derived(selected ?? TYPES[0]);
function select(type: GeschichteType) {
selected = type;
announcement = '';
@@ -32,15 +26,10 @@ function handleWeiter() {
onweiter(selected);
}
const titles: Record<GeschichteType, () => string> = {
STORY: m.journey_selector_story_title,
JOURNEY: m.journey_selector_journey_title
};
const descs: Record<GeschichteType, () => string> = {
STORY: m.journey_selector_story_desc,
JOURNEY: m.journey_selector_journey_desc
};
const typeOptions = [
{ value: 'STORY', label: m.journey_selector_story_title() },
{ value: 'JOURNEY', label: m.journey_selector_journey_title() }
];
</script>
<div>
@@ -48,31 +37,14 @@ const descs: Record<GeschichteType, () => string> = {
{m.journey_selector_question()}
</p>
<div
role="radiogroup"
aria-labelledby="type-selector-label"
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
use:radioGroupNav={(v) => {
if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType);
<SegmentedControl
options={typeOptions}
value={selected ?? ''}
onChange={(v) => {
if (v === 'STORY' || v === 'JOURNEY') select(v);
}}
>
{#each TYPES as type (type)}
<button
type="button"
role="radio"
value={type}
aria-checked={selected === type}
tabindex={type === rovingFocusType ? 0 : -1}
onclick={() => select(type)}
class="min-h-[64px] cursor-pointer rounded border px-4 py-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected === type
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink hover:border-primary/50'}"
>
<span class="block font-sans text-sm font-bold">{titles[type]()}</span>
<span class="mt-1 block font-sans text-xs text-current opacity-70">{descs[type]()}</span>
</button>
{/each}
</div>
label={m.journey_selector_question()}
/>
<div aria-live="polite" aria-atomic="true" class="sr-only">{announcement}</div>

View File

@@ -18,13 +18,18 @@ describe('TypeSelector', () => {
await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible();
});
it('radiogroup is correctly labelled', async () => {
it('radiogroup is correctly labelled (via aria-label or aria-labelledby)', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const group = document.querySelector('[role="radiogroup"]');
const labelledBy = group?.getAttribute('aria-labelledby');
// SegmentedControl uses aria-label; the old TypeSelector used aria-labelledby.
// Accept either as long as the accessible name is non-empty.
const ariaLabel = group?.getAttribute('aria-label') ?? '';
const labelledBy = group?.getAttribute('aria-labelledby') ?? '';
const labelEl = labelledBy ? document.getElementById(labelledBy) : null;
expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0);
const hasAccessibleName =
ariaLabel.trim().length > 0 || (labelEl?.textContent?.trim().length ?? 0) > 0;
expect(hasAccessibleName).toBe(true);
});
it('Weiter button has aria-disabled=true when nothing is selected', async () => {

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}