From 77b61b4a041ca829c2bc86fcd2e61bbb0bafe9ca Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Thu, 17 Sep 2026 17:35:16 +0200 Subject: [PATCH] feat(design): neues Theme Rainy mit Nieselregen und Wetterleuchten Schieferblau in hell und dunkel. Ein paar Tropfen fallen per Canvas ueber den Bildschirm und zerspritzen am unteren Rand; die Wolken der oberen Leiste leuchten alle 7-18 s auf (Maske = Wolken-SVG). Bei prefers-reduced-motion bleibt es still. Co-Authored-By: Claude Opus 5 (1M context) --- docs/05-frontend.md | 30 +++- docs/07-anleitung-benutzer.md | 19 ++- docs/09-quellcode.md | 2 +- index.html | 5 +- public/themes/rainy-header.svg | 46 +++++ public/themes/rainy-sidebar.svg | 54 ++++++ server/src/userFields.js | 2 +- src/components/RainyWeather.tsx | 290 ++++++++++++++++++++++++++++++++ src/index.css | 165 +++++++++++++++++- src/lib/theme.tsx | 16 +- src/types.ts | 3 +- 11 files changed, 617 insertions(+), 15 deletions(-) create mode 100644 public/themes/rainy-header.svg create mode 100644 public/themes/rainy-sidebar.svg create mode 100644 src/components/RainyWeather.tsx diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 053474d..7553639 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -138,7 +138,7 @@ Die Darstellung hat **zwei unabhängige Achsen**, beide als Klasse am ``: | Achse | Klasse | Werte | | --- | --- | --- | -| Farbwelt | `theme-perinco`, `theme-pixel`, `theme-outdoor`, `theme-cyberpunk` | perinco ist der Standard | +| Farbwelt | `theme-perinco`, `theme-pixel`, `theme-outdoor`, `theme-cyberpunk`, `theme-rainy` | perinco ist der Standard | | Modus | `dark` bzw. keine | hell, dunkel, oder der Systemeinstellung folgen | `src/index.css` tauscht darin nur Variablenwerte: `canvas`, `surface`, `ink-*`, @@ -173,7 +173,7 @@ er liest den Benutzer. 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. Die Dateien liegen als **SVG** in +Neonröhren und regnerische Gasse, Regenwolken und nasse Hügel. 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. @@ -212,6 +212,32 @@ Das ist kein Umweg, sondern erledigt zwei Dinge zugleich: > 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: das Theme mit Wetter + +Rainy ist das einzige Theme mit Bewegung. 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 `` ü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. + ### Was ein neues Theme können muss Die Rollen der Skalen sind über die ganze App gleich; ein Theme, das sie diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index a57ad55..8b53380 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -66,7 +66,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. zusammen - hilfreich auf kleinen Bildschirmen. * 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 vier zur Wahl, jedes in hell und +* Links daneben steht das **Design**. Es gibt fünf zur Wahl, jedes in hell und dunkel: | Design | Aussehen | @@ -75,6 +75,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. | **Pixel Art** | 8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften | | **Outdoor** | Wanderkarte: Sandpapier, Bergsee-Türkis, weiche Ecken | | **Cyberpunk** | Neon in der Nacht: scharfe Kanten, violettes Leuchten, Blaustahl | + | **Rainy** | Regenwetter: Schieferblau, leichter Nieselregen, Wolken mit Wetterleuchten | > **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 @@ -85,11 +86,19 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. haben bis zu acht Spalten, und eine Pixelschrift wäre darin nicht mehr zu lesen. - Die drei Designs neben perinco haben zusätzlich eine **Grafik** hinter der + Die Designs neben perinco haben zusätzlich eine **Grafik** hinter der oberen Leiste und der Seitenleiste: 8-bit-Himmel und Steinmauer, blauer Himmel - und Wiese, Neonröhren und Regen. Sie liegt hinter einem Schleier, damit die - Schrift darauf lesbar bleibt, und ist dort am deutlichsten, wo die Leisten frei - sind - in der Mitte oben und unten links. perinco bleibt einfarbig. + und Wiese, Neonröhren und Regen, Regenwolken und nasse Hügel. Sie liegt hinter + einem Schleier, damit die Schrift darauf lesbar bleibt, und ist dort am + deutlichsten, wo die Leisten frei sind - in der Mitte oben und unten links. perinco bleibt einfarbig. + + **Rainy** ist das einzige Design, in dem sich etwas bewegt: ein paar + Regentropfen fallen über den Bildschirm und zerspritzen am unteren Rand, und + die Wolken in der oberen Leiste leuchten alle paar Sekunden kurz auf. Der + Regen liegt nur optisch über der Oberfläche - er verdeckt keine Knöpfe und + fängt keine Klicks ab, und in geöffneten Dialogen regnet es nicht. Wer im + Betriebssystem **„Bewegung reduzieren"** eingeschaltet hat, sieht nur die + Farben und die Wolken, ohne Regen und Blitz. ![Dieselbe Ansicht im dunklen Design](bilder/aus-mails-dunkel.png) * Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 7855c73..ba28e5c 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -295,7 +295,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` | Farbwelt (perinco/Pixel Art/Outdoor/Cyberpunk, Hintergrundgrafiken in `public/themes/`) 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 | +| `theme.tsx` | Farbwelt (perinco/Pixel Art/Outdoor/Cyberpunk/Rainy, Hintergrundgrafiken in `public/themes/`; bei Rainy hängt er zusätzlich `components/RainyWeather.tsx` mit Regen und Wetterleuchten ein) 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 e06cf29..9744e72 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,10 @@ var palette = localStorage.getItem('perinco_crm_palette'); // Whitelist, damit ein manipulierter Eintrag keine Klasse setzt. - if (palette === 'pixel' || palette === 'outdoor' || palette === 'cyberpunk') { + if (palette === 'pixel' || + palette === 'outdoor' || + palette === 'cyberpunk' || + palette === 'rainy') { document.documentElement.classList.add('theme-' + palette); } } catch (e) { diff --git a/public/themes/rainy-header.svg b/public/themes/rainy-header.svg new file mode 100644 index 0000000..3d03595 --- /dev/null +++ b/public/themes/rainy-header.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/rainy-sidebar.svg b/public/themes/rainy-sidebar.svg new file mode 100644 index 0000000..41ebdd3 --- /dev/null +++ b/public/themes/rainy-sidebar.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/src/userFields.js b/server/src/userFields.js index c088946..7279710 100644 --- a/server/src/userFields.js +++ b/server/src/userFields.js @@ -48,7 +48,7 @@ export function normalizeAvatar(value) { * 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', 'cyberpunk']; +export const THEMES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy']; export const THEME_MODES = ['light', 'dark']; /** Farbwelt; null bzw. '' bedeutet "perinco", der Standard. */ diff --git a/src/components/RainyWeather.tsx b/src/components/RainyWeather.tsx new file mode 100644 index 0000000..3d26814 --- /dev/null +++ b/src/components/RainyWeather.tsx @@ -0,0 +1,290 @@ +import React, { useEffect, useRef } from 'react'; + +/* + * Das Wetter des Themes "Rainy": Nieselregen ueber dem Bildschirm und + * Wetterleuchten in den Wolken der oberen Leiste. + * + * Beides liegt ueber der ganzen Oberflaeche, faengt aber keinen Klick ab + * (pointer-events: none) und bleibt unter den Dialogen (z-[100]) - ein Tropfen, + * der ueber ein Formular laeuft, ist Stimmung, einer, der den Speichern-Knopf + * verdeckt, waere ein Fehler. + * + * Bewusst sparsam: es sind nie mehr als eine Handvoll Tropfen gleichzeitig + * unterwegs, und es blitzt nur alle paar Sekunden. Ein CRM, in dem es schuettet, + * lenkt ab. Wer im Betriebssystem "Bewegung reduzieren" eingestellt hat, bekommt + * weder Regen noch Blitz; die Wolken in der Leiste bleiben als Grafik stehen. + * + * Der Regen ist ein Canvas und keine CSS-Animation: der Aufprall haengt davon + * ab, wo der Tropfen unten ankommt, und die Spritzer fliegen mit Schwerkraft - + * beides ist in Keyframes nicht sauber zu haben. + */ + +interface Drop { + x: number; + y: number; + /** Geschwindigkeit in px/s */ + vx: number; + vy: number; + length: number; +} + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + /** Restlebenszeit in s */ + life: number; + maxLife: number; + size: number; +} + +interface Ripple { + x: number; + y: number; + life: number; + maxLife: number; + width: number; +} + +/** Abstand zwischen zwei neuen Tropfen in ms - daraus folgt "ein paar". */ +const SPAWN_MIN_MS = 220; +const SPAWN_MAX_MS = 650; +/** Obergrenze, falls der Tab lange im Hintergrund war. */ +const MAX_DROPS = 14; +const GRAVITY = 1400; + +/** Pause zwischen zwei Blitzen in ms. */ +const FLASH_MIN_MS = 7000; +const FLASH_MAX_MS = 18000; + +function random(min: number, max: number) { + return min + Math.random() * (max - min); +} + +function prefersReducedMotion() { + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; +} + +/** Farbe aus index.css (--rain-color), damit hell und dunkel passen. */ +function rainColor() { + const value = getComputedStyle(document.documentElement).getPropertyValue('--rain-color').trim(); + return value || 'rgb(120 140 165 / 0.5)'; +} + +export const RainyWeather: React.FC = () => { + const canvasRef = useRef(null); + const flashRef = useRef(null); + + // --- Regen ----------------------------------------------------------------- + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx || prefersReducedMotion()) return; + + let width = 0; + let height = 0; + const resize = () => { + const dpr = window.devicePixelRatio || 1; + width = window.innerWidth; + height = window.innerHeight; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + resize(); + window.addEventListener('resize', resize); + + const drops: Drop[] = []; + const particles: Particle[] = []; + const ripples: Ripple[] = []; + let nextSpawn = 0; + let last = performance.now(); + let frame = 0; + // Die Farbe haengt am Modus; sie wird gelegentlich nachgelesen statt in + // jedem Frame, getComputedStyle ist nicht umsonst. + let color = rainColor(); + let colorReadAt = 0; + + const spawn = () => { + if (drops.length >= MAX_DROPS) return; + // Leichter Wind von rechts: die Tropfen fallen etwas schraeg. + const vy = random(620, 900); + drops.push({ + x: random(0, width + 60), + y: random(-80, -20), + vx: -vy * 0.08, + vy, + length: random(9, 17), + }); + }; + + const splash = (x: number) => { + const count = Math.round(random(3, 6)); + for (let i = 0; i < count; i++) { + const life = random(0.25, 0.45); + particles.push({ + x, + y: height - 1, + vx: random(-110, 110), + vy: -random(110, 240), + life, + maxLife: life, + size: random(0.8, 1.6), + }); + } + const life = random(0.3, 0.45); + ripples.push({ x, y: height - 1.5, life, maxLife: life, width: random(10, 18) }); + }; + + const tick = (now: number) => { + // Nach einer Pause im Hintergrund nicht alles auf einmal nachholen. + const dt = Math.min((now - last) / 1000, 0.05); + last = now; + + if (now >= nextSpawn) { + spawn(); + nextSpawn = now + random(SPAWN_MIN_MS, SPAWN_MAX_MS); + } + + if (now - colorReadAt > 500) { + color = rainColor(); + colorReadAt = now; + } + + ctx.clearRect(0, 0, width, height); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineCap = 'round'; + + // Tropfen + ctx.lineWidth = 1.2; + for (let i = drops.length - 1; i >= 0; i--) { + const drop = drops[i]; + drop.x += drop.vx * dt; + drop.y += drop.vy * dt; + if (drop.y >= height) { + drops.splice(i, 1); + if (drop.x >= 0 && drop.x <= width) splash(drop.x); + continue; + } + const scale = drop.length / drop.vy; + ctx.beginPath(); + ctx.moveTo(drop.x, drop.y); + ctx.lineTo(drop.x - drop.vx * scale, drop.y - drop.vy * scale); + ctx.stroke(); + } + + // Spritzer + for (let i = particles.length - 1; i >= 0; i--) { + const p = particles[i]; + p.life -= dt; + if (p.life <= 0) { + particles.splice(i, 1); + continue; + } + p.vy += GRAVITY * dt; + p.x += p.vx * dt; + p.y = Math.min(p.y + p.vy * dt, height - 0.5); + ctx.globalAlpha = p.life / p.maxLife; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } + + // Kleiner Ring am Boden, wo der Tropfen aufgekommen ist + ctx.lineWidth = 1; + for (let i = ripples.length - 1; i >= 0; i--) { + const r = ripples[i]; + r.life -= dt; + if (r.life <= 0) { + ripples.splice(i, 1); + continue; + } + const progress = 1 - r.life / r.maxLife; + ctx.globalAlpha = (1 - progress) * 0.8; + ctx.beginPath(); + ctx.ellipse(r.x, r.y, 2 + r.width * progress, 1 + 1.5 * progress, 0, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.globalAlpha = 1; + + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(frame); + window.removeEventListener('resize', resize); + }; + }, []); + + // --- Wetterleuchten -------------------------------------------------------- + useEffect(() => { + const flash = flashRef.current; + if (!flash || prefersReducedMotion() || typeof flash.animate !== 'function') return; + + let timer = 0; + let cancelled = false; + const schedule = () => { + if (cancelled) return; + timer = window.setTimeout(strike, random(FLASH_MIN_MS, FLASH_MAX_MS)); + }; + + const strike = () => { + /* + * Die Leiste wird bei jedem Blitz neu vermessen statt einmal: sie ist + * nicht immer da (Anmeldeseite) und aendert ihre Breite mit der + * Seitenleiste. Das Overlay bekommt exakt ihre Masse, damit die Maske + * (dieselbe SVG wie das Hintergrundbild, gleich skaliert) auf den Wolken + * sitzt. + */ + const header = document.querySelector('.app-header'); + if (!header || document.hidden) { + schedule(); + return; + } + const rect = header.getBoundingClientRect(); + flash.style.left = `${rect.left}px`; + flash.style.top = `${rect.top}px`; + flash.style.width = `${rect.width}px`; + flash.style.height = `${rect.height}px`; + // Nur in der Mitte, wo der Schleier die Wolken durchlaesst - am Rand + // stuende der Blitz auf dem Titel bzw. den Schaltern. + flash.style.setProperty('--flash-x', `${random(28, 72)}%`); + + // Kurz, zurueck, noch einmal heller, langes Ausklingen - so flackert + // Wetterleuchten. + const animation = flash.animate( + [ + { opacity: 0 }, + { opacity: 0.75, offset: 0.06 }, + { opacity: 0.15, offset: 0.14 }, + { opacity: 1, offset: 0.22 }, + { opacity: 0.35, offset: 0.45 }, + { opacity: 0 }, + ], + { duration: 900, easing: 'ease-out' } + ); + animation.onfinish = schedule; + }; + + // Der erste Blitz kommt frueher, damit man das Theme beim Umschalten erlebt. + timer = window.setTimeout(strike, random(1500, 3500)); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, []); + + return ( + <> +