From 87759a8a24192c0451092c9ba74af853db2f4064 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Wed, 9 Sep 2026 13:41:17 +0200 Subject: [PATCH] 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) --- docs/02-datenmodell.md | 2 + docs/05-frontend.md | 81 +++++++-- docs/07-anleitung-benutzer.md | 23 ++- docs/08-anleitung-entwickler.md | 8 +- docs/09-quellcode.md | 2 +- index.html | 32 +++- server/package.json | 2 +- server/src/routes/auth.js | 21 ++- server/src/routes/users.js | 12 +- server/src/schema.sql | 10 ++ server/src/userFields.js | 36 ++++ server/test/userFields.test.mjs | 67 ++++++++ src/components/Header.tsx | 26 ++- src/components/leads/leadUi.tsx | 8 +- src/index.css | 283 ++++++++++++++++++++++++++++++++ src/lib/api.ts | 13 +- src/lib/theme.tsx | 199 ++++++++++++++++++---- src/main.tsx | 19 ++- src/types.ts | 23 +++ 19 files changed, 786 insertions(+), 81 deletions(-) create mode 100644 server/test/userFields.test.mjs diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 38e43e5..97e4ffd 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -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 } diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 49f8de6..7adc15a 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -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 `` 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 ``: + +| 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
perinco_crm_theme"] - P --> H["class 'dark' auf html"] - H --> V["Variablen in index.css
canvas, surface, ink-*, brand-*"] - V --> U["Utility-Klassen der Komponenten"] + U["Benutzer wählt
im Header"] --> DB["crm_users.theme
crm_users.theme_mode"] + U --> LS["localStorage
nur Zwischenspeicher"] + DB -->|"beim Anmelden"| H["Klassen am html
theme-*, dark"] + LS -->|"vor dem ersten Frame"| H + H --> V["Variablen in index.css
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 diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index ce967e2..d41fc22 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 diff --git a/docs/08-anleitung-entwickler.md b/docs/08-anleitung-entwickler.md index 5fa3988..8b825fa 100644 --- a/docs/08-anleitung-entwickler.md +++ b/docs/08-anleitung-entwickler.md @@ -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`. diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index e2b90e7..c3cf22a 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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 | diff --git a/index.html b/index.html index b4990ed..e8f75bc 100644 --- a/index.html +++ b/index.html @@ -7,25 +7,43 @@ perinco CRM + diff --git a/server/package.json b/server/package.json index 52a725e..7e9c18f 100644 --- a/server/package.json +++ b/server/package.json @@ -13,7 +13,7 @@ "dev": "node --watch src/server.js", "migrate": "node src/migrate.js", "scanner:import": "node src/scanner/importLegacy.js", - "test": "node test/mail.test.mjs && node test/acquisition.test.mjs" + "test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs" }, "dependencies": { "bcryptjs": "^2.4.3", diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 5e03c34..f2bf9cd 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -2,7 +2,12 @@ import express from 'express'; import bcrypt from 'bcryptjs'; import { pool } from '../db.js'; import { signToken, requireAuth } from '../middleware/auth.js'; -import { normalizeAvatar, normalizeColor } from '../userFields.js'; +import { + normalizeAvatar, + normalizeColor, + normalizeTheme, + normalizeThemeMode, +} from '../userFields.js'; const router = express.Router(); @@ -15,6 +20,9 @@ function publicUser(row) { role: row.role, color: row.color ?? null, avatar: row.avatar ?? null, + // Persoenliche Darstellung; null heisst "Standard" bzw. "folgt dem System". + theme: row.theme ?? null, + theme_mode: row.theme_mode ?? null, is_active: Boolean(row.is_active), created_at: row.created_at, updated_at: row.updated_at, @@ -59,10 +67,11 @@ router.get('/me', requireAuth, (req, res) => { res.json({ user: publicUser(req.user) }); }); -// PATCH /api/auth/me { color?, avatar? } -// Jeder darf seine eigene Terminfarbe und sein Foto setzen - dafuer braucht -// es keine Adminrechte. null bzw. "" setzt das Feld zurueck (Farbe: automatisch -// aus der Palette, Foto: Initiale). +// PATCH /api/auth/me { color?, avatar?, theme?, theme_mode? } +// Jeder darf seine eigene Terminfarbe, sein Foto und seine Darstellung setzen - +// dafuer braucht es keine Adminrechte. null bzw. "" setzt das Feld zurueck +// (Farbe: automatisch aus der Palette, Foto: Initiale, Design: perinco, Modus: +// folgt dem System). router.patch('/me', requireAuth, async (req, res, next) => { const fields = []; const values = []; @@ -71,6 +80,8 @@ router.patch('/me', requireAuth, async (req, res, next) => { for (const [field, normalize] of [ ['color', normalizeColor], ['avatar', normalizeAvatar], + ['theme', normalizeTheme], + ['theme_mode', normalizeThemeMode], ]) { if (req.body?.[field] === undefined) continue; const result = normalize(req.body[field]); diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 5d2a211..7e26668 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -31,7 +31,8 @@ function normalizeEmail(value) { router.get('/directory', async (req, res, next) => { try { const [rows] = await pool.query( - `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + `SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active, + created_at, updated_at FROM crm_users ORDER BY name ASC` ); res.json(rows.map(publicUser)); @@ -65,7 +66,8 @@ function duplicateMessage(err) { router.get('/', async (req, res, next) => { try { const [rows] = await pool.query( - `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + `SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active, + created_at, updated_at FROM crm_users ORDER BY role DESC, name ASC` ); res.json(rows.map(publicUser)); @@ -105,7 +107,8 @@ router.post('/', async (req, res, next) => { [username, email, name, role, optional.color, optional.avatar, hash, isActive] ); const [rows] = await pool.query( - `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + `SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active, + created_at, updated_at FROM crm_users WHERE id = ?`, [result.insertId] ); @@ -194,7 +197,8 @@ router.patch('/:id', async (req, res, next) => { await pool.query(`UPDATE crm_users SET ${fields.join(', ')} WHERE id = ?`, values); const [rows] = await pool.query( - `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + `SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active, + created_at, updated_at FROM crm_users WHERE id = ?`, [id] ); diff --git a/server/src/schema.sql b/server/src/schema.sql index 996fcc1..44bcae0 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -24,6 +24,16 @@ ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS color VARCHAR(9) NULL AFTER role; -- Benutzerfoto als Data-URL oder externe URL - wie bei crm_leads.avatar. ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color; +-- Persoenliche Darstellung. Sie haengt am Benutzer und nicht am Browser: wer +-- sich an einem anderen Rechner anmeldet, findet sein Design wieder, und zwei +-- Benutzer am selben Rechner kommen sich nicht in die Quere. +-- theme = Farbwelt ('perinco', 'pixel', 'outdoor'); NULL = 'perinco' +-- theme_mode = 'light' oder 'dark'; NULL = der Systemeinstellung folgen +-- Bewusst VARCHAR und kein ENUM: ein weiteres Theme soll eine Zeile im +-- Frontend sein und keine Datenbankmigration. Geprueft wird in userFields.js. +ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme VARCHAR(32) NULL AFTER avatar; +ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme; + CREATE TABLE IF NOT EXISTS crm_leads ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, salutation VARCHAR(32) NULL, diff --git a/server/src/userFields.js b/server/src/userFields.js index 6432356..6e36051 100644 --- a/server/src/userFields.js +++ b/server/src/userFields.js @@ -34,3 +34,39 @@ export function normalizeAvatar(value) { } return { value: avatar }; } + +/* + * Persoenliche Darstellung. + * + * Die Liste steht hier und nicht in der Datenbank (kein ENUM): ein weiteres + * Theme soll ein Block in src/index.css und eine Zeile hier sein, keine + * Migration. Das Gegenstueck fuer das Frontend steht in src/types.ts - wer hier + * etwas ergaenzt, muss dort mitziehen (server/test/userFields.test.mjs prueft, + * dass beide Listen gleich bleiben). + * + * Ein unbekannter Wert wird abgewiesen und nicht stillschweigend auf den + * Standard gesetzt: sonst faellt ein Tippfehler im Frontend nie auf, die + * Oberflaeche zeigt weiter das alte Design und niemand weiss, warum. + */ +export const THEMES = ['perinco', 'pixel', 'outdoor']; +export const THEME_MODES = ['light', 'dark']; + +/** Farbwelt; null bzw. '' bedeutet "perinco", der Standard. */ +export function normalizeTheme(value) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const theme = String(value).trim().toLowerCase(); + if (!THEMES.includes(theme)) { + return { error: `Unbekanntes Design (erlaubt: ${THEMES.join(', ')}).` }; + } + return { value: theme }; +} + +/** Hell oder dunkel; null bzw. '' bedeutet "der Systemeinstellung folgen". */ +export function normalizeThemeMode(value) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const mode = String(value).trim().toLowerCase(); + if (!THEME_MODES.includes(mode)) { + return { error: `Unbekannter Modus (erlaubt: ${THEME_MODES.join(', ')}).` }; + } + return { value: mode }; +} diff --git a/server/test/userFields.test.mjs b/server/test/userFields.test.mjs new file mode 100644 index 0000000..9afd762 --- /dev/null +++ b/server/test/userFields.test.mjs @@ -0,0 +1,67 @@ +/* + * Pruefungen der Benutzerfelder: laeuft mit `npm test` im Verzeichnis server/. + * + * Der eigentliche Zweck ist der letzte Block: die Liste der Farbwelten steht + * zweimal - hier in userFields.js (das Backend weist Unbekanntes ab) und in + * src/types.ts (das Frontend baut daraus die Auswahl). Laufen die beiden + * auseinander, faellt es sonst niemandem auf: das Auswahlfeld zeigt ein Design + * an, das Speichern schlaegt fehl, und die Oberflaeche bleibt beim alten + * Aussehen. + */ +import { readFileSync } from 'node:fs'; + +const { normalizeTheme, normalizeThemeMode, normalizeColor, THEMES, THEME_MODES } = await import( + '../src/userFields.js' +); + +let failures = 0; + +function check(name, condition, detail = '') { + if (condition) { + console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`); + } else { + console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`); + failures += 1; + } +} + +console.log('\nFarbwelt'); +check('Bekanntes Design', normalizeTheme('pixel').value === 'pixel'); +check('Gross geschrieben', normalizeTheme('Outdoor').value === 'outdoor'); +check('Mit Leerzeichen', normalizeTheme(' perinco ').value === 'perinco'); +check('Leer wird null', normalizeTheme('').value === null); +check('null bleibt null', normalizeTheme(null).value === null); +check('Unbekanntes wird abgewiesen', Boolean(normalizeTheme('barbie').error)); +check( + 'Die Fehlermeldung nennt die erlaubten Werte', + (normalizeTheme('barbie').error ?? '').includes('outdoor') +); + +console.log('\nModus'); +check('hell', normalizeThemeMode('light').value === 'light'); +check('dunkel', normalizeThemeMode('dark').value === 'dark'); +check('Leer heisst "folgt dem System"', normalizeThemeMode('').value === null); +check('Unbekanntes wird abgewiesen', Boolean(normalizeThemeMode('auto').error)); + +console.log('\nFarbe (unveraendert, als Regressionsschutz)'); +check('Hex wird angenommen', normalizeColor('#6BA04A').value === '#6ba04a'); +check('Kurzform wird abgewiesen', Boolean(normalizeColor('#fff').error)); + +console.log('\nBackend und Frontend kennen dieselben Designs'); +const types = readFileSync(new URL('../../src/types.ts', import.meta.url), 'utf8'); +const match = /export const THEME_PALETTES = \[([^\]]*)\]/.exec(types); +check('THEME_PALETTES in src/types.ts gefunden', Boolean(match)); +if (match) { + const frontend = [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]); + check( + 'Beide Listen sind gleich', + JSON.stringify(frontend) === JSON.stringify(THEMES), + `Frontend ${JSON.stringify(frontend)} / Backend ${JSON.stringify(THEMES)}` + ); +} +check('Es gibt genau zwei Modi', THEME_MODES.length === 2); + +console.log( + failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.` +); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index eed0f00..0619f96 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTheme } from '../lib/theme'; +import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../types'; interface HeaderProps { /** Beschriftung der Ansicht - meist ihr Name, siehe viewTitle(). */ @@ -13,7 +14,7 @@ const SUN_ICON = const MOON_ICON = 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z'; export const Header: React.FC = ({ title, subtitle, onToggleSidebar }) => { - const { theme, preference, toggle, useSystem } = useTheme(); + const { theme, preference, toggle, useSystem, palette, setPalette } = useTheme(); const isDark = theme === 'dark'; return ( @@ -35,6 +36,29 @@ export const Header: React.FC = ({ title, subtitle, onToggleSidebar
+ {/* + Farbwelt. Ein Auswahlfeld und keine Knopfreihe: es kommen weitere + Designs dazu, und drei bis fuenf Knoepfe im Kopf wuerden den Titel der + Ansicht verdraengen. Jede Wahl gilt nur fuer den angemeldeten + Benutzer - sie steht an seinem Konto, nicht im Browser. + */} + + + {/* Doppelklick loest die manuelle Wahl und folgt wieder dem System. */}