feat(team): Porträts der digitalen Mitarbeiter aus dem Konzept übernehmen

Bisher zeigten die Avatare Initialen auf einer Tonfarbe. Ich hatte das bewusst
so gebaut, weil erfundene Gesichter in einer Kundendemo irreführend wären — das
Konzeptdokument «Agenten_Bewirtschaftung_v2.html» trifft diese Entscheidung
jedoch bereits: dort hängt an jedem der 36 Agenten ein Porträt als eingebettetes
JPEG. Die sieben Kernagenten sind daraus extrahiert.

- src/assets/team/*.jpg — sieben Porträts, je rund 6 KB, Zuordnung über die
  Mailadresse aus dem Konzept, die exakt unseren Agenten-IDs entspricht
- agentPhotos.ts hält die Zuordnung in der Darstellungsschicht. Ein Domain-Typ,
  der Binärdateien importiert, wäre an den Bundler gekoppelt und weder im Test
  noch später gegen ein echtes Backend sauber verwendbar.
- Fehlt zu einer ID ein Bild, zeigt der Avatar weiterhin die Initialen; dasselbe
  greift, wenn das Laden fehlschlägt

Der zugängliche Name hängt weiterhin an genau einem Element, nie an zweien: mit
Porträt am erzeugten <img> über `alt`, ohne Porträt am Container über role="img"
und aria-label.

vite-env.d.ts ergänzt — die Referenz auf vite/client fehlte, ohne sie kennt
TypeScript die Modul-Deklarationen für Assets nicht.

415 Tests grün, Build grün, die sieben JPEGs werden korrekt als Assets emittiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-08-03 18:23:03 +02:00
parent a7e05fb7f0
commit 576733d7da
11 changed files with 64 additions and 9 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+12 -5
View File
@@ -4,6 +4,7 @@ import { Check } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent' import type { TeamAgent } from '../../domain/teamAgent'
import { AgentStatus } from '../../domain/teamAgent' import { AgentStatus } from '../../domain/teamAgent'
import { AVATAR_TONE_COLOR } from './teamTokens' import { AVATAR_TONE_COLOR } from './teamTokens'
import { AGENT_PHOTOS } from './agentPhotos'
import { AGENT_STATUS_LABELS } from '../../lib/constants' import { AGENT_STATUS_LABELS } from '../../lib/constants'
import { DS_BG, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_TEXT } from '../../lib/ds'
@@ -39,15 +40,20 @@ export const AgentAvatar = memo(function AgentAvatar({ agent, size = 'medium', s
const isActive = agent.status === AgentStatus.ACTIVE const isActive = agent.status === AgentStatus.ACTIVE
const statusLabel = AGENT_STATUS_LABELS[agent.status] ?? agent.status const statusLabel = AGENT_STATUS_LABELS[agent.status] ?? agent.status
const badgePx = size === 'large' ? 20 : 14 const badgePx = size === 'large' ? 20 : 14
const photo = AGENT_PHOTOS[agent.id]
const label = `${agent.name}, ${agent.role}`
return ( return (
<Box sx={{ position: 'relative', width: px, height: px, flexShrink: 0 }}> <Box sx={{ position: 'relative', width: px, height: px, flexShrink: 0 }}>
{/* `alt` reicht MUI ausschliesslich an den img-Slot weiter, und ohne `src` {/* Der zugängliche Name hängt an genau einem Element, nie an zweien:
rendert Avatar gar kein <img> — die Beschriftung landete damit nirgends mit Porträt trägt ihn das von MUI erzeugte <img> über `alt`, ohne
im DOM. Der zugängliche Name muss deshalb direkt auf das Element. */} Porträt der Container über role="img" und aria-label. `alt` allein
genügt nämlich nicht — ohne `src` rendert MUI gar kein <img>, und der
Wert landet nirgends im DOM. */}
<Avatar <Avatar
role="img" src={photo}
aria-label={`${agent.name}, ${agent.role}`} alt={photo ? label : undefined}
{...(photo ? {} : { role: 'img', 'aria-label': label })}
sx={{ sx={{
width: px, width: px,
height: px, height: px,
@@ -57,6 +63,7 @@ export const AgentAvatar = memo(function AgentAvatar({ agent, size = 'medium', s
fontWeight: 700, fontWeight: 700,
}} }}
> >
{/* Fällt das Bild aus, zeigt MUI wieder die Initialen statt eines Lochs. */}
{initialsOf(agent.name)} {initialsOf(agent.name)}
</Avatar> </Avatar>
@@ -139,9 +139,20 @@ describe('AgentCard', () => {
expect(within(card).getByText(/Zuletzt aktiv|Noch nicht gelaufen/)).toBeInTheDocument() expect(within(card).getByText(/Zuletzt aktiv|Noch nicht gelaufen/)).toBeInTheDocument()
}) })
it('rendert den Avatar mit den Initialen des Mitarbeiters', () => { it('rendert das Porträt des Mitarbeiters', () => {
renderWithProviders(<AgentCard agent={ferdi} onOpen={vi.fn()} />) renderWithProviders(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
const portrait = screen.getByRole('img', { name: `${ferdi.name}, ${ferdi.role}` })
expect(portrait).toHaveAttribute('src')
expect(portrait.getAttribute('src')).toBeTruthy()
})
it('fällt ohne hinterlegtes Porträt auf die Initialen zurück', () => {
// Ein Mitarbeiter, zu dem in `agentPhotos.ts` kein Bild liegt — so verhält
// sich die Karte, falls der Katalog später um jemanden ohne Foto wächst.
const ohneFoto = { ...ferdi, id: 'unbekannt' }
renderWithProviders(<AgentCard agent={ohneFoto} onOpen={vi.fn()} />)
const card = screen.getByRole('article') const card = screen.getByRole('article')
expect(within(card).getByText(ferdi.name.slice(0, 2).toUpperCase())).toBeInTheDocument() expect(within(card).getByText(ferdi.name.slice(0, 2).toUpperCase())).toBeInTheDocument()
}) })
@@ -149,9 +160,8 @@ describe('AgentCard', () => {
it('gibt dem Avatar einen zugänglichen Namen aus Name und Rolle', () => { it('gibt dem Avatar einen zugänglichen Namen aus Name und Rolle', () => {
renderWithProviders(<AgentCard agent={ferdi} onOpen={vi.fn()} />) renderWithProviders(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
// `alt` allein genügt bei MUI nicht: ohne `src` rendert Avatar kein <img>, // Der Name hängt an genau einem Element: mit Porträt am <img> über `alt`,
// und der Wert landet nirgends im DOM. Der Name muss deshalb über // ohne Porträt am Container über role="img" + aria-label (§18).
// role="img" + aria-label direkt am Element hängen (§18).
expect( expect(
screen.getByRole('img', { name: `${ferdi.name}, ${ferdi.role}` }), screen.getByRole('img', { name: `${ferdi.name}, ${ferdi.role}` }),
).toBeInTheDocument() ).toBeInTheDocument()
+33
View File
@@ -0,0 +1,33 @@
/**
* Property On — Porträts der digitalen Mitarbeiter.
*
* Die Bilder stammen aus dem Konzeptdokument «Agenten_Bewirtschaftung_v2.html»,
* wo sie als eingebettete JPEGs am jeweiligen Agenten hängen. Sie sind damit
* Teil der fachlichen Vorgabe, nicht eine gestalterische Zutat: das digitale
* Personalbüro zeigt Mitarbeiter mit Gesicht, nicht abstrakte Werkzeuge.
*
* Die Zuordnung liegt bewusst in der Darstellungsschicht und nicht in
* `domain/teamAgent.ts` oder den Mockdaten. Ein Domain-Typ, der Binärdateien
* importiert, wäre an den Bundler gekoppelt und liesse sich weder in einem Test
* noch später gegen ein echtes Backend sauber verwenden.
*
* Fehlt zu einer ID ein Bild, fällt `AgentAvatar` auf die Initialen zurück.
*/
import bruno from '../../assets/team/bruno.jpg'
import ferdi from '../../assets/team/ferdi.jpg'
import lea from '../../assets/team/lea.jpg'
import livia from '../../assets/team/livia.jpg'
import nora from '../../assets/team/nora.jpg'
import reto from '../../assets/team/reto.jpg'
import sina from '../../assets/team/sina.jpg'
export const AGENT_PHOTOS: Record<string, string | undefined> = {
bruno,
ferdi,
lea,
livia,
nora,
reto,
sina,
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
// Ohne diese Referenz kennt TypeScript die von Vite bereitgestellten
// Modul-Deklarationen für Assets nicht — ein `import portrait from './x.jpg'`
// würde als Fehler gemeldet, obwohl der Bundler es korrekt auflöst.