Files
Dashboard/docs/DESIGN-SYSTEM.md
T
dejanandClaude Opus 5 ab79c711ad Karten einer Zeile gleich hoch, Ueberschuss mittig verteilen
Aus dem Sparring: Bei duenner Datenlage riss die oben buendige Ausrichtung
Loecher in die Seite. Nachgemessen auf /analytics mit Zeitraum "heute" und
Gewerk "Heizung": "Umsatz nach Gewerk" 112 px neben "Zahlungseingang im
Zeitverlauf" 427 px — 315 px Seitenhintergrund dazwischen. Das liest sich
nicht als "wenig Daten", sondern als "etwas ist kaputt".

Zwei Aenderungen, beide klein:

- Der Kartenkoerper zentriert seinen Inhalt senkrecht. Der Ueberschuss geht
  je zur Haelfte nach oben und unten, statt unten abgeladen zu werden. Eine
  Stelle in ChartCard und eine in WidgetKachel — nicht in fuenf Diagrammen.

- items-start faellt auf den Zeilen, die ausschliesslich Diagrammkarten
  tragen. Die Zeile mit den beiden Tabellen auf der Kundenseite bleibt oben
  buendig: Eine Tabelle hat ein natuerliches Ende und eine eigene
  Blaetterleiste, Strecken schiebt die nur nach unten.

Die Karten bleiben dynamisch — keine feste Hoehe, mehr Inhalt laesst die
Karte und mit ihr die Zeile weiter wachsen.

Die Leere verschwindet nicht, sie zieht aus dem Seitenhintergrund in die
Karte. Das ist der Unterschied zwischen einem Raster mit Luecken und einer
Karte, in der gerade wenig steht.

Nachgemessen nach der Aenderung: 427/427, 389/389 auf /analytics,
372/372 und 423/423 auf /dashboard. Dabei ist nebenbei die Leerflaeche unter
dem Vertriebstrichter verschwunden, die vorher auffiel.

In DESIGN-SYSTEM.md als Regel festgehalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-31 17:38:57 +02:00

15 KiB
Raw Blame History

Design-System: Datenvisualisierung

Grundlage ist IBM Carbon Data Visualization, ergänzt um die Spacing- und Zahlenregeln aus der eigenen Planung (UI_UX_GUIDELINES.md, Januar 2026).

Die konkreten Farb- und Linienwerte sind aus dem ausgelieferten Stylesheet von @carbon/charts (Light Theme) übernommen — nicht aus der Dokumentation abgeschrieben. Alles Folgende ist in features/dashboard/utils/chartTheme.ts als Code hinterlegt.

0. Was Carbon regelt — und was nicht

Wichtig für jeden, der hier etwas anzweifelt: Carbon regelt Datenvisualisierung nur teilweise. Die Grenze verläuft so:

Frage Regelt Carbon? Wo steht die Antwort
Farbpalette, Reihenfolge, Statusfarben ja, verbindlich ausgeliefertes Stylesheet, CSS-Variablen --cds-charts-14-1-*
Gitter-, Achsen- und Textfarben ja dieselben Quellen
Tooltip-Aufbau ja .cds--cc--tooltip im Stylesheet
Tickanzahl je Achse ja configuration.ts (y: 5, x: 15)
Wann welcher Diagrammtyp teilweise, qualitativ Carbon „Simple/Complex charts"
Padding, Margins, Kartenabstände nein siehe unten
Dashboard-Raster, Kartenhöhen nein, nur Prinzipien siehe unten

Die Carbon-Seiten zu Chart-Anatomie und Dashboards tragen ausdrücklich den Hinweis „this guidance is a work in progress" und enthalten keine Zahlenwerte für Abstände. Wer dort nach Paddings sucht, findet Prinzipien („create a clear visual hierarchy", „limit the number of metrics") — richtig, aber nicht nachprüfbar.

Die Abstandswerte in diesem Dokument stammen deshalb nicht von Carbon, sondern aus zwei anderen Quellen:

  1. der Carbon-Spacing-Skala und dem 2x-Grid des allgemeinen Design-Systems (4/8/12/16/24/32/48…) — dieselbe Skala, aus der auch Davids INTERIM-UI-DESIGN-STANDARDS.md im Zielsystem schöpft;
  2. der eigenen Planung mit dem Branchenpartner (UI_UX_GUIDELINES.md).

Beides ist konsistent zueinander. Es ist aber ehrlicher zu sagen: Bei Farben und Achsen folgen wir einer Norm, bei Abständen einer begründeten Hauskonvention.

Nachprüfbar gemacht: npm run verify:carbon gleicht chartTheme.ts gegen docs/carbon-referenz.json ab — Palette, neutrale Werte, Tooltip, Achsenraster. Die Referenzdatei nennt Quelle, Version und Prüfdatum. Bewusste Abweichungen (Balkenbreite, feste Kategoriefarben) sind dort begründet hinterlegt und werden als Hinweis ausgegeben, nicht als Fehler.


1. Farben

Kategoriale Palette (14 Farben, Reihenfolge verbindlich)

1  #6929c4   2  #1192e8   3  #005d5d   4  #9f1853   5  #fa4d56
6  #520408   7  #198038   8  #002d9c   9  #ee5396  10  #b28600
11 #009d9a  12  #012749  13  #8a3800  14  #a56eff

Die Reihenfolge ist auf Unterscheidbarkeit optimiert — auch bei Farbfehlsichtigkeit und in Graustufen. Farben deshalb immer von vorne vergeben, nie nach Geschmack auswählen und nie umsortieren.

Maximal fünf Kategorien je Chart. Alles darüber fasst gruppiereRest() zu „Sonstige" zusammen. Ein Ring mit zwölf Segmenten ist keine Information mehr, sondern Dekoration.

Statusfarben (nur für Aussagen, nie für Kategorien)

Bedeutung Wert
positiv #198038
Warnung #b28600
negativ #da1e28
neutral #8d8d8d
Information #1192e8

Kategorie- und Statusfarben nie im selben Chart mischen — eine grüne Kategorie neben einem grünen Positiv-Status ist nicht mehr eindeutig lesbar.

Rangfolgen einfarbig. Top-Kunden oder Aufträge je Mitarbeiter sind keine Kategorien: Dort trägt die Farbe keine Information, und eine bunte Palette erzeugt nur Unruhe. Palette nur, wenn die Farbe etwas bedeutet.

Feste Farbzuordnung

Wiederkehrende Kategorien behalten ihre Farbe über alle Charts und Seiten hinweg (GEWERK_FARBEN, STATUS_KATEGORIE_FARBEN in chartTheme.ts). Sonst ist Heizung im Ring lila und im Balkendiagramm blau, und jede Legende muss neu gelesen werden.

Gewerk Farbe Status Farbe
Heizung #6929c4 Verloren / Überfällig #fa4d56 / #da1e28
Sanitär #1192e8 In Arbeit / Bezahlt #198038
Klima & Lüftung #005d5d Beauftragt #009d9a
Photovoltaik #9f1853 Angebot #6929c4
Elektro #198038 Entwurf / Sonstige #8d8d8d

Bei Statuswerten trägt die Farbe Bedeutung: „Verloren" und „Überfällig" sind rot, „Bezahlt" grün. Eine zufällige Palettenfarbe für „Verloren" würde die Aussage der Grafik verwässern.

Geordnete Dimensionen

Altersklassen, Risikostufen und Ähnliches sind keine Kategorien, sondern eine Reihenfolge mit wachsendem Risiko. Dafür gilt die vierstufige Risikorampe (RISIKO_RAMPE in chartTheme.ts) — durchgehend Statusfarben, keine Palettenfarbe dazwischen:

Stufe Wert Bedeutung
1 #198038 im Zahlungsziel
2 #b28600 beobachten
3 #ba4e00 mahnen
4 #da1e28 Abschreibungsrisiko

Einzelserien

Rolle Wert
Hauptreihe (Umsatzverlauf o. Ä.) #1192e8
Vergleichsreihe (Vorjahr) #8d8d8d, gestrichelt

Die Vergleichsreihe ist bewusst neutral: Sie soll einordnen, nicht konkurrieren.


2. Achsen und Gitter

Element Wert Begründung
Gitterlinien #e0e0e0, nur horizontal Vertikale Linien zerschneiden den Verlauf
Achsenlinie #8d8d8d, 1 px Nur an der X-Achse; die Y-Achse braucht keine
Tick-Marks keine Die Beschriftung reicht als Orientierung
Beschriftung 12 px, #525252 Carbon-Standard
Y-Achsen-Breite 58 px Platz für „245 T€" ohne Umbruch

Bei mehr als 12 Datenpunkten wird nur jede n-te Beschriftung gezeigt, sonst überlappen sie (MAX_ACHSENLABELS in chartTheme.ts — eine Konstante für alle Diagrammtypen).

Achsenwerte sind rund. Recharts leitet Ticks aus max/(n-1) ab und rundet den Rohschritt auf; bei 219 T€ ergibt das 0/55/110/165/220. Krumme Achsenwerte kosten den Leser bei jedem Blick eine Rechenoperation. nettesRaster() erzwingt deshalb Schrittweiten aus der 1-2-2,5-5-Reihe.

Die Einheit gehört zur Achse, nicht zum Wert. achsenFormat(max) wählt sie einmal aus dem Achsenmaximum. Sonst steht „0 €" neben „10 T€" und die Skala wechselt mitten im Lesen die Einheit; die Nulllinie trägt gar keine Einheit.


3. Tooltip

Einheitlich für alle Charts (components/charts/ChartTooltip.tsx), Werte aus .cds--cc--tooltip im ausgelieferten Stylesheet:

  • Fläche #ffffff (--cds-layer-02), Text #161616 (--cds-text-primary)
  • abgesetzt allein durch den Schatten 0 1px 6px 0 rgba(0,0,0,.2) — kein Rahmen
  • Label und Reihenname 12 px in #525252, Wert fett in Tabellenziffern
  • kein Doppelpunkt zwischen Name und Wert, maximal 270 px breit
  • Farbmarker je Reihe, 10 × 10 px

Achtung bei älteren Quellen: Bis Carbon v10 war der Tooltip dunkel (#393939 auf weißem Text). Sehr viele Artikel und Screenshots zeigen noch diesen Stand. Maßgeblich ist das ausgelieferte Stylesheet, nicht die Bildersuche.

Ein gemeinsamer Tooltip verhindert, dass jedes Chart eigene Regeln entwickelt.


4. Balken, Flächen, Ringe

Element Regel
Balkenbreite maximal 48 px
Balkenabstand mindestens halbe Balkenbreite (barCategoryGap: 28 %)
Balkenecken 2 px, nur an der Werteseite
Linienstärke Fläche 2 px, Verlaufsfüllung 24 % → 2 %
Interpolation linear — eine Monotone-Kurve erfindet Zwischenwerte zwischen zwei Messpunkten
Punkte nur beim Hovern, Radius 4, weißer Rand
Ring innen/außen 62 % / 88 %, 2° Segmentabstand
Ringmitte Gesamtwert — sonst muss man die Segmente addieren
Ringlegende rechts, einzeilig, mit Wert und Prozentanteil

Ringsegmente starten oben (12 Uhr) mit dem größten Wert und laufen im Uhrzeigersinn absteigend. Auch „Sonstige" wird nach Größe einsortiert, nicht ans Ende gehängt.

Ein Ring braucht ein echtes Ganzes. Beträgt ein Segment über 80 %, trägt die Grafik nichts mehr bei — dann ist die Bezugsgröße falsch gewählt. Beispiel: Der Rechnungsbestand aus zwei Jahren gegen den heutigen Forderungsstand ergibt 92 % „Bezahlt". Richtig ist der offene Bestand allein; dann zeigt der Ring, wie viel davon bereits überfällig ist.

Werte in einem engen Band: Punkt statt Balken

Auslastung liegt betrieblich zwischen etwa 70 und 100 %. Ein Balken muss bei Null beginnen, sonst verzerrt seine Länge — und dann sind 74 % und 96 % optisch fast gleich lang. Ein Punkt kodiert über die Position, nicht über die Länge, und darf deshalb einen ausgeschnittenen Bereich zeigen.

Der Zielkorridor liegt als graues Band hinter den Punkten, die 100-%-Marke als beschriftete Linie, darunter eine Achse. Damit erklärt sich die Grafik ohne Ampel-Legende — und tote Legendeneinträge („Luft frei", wenn niemand darunter liegt) entfallen.


5. Abstände (8-px-System)

Bereich Wert
Seitenrand 24 px
Abstand zwischen Karten 16 px
Innenabstand Karte 16 px
Kopf zu Inhalt in der Karte 12 px
Label zu Wert in der KPI-Karte 4 px
Icon zu Text 8 px

Grundregel: innen ≤ außen. Ist der Innenabstand größer als der Abstand zwischen den Karten, zerfällt das Raster optisch.


6. Typografie

Rolle Größe Gewicht
Seitentitel 22 px 600
KPI-Wert 28 px 600
Kartentitel 14 px 600
Fließtext / Tabelle 13 px 400
Label, Achsen, Legende 12 px 400–500
Kleinstschrift (Meta) 11 px 400

Eine Schriftfamilie: Instrument Sans, selbst gehostet aus denselben Schriftdateien wie die Software (public/fonts/, variabel 400–700). Vorher lief der Prototyp auf Inter, während ui/theme.css für alle Subframe-Typo-Token bereits „Instrument Sans" verlangte — zwei Schriften auf einer Seite, keine davon die der Software. Zahlen immer in Tabellenziffern (font-variant-numeric: tabular-nums, global gesetzt), damit Spalten untereinander stehen.


7. Zahlenformate (deutsch)

Fall Ausgabe
Währung, KPI 238.412 €
Währung, kompakt (Achsen, Balken) 8,4 T€ · 245 T€ · 1,25 Mio. €
Prozent 58,2 %
Tage / Stunden 34,2 Tage · 7,5 h
Datum 30.07.2026, kurz 30.07., Monat Jul 26

Punkt als Tausendertrenner, Komma als Dezimaltrenner. Innerhalb einer Ansicht immer dasselbe Format — nicht in einer Zeile 245.000 € und in der nächsten 245 T€.


8. Zustände

Jedes Chart braucht einen Leerzustand — Filter können jede Ansicht leer laufen lassen. ChartLeer zeigt dann eine gestrichelte Fläche mit Hinweistext statt eines leeren Rechtecks.

Hover-Zustände: Karten erhalten einen stärkeren Schatten, Balken und Ringsegmente werden hervorgehoben (die übrigen auf 35–45 % Deckkraft). Tabellenzeilen bekommen keinen Hover, solange sie nicht anklickbar sind — ein Hover ohne Ziel verspricht eine Interaktion, die es nicht gibt.

9. Textabstände und Umbruch

Zwei Regeln, die Carbon nicht als Zahl nennt, ohne die eine Oberfläche aber kaputtgeht:

Mindestens 8 px zwischen benachbarten Textelementen (Carbon-Spacing-Token $spacing-03, der kleinste Abstand der Skala). Alles darunter liest sich als zusammengehörig, bei 0 px kleben Beschriftung und Wert aneinander.

Kein Text läuft aus seinem Kasten. Die häufigste Ursache ist min-w-0 zusammen mit grow ohne truncate oder break-words: Der Kasten schrumpft auf null, der Text bleibt stehen und läuft in das Nachbarelement. Deshalb trägt jedes Textfeld, das schrumpfen kann, entweder truncate (wenn Kürzen vertretbar ist) oder break-words (wenn nicht). Dateipfade und lange Einzelwörter brauchen zwingend break-words — sie haben keine Leerzeichen zum Umbrechen.

Legenden weichen nach unten aus. Carbon sagt, die Legende gehört rechts neben das Diagramm, „when space is plentiful". Ist der Platz nicht reichlich, rutscht sie unter das Diagramm, statt Namen und Werte ineinanderlaufen zu lassen. Gesteuert über Container-Queries (@container / @md:), nicht über den Viewport: Dieselbe Karte nimmt mal ein Drittel und mal die halbe Seitenbreite ein — der Viewport weiß davon nichts.

Nachprüfbar gemacht: npm run verify:layout lädt alle sechs Seiten in sieben Fensterbreiten (1024–1920) und misst jede Textzeile. Kollisionen unter 8 px und Text, der seinen Kasten verlässt, brechen den Lauf ab.

Warum das ein eigener Prüfer sein muss: Diese Fehler treten nur in bestimmten Breiten auf. Die Ringlegende auf der Finanzseite kollidierte bei 1280 und 1440 px und war bei 1600 px unauffällig — auf einem Screenshot also unsichtbar. Auge und Einzelbild reichen dafür nicht.

10. Fokus und Kontrast

Fokus ist eine einzige Regel in app/globals.css statt focus:-Klassen an jedem Element:

:where(a, button, [role="button"], input, select, textarea, [tabindex]):focus-visible {
  outline: none;
  box-shadow: var(--shadow-focus);
  border-radius: var(--radius-sm);
}

:focus-visible zeigt den Ring nur bei Tastaturbedienung. Overlays (Filter- Dropdown, Karten-Menü) schließen mit Escape und geben den Fokus an den auslösenden Knopf zurück.

Kontrast: --color-text-neutral-tertiary liegt auf neutral-700 (5,15:1 auf Weiß), nicht auf neutral-600 (3,68:1 — unter WCAG AA). Das entspricht dem a11y-Override im Zielsystem. text-neutral-subtle ist ein Placeholder-Token und für lesbaren Text nicht geeignet.


Kartenhöhen in einer Zeile

Karten einer Zeile sind gleich hoch, aber nicht gleich voll. Die Höhe gibt die inhaltsreichste Karte vor; die übrigen strecken sich mit und verteilen den Überschuss oben und unten je zur Hälfte (justify-center im Kartenkörper).

Vorher waren die Zeilen oben bündig (items-start), und jede Karte endete dort, wo ihr Inhalt endete. Bei ähnlich vollen Karten fiel das nicht auf. Bei dünner Datenlage schon: Ein Balkendiagramm mit einer einzigen Kategorie war 112 px hoch und stand neben einem Zeitverlauf mit 427 px — 315 px Seitenhintergrund als Loch dazwischen. Das liest sich nicht als „wenig Daten", sondern als „etwas ist kaputt".

Die Leere verschwindet dadurch nicht — sie zieht aus dem Seitenhintergrund in die Karte. Das ist der Unterschied zwischen einem Raster mit Lücken und einer Karte, in der gerade wenig steht.

Die Karten bleiben dabei dynamisch. Es gibt keine feste Höhe: Kommt mehr Inhalt hinein, wächst die Karte und mit ihr die ganze Zeile.

Ausnahme: Zeilen mit Tabellen. Eine Tabelle hat ein natürliches Ende und eine eigene Fußzeile zum Blättern. Sie zu strecken schiebt nur die Blätterleiste nach unten. Solche Zeilen bleiben oben bündig — im Prototyp die Reaktivierungs- und Zahlungsmoral-Tabellen auf der Kundenseite.