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
+283
View File
@@ -9,9 +9,40 @@
* installiert, wird es verwendet, sonst Nunito Sans als freies Aequivalent.
*/
/*
* Drei Farbwelten, jede in hell und dunkel. Sie haengen an einer Klasse am
* <html> (theme-pixel, theme-outdoor); ohne Klasse gilt perinco. Der Modus
* bleibt die vorhandene Klasse "dark".
*
* Warum das ohne Aenderung an den Komponenten geht: im Markup steht nirgends
* eine feste Farbe, sondern durchgaengig ink-*, brand-*, canvas und surface -
* und Tailwind v4 loest auch Eckenradien (--radius-*), Schatten und Schriften
* ueber Variablen auf. Ein Theme tauscht also nur Werte.
*
* Die Rollen der Skalen sind ueber die ganze App gleich (nachgezaehlt, damit
* ein Theme nichts unlesbar macht):
* ink-50/100 Flaechen und Hover
* ink-200 Rahmen (224 von 225 Verwendungen)
* ink-300-900 Text, 400 traegt die Hinweistexte
* ink-950 liegt nur unter Dialog-Overlays, bleibt immer dunkel
* brand-50/100 getoente Flaechen
* brand-200-400 Rahmen
* brand-500/600 gefuellte Knoepfe - hier steht WEISSE Schrift drauf, jedes
* Theme braucht dort also einen ausreichend dunklen Ton
* brand-600-800 Text auf getoenten Flaechen
*/
@theme {
--font-sans: 'Avenir Next', 'Avenir', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
/*
* Zweite Schrift fuer Ueberschriften und Feldbeschriftungen. Bei perinco und
* Outdoor ist sie mit der Grundschrift identisch, nur "Pixel Art" setzt hier
* eine Pixelschrift ein - ohne dass ein Theme dafuer eigene Selektoren
* braucht.
*/
--font-display: 'Avenir Next', 'Avenir', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
/*
* Flaechen sind bewusst semantisch benannt statt "weiss": im Dark Mode werden
* unten nur diese Variablen umgesetzt, die Utility-Klassen in den Komponenten
@@ -115,6 +146,218 @@
--color-red-700: #f28b8b;
}
/* ===========================================================================
* Outdoor
*
* Wanderkarte und Bergsee: Sandpapier als Flaeche, Seeblau-Gruen als Akzent,
* warme Lehmgrautoene fuer Text. Bewusst kein zweites Blattgruen - das haette
* neben perinco wie ein Versehen gewirkt. Der Unterschied faellt an drei
* Stellen auf: die Flaeche ist warm statt neutral, die Ecken sind runder und
* die Schatten weicher.
*
* Der helle Block ist mit :not(.dark) abgegrenzt. Ohne das gewinnt er gegen
* .dark (eine Klasse mehr in der Spezifitaet) und der Dunkelmodus waere
* kaputt - genau das ist der Grund fuer die Schreibweise in allen Themes.
* =========================================================================== */
html.theme-outdoor:not(.dark) {
--color-canvas: #f2eee3;
--color-surface: #fdfbf6;
--color-brand-50: #e8f2f1;
--color-brand-100: #d2e6e3;
--color-brand-200: #a8cfca;
--color-brand-300: #74b3ac;
--color-brand-400: #3f938b;
--color-brand-500: #12736c;
--color-brand-600: #0d5d57;
--color-brand-700: #0a4a45;
--color-brand-800: #073833;
--color-brand-900: #052b27;
--color-brand-accent: #12736c;
/* Lehmgrau: dieselbe Helligkeitstreppe wie perinco, nur mit warmem Stich. */
--color-ink-50: #f4f0e6;
--color-ink-100: #e9e3d4;
--color-ink-200: #d6cdb8;
--color-ink-300: #a1977f;
/* Hinweistexte (text-ink-400, 241 Stellen) muessen auf dem Sand lesbar
bleiben - deshalb dunkler als die reine Umkehrung. */
--color-ink-400: #6f6650;
--color-ink-500: #5c5344;
--color-ink-600: #4b4436;
--color-ink-700: #3b352a;
--color-ink-800: #2b271e;
--color-ink-900: #1d1a14;
--color-ink-950: #100e0a;
/* Runder und weicher als perinco - der zweite Wiedererkennungswert. */
--radius-sm: 0.3rem;
--radius-md: 0.45rem;
--radius-lg: 0.65rem;
--radius-xl: 0.9rem;
--radius-2xl: 1.25rem;
--shadow-card: 0 2px 4px rgb(61 53 42 / 0.06), 0 8px 24px rgb(61 53 42 / 0.1);
--shadow-lift: 0 12px 32px rgb(61 53 42 / 0.16);
--font-sans: 'Cabin', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Cabin', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
}
/*
* Outdoor, dunkel: Waldnacht. Die Flaechen sind nicht neutral schwarz, sondern
* ins Tannengruen gezogen; der Text bekommt einen warmen Stich, damit es nicht
* nach kaltem Terminal aussieht.
*/
html.theme-outdoor.dark {
--color-canvas: #12170f;
--color-surface: #1a2016;
--color-brand-50: #0c2b28;
--color-brand-100: #103833;
--color-brand-200: #17544d;
--color-brand-300: #1d6d65;
--color-brand-400: #2f9187;
--color-brand-500: #14837a;
--color-brand-600: #1f9c91;
--color-brand-700: #74b3ac;
--color-brand-800: #a8cfca;
--color-brand-900: #d2e6e3;
--color-brand-accent: #1f9c91;
--color-ink-50: #1d2419;
--color-ink-100: #26301f;
--color-ink-200: #35412c;
--color-ink-300: #6f7a63;
--color-ink-400: #8e9a81;
--color-ink-500: #a7b39a;
--color-ink-600: #bdc7b2;
--color-ink-700: #d3dbca;
--color-ink-800: #e6ebe0;
--color-ink-900: #f4f7f0;
--color-ink-950: #000000;
--radius-sm: 0.3rem;
--radius-md: 0.45rem;
--radius-lg: 0.65rem;
--radius-xl: 0.9rem;
--radius-2xl: 1.25rem;
--shadow-card: 0 2px 4px rgb(0 0 0 / 0.4), 0 8px 24px rgb(0 0 0 / 0.45);
--shadow-lift: 0 12px 32px rgb(0 0 0 / 0.6);
--font-sans: 'Cabin', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Cabin', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
/* Die Statusfarben kommen aus .dark und passen dort schon - hier nur die
Pillen leicht ins Warme gezogen, damit sie nicht herausfallen. */
--color-amber-50: #2c2109;
--color-amber-200: #574112;
}
/* ===========================================================================
* Pixel Art
*
* 8-bit: keine runden Ecken, harte Schatten ohne Weichzeichnung, Rahmen als
* schwarze Outlines, gesaettigtes Arcade-Gruen.
*
* Die Pixelschrift steckt nur in --font-display (Ueberschriften und
* Feldbeschriftungen). Der Fliesstext bleibt bewusst Nunito Sans: die
* Akquise-Tabelle hat acht Spalten bei 11-13 px, und eine Pixelschrift waere
* darin nicht mehr zu lesen - ein Design, das die Arbeit behindert, waere ein
* schlechtes Design.
* =========================================================================== */
html.theme-pixel:not(.dark) {
/* Deutlich dunkler als die Karten: Pixelgrafik lebt von harten Kanten, und
bei zu aehnlichen Toenen verschwimmen Karte und Grund. */
--color-canvas: #cdd8c2;
--color-surface: #f6f8f1;
--color-brand-50: #e0f2d4;
--color-brand-100: #c8e8b4;
/* Rahmen sind hier keine Andeutung, sondern Kontur - deshalb dunkel. */
--color-brand-200: #2b7a1c;
--color-brand-300: #256b18;
--color-brand-400: #35931f;
--color-brand-500: #2e8b22;
--color-brand-600: #24701a;
--color-brand-700: #1b5613;
--color-brand-800: #143f0e;
--color-brand-900: #0e2f0a;
--color-brand-accent: #2e8b22;
--color-ink-50: #eef2e8;
--color-ink-100: #dde4d4;
/* Der Kniff des Themes: ink-200 ist fast ausschliesslich Rahmen, wird hier
also zur schwarzen Outline - 224 Stellen ohne eine Zeile im Markup. */
--color-ink-200: #1f2a1a;
--color-ink-300: #5a6653;
--color-ink-400: #47523f;
--color-ink-500: #38412f;
--color-ink-600: #2c3425;
--color-ink-700: #22291c;
--color-ink-800: #182015;
--color-ink-900: #0e140c;
--color-ink-950: #000000;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
/* Versetzt statt weichgezeichnet - so sehen Schatten in Pixelgrafik aus. */
--shadow-card: 4px 4px 0 0 #1f2a1a;
--shadow-lift: 6px 6px 0 0 #1f2a1a;
--font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace;
}
/*
* Pixel Art, dunkel: Arcade-Automat im dunklen Raum. Flaechen fast schwarz mit
* blauem Stich, Konturen in gedecktem Gruen (schwarze Outlines waeren auf
* schwarzem Grund unsichtbar), das Gruen der Knoepfe leuchtet kraeftiger.
*/
html.theme-pixel.dark {
--color-canvas: #0d0f14;
--color-surface: #151a22;
--color-brand-50: #0f2a0c;
--color-brand-100: #163c11;
--color-brand-200: #2c7a1f;
--color-brand-300: #35931f;
--color-brand-400: #3aa524;
--color-brand-500: #3aa524;
--color-brand-600: #49bf30;
--color-brand-700: #7fdc63;
--color-brand-800: #b0ec9c;
--color-brand-900: #d8f7cd;
--color-brand-accent: #49bf30;
--color-ink-50: #1a2029;
--color-ink-100: #232b36;
--color-ink-200: #33734a;
--color-ink-300: #6f7f92;
--color-ink-400: #93a3b5;
--color-ink-500: #adbccb;
--color-ink-600: #c2cfdb;
--color-ink-700: #d6e0ea;
--color-ink-800: #e9eff5;
--color-ink-900: #f6f9fc;
--color-ink-950: #000000;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--shadow-card: 4px 4px 0 0 #000000;
--shadow-lift: 6px 6px 0 0 #000000;
--font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace;
}
@layer base {
html,
body {
@@ -139,10 +382,38 @@
h4,
h5,
h6 {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.015em;
}
/*
* Die Feldbeschriftungen (labelClass in components/leads/leadUi.tsx) tragen
* dieselbe Klasse, damit ein Theme sie mit einer Regel erreicht. Bei perinco
* und Outdoor ist --font-display die Grundschrift, hier aendert sich also
* nichts.
*/
.field-label {
font-family: var(--font-display);
}
/*
* Pixelschrift hat eine feste Zeichenbreite und traegt ihre Sperrung selbst;
* die negative Laufweite der Ueberschriften wuerde die Buchstaben ineinander
* schieben. Ausserdem kein Kantenglaetten - Pixel sollen Pixel bleiben.
*/
html.theme-pixel h1,
html.theme-pixel h2,
html.theme-pixel h3,
html.theme-pixel h4,
html.theme-pixel h5,
html.theme-pixel h6,
html.theme-pixel .field-label {
letter-spacing: 0;
-webkit-font-smoothing: none;
font-smooth: never;
}
/* Fokus-Ring durchgaengig in CI-Gruen */
:focus-visible {
outline: 2px solid var(--color-brand-500);
@@ -160,6 +431,18 @@
filter: invert(1) hue-rotate(180deg);
}
/*
* "rounded-full" laesst sich nicht ueber eine Variable abschalten - Tailwind
* schreibt dort einen festen Wert. Fuer Pixel Art muss es aber weg: runde
* Punkte, Pillen und Bilder neben eckigen Karten waeren ein Bruch. Der
* Attributselektor greift genau das eine Klassenwort und ist mit dem
* vorangestellten html spezifischer als die Utility-Klasse, deshalb ohne
* !important.
*/
html.theme-pixel [class~='rounded-full'] {
border-radius: 0;
}
.scrollbar-slim::-webkit-scrollbar {
width: 8px;
height: 8px;