FEAT-4: interactive Stammbaum viewer - foreignObject cards (photo slot, sex, Farbschlag chip, year), tap-to-re-root, lazy expand, zoom controls + CSS-grid print pedigree & Inzuchtkoeffizient slot

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 00:22:45 +02:00
parent 61cfab046a
commit 9c0a2fec56
2 changed files with 784 additions and 19 deletions

View File

@@ -1,26 +1,269 @@
/**
* FEAT-4: Stammbaum (Ahnentafel) — interaktiver Stammbaum-Viewer.
*
* Erster Ausbauschritt: Route + Grundgerüst (Tier laden, Titel, Rücksprung).
* Der eigentliche Viewer (react-d3-tree, Druckansicht, Inzuchtkoeffizient)
* folgt in den nächsten FEAT-4-Commits.
* - react-d3-tree, horizontal (klassische Ahnentafel: Tier links, Vorfahren
* rechts; Vater oben, Mutter unten), Pinch-Zoom/Pan via d3-zoom plus
* explizite +//Einpassen-Buttons.
* - Karten als foreignObject-HTML: Foto-Platzhalter (Fotos kommen mit FEAT-6),
* Name, ♂/♀, Farbschlag-Chip, Geburtsjahr. Tipp auf eine Karte wurzelt den
* Baum auf dieses Tier um; der Quellen-Cache bleibt erhalten, dadurch ist
* der Wechsel unmittelbar.
* - Tiefe: 4 Generationen beim Laden (Board-Entscheidung), tiefere werden pro
* Ast über den -Knopf nachgeladen. Die Tiefenbegrenzung steckt im
* Datenmodul (src/pedigree), nicht in react-d3-trees initialDepth — so sind
* nachgeladene Generationen sofort sichtbar und es wird nie blind vorab
* geladen.
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
*/
import { Link, useParams } from 'react-router-dom'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
import { de } from '../strings/de'
import { getGerbil } from '../api/gerbils'
import { ApiError } from '../api/client'
import { listColorVarieties } from '../api/lookups'
import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
import {
DEFAULT_GENERATIONS,
ancestorsAt,
buildPedigree,
collectNodes,
expandPedigree,
toRawNodeDatum,
} from '../pedigree/build'
import { chipColorFor } from '../pedigree/chipColors'
import { createApiPedigreeSource } from '../pedigree/source'
import type { AnimalNode, PedigreeNode } from '../pedigree/types'
import './stammbaum.css'
/* Kartengeometrie (px im Baum-Koordinatensystem). */
const CARD_W = 190
const CARD_H = 88
/* nodeSize bei horizontaler Orientierung: x = Abstand der Generationen,
y = vertikales Raster der Karten (siehe react-d3-tree generateTree). */
const NODE_X = 250
const NODE_Y = 100
const ZOOM_MIN = 0.2
const ZOOM_MAX = 2
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function errorMessage(err: unknown): string {
if (err instanceof ApiError) return err.message
return de.api.errors.unknown
}
/** Tiefe und Blattzahl des sichtbaren Baums (für „Einpassen“). */
function datumExtent(datum: RawNodeDatum): { depth: number; leaves: number } {
if (!datum.children || datum.children.length === 0) return { depth: 0, leaves: 1 }
let depth = 0
let leaves = 0
for (const child of datum.children) {
const e = datumExtent(child)
depth = Math.max(depth, e.depth + 1)
leaves += e.leaves
}
return { depth, leaves }
}
interface View {
zoom: number
translate: Point
}
interface TreeState {
forId: string | null
root: AnimalNode | null
error: string | null
}
export default function StammbaumPage() {
const t = de.pages.stammbaum
const { id = '' } = useParams()
const navigate = useNavigate()
const gerbil = useApi(() => getGerbil(id), [id])
/* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */
const [source] = useState(createApiPedigreeSource)
const [tree, setTree] = useState<TreeState>({ forId: null, root: null, error: null })
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
if (gerbil.error || !gerbil.data) {
useEffect(() => {
let cancelled = false
buildPedigree(source, id)
.then((root) => {
if (!cancelled) setTree({ forId: id, root, error: root ? null : t.notFound })
})
.catch((err: unknown) => {
if (!cancelled) setTree({ forId: id, root: null, error: errorMessage(err) })
})
return () => {
cancelled = true
}
}, [id, source, t])
const loading = tree.forId !== id
const root = loading ? null : tree.root
const handleExpand = useCallback(
(path: string) => {
const current = tree.root
if (!current || tree.forId !== id) return
expandPedigree(source, current, path)
.then((next) =>
// Nur übernehmen, wenn inzwischen nicht umgewurzelt wurde.
setTree((s) => (s.forId === id && s.root === current ? { ...s, root: next } : s)),
)
.catch(() => {
/* Nachladen fehlgeschlagen: Baum unverändert lassen; erneuter Tipp versucht es wieder. */
})
},
[tree, source, id],
)
/* ── Farbschlag: Lookup-Name, sonst aus dem Genotyp (GEN-1) ── */
const colorVarieties = useApi(() => listColorVarieties(), [])
const colorNameById = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
const farbschlagOf = useCallback(
(g: Gerbil): string | null => {
const byId = g.colorVarietyId ? colorNameById.get(g.colorVarietyId) : undefined
if (byId) return byId
if (g.genotype && g.genotype.trim()) {
try {
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))
return name === UNKNOWN_FARBSCHLAG ? null : name
} catch {
return null
}
}
return null
},
[colorNameById],
)
/* ── Inzuchtkoeffizient (Endpunkt folgt serverseitig — bis dahin 404) ── */
const inbreeding = useApi(() => getInbreedingCoefficient(id), [id])
const inbreedingText =
!inbreeding.loading && !inbreeding.error && inbreeding.data != null
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
: t.inbreeding.unavailable
/* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
/* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */
const canvasRef = useRef<HTMLDivElement | null>(null)
const [size, setSize] = useState<{ w: number; h: number } | null>(null)
const hasRoot = root !== null
useEffect(() => {
const el = canvasRef.current
if (!el) return
const update = () => setSize({ w: el.clientWidth, h: el.clientHeight })
update()
const observer = new ResizeObserver(update)
observer.observe(el)
return () => observer.disconnect()
}, [hasRoot])
/* ── Ansicht (Zoom/Position): Wurzelkarte beim Laden zentriert ── */
const defaultView = useMemo<View | null>(() => {
if (!size) return null
const zoom = size.w < 520 ? 0.8 : 1
return {
zoom,
translate: { x: Math.min(size.w / 2, CARD_W * zoom + 48), y: size.h / 2 },
}
}, [size])
const [viewOverride, setViewOverride] = useState<(View & { forId: string }) | null>(null)
const view = viewOverride && viewOverride.forId === id ? viewOverride : defaultView
/* Letzter Live-Stand aus d3 (Nutzer-Pan/-Pinch), Basis der Zoom-Buttons. */
const liveView = useRef<(View & { forId: string }) | null>(null)
const handleTreeUpdate = useCallback(
(target: { node: unknown; zoom: number; translate: Point }) => {
liveView.current = { forId: id, zoom: target.zoom, translate: target.translate }
},
[id],
)
const setView = useCallback(
(next: View) => {
setViewOverride({ forId: id, ...next })
liveView.current = { forId: id, ...next }
},
[id],
)
/** Zoomt um `factor` um die Mitte der Zeichenfläche. */
const applyZoom = useCallback(
(factor: number) => {
if (!size || !view) return
const current = liveView.current && liveView.current.forId === id ? liveView.current : view
const zoom = clamp(current.zoom * factor, ZOOM_MIN, ZOOM_MAX)
const k = zoom / current.zoom
const c = { x: size.w / 2, y: size.h / 2 }
setView({
zoom,
translate: {
x: c.x - (c.x - current.translate.x) * k,
y: c.y - (c.y - current.translate.y) * k,
},
})
},
[size, view, id, setView],
)
/** Passt den ganzen Baum in die Zeichenfläche ein. */
const fitView = useCallback(() => {
if (!size || !datum) return
const extent = datumExtent(datum)
const width = extent.depth * NODE_X + CARD_W + 32
const height = extent.leaves * NODE_Y + 32
const zoom = clamp(Math.min(size.w / width, size.h / height), ZOOM_MIN, ZOOM_MAX)
setView({ zoom, translate: { x: (CARD_W / 2 + 16) * zoom, y: size.h / 2 } })
}, [size, datum, setView])
/* ── Ahnenkarten ── */
const renderNode = useCallback(
({ nodeDatum }: CustomNodeElementProps) => {
const path = String(nodeDatum.attributes?.path ?? '')
const node = nodesByPath?.get(path)
return (
<g>
<foreignObject width={CARD_W} height={CARD_H} x={-CARD_W / 2} y={-CARD_H / 2}>
{!node || node.kind === 'unknown' ? (
<div className="pedigree-card pedigree-card--unknown">{t.unknown}</div>
) : (
<PedigreeCard
node={node}
isRoot={path === ''}
farbschlag={farbschlagOf(node.gerbil)}
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
onExpand={() => handleExpand(path)}
/>
)}
</foreignObject>
</g>
)
},
[nodesByPath, farbschlagOf, navigate, handleExpand, t],
)
/* ── Zustände: Laden / Fehler ── */
if (loading) return <p className="muted">{de.common.loading}</p>
if (!root || !datum) {
return (
<section className="page">
<p className="muted">{gerbil.error ?? t.notFound}</p>
<p className="muted">{tree.error ?? t.notFound}</p>
<Link to="/rennmaeuse" className="btn">
{de.pages.gerbils.detail.back}
</Link>
@@ -28,17 +271,237 @@ export default function StammbaumPage() {
)
}
const g = gerbil.data
return (
<section className="page">
<header className="page-head">
<h2>{t.titleFor(g.name)}</h2>
<div className="head-actions">
<Link to={`/rennmaeuse/${g.id}`} className="btn">
{t.backToAnimal}
</Link>
<>
<section className="page stammbaum-screen">
<header className="page-head">
<h2>{t.titleFor(root.gerbil.name)}</h2>
<div className="head-actions">
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
{t.backToAnimal}
</Link>
</div>
</header>
<div className="stammbaum-toolbar">
<button
type="button"
className="btn stammbaum-zoombtn"
onClick={() => applyZoom(1.25)}
aria-label={t.zoomIn}
title={t.zoomIn}
>
</button>
<button
type="button"
className="btn stammbaum-zoombtn"
onClick={() => applyZoom(0.8)}
aria-label={t.zoomOut}
title={t.zoomOut}
>
</button>
<button type="button" className="btn" onClick={fitView}>
{t.zoomFit}
</button>
<span className="stammbaum-toolbar__spacer" />
<span className="stammbaum-inzucht">
{t.inbreeding.label}: <b>{inbreedingText}</b>
</span>
<button type="button" className="btn" onClick={() => window.print()}>
{t.print}
</button>
</div>
</header>
</section>
<div className="stammbaum-canvas" ref={canvasRef}>
{view && (
<Tree
key={id}
data={datum}
renderCustomNodeElement={renderNode}
orientation="horizontal"
pathFunc="step"
translate={view.translate}
zoom={view.zoom}
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
zoomable
draggable
collapsible={false}
nodeSize={{ x: NODE_X, y: NODE_Y }}
separation={{ siblings: 1, nonSiblings: 1 }}
onUpdate={handleTreeUpdate}
/>
)}
</div>
<p className="stammbaum-hint">{t.tapHint}</p>
</section>
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
<PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} />
</>
)
}
/* ── Ahnenkarte (interaktive Ansicht) ───────────────────────────── */
function PedigreeCard({
node,
isRoot,
farbschlag,
onOpen,
onExpand,
}: {
node: AnimalNode
isRoot: boolean
farbschlag: string | null
onOpen: () => void
onExpand: () => void
}) {
const t = de.pages.stammbaum
const g = node.gerbil
const chip = farbschlag ? chipColorFor(farbschlag) : null
const year = g.dateOfBirth ? g.dateOfBirth.slice(0, 4) : null
return (
<div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
onClick={isRoot ? undefined : onOpen}
role={isRoot ? undefined : 'button'}
title={isRoot ? undefined : t.tapHint}
>
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */}
<div className="pedigree-card__photo" aria-hidden="true">
🐭
</div>
<div className="pedigree-card__body">
<div className="pedigree-card__name">
<SexIcon gender={g.gender} />
<span className="pedigree-card__nametext">{g.name}</span>
</div>
{farbschlag && (
<span
className="pedigree-chip"
style={chip ? { background: chip.bg, color: chip.fg } : undefined}
>
{farbschlag}
</span>
)}
{year && <span className="pedigree-card__year">* {year}</span>}
</div>
{node.expandable && (
<button
type="button"
className="pedigree-card__expand"
aria-label={t.expand}
title={t.expand}
onClick={(e) => {
e.stopPropagation()
onExpand()
}}
>
</button>
)}
</div>
)
}
function SexIcon({ gender }: { gender: Gender }) {
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
return (
<span
className={`pedigree-card__sex pedigree-card__sex--${gender}`}
title={genderLabel(gender)}
aria-label={genderLabel(gender)}
>
{symbol}
</span>
)
}
/* ── Druckansicht: Ahnentafel als CSS-Grid (Zertifikats-Layout) ── */
function PrintPedigree({
root,
farbschlagOf,
inbreedingText,
}: {
root: AnimalNode
farbschlagOf: (g: Gerbil) => string | null
inbreedingText: string
}) {
const t = de.pages.stammbaum
const generations = Array.from({ length: DEFAULT_GENERATIONS + 1 }, (_, g) =>
ancestorsAt(root, g),
)
const today = new Date().toLocaleDateString('de-DE')
return (
<div className="stammbaum-print">
<header className="stammbaum-print__head">
<h1>{t.titleFor(root.gerbil.name)}</h1>
<p className="stammbaum-print__meta">
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
</p>
</header>
<div className="stammbaum-print__grid">
{generations.map((_, g) => (
<div key={`gen-${g}`} className="stammbaum-print__gen" style={{ gridColumn: g + 1, gridRow: 1 }}>
{t.printView.generations[g]}
</div>
))}
{generations.flatMap((slots, g) => {
const span = 2 ** (DEFAULT_GENERATIONS - g)
return slots.map((node, i) => (
<PrintCell
key={`${g}:${i}`}
node={node}
gen={g}
farbschlag={node?.kind === 'animal' ? farbschlagOf(node.gerbil) : null}
style={{ gridColumn: g + 1, gridRow: `${i * span + 2} / span ${span}` }}
/>
))
})}
</div>
</div>
)
}
function PrintCell({
node,
gen,
farbschlag,
style,
}: {
node: PedigreeNode | null
gen: number
farbschlag: string | null
style: CSSProperties
}) {
const t = de.pages.stammbaum
const base = `stammbaum-print__cell stammbaum-print__cell--g${gen}`
if (!node) return <div className={`${base} stammbaum-print__cell--empty`} style={style} />
if (node.kind === 'unknown') {
return (
<div className={`${base} stammbaum-print__cell--unknown`} style={style}>
{t.unknown}
</div>
)
}
const g = node.gerbil
const sexSymbol = g.gender === 'male' ? '♂ ' : g.gender === 'female' ? '♀ ' : ''
return (
<div className={base} style={style}>
<div className="stammbaum-print__name">
{sexSymbol}
{g.name}
</div>
{g.dateOfBirth && (
<div className="stammbaum-print__sub">
{t.printView.born} {formatDate(g.dateOfBirth)}
</div>
)}
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
</div>
)
}

View File

@@ -0,0 +1,302 @@
/* FEAT-4: Stammbaum — Bildschirm- und Druckstile.
Bewusst eigene Datei statt index.css: index.css wird parallel von
FEAT-1/2/5 bearbeitet (geteilter Arbeitsbaum), und die Druckregeln
bleiben hier gekapselt. */
/* ── Werkzeugleiste ───────────────────────────────────────────── */
.stammbaum-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin: 0.75rem 0 0.5rem;
}
.stammbaum-toolbar__spacer {
flex: 1;
}
.stammbaum-zoombtn {
min-width: 44px;
padding: 0.5rem 0.6rem;
font-size: 1.1rem;
line-height: 1;
}
.stammbaum-inzucht {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.stammbaum-inzucht b {
color: var(--color-text);
}
.stammbaum-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0.4rem 0 0;
}
/* ── Zeichenfläche ────────────────────────────────────────────── */
.stammbaum-canvas {
height: clamp(18rem, 62dvh, 46rem);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
background:
radial-gradient(var(--color-border) 1px, transparent 1px) 0 0 / 22px 22px,
var(--color-surface);
overflow: hidden;
/* Pinch/Pan gehören d3-zoom, nicht dem Browser-Scrolling. */
touch-action: none;
}
.stammbaum-canvas .rd3t-tree-container {
width: 100%;
height: 100%;
}
.stammbaum-canvas .rd3t-link {
stroke: var(--color-border);
stroke-width: 1.5;
}
/* ── Ahnenkarten (foreignObject-HTML) ─────────────────────────── */
.pedigree-card {
box-sizing: border-box;
width: 100%;
height: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
box-shadow: 0 1px 2px rgb(59 48 38 / 10%);
overflow: hidden;
cursor: pointer;
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: var(--color-text);
}
.pedigree-card:hover:not(.pedigree-card--root):not(.pedigree-card--unknown) {
background: var(--color-accent-soft);
}
.pedigree-card--root {
border: 2px solid var(--color-accent);
cursor: default;
}
.pedigree-card--unknown {
justify-content: center;
border-style: dashed;
background: transparent;
color: var(--color-text-muted);
font-style: italic;
font-size: 0.85rem;
cursor: default;
}
.pedigree-card__photo {
flex: none;
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--color-accent-soft);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
.pedigree-card__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.pedigree-card__name {
display: flex;
align-items: center;
gap: 0.3rem;
font-weight: 600;
font-size: 0.9rem;
}
.pedigree-card__nametext {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pedigree-card__sex--male {
color: #3a6ea5;
}
.pedigree-card__sex--female {
color: #b5527d;
}
.pedigree-card__sex--unknown {
color: var(--color-text-muted);
}
.pedigree-chip {
align-self: flex-start;
max-width: 100%;
font-size: 0.68rem;
padding: 0.1rem 0.45rem;
border-radius: 1rem;
background: var(--color-accent-soft);
color: var(--color-accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pedigree-card__year {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.pedigree-card__expand {
flex: none;
margin-left: auto;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--color-accent);
background: var(--color-accent-soft);
color: var(--color-accent);
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
}
.pedigree-card__expand:hover {
background: var(--color-accent);
color: #fff;
}
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
Am Bildschirm unsichtbar; @media print blendet App-Chrome und die
interaktive Ansicht aus und zeigt stattdessen die Tafel.
(:has-Scoping, damit der Druck ANDERER Seiten unberührt bleibt.) */
.stammbaum-print {
display: none;
}
@media print {
@page {
size: A4 landscape;
margin: 10mm;
}
body:has(.stammbaum-print) .app-header,
body:has(.stammbaum-print) .app-nav,
body:has(.stammbaum-print) .stammbaum-screen {
display: none !important;
}
.stammbaum-print {
display: flex;
flex-direction: column;
height: 100%;
color: #000;
}
}
.stammbaum-print__head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 4mm;
border-bottom: 2pt solid #333;
padding-bottom: 2mm;
margin-bottom: 3mm;
}
.stammbaum-print__head h1 {
font-size: 16pt;
margin: 0;
}
.stammbaum-print__meta {
font-size: 9pt;
color: #444;
margin: 0;
}
.stammbaum-print__grid {
flex: 1;
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-rows: auto repeat(16, minmax(0, 1fr));
gap: 1.5mm;
min-height: 0;
}
.stammbaum-print__gen {
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #555;
align-self: end;
padding-bottom: 0.5mm;
}
.stammbaum-print__cell {
border: 0.5pt solid #999;
border-radius: 1mm;
padding: 1mm 2mm;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5mm;
font-size: 9pt;
break-inside: avoid;
}
/* Hintere Generationen: kleinere Schrift, damit 16 Zellen passen. */
.stammbaum-print__cell--g3 {
font-size: 8pt;
}
.stammbaum-print__cell--g4 {
font-size: 7pt;
padding: 0.5mm 1mm;
}
.stammbaum-print__cell--empty {
border-style: dashed;
border-color: #ccc;
}
.stammbaum-print__cell--unknown {
color: #888;
font-style: italic;
}
.stammbaum-print__name {
font-weight: 600;
}
.stammbaum-print__sub {
font-size: 0.85em;
color: #444;
}
.stammbaum-print__geno {
font-family: Consolas, 'Courier New', monospace;
font-size: 0.8em;
color: #555;
}