feat(ui): drei Farbwelten je hell und dunkel, gewaehlt pro Benutzer

Neben dem Design des Hauses ("perinco") gibt es "Pixel Art" und
"Outdoor", jedes in hell und dunkel. Die Wahl steht neben dem
Hell/Dunkel-Schalter im Kopf der App.

Sie gehoert dem BENUTZER und nicht dem Browser: crm_users.theme und
crm_users.theme_mode, gesetzt ueber PATCH /api/auth/me, das jeder fuer
sich selbst darf. 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 als Zwischenspeicher daneben, damit
das Inline-Skript in index.html die Klassen vor dem ersten Frame setzen
kann - ohne das blitzt beim Laden das Standarddesign auf. Das hat eine
sichtbare Folge: melden sich zwei Leute an demselben Rechner an, sieht
der zweite fuer einen Augenblick das Design des ersten.

Der ThemeProvider steht deshalb in main.tsx jetzt unter dem AuthProvider
- er liest den Benutzer.

Keine Komponente wurde umgefaerbt. Im Markup steht nirgends eine feste
Farbe, sondern durchgaengig ink-*, brand-*, canvas und surface; Tailwind
v4 loest auch --radius-*, --shadow-* und die Schriften ueber Variablen
auf. Ein Theme tauscht also nur Werte, und deshalb kann Pixel Art alle
380 abgerundeten Ecken eckig machen, ohne eine Klasse anzufassen.

Zwei Dinge waren Voraussetzung:

- Der helle Block eines Themes braucht :not(.dark). html.theme-x hat
  eine Klasse mehr in der Spezifitaet als .dark und wuerde den
  Dunkelmodus sonst ueberstimmen.
- Auf brand-500/600 steht 69 Mal weisse Schrift. Jedes Theme braucht
  dort einen ausreichend dunklen Ton - ein helles Pastell waere
  unlesbar.

Die Pixelschrift (Silkscreen) sitzt nur in --font-display, also in den
Ueberschriften und den Feldbeschriftungen; labelClass tragt dafuer die
neue Klasse "field-label". Der Fliesstext bleibt Nunito Sans: die Listen
haben bis zu acht Spalten bei 11-13 px, und eine Pixelschrift waere darin
nicht mehr zu lesen. Outdoor bringt Cabin fuer beides mit.

server/test/userFields.test.mjs prueft die Werte und dass die Liste der
Farbwelten in userFields.js und src/types.ts nicht auseinanderlaeuft -
sonst zeigt die Auswahl ein Design an, dessen Speichern das Backend
abweist, und niemand weiss warum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-09 13:41:17 +02:00
co-authored by Claude Opus 5
parent 5a0f154851
commit 87759a8a24
19 changed files with 786 additions and 81 deletions
+2
View File
@@ -35,6 +35,8 @@ erDiagram
enum role "admin | user"
varchar color "Terminfarbe, NULL = aus Id abgeleitet"
longtext avatar "Data-URL"
varchar theme "Farbwelt, NULL = perinco"
varchar theme_mode "light | dark, NULL = folgt dem System"
varchar password_hash "bcrypt"
tinyint is_active
}
+67 -14
View File
@@ -120,7 +120,7 @@ src/
imageCompress.ts Bilder vor dem Upload verkleinern
components/
Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner)
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
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),
@@ -132,26 +132,79 @@ src/
ui/ Modal, Avatare, Diktatfeld
```
## Dark Mode ohne `dark:`-Varianten
## Themes ohne `dark:`-Varianten
Die Klasse `dark` auf `<html>` tauscht in `src/index.css` nur die Werte der
Variablen `canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben.
Die Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch -
kein `dark:` im Markup.
Die Darstellung hat **zwei unabhängige Achsen**, beide als Klasse am `<html>`:
| Achse | Klasse | Werte |
| --- | --- | --- |
| Farbwelt | `theme-perinco`, `theme-pixel`, `theme-outdoor` | 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
T["Umschalter im Header"] --> P["localStorage<br/>perinco_crm_theme"]
P --> H["class 'dark' auf html"]
H --> V["Variablen in index.css<br/>canvas, surface, ink-*, brand-*"]
V --> U["Utility-Klassen der Komponenten"]
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
```
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi
gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf
farbigen Flächen) und `brand-500` (das CI-Grün der Buttons).
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.
### 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
+20 -3
View File
@@ -32,7 +32,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
```
┌──────────────┬───────────────────────────────────────────┐
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
│ Kategorien │ Titel der Ansicht [Design][Hell/Dunkel] │
│ Dashboard │ │
│ Akquise ▾ │ │
│ Eingang │ │
@@ -64,8 +64,25 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
das ist auch die Ansicht, mit der die Anwendung startet.
* Der Knopf mit den drei Strichen oben links klappt das Menü auf Symbolbreite
zusammen - hilfreich auf kleinen Bildschirmen.
* Rechts oben schaltet zwischen hellem und dunklem Design. Ein **Doppelklick**
auf den Schalter folgt wieder der Systemeinstellung.
* Rechts oben stehen zwei Schalter. Der rechte wechselt zwischen **hell und
dunkel**; ein **Doppelklick** darauf folgt wieder der Systemeinstellung.
* Links daneben steht das **Design**. Es gibt drei zur Wahl, jedes in hell und
dunkel:
| Design | Aussehen |
| --- | --- |
| **perinco** | Das Design des Hauses: Blattgrün auf neutralem Grau |
| **Pixel Art** | 8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften |
| **Outdoor** | Wanderkarte: Sandpapier, Bergsee-Türkis, weiche Ecken |
> **Die Wahl gehört dir, nicht dem Rechner.** Sie wird an deinem Benutzerkonto
> gespeichert. Meldest du dich an einem anderen Rechner an, ist dein Design
> wieder da - und wenn Kai an derselben Maschine arbeitet, sieht er weiter
> sein eigenes. Wer nichts umstellt, bleibt bei perinco.
Beim Fließtext bleibt Pixel Art bewusst bei der normalen Schrift: die Listen
haben bis zu acht Spalten, und eine Pixelschrift wäre darin nicht mehr zu
lesen.
![Dieselbe Ansicht im dunklen Design](bilder/aus-mails-dunkel.png)
* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
+6 -2
View File
@@ -220,8 +220,12 @@ Browser-Download.
* **Deutsch** in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben
englisch (`Match`, `Opportunity`, `Lead`).
* **Kommentare erklären das Warum.** Was der Code tut, steht im Code.
* **Kein `dark:` im Markup.** Nur die Skalen `ink-*`, `brand-*`, `canvas`,
`surface` benutzen, den Rest macht `index.css`.
* **Kein `dark:` und keine Theme-Klasse im Markup.** Nur die Skalen `ink-*`,
`brand-*`, `canvas`, `surface` benutzen, den Rest macht `index.css`. Das ist
nicht Kosmetik: daran hängen drei Farbwelten in je zwei Modi, und eine feste
Farbe im Markup bricht fünf davon. Die Rollen der Skalen und die Regeln für ein
neues Theme stehen in [05 Frontend](05-frontend.md#themes-ohne-dark-varianten) -
wichtigste davon: auf `brand-500`/`brand-600` steht weisse Schrift.
* **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`,
`labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`,
`primaryButtonClass` aus `components/scanner/scannerUi.tsx`.
+1 -1
View File
@@ -247,7 +247,7 @@ Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
| Datei | Hält | Eigenheit |
| --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prüft beim Start das gespeicherte Token gegen `/auth/me` |
| `theme.tsx` | hell/dunkel/System | Wahl in `localStorage`, Doppelklick löst sie wieder |
| `theme.tsx` | Farbwelt (perinco/Pixel Art/Outdoor) und Modus (hell/dunkel/System) | Beides gehört dem Benutzer und steht in `crm_users`; `localStorage` ist nur der Zwischenspeicher fürs Inline-Skript in `index.html`. Doppelklick auf den Modus-Schalter folgt wieder dem System |
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt |
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App |