From 831429d5590ec8c8d0359784b770bb0438208c2e Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Thu, 17 Sep 2026 18:02:06 +0200 Subject: [PATCH] feat(design): neues Theme Snow mit Schneefall und Schneedecke Eisblau in hell und dunkel. Flocken fallen per Canvas und sammeln sich am unteren Fensterrand in einer Hoehenkarte, die per Diffusion geglaettet wird; nach rund vier Minuten ist die Decke voll (26 px). Bei prefers-reduced-motion bleibt es still. Co-Authored-By: Claude Opus 5 (1M context) --- docs/05-frontend.md | 29 +++- docs/07-anleitung-benutzer.md | 31 ++-- docs/09-quellcode.md | 2 +- index.html | 3 +- public/themes/snow-header.svg | 79 ++++++++++ public/themes/snow-sidebar.svg | 65 ++++++++ server/src/userFields.js | 2 +- src/components/SnowWeather.tsx | 268 +++++++++++++++++++++++++++++++++ src/index.css | 141 ++++++++++++++++- src/lib/theme.tsx | 6 +- src/types.ts | 3 +- 11 files changed, 606 insertions(+), 23 deletions(-) create mode 100644 public/themes/snow-header.svg create mode 100644 public/themes/snow-sidebar.svg create mode 100644 src/components/SnowWeather.tsx diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 7553639..42a91a5 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`, `theme-rainy` | perinco ist der Standard | +| Farbwelt | `theme-perinco`, `theme-pixel`, `theme-outdoor`, `theme-cyberpunk`, `theme-rainy`, `theme-snow` | 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, Regenwolken und nasse Hügel. Die Dateien liegen als **SVG** in +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. @@ -212,9 +212,9 @@ 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 und Snow: Themes mit Wetter -Rainy ist das einzige Theme mit Bewegung. Farben und Grafiken funktionieren wie +Rainy und Snow sind die Themes mit Bewegung. Hier zuerst Rainy, Snow folgt 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. @@ -238,6 +238,27 @@ 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 `` ü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 | + +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. + ### 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 8b53380..f93411c 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 fünf zur Wahl, jedes in hell und +* Links daneben steht das **Design**. Es gibt sechs zur Wahl, jedes in hell und dunkel: | Design | Aussehen | @@ -76,6 +76,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. | **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 | + | **Snow** | Winter: Eisblau, Schneefall, der sich am unteren Rand zu einer Schneedecke sammelt | > **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 @@ -88,17 +89,25 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. 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, 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. + und Wiese, Neonröhren und Regen, Regenwolken und nasse Hügel, verschneite + Berge und Tannen. 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. + **Rainy** und **Snow** sind die Designs, in denen sich etwas bewegt. Das + Wetter liegt nur optisch über der Oberfläche: es fängt keine Klicks ab, und + geöffnete Dialoge liegen darüber. + + * Bei **Rainy** fallen ein paar Regentropfen über den Bildschirm und + zerspritzen am unteren Rand, und die Wolken in der oberen Leiste leuchten + alle paar Sekunden kurz auf. + * Bei **Snow** schneit es, und der Schnee bleibt unten liegen: nach etwa vier + Minuten ist am unteren Rand eine Schneedecke gewachsen. Sie bleibt ein + schmaler Streifen und wächst nicht weiter. Neu laden, und der Boden ist + wieder frei. + + Wer im Betriebssystem **„Bewegung reduzieren"** eingeschaltet hat, sieht bei + beiden nur Farben und Grafiken - ohne Regen, Blitz und Schnee. ![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 ba28e5c..3df1680 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/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 | +| `theme.tsx` | Farbwelt (perinco/Pixel Art/Outdoor/Cyberpunk/Rainy/Snow, Hintergrundgrafiken in `public/themes/`; bei Rainy hängt er zusätzlich `components/RainyWeather.tsx` mit Regen und Wetterleuchten ein, bei Snow `components/SnowWeather.tsx` mit Schneefall und Schneedecke) 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 9744e72..d0b0659 100644 --- a/index.html +++ b/index.html @@ -29,7 +29,8 @@ if (palette === 'pixel' || palette === 'outdoor' || palette === 'cyberpunk' || - palette === 'rainy') { + palette === 'rainy' || + palette === 'snow') { document.documentElement.classList.add('theme-' + palette); } } catch (e) { diff --git a/public/themes/snow-header.svg b/public/themes/snow-header.svg new file mode 100644 index 0000000..da0f43b --- /dev/null +++ b/public/themes/snow-header.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/snow-sidebar.svg b/public/themes/snow-sidebar.svg new file mode 100644 index 0000000..7f6eea9 --- /dev/null +++ b/public/themes/snow-sidebar.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/src/userFields.js b/server/src/userFields.js index 7279710..d1f8546 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', 'rainy']; +export const THEMES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow']; export const THEME_MODES = ['light', 'dark']; /** Farbwelt; null bzw. '' bedeutet "perinco", der Standard. */ diff --git a/src/components/SnowWeather.tsx b/src/components/SnowWeather.tsx new file mode 100644 index 0000000..eb3b7b4 --- /dev/null +++ b/src/components/SnowWeather.tsx @@ -0,0 +1,268 @@ +import React, { useEffect, useRef } from "react"; + +/* + * Das Wetter des Themes "Snow": Schneefall ueber dem Bildschirm, und was + * unten ankommt, bleibt liegen und haeuft sich am unteren Fensterrand an. + * + * Wie beim Regen (RainyWeather.tsx) ein Canvas ueber der ganzen Oberflaeche, + * ohne Klicks abzufangen und unter den Dialogen (z-[100]). Bei "Bewegung + * reduzieren" im Betriebssystem schneit es nicht. + * + * Die Schneedecke ist eine Hoehenkarte: der Fensterrand ist in schmale Spalten + * geteilt, jede Flocke erhoeht die Spalte, in der sie landet, ein wenig. Damit + * keine Nadeln wachsen, gleicht sich jede Spalte laufend an ihre Nachbarn an - + * so entstehen weiche Verwehungen. Die Decke waechst langsam + * (bis zur vollen Hoehe rund vier Minuten) und ist gedeckelt, damit sie nie + * mehr als einen schmalen Streifen am Rand verdeckt. + * + * Die Decke lebt nur im Speicher: neu laden, und der Boden ist wieder frei. + */ + +interface Flake { + x: number; + y: number; + radius: number; + /** Fallgeschwindigkeit in px/s */ + vy: number; + /** Phase und Staerke des seitlichen Schaukelns */ + phase: number; + sway: number; +} + +/** Breite einer Spalte der Hoehenkarte in px. */ +const COLUMN = 3; +/** Hoechste Schneedecke in px - danach schmilzt, was noch landet. */ +const MAX_DEPTH = 26; +/** Wie schnell sich die Decke glaettet (Diffusion, Spalten^2 je Sekunde). */ +const SPREAD = 6; + +const SPAWN_MIN_MS = 70; +const SPAWN_MAX_MS = 160; +const MAX_FLAKES = 70; + +function random(min: number, max: number) { + return min + Math.random() * (max - min); +} + +function prefersReducedMotion() { + return ( + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false + ); +} + +/** Farben aus index.css, damit hell und dunkel passen. */ +function snowColors() { + const style = getComputedStyle(document.documentElement); + const read = (name: string, fallback: string) => + style.getPropertyValue(name).trim() || fallback; + return { + flake: read("--snow-flake", "#ffffff"), + flakeEdge: read("--snow-flake-edge", "rgb(120 145 170 / 0.35)"), + pile: read("--snow-pile", "#ffffff"), + pileShade: read("--snow-pile-shade", "#dbe6f1"), + pileEdge: read("--snow-pile-edge", "rgb(120 145 170 / 0.5)"), + }; +} + +export const SnowWeather: React.FC = () => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx || prefersReducedMotion()) return; + + let width = 0; + let height = 0; + let pile = new Float32Array(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); + + // Die Decke beim Aendern der Fensterbreite mitnehmen statt wegwerfen: + // auf die neue Spaltenzahl umrechnen. + const next = new Float32Array(Math.ceil(width / COLUMN) + 1); + if (pile.length > 0) { + for (let i = 0; i < next.length; i++) { + next[i] = + pile[ + Math.min( + pile.length - 1, + Math.floor((i / next.length) * pile.length), + ) + ]; + } + } + pile = next; + }; + resize(); + window.addEventListener("resize", resize); + + const flakes: Flake[] = []; + let nextSpawn = 0; + let last = performance.now(); + let frame = 0; + let colors = snowColors(); + let colorsReadAt = 0; + + const spawn = () => { + if (flakes.length >= MAX_FLAKES) return; + const radius = random(1, 3); + flakes.push({ + x: random(-20, width + 20), + y: -radius - random(0, 30), + radius, + // Grosse Flocken fallen etwas schneller - das gibt Tiefe. + vy: random(28, 45) + radius * 14, + phase: random(0, Math.PI * 2), + sway: random(8, 22), + }); + }; + + /** Flocke landet: Spalte erhoehen, die Nachbarn etwas mit. */ + const land = (x: number, radius: number) => { + const i = Math.round(x / COLUMN); + const amount = radius * 5; + const add = (index: number, value: number) => { + if (index < 0 || index >= pile.length) return; + pile[index] = Math.min(MAX_DEPTH, pile[index] + value); + }; + // Ueber fuenf Spalten verteilt statt als Spitze: sonst waechst die Decke + // als Saegezahn. + add(i, amount * 0.36); + add(i - 1, amount * 0.24); + add(i + 1, amount * 0.24); + add(i - 2, amount * 0.08); + add(i + 2, amount * 0.08); + }; + + /* + * Glaetten: jede Spalte gleicht sich ein wenig an ihre Nachbarn an + * (Diffusion). Das erhaelt die Schneemenge, laesst einzelne Flocken zu + * flachen Huegeln zerlaufen und bleibt auf 60 und 144 Hz gleich schnell, + * weil der Faktor an der Zeit haengt und nicht an der Framezahl. + */ + let smoothed = new Float32Array(0); + const settle = (dt: number) => { + if (smoothed.length !== pile.length) + smoothed = new Float32Array(pile.length); + const k = Math.min(0.25, SPREAD * dt); + const last = pile.length - 1; + for (let i = 0; i <= last; i++) { + const left = pile[i > 0 ? i - 1 : i]; + const right = pile[i < last ? i + 1 : i]; + smoothed[i] = pile[i] + k * (left + right - 2 * pile[i]); + } + pile.set(smoothed); + }; + + const drawPile = () => { + let top = 0; + for (let i = 0; i < pile.length; i++) top = Math.max(top, pile[i]); + if (top < 0.3) return; + + ctx.beginPath(); + ctx.moveTo(0, height); + ctx.lineTo(0, height - pile[0]); + // Durch die Mittelpunkte zwischen den Spalten - weiche Kanten statt Treppe. + for (let i = 1; i < pile.length; i++) { + const x0 = (i - 1) * COLUMN; + const x1 = i * COLUMN; + ctx.quadraticCurveTo( + x0, + height - pile[i - 1], + (x0 + x1) / 2, + height - (pile[i - 1] + pile[i]) / 2, + ); + } + ctx.lineTo(width, height - pile[pile.length - 1]); + ctx.lineTo(width, height); + ctx.closePath(); + + const gradient = ctx.createLinearGradient(0, height - top, 0, height); + gradient.addColorStop(0, colors.pile); + gradient.addColorStop(1, colors.pileShade); + ctx.fillStyle = gradient; + ctx.fill(); + + // Nur die Oberkante nachziehen, nicht den Rahmen unten und seitlich. + ctx.beginPath(); + ctx.moveTo(0, height - pile[0]); + for (let i = 1; i < pile.length; i++) { + const x0 = (i - 1) * COLUMN; + const x1 = i * COLUMN; + ctx.quadraticCurveTo( + x0, + height - pile[i - 1], + (x0 + x1) / 2, + height - (pile[i - 1] + pile[i]) / 2, + ); + } + ctx.strokeStyle = colors.pileEdge; + ctx.lineWidth = 1; + ctx.stroke(); + }; + + const tick = (now: number) => { + const dt = Math.min((now - last) / 1000, 0.05); + last = now; + + if (now - colorsReadAt > 500) { + colors = snowColors(); + colorsReadAt = now; + } + + if (now >= nextSpawn) { + spawn(); + nextSpawn = now + random(SPAWN_MIN_MS, SPAWN_MAX_MS); + } + + ctx.clearRect(0, 0, width, height); + + ctx.fillStyle = colors.flake; + ctx.strokeStyle = colors.flakeEdge; + ctx.lineWidth = 0.6; + const t = now / 1000; + for (let i = flakes.length - 1; i >= 0; i--) { + const flake = flakes[i]; + flake.y += flake.vy * dt; + const x = flake.x + Math.sin(t * 1.3 + flake.phase) * flake.sway; + const column = Math.round(x / COLUMN); + const ground = height - (pile[column] ?? 0); + if (flake.y + flake.radius >= ground) { + flakes.splice(i, 1); + if (x >= 0 && x <= width) land(x, flake.radius); + continue; + } + ctx.beginPath(); + ctx.arc(x, flake.y, flake.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + + settle(dt); + drawPile(); + + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("resize", resize); + }; + }, []); + + return ( +