OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.
Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.
Bewusst so entschieden:
- Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
- Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
Notzugang, falls Entra oder das Secret ausfaellt.
- Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
Apache-Log.
- Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.
Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
335 lines
19 KiB
Markdown
335 lines
19 KiB
Markdown
# 05 Frontend
|
||
|
||
Eine einzige React-Anwendung ohne Router: `App.tsx` hält die aktuelle Ansicht in
|
||
einem Zustand und rendert sie. Das genügt, weil die App hinter einer Anmeldung
|
||
sitzt und keine tiefen Links braucht.
|
||
|
||
## Menü und Ansichten
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
SB["Seitenleiste"] --> K["Kategorien"]
|
||
K --> D["Dashboard<br/>(Startansicht, auch über das Logo)"]
|
||
K --> AQ["Akquise"]
|
||
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
|
||
AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
|
||
AQ --> AQ3["Papierkorb<br/>von Hand gelöscht; 90 Tage"]
|
||
AQ2 -. "Zurück in den Eingang<br/>(Funddatum = heute)" .-> AQ1
|
||
AQ3 -. "Wiederherstellen<br/>(Funddatum bleibt)" .-> AQ2
|
||
K --> O["Opportunities"]
|
||
K --> C["Interviews"]
|
||
K --> L["Kontakte"]
|
||
L --> L1["Alle Kontakte"]
|
||
L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
|
||
K --> M["Mails"]
|
||
M --> M1["Postfach<br/>Reiter: Postfach, Kontakt-Vorschläge"]
|
||
M --> M2["Papierkorb<br/>im CRM gelöschte Mails"]
|
||
K --> SC["Scanner"]
|
||
SC --> SC1["Dashboard"]
|
||
SC --> SC2["Unpassend"]
|
||
SC --> SC3["Suchagenten"]
|
||
SC --> SC4["Börsen-Konten"]
|
||
SB --> E["Einstellungen"]
|
||
E --> E1["Nutzerverwaltung (admin)"]
|
||
E --> E2["Mein Konto"]
|
||
E --> E3["Postfach (admin)"]
|
||
E --> E4["Anmeldung (admin)"]
|
||
E --> E5["Scanner (admin)"]
|
||
E --> E6["Whisper"]
|
||
```
|
||
|
||
*Postfach* und *Kontakt-Vorschläge* sind Reiter **innerhalb** der Ansicht
|
||
*Mails*, keine Menüpunkte - sie gehören zusammen und wechseln oft hin und her.
|
||
|
||
Die Menüpunkte *Kontakte* und *Scanner* klappen ihre Unterpunkte auf; der
|
||
Kopfeintrag ist dabei selbst eine Ansicht (Kontaktliste bzw. Scanner-Dashboard).
|
||
Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole, die
|
||
Unterpunkte kommen mit den Beschriftungen zurück. Beide Gruppen entstehen aus
|
||
derselben Struktur `MenuGroup` in `Sidebar.tsx`.
|
||
|
||
*Aus Mails* trägt einen Zähler mit der Anzahl der offenen Funde - er kommt aus
|
||
`App.tsx`, das die Kontakte ohnehin geladen hat.
|
||
|
||
## Zustand und Datenfluss
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
subgraph Kontexte["Kontexte in main.tsx"]
|
||
TH["ThemeProvider<br/>hell/dunkel/System"]
|
||
TO["ToastProvider"]
|
||
AU["AuthProvider<br/>JWT, Benutzer"]
|
||
WH["WhisperProvider<br/>Modell, Worker"]
|
||
end
|
||
|
||
APP["App.tsx<br/>currentView, settingsTab, focusLeadId"]
|
||
NAV["NavigationProvider<br/>goTo(view, {tab, leadId})"]
|
||
DATA["loadData()<br/>leads, connections, events, users, opportunities"]
|
||
|
||
Kontexte --> APP
|
||
APP --> NAV
|
||
APP --> DATA
|
||
DATA --> V1["Opportunities"]
|
||
NOTE["Akquise lädt selbst<br/>acquisitionsApi, nicht über loadData()"]
|
||
DATA --> V2["Interviews"]
|
||
DATA --> V3["Kontakte<br/>mail_pool = false"]
|
||
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
|
||
DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
|
||
NAV --> DF["DictationField<br/>springt nach Whisper"]
|
||
NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
|
||
|
||
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
|
||
MV["Mailansicht"] -->|"lädt selbst"| MAPI["/api/mail"]
|
||
```
|
||
|
||
Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die
|
||
Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er
|
||
eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails,
|
||
weil sie blättern, suchen und filtern und dabei nicht die ganze Liste im Speicher
|
||
halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als
|
||
Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
|
||
|
||
`/api/leads` liefert beide Bestände in einem Aufruf; `App.tsx` teilt sie in
|
||
`leads` (Kontakte) und `mailPoolLeads` (Vorbereich „Aus Mails") auf. Alle
|
||
anderen Ansichten bekommen ausschließlich `leads` - ein Fund aus einer Mail soll
|
||
in keiner Auswahlliste auftauchen. Die Ansicht *Aus Mails* braucht beides: sie
|
||
vergleicht jeden Fund gegen den Kontaktbestand und markiert Dubletten.
|
||
|
||
`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
|
||
Kontakt. `App.tsx` hält die Id in `focusLeadId`, die Kontaktansicht öffnet den
|
||
Kontakt und meldet mit `onFocusHandled()` zurück - der Effekt hängt an `leads`
|
||
und wartet damit auf einen gerade erst angelegten Kontakt.
|
||
|
||
`reload={() => loadData(true)}` lädt still nach - ohne Ladeanzeige, damit ein
|
||
Speichern im Dialog nicht die ganze Liste flackern lässt.
|
||
|
||
## Komponentenkarte
|
||
|
||
```
|
||
src/
|
||
App.tsx Layout, Datenladen, Ansichtswechsel
|
||
types.ts Gemeinsame Typen, Farbpalette, Rechenhilfen
|
||
lib/
|
||
api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
|
||
scannerApi.ts Typisierte Endpunkte unter /api/scanner
|
||
mailApi.ts Endpunkte unter /api/mail, UTC-Datumshelfer
|
||
auth.tsx Anmeldung, Benutzer, Rolle
|
||
theme.tsx Hell/Dunkel/System
|
||
navigation.tsx Ansichtswechsel für tiefe Komponenten
|
||
whisper.tsx Modell, Worker-Lebenszyklus
|
||
dictation.tsx Aufnahme-Hook
|
||
audio.ts Aufnahme zu 16-kHz-Mono-PCM
|
||
imageCompress.ts Bilder vor dem Upload verkleinern (auch das LinkedIn-Foto)
|
||
components/
|
||
Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner)
|
||
Header.tsx Titel, Untertitel, Design-Auswahl, Hell/Dunkel-Schalter
|
||
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
|
||
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
|
||
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force),
|
||
Vorbereich "Aus Mails" mit Dublettenabgleich
|
||
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschläge
|
||
scanner/ Dashboard, Unpassend, Suchagenten, Börsen-Konten
|
||
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Anmeldung, Scanner, Whisper
|
||
whisper/ Einrichtung der Spracherkennung
|
||
ui/ Modal, Avatare, Diktatfeld
|
||
```
|
||
|
||
## Themes ohne `dark:`-Varianten
|
||
|
||
Die Darstellung hat **zwei unabhängige Achsen**, beide als Klasse am `<html>`:
|
||
|
||
| Achse | Klasse | Werte |
|
||
| --- | --- | --- |
|
||
| Farbwelt | `theme-perinco`, `theme-pixel`, `theme-outdoor`, `theme-cyberpunk`, `theme-rainy`, `theme-snow`, `theme-aura` | perinco ist der Standard |
|
||
| Modus | `dark` bzw. keine | hell, dunkel, oder der Systemeinstellung folgen |
|
||
|
||
`src/index.css` tauscht darin nur Variablenwerte: `canvas`, `surface`, `ink-*`,
|
||
`brand-*`, die getönten Statusfarben, dazu `--radius-*`, `--shadow-*` und die
|
||
Schriften. Die Komponenten benutzen durchgängig diese Skalen und folgen damit
|
||
automatisch - **kein `dark:` und keine themespezifische Klasse im Markup**.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
U["Benutzer wählt<br/>im Header"] --> DB["crm_users.theme<br/>crm_users.theme_mode"]
|
||
U --> LS["localStorage<br/>nur Zwischenspeicher"]
|
||
DB -->|"beim Anmelden"| H["Klassen am html<br/>theme-*, dark"]
|
||
LS -->|"vor dem ersten Frame"| H
|
||
H --> V["Variablen in index.css<br/>Farben, Radien, Schatten, Schrift"]
|
||
V --> W["Utility-Klassen der Komponenten"]
|
||
SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H
|
||
```
|
||
|
||
Die Wahl gehört dem **Benutzer**, nicht dem Browser: sie steht in `crm_users`
|
||
und kommt mit `/api/auth/me`. Wer sich an einem anderen Rechner anmeldet, findet
|
||
sein Design wieder, und zwei Benutzer an derselben Maschine kommen sich nicht in
|
||
die Quere. Der `localStorage` bleibt daneben als Zwischenspeicher liegen, damit
|
||
das Inline-Skript in `index.html` die Klassen vor dem ersten Frame setzen kann -
|
||
sonst blitzt beim Laden das Standarddesign auf. Das hat genau eine sichtbare
|
||
Folge: melden sich zwei Leute an demselben Rechner an, sieht der zweite für einen
|
||
Augenblick das Design des ersten.
|
||
|
||
Der `ThemeProvider` steht deshalb in `main.tsx` **unter** dem `AuthProvider` -
|
||
er liest den Benutzer.
|
||
|
||
### Hintergrundbilder in Leiste und Seitenleiste
|
||
|
||
Alle Themes außer perinco legen eine Grafik hinter die obere Leiste und die
|
||
Seitenleiste: 8-bit-Himmel und Steinmauer, blauer Himmel und Wiese,
|
||
Neonröhren und regnerische Gasse, Regenwolken und nasse Hügel, verschneite Berge und Waldrand. Die Dateien liegen als **SVG** in
|
||
`public/themes/` und sind von Hand gezeichnet - stilisierte Grafik statt Foto,
|
||
damit sie in jeder Auflösung scharf bleibt und wenige Kilobyte wiegt.
|
||
|
||
Zwei Anker im Markup: `app-header` an `<header>` und `app-sidebar` an `<aside>`.
|
||
Mehr braucht es nicht, den Rest machen zwei Variablen:
|
||
|
||
```css
|
||
.app-header { background-image: var(--theme-header-image, none); background-size: cover; }
|
||
.app-sidebar { background-image: var(--theme-sidebar-image, none); background-position: bottom center; }
|
||
```
|
||
|
||
Ohne Theme steht in den Variablen nichts, der Rückfall `none` greift, und
|
||
perinco bleibt einfarbig. `bg-surface` bleibt an beiden Elementen: die Farbe
|
||
liegt unter dem Bild und trägt, solange es lädt oder fehlt.
|
||
|
||
Die Variable enthält **zwei Ebenen** - einen Schleier aus der Flächenfarbe des
|
||
Themes und darunter die Grafik:
|
||
|
||
```css
|
||
--theme-header-image: linear-gradient(90deg, rgb(26 32 22 / .95) 0%, rgb(26 32 22 / .6) 55%, …),
|
||
url('/themes/outdoor-header.svg');
|
||
```
|
||
|
||
Das ist kein Umweg, sondern erledigt zwei Dinge zugleich:
|
||
|
||
1. **Eine Grafik für beide Modi.** Der dunkle Schleier des Dunkelmodus tönt
|
||
denselben blauen Himmel zur Nacht ab - es braucht keine zweite Datei.
|
||
2. **Lesbarkeit.** In der oberen Leiste liegt der Schleier an den Rändern dicht
|
||
(Titel links, Schalter rechts) und lässt die Grafik nur in der leeren Mitte
|
||
durch; in der Seitenleiste deckt er oben über den Menüpunkten und gibt nach
|
||
unten frei, wo die Leiste ohnehin leer ist.
|
||
|
||
> **Eine Lehre aus dem ersten Versuch:** Die Cyberpunk-Grafik hatte zuerst eine
|
||
> deckende Nachtfläche. Im **hellen** Modus wurde daraus unter dem weissen
|
||
> Schleier ein grauer Schmutzstreifen. Seither ist sie durchsichtig und zeigt
|
||
> nur Licht - über Dunkel leuchtet das Neon, über Weiss bleiben blasse farbige
|
||
> Linien. Wer eine Grafik ergänzt: **keine deckenden Flächen**, nur Motiv.
|
||
|
||
### Rainy, Snow und Aura: Themes mit Wetter
|
||
|
||
Rainy, Snow und Aura sind die Themes mit Bewegung. Hier zuerst Rainy, Snow und Aura folgen unten. Farben und Grafiken funktionieren wie
|
||
bei den anderen; dazu kommt `src/components/RainyWeather.tsx`, das der
|
||
`ThemeProvider` nur bei aktivem Rainy einhängt - auch auf der Anmeldeseite, und
|
||
beim Wechsel auf ein anderes Theme verschwindet es samt Animationsschleife.
|
||
|
||
| Effekt | Umsetzung |
|
||
| --- | --- |
|
||
| **Nieselregen** | Ein `<canvas>` über dem ganzen Fenster. Alle 220–650 ms startet ein Tropfen (höchstens 14 gleichzeitig), fällt leicht schräg und zerspritzt am unteren Fensterrand in 3–6 Spritzer mit Schwerkraft plus einen kleinen Ring. Farbe aus `--rain-color` |
|
||
| **Wetterleuchten** | Ein Element, das beim Blitz exakt über `.app-header` gelegt und per Web Animations API kurz eingeblendet wird (flackernd, 0,9 s, alle 7–18 s). Das Licht ist ein Schein um eine zufällige Stelle in der Mitte der Leiste |
|
||
|
||
Der Trick beim Blitz ist die **Maske**: `.rainy-flash` benutzt
|
||
`rainy-header.svg` - dieselbe Datei wie das Hintergrundbild, gleich skaliert -
|
||
als `mask-image`. Das Licht erscheint dadurch nur auf den Wolken selbst. Die
|
||
Grafik darf deshalb **nur Wolken** enthalten, keinen Himmel: sonst leuchtet die
|
||
ganze Leiste. Im Dunkelmodus hellt der Blitz per `mix-blend-mode: screen` auf,
|
||
im hellen Modus deckt er normal mit warmem Weiss (`--flash-blend`,
|
||
`--flash-color`) - `screen` macht aus hellem Grau nur etwas helleres Grau.
|
||
|
||
Beide Ebenen haben `pointer-events: none` und `z-[90]`, liegen also unter den
|
||
Dialogen (`z-[100]`). Bei `prefers-reduced-motion: reduce` startet weder Regen
|
||
noch Blitz. Die Canvas-Schleife läuft über `requestAnimationFrame` und ruht
|
||
damit in Hintergrund-Tabs; der Zeitschritt ist auf 50 ms begrenzt, damit nach
|
||
der Rückkehr nichts nachgeholt wird.
|
||
|
||
#### Snow
|
||
|
||
`src/components/SnowWeather.tsx`, eingehängt vom `ThemeProvider` wie der Regen:
|
||
ein `<canvas>` über dem Fenster (`z-[90]`, `pointer-events: none`), bei
|
||
`prefers-reduced-motion` still.
|
||
|
||
| Teil | Umsetzung |
|
||
| --- | --- |
|
||
| **Flocken** | Alle 70–160 ms eine neue, höchstens 70 gleichzeitig. Radius 1–3 px, große fallen schneller, alle schaukeln seitlich (Sinus). Farbe `--snow-flake`, im hellen Modus mit Kontur `--snow-flake-edge` - weiss auf fast weiss wäre unsichtbar |
|
||
| **Schneedecke** | Eine Höhenkarte in 3-px-Spalten über die Fensterbreite. Eine Flocke landet, sobald sie die Oberfläche **ihrer** Spalte erreicht, und verteilt sich auf fünf Spalten |
|
||
| **Glätten** | Diffusion: jede Spalte gleicht sich an ihre Nachbarn an, Faktor an der Zeit (`SPREAD`), nicht an der Framezahl - gleich auf 60 und 144 Hz. Mengenerhaltend, aus Einzelflocken werden flache Hügel |
|
||
| **Deckel** | `MAX_DEPTH` = 26 px, voll nach rund vier Minuten. Was danach landet, verschwindet |
|
||
| **Räumfahrzeug** | Startet 2,5 s nach dem Laden, dann alle 4 min (`PLOW_INTERVAL_MS`), Richtung wechselt jedes Mal. Fährt mit 140 px/s von außerhalb des Fensters herein, setzt jede Spalte hinter der Schaufelkante (und unter dem Fahrzeug) auf null und sammelt die Menge in `pushed`. Daraus wächst der Wall vor der Schaufel (Wurzel der Menge, gedeckelt auf `MAX_DEPTH + 8`); dazu fliegen Brocken. Hinter dem Fensterrand verschwindet alles. Gezeichnet im Canvas, nach rechts und für die Rückfahrt per `scale(-1, 1)` gespiegelt |
|
||
|
||
Vier Minuten sind abgestimmt: so lange braucht die Decke, bis sie voll ist.
|
||
|
||
Die Decke lebt nur im Speicher; beim Ändern der Fensterbreite wird sie auf die
|
||
neue Spaltenzahl umgerechnet, nach dem Neuladen ist der Boden frei.
|
||
|
||
> **Erfahrungswert Wachstum:** Das Flockenlimit bestimmt die Landerate, nicht
|
||
> das Spawn-Intervall - eine Flocke braucht gut 10 s durchs Fenster, bei 70
|
||
> gleichzeitig landen also nur etwa 5 je Sekunde. Wer die Wachstumszeit
|
||
> ändert, rechnet mit dieser Zahl.
|
||
|
||
#### Aura
|
||
|
||
Ein Theme mit zwei Namen: hell heißt es **Sunshine**, dunkel **Mond**. Beides
|
||
ist `theme-aura`, der Unterschied kommt wie immer aus `.dark`.
|
||
|
||
| Teil | Umsetzung |
|
||
| --- | --- |
|
||
| **Leiste** | Vier Hintergrundebenen statt zwei: Sonne bzw. Mond (`aura-sun.svg`, `aura-moon.svg`, 130 px in der Ecke oben rechts, halb angeschnitten), Schleier, Sterne (`aura-stars.svg`, nur nachts) und ein Himmelsverlauf. Weil jede Ebene eigene Lage und Größe braucht, stehen die in `--aura-header-position` und `--aura-header-size` |
|
||
| **Ziehende Wolken** | Reines CSS: `.app-header::before` mit zwei nahtlosen 800-px-Kacheln (`aura-clouds-near.svg`, `aura-clouds-far.svg`). `@keyframes aura-clouds` schiebt sie in 150 s um genau 1600 bzw. 800 px - Neustart ohne Ruck, vordere Lage doppelt so schnell. Nachts auf `--aura-cloud-opacity` 0,28 abgedunkelt |
|
||
| **Rollende Wellen** | Reines CSS, beide Modi: drei Wellenkacheln (`aura-wave-back/mid/front.svg`) liegen in der Seitenleiste unter dem Schleier und über dem Dunst. Jede ist genau **eine Sinusperiode** breit, passt deshalb waagerecht nahtlos an sich selbst und wiederholt sich (`repeat-x`). `@keyframes aura-waves` schiebt jede in 40 s um genau ihre eigene Kachelbreite (260, 320, 400 px) - am Rundenende ist das Bild deckungsgleich, also kein Ruck, und weil die Kacheln verschieden breit sind, läuft die vorderste am schnellsten. Schleier und Dunst stehen still, sonst wanderte die Helligkeit unter den Menüpunkten |
|
||
| **Sonnenstrahlen** | `src/components/AuraWeather.tsx`, nur hell. Alle 16–24 s zwei oder drei `.aura-ray`-Elemente: vom Sonnenmittelpunkt in Richtung Ecke unten links, leicht aufgefächert, per `clip-path` zur Sonne hin verjüngt und weichgezeichnet. Web Animations API: 4 s anschwellen, leicht schwenken, verblassen, danach entfernt |
|
||
|
||
Zwei Stolperstellen:
|
||
|
||
1. **Wolken hinter die Schrift.** `::before` mit `z-index: -1` verschwände
|
||
hinter dem Hintergrund der Leiste. `isolation: isolate` an `.app-header`
|
||
macht sie zum eigenen Stapelkontext - dann liegen die Wolken über dem
|
||
Himmel, aber unter Titel und Knöpfen. `overflow: hidden` an der Leiste wäre
|
||
der naheliegende Weg und ist falsch: sie enthält Aufklappmenüs.
|
||
2. **Sonne und Strahlen gehören zusammen.** `SUN_FROM_RIGHT`/`SUN_FROM_TOP` in
|
||
`AuraWeather.tsx` sind aus Position und Größe der Sonne in `index.css`
|
||
gerechnet. Wer die Sonne verschiebt, muss beide mitziehen, sonst kommen die
|
||
Strahlen aus dem Nichts.
|
||
3. **Hügel bewegen, Schleier nicht.** `background-position` gilt je Ebene.
|
||
Wer im Keyframe nur einen Wert schreibt, verschiebt auch den Schleier - die
|
||
Menüpunkte bekommen dann wandernde Helligkeit. Deshalb stehen dort immer
|
||
zwei Werte: `center top` für den Schleier, die Bewegung für die Grafik.
|
||
|
||
### Was ein neues Theme können muss
|
||
|
||
Die Rollen der Skalen sind über die ganze App gleich; ein Theme, das sie
|
||
verletzt, macht Text unlesbar. Nachgezählt im Markup:
|
||
|
||
| Skala | Rolle | Verwendungen |
|
||
| --- | --- | --- |
|
||
| `ink-50`, `ink-100` | Flächen und Hover | 102 × `bg-` |
|
||
| `ink-200` | **Rahmen** | 224 × `border-`, 1 × `bg-` |
|
||
| `ink-300`–`ink-900` | Text; `ink-400` trägt die Hinweistexte | 241 × `text-ink-400` |
|
||
| `ink-950` | nur unter Dialog-Overlays, bleibt immer dunkel | 5 × `bg-` |
|
||
| `brand-50`, `brand-100` | getönte Flächen | 70 × `bg-` |
|
||
| `brand-200`–`brand-400` | Rahmen | 76 × `border-` |
|
||
| `brand-500`, `brand-600` | gefüllte Knöpfe - **weisse Schrift darauf** | 100 × `bg-` |
|
||
| `brand-600`–`brand-800` | Text auf getönten Flächen | 108 × `text-` |
|
||
|
||
Daraus folgen zwei harte Regeln:
|
||
|
||
1. **`brand-500` und `brand-600` müssen dunkel genug für weisse Schrift sein.**
|
||
`text-white` steht 69 × im Markup, immer auf einer Markenfläche. Ein helles
|
||
Theme kann dort kein Pastell verwenden.
|
||
2. **Der helle Block eines Themes braucht `:not(.dark)`.** `html.theme-x` hat
|
||
eine Klasse mehr in der Spezifität als `.dark` und würde den Dunkelmodus
|
||
sonst überstimmen.
|
||
|
||
Was ein Theme über Farben hinaus ändern kann: Eckenradien (`--radius-*`, weil
|
||
Tailwind v4 `rounded-xl` zu `var(--radius-xl)` auflöst), Schatten (`--shadow-*`)
|
||
und die Schriften (`--font-sans` für Fliesstext, `--font-display` für
|
||
Überschriften und Feldbeschriftungen - letztere tragen dafür die Klasse
|
||
`field-label`). `rounded-full` ist die eine Ausnahme: dort schreibt Tailwind
|
||
einen festen Wert, deshalb hat Pixel Art dafür eine eigene Regel.
|
||
|
||
Drei Werte bleiben in jedem Theme und Modus gleich: `ink-950` (liegt nur unter
|
||
Dialog-Overlays), `text-white` (steht immer auf farbigen Flächen) und die 39
|
||
festen Hex-Werte für Phasenfarben, Benutzerfarben und Diagramme - die folgen
|
||
bewusst keinem Theme, weil sie Bedeutung tragen und nicht Dekoration sind.
|
||
|
||
Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem
|
||
Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund
|
||
darunter macht schwarze Schrift auf schwarzer Fläche. Der Rahmen liest sich damit
|
||
wie ein Blatt Papier im dunklen Fenster - gewollt.
|