[Mappe·Shared] Avatar + avatarFor(name) — one deterministic name-hash avatar (§5) #855

Open
opened 2026-06-16 10:53:27 +02:00 by marcel · 0 comments
Owner

Shared component · Story 3. Part of #853. The most consequential token violation.

Context

Two conflicting avatar systems exist: personFormat.ts → personAvatarColor(id) uses a 5-color palette hashed by person-id (djb2); ReaderPersonChips duplicates it with a divergent 5-color palette; PersonCard and CoCorrespondentsList just paint a flat bg-primary. DESIGN_RULES §5 mandates one util: a 10-color palette hashed by name, white Montserrat initials, so the same person is the same color on every screen.

Scope

  • Create $lib/shared/avatar.tsavatarFor(name): hash h = (h*31 + name.charCodeAt(i)) >>> 0, name-keyed, over the 10-color §1 palette; initials = first + last initial. Both the hash util and the palette constant live in $lib/shared/Avatar.svelte (shared primitive) must not import from $lib/person/ or any domain; a shared→domain import violates eslint.config.js boundaries/dependencies (constitution §1.4).
  • Create $lib/shared/primitives/Avatar.svelte — props name, size ∈ {26,28,40,48}; plus a stacked variant (-ml-1.5 + ring-2 ring-surface). The name prop is a plain string — never pass a Person/AppUser entity; the primitive must not know about either domain.
  • Replace all call sites: PersonChip, PersonCard, CoCorrespondentsList, ReaderPersonChips (delete its local copy), and fold ContributorStack onto the stacked variant (fix ring-white → ring-surface; size 22 → 26).
  • Delete the now-orphaned personAvatarColor/AVATAR_PALETTE in personFormat.ts and migrate any existing tests in personFormat.spec.ts to avatar.spec.ts.
  • The avatarFor initials line must not be ported verbatim from the §5 prototype — guard against empty/single-token names (parts[0][0] throws on ""): an empty name yields safe fallback (empty initials, palette[0]); a single-token name yields one initial.
  • ContributorStack overflow/placeholder states: the Avatar primitive must support an overflow chip (+N) and an empty/dashed-border placeholder slot so ContributorStack's current states survive in the stacked variant.
  • Dark mode: ReaderPersonChips carries dark:shadow-none dark:ring-1 dark:ring-white/10 — preserve an equivalent dark-mode edge in Avatar.svelte so the dashboard reader chips retain definition against --c-surface #011526. Verify the 10 palette colors pass contrast against the dark canvas.
  • name-hash trade-off (acknowledged): §5 chose name-hash (stable across screens) over id-hash (stable across renames) deliberately; do not "fix" it back to id-hash.

Acceptance

  • Same name → same color on Personen, PersonDetail, Geschichte byline, activity feed, Stammbaum (verify each surface individually so a per-site regression is bisectable)
  • 10-color palette constant lives in $lib/shared/avatar.ts; one named export; no hex values duplicated elsewhere
  • Hash util and palette in $lib/shared/npm run lint passes with no boundary violation
  • Initials: Montserrat 700 white; sizes 26/28/40/48; stacked variant uses ring-surface
  • No duplicate hash util remains; personAvatarColor/AVATAR_PALETTE deleted from personFormat.ts
  • Empty name → safe fallback (empty initials, palette[0]); single-token name → one initial
  • Overflow +N chip and empty/placeholder slot states covered (for ContributorStack)
  • Contrast floor: white initials meet ≥ 4.5:1 on every palette color at 26/28px sizes (amber #c17a00 ~2.9:1 and sand #9a8040 ~3.3:1 currently fail AA for normal text at those sizes); those swatches must use a darkened variant or the AA floor must be met — measure and document
  • a11y: decorative avatar (name already adjacent, e.g. PersonChip/Card) → aria-hidden="true"; standalone avatar → role="img" + aria-label={name} (attribute binding, never string concatenation)
  • Security: name and initials render via default Svelte {...} escaping; {@html} is never used anywhere in the component
  • Dark mode: Avatar.svelte preserves a ring/shadow edge in dark mode; all 10 palette colors verified against --c-surface #011526 (run axe in dark mode)
  • No overflow at 320px in ReaderPersonChips grid (grid-cols-2 sm:grid-cols-4) with 48px avatar + name
  • Unit tests: avatar.spec.ts — determinism (same name → same bg), palette.length === 10, first+last initials, empty/single-token guard; Avatar.svelte.spec.ts — renders initials, applies size class, stacked adds -ml-1.5 ring-2 ring-surface
  • Accepted visible change: existing avatar colors shift (approved as part of the reskin)

Out of scope

  • Avatar image uploads (initials-only; no upload surface)
  • The §6 segmented-control work

Depends on: token close-out (§1 palette token must be merged before this starts). Blocks: every page with avatars. Refs: DESIGN_RULES §5, the prototypes' av() helper, _AUTHORING_KIT.md §1, constitution §1.4.

**Shared component · Story 3.** Part of #853. **The most consequential token violation.** ## Context Two conflicting avatar systems exist: `personFormat.ts → personAvatarColor(id)` uses a **5-color palette hashed by person-id** (djb2); `ReaderPersonChips` duplicates it with a *divergent* 5-color palette; `PersonCard` and `CoCorrespondentsList` just paint a flat `bg-primary`. `DESIGN_RULES §5` mandates **one** util: a 10-color palette hashed **by name**, white Montserrat initials, so the same person is the same color on every screen. ## Scope - Create `$lib/shared/avatar.ts` — `avatarFor(name)`: hash `h = (h*31 + name.charCodeAt(i)) >>> 0`, name-keyed, over the **10-color §1 palette**; initials = first + last initial. **Both the hash util and the palette constant live in `$lib/shared/`** — `Avatar.svelte` (shared primitive) must not import from `$lib/person/` or any domain; a shared→domain import violates `eslint.config.js boundaries/dependencies` (constitution §1.4). - Create `$lib/shared/primitives/Avatar.svelte` — props `name`, `size` ∈ {26,28,40,48}; plus a `stacked` variant (`-ml-1.5` + `ring-2 ring-surface`). The `name` prop is a plain `string` — never pass a Person/AppUser entity; the primitive must not know about either domain. - Replace **all** call sites: `PersonChip`, `PersonCard`, `CoCorrespondentsList`, `ReaderPersonChips` (delete its local copy), and fold `ContributorStack` onto the stacked variant (fix `ring-white → ring-surface`; size 22 → 26). - **Delete** the now-orphaned `personAvatarColor`/`AVATAR_PALETTE` in `personFormat.ts` and migrate any existing tests in `personFormat.spec.ts` to `avatar.spec.ts`. - The `avatarFor` initials line must **not** be ported verbatim from the §5 prototype — guard against empty/single-token names (`parts[0][0]` throws on `""`): an empty name yields safe fallback (empty initials, `palette[0]`); a single-token name yields one initial. - **`ContributorStack` overflow/placeholder states:** the `Avatar` primitive must support an overflow chip (`+N`) and an empty/dashed-border placeholder slot so `ContributorStack`'s current states survive in the stacked variant. - **Dark mode:** `ReaderPersonChips` carries `dark:shadow-none dark:ring-1 dark:ring-white/10` — preserve an equivalent dark-mode edge in `Avatar.svelte` so the dashboard reader chips retain definition against `--c-surface #011526`. Verify the 10 palette colors pass contrast against the dark canvas. - **name-hash trade-off (acknowledged):** §5 chose name-hash (stable across screens) over id-hash (stable across renames) deliberately; do not "fix" it back to id-hash. ## Acceptance - [ ] Same name → same color on Personen, PersonDetail, Geschichte byline, activity feed, Stammbaum (verify each surface individually so a per-site regression is bisectable) - [ ] 10-color palette constant lives in `$lib/shared/avatar.ts`; one named export; no hex values duplicated elsewhere - [ ] Hash util and palette in `$lib/shared/` — `npm run lint` passes with no boundary violation - [ ] Initials: Montserrat 700 white; sizes 26/28/40/48; stacked variant uses `ring-surface` - [ ] No duplicate hash util remains; `personAvatarColor`/`AVATAR_PALETTE` deleted from `personFormat.ts` - [ ] Empty name → safe fallback (empty initials, `palette[0]`); single-token name → one initial - [ ] Overflow `+N` chip and empty/placeholder slot states covered (for `ContributorStack`) - [ ] **Contrast floor:** white initials meet ≥ 4.5:1 on every palette color at 26/28px sizes (amber `#c17a00` ~2.9:1 and sand `#9a8040` ~3.3:1 currently fail AA for normal text at those sizes); those swatches must use a darkened variant or the AA floor must be met — measure and document - [ ] **a11y:** decorative avatar (name already adjacent, e.g. PersonChip/Card) → `aria-hidden="true"`; standalone avatar → `role="img"` + `aria-label={name}` (attribute binding, never string concatenation) - [ ] **Security:** name and initials render via default Svelte `{...}` escaping; `{@html}` is never used anywhere in the component - [ ] Dark mode: `Avatar.svelte` preserves a ring/shadow edge in dark mode; all 10 palette colors verified against `--c-surface #011526` (run axe in dark mode) - [ ] No overflow at 320px in `ReaderPersonChips` grid (`grid-cols-2 sm:grid-cols-4`) with 48px avatar + name - [ ] Unit tests: `avatar.spec.ts` — determinism (same name → same bg), `palette.length === 10`, first+last initials, empty/single-token guard; `Avatar.svelte.spec.ts` — renders initials, applies size class, `stacked` adds `-ml-1.5 ring-2 ring-surface` - [ ] **Accepted visible change:** existing avatar colors shift (approved as part of the reskin) ## Out of scope - Avatar image uploads (initials-only; no upload surface) - The §6 segmented-control work **Depends on:** token close-out (§1 palette token must be merged before this starts). **Blocks:** every page with avatars. **Refs:** `DESIGN_RULES §5`, the prototypes' `av()` helper, `_AUTHORING_KIT.md §1`, constitution §1.4.
marcel added this to the Mappe Visual Redesign milestone 2026-06-16 10:53:27 +02:00
marcel added the P1-highfeatureredesign-mappeui labels 2026-06-16 11:06:16 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#855