From a24fd938a2d58b8d75924690e0d536e9413cbb67 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Thu, 1 Oct 2026 10:45:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Theme=20Snow=20aus=20Pulse=20=C3=BCbern?= =?UTF-8?q?ommen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eisblau auf kaltem Weiß (hell) bzw. Winternacht (dunkel), verschneite Berg- und Waldgrafik hinter Kopfleiste und Menü. Schneefall im Canvas (SnowWeather) sammelt sich am unteren Rand, alle 4 Minuten räumt ein Pflug in ICON-Gold. Canvas liegt mit z-[45] unter den Dialogen (z-50). Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- docs/03-frontend.md | 11 +- docs/08-anleitung-benutzer.md | 2 +- public/themes/snow-header.svg | 79 ++++++ public/themes/snow-sidebar.svg | 65 +++++ server/src/userFields.js | 2 +- src/components/ui/SnowWeather.tsx | 424 ++++++++++++++++++++++++++++++ src/index.css | 132 ++++++++++ src/lib/theme.tsx | 9 +- src/types.ts | 3 +- 10 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 public/themes/snow-header.svg create mode 100644 public/themes/snow-sidebar.svg create mode 100644 src/components/ui/SnowWeather.tsx diff --git a/README.md b/README.md index df8150b..d9d936d 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ an seinem Konto, nicht nur im Browser. | **ICON** | Das Design des Hauses – Gold `#C1862F` auf neutralem Grau, Montserrat. Bewusst ohne Grafik: das Corporate Design bleibt sachlich | | **Half-Life** | Black Mesa: HEV-Orange auf Sichtbeton, fast rechtwinklige Ecken, harte versetzte Schatten, Überschriften in Oswald | | **Pixel Art** | 8-bit: keine Radien, versetzte Schatten ohne Weichzeichnung, Arcade-Grün, Überschriften in Silkscreen | +| **Snow** | Winter: Eisblau auf kaltem Weiß, verschneite Berglandschaft; Schneefall sammelt sich am unteren Rand, alle 4 Minuten fährt ein Räumfahrzeug durch (`src/components/ui/SnowWeather.tsx`, aus Pulse übernommen) | -Half-Life und Pixel Art bringen zusätzlich **Grafiken** hinter der Kopfleiste +Half-Life, Pixel Art und Snow bringen zusätzlich **Grafiken** hinter der Kopfleiste (`.app-header`) und dem Menü (`.app-sidebar`) mit – als SVG unter `public/themes/`. Die Variable enthält dabei **zwei** Ebenen: einen Schleier aus der Flächenfarbe und darunter die Zeichnung. Das ist kein Umweg, sondern der diff --git a/docs/03-frontend.md b/docs/03-frontend.md index d9fca68..f8c5ad2 100644 --- a/docs/03-frontend.md +++ b/docs/03-frontend.md @@ -86,12 +86,21 @@ angemeldeten Benutzer, nicht nur für den Browser. | **ICON** | Gold `#C1862F` auf neutralem Grau, Montserrat – bewusst ohne Grafik | | **Half-Life** | Black Mesa: HEV-Orange auf Sichtbeton, harte Schatten, Oswald in den Überschriften | | **Pixel Art** | 8-bit: keine Radien, versetzte Schatten, Arcade-Grün, Silkscreen in den Überschriften | +| **Snow** | Winter: Eisblau auf kaltem Weiß, verschneite Berge und Tannen, Schneefall mit Räumfahrzeug | -Half-Life und Pixel Art bringen Grafiken hinter Kopfleiste und Menü mit (SVG +Half-Life, Pixel Art und Snow bringen Grafiken hinter Kopfleiste und Menü mit (SVG unter `public/themes/`). Die CSS-Variable enthält dabei **zwei** Ebenen: einen Schleier aus der Flächenfarbe und darunter die Zeichnung – nur so funktioniert dieselbe Grafik in hell und dunkel, ohne dass die Schrift verliert. +Snow hat zusätzlich **Wetter**: `src/components/ui/SnowWeather.tsx` zeichnet in +einem Canvas über der Oberfläche (`z-[45]`, unter den Dialogen mit `z-50`, +`pointer-events: none`). Die Flocken landen in einem Höhenfeld am unteren Rand, +alle 4 Minuten räumt ein Pflug in ICON-Gold durch. Die Farben kommen aus +`--snow-*` in `src/index.css`. Bei „Bewegung reduzieren" im Betriebssystem läuft +keine Animation. Eingehängt ist das Wetter im `ThemeProvider` (`src/lib/theme.tsx`). +Übernommen aus Pulse. + Ein Design tauscht ausschließlich Variablenwerte; im Markup steht nirgends eine feste Farbe. Auch der Dunkelmodus arbeitet **ohne `dark:`-Varianten**: die Klasse `dark` auf `` tauscht in `src/index.css` die Werte von `canvas`, diff --git a/docs/08-anleitung-benutzer.md b/docs/08-anleitung-benutzer.md index 9224a85..ac5a8fb 100644 --- a/docs/08-anleitung-benutzer.md +++ b/docs/08-anleitung-benutzer.md @@ -8,7 +8,7 @@ Benutzername oder E-Mail und Passwort. Die Anmeldung gilt 7 Tage; danach fragt das CRM erneut. Passwort ändern: *Einstellungen → Mein Konto*. Rechts oben stehen zwei Umschalter: die **Farbwelt** (ICON, Half-Life, Pixel -Art) und **hell/dunkel**. Beides hängt am Konto und gilt auf jedem Gerät. Ein +Art, Snow) und **hell/dunkel**. Beides hängt am Konto und gilt auf jedem Gerät. Ein Doppelklick auf den Hell/Dunkel-Knopf stellt „wie das System" wieder her. ## Suchen 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 c20d40a..9939a27 100644 --- a/server/src/userFields.js +++ b/server/src/userFields.js @@ -38,7 +38,7 @@ export function normalizeAvatar(input) { * übereinstimmen. Ein unbekannter Wert wird abgewiesen, damit ein Tippfehler * nicht still zum Standarddesign führt. */ -const THEMES = new Set(['icon', 'halflife', 'pixel']); +const THEMES = new Set(['icon', 'halflife', 'pixel', 'snow']); export function normalizeTheme(input) { if (input === null || input === undefined || input === '') return { value: null }; diff --git a/src/components/ui/SnowWeather.tsx b/src/components/ui/SnowWeather.tsx new file mode 100644 index 0000000..ff97351 --- /dev/null +++ b/src/components/ui/SnowWeather.tsx @@ -0,0 +1,424 @@ +import React, { useEffect, useRef } from 'react'; + +/* + * Das Wetter des Themes "Snow": Flocken fallen, sammeln sich am unteren Rand zu + * einem Haufen - und alle vier Minuten fährt ein Schneepflug durch und schiebt + * ihn zur Seite. + * + * Alles liegt über der Oberfläche, fängt aber keinen Klick ab + * (pointer-events: none) und bleibt unter den Dialogen (z-[45] gegen z-50). + * Wer im Betriebssystem "Bewegung reduzieren" eingestellt hat, bekommt gar + * keine Animation; die Winterlandschaft in den Leisten bleibt als Grafik stehen. + * + * Der Haufen ist ein Höhenfeld: je 3 px eine Spalte, in der die Höhe steht. + * Deshalb kann eine Flocke dort liegen bleiben, wo sie aufkommt, der Haufen + * fließt langsam flach (Glättung) und der Pflug kann ein Stück davon leer + * räumen. + */ + +/** Breite einer Spalte des Höhenfelds in px. */ +const COLUMN = 3; +/** Höchster Schnee in px - sonst wächst er irgendwann ins Bild. */ +const MAX_PILE = 26; +/** Wie schnell der Haufen flach fließt. */ +const SMOOTHING = 6; +/** Abstand zwischen zwei Flocken in ms. */ +const SPAWN_MIN_MS = 70; +const SPAWN_MAX_MS = 160; +/** Obergrenze gleichzeitiger Flocken. */ +const MAX_FLAKES = 70; +/** Pause zwischen zwei Durchfahrten des Pflugs. */ +const PLOW_INTERVAL_MS = 240 * 1000; +/** Die erste Fahrt kommt früher, damit man das Theme beim Umschalten erlebt. */ +const PLOW_FIRST_MS = 2500; +/** Fahrgeschwindigkeit in px/s. */ +const PLOW_SPEED = 140; +/** Abstand Fahrzeugmitte zur Schaufel bzw. halbe Fahrzeuglänge. */ +const PLOW_BLADE = 38; +const PLOW_HALF = 36; + +interface Flake { + x: number; + y: number; + radius: number; + /** Fallgeschwindigkeit in px/s */ + vy: number; + /** Phase und Weite des Hin- und Herwehens */ + phase: number; + sway: number; +} + +interface Spray { + x: number; + y: number; + vx: number; + vy: number; + radius: number; +} + +interface Plow { + x: number; + /** 1 = fährt nach rechts, -1 = nach links */ + dir: 1 | -1; + /** Wie viel Schnee schon vor der Schaufel liegt */ + pushed: number; +} + +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 (--snow-*), 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; + /** Höhe des Schnees je Spalte. */ + 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); + // Vorhandenen Schnee auf die neue Breite umlegen statt wegzuwerfen. + 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[] = []; + const spray: Spray[] = []; + let nextSpawn = 0; + let last = performance.now(); + let frame = 0; + let colors = snowColors(); + let colorsReadAt = 0; + let plow: Plow | null = null; + let nextDir: 1 | -1 = 1; + let nextPlowAt = performance.now() + PLOW_FIRST_MS; + + 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, + // Große Flocken fallen etwas schneller. + vy: random(28, 45) + radius * 14, + phase: random(0, Math.PI * 2), + sway: random(8, 22), + }); + }; + + /** Flocke bleibt liegen: verteilt ihr Volumen auf fünf Spalten. */ + const land = (x: number, radius: number) => { + const center = 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_PILE, pile[index] + value); + }; + add(center, amount * 0.36); + add(center - 1, amount * 0.24); + add(center + 1, amount * 0.24); + add(center - 2, amount * 0.08); + add(center + 2, amount * 0.08); + }; + + let buffer = new Float32Array(0); + /** Der Haufen fließt langsam flach, sonst stehen dort Zacken. */ + const smooth = (dt: number) => { + if (buffer.length !== pile.length) buffer = new Float32Array(pile.length); + const rate = Math.min(0.25, SMOOTHING * 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]; + buffer[i] = pile[i] + rate * (left + right - 2 * pile[i]); + } + pile.set(buffer); + }; + + const drawPile = () => { + let highest = 0; + for (let i = 0; i < pile.length; i++) highest = Math.max(highest, pile[i]); + if (highest < 0.3) return; + + const outline = () => { + ctx.moveTo(0, height - pile[0]); + for (let i = 1; i < pile.length; i++) { + const from = (i - 1) * COLUMN; + const to = i * COLUMN; + ctx.quadraticCurveTo(from, height - pile[i - 1], (from + to) / 2, height - (pile[i - 1] + pile[i]) / 2); + } + }; + + ctx.beginPath(); + ctx.moveTo(0, height); + outline(); + ctx.lineTo(width, height - pile[pile.length - 1]); + ctx.lineTo(width, height); + ctx.closePath(); + const gradient = ctx.createLinearGradient(0, height - highest, 0, height); + gradient.addColorStop(0, colors.pile); + gradient.addColorStop(1, colors.pileShade); + ctx.fillStyle = gradient; + ctx.fill(); + + ctx.beginPath(); + outline(); + ctx.strokeStyle = colors.pileEdge; + ctx.lineWidth = 1; + ctx.stroke(); + }; + + /** Der Schneeberg, den die Schaufel vor sich herschiebt. */ + const drawPushedHeap = (x: number, dir: number, pushed: number) => { + if (pushed < 1) return; + const heapHeight = Math.min(MAX_PILE + 8, 6 + Math.sqrt(pushed) * 1.2); + const heapWidth = heapHeight * 1.6; + ctx.beginPath(); + ctx.moveTo(x - dir * 2, height); + ctx.lineTo(x - dir * 2, height - heapHeight); + ctx.quadraticCurveTo(x + dir * heapWidth * 0.55, height - heapHeight * 1.05, x + dir * heapWidth, height); + ctx.closePath(); + const gradient = ctx.createLinearGradient(0, height - heapHeight, 0, height); + gradient.addColorStop(0, colors.pile); + gradient.addColorStop(1, colors.pileShade); + ctx.fillStyle = gradient; + ctx.fill(); + ctx.strokeStyle = colors.pileEdge; + ctx.lineWidth = 1; + ctx.stroke(); + }; + + /** Der Pflug selbst: Kabine, Schaufel und blinkendes Rundumlicht. */ + const drawPlow = (x: number, dir: number, seconds: number) => { + ctx.save(); + ctx.translate(x, height); + ctx.scale(dir, 1); + const blinkOn = Math.sin(seconds * 9) > 0; + + if (blinkOn) { + const glow = ctx.createRadialGradient(-15, -41, 0, -15, -41, 16); + glow.addColorStop(0, 'rgb(255 170 40 / 0.7)'); + glow.addColorStop(1, 'rgb(255 170 40 / 0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(-15, -41, 16, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.lineJoin = 'round'; + ctx.strokeStyle = 'rgb(72 49 15 / 0.6)'; + ctx.lineWidth = 1; + + // Auspuff, Pritsche, Motorhaube, Kabine - im ICON-Gold. + ctx.fillStyle = '#5b6470'; + ctx.fillRect(18, -15, 14, 4); + ctx.fillStyle = '#c1862f'; + ctx.beginPath(); + ctx.roundRect(-PLOW_HALF, -21, 56, 14, 3); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + ctx.roundRect(-4, -28, 24, 10, [5, 3, 0, 0]); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + ctx.roundRect(-31, -38, 27, 19, [3, 3, 0, 0]); + ctx.fill(); + ctx.stroke(); + + // Fensterscheibe, Kühlergrill, Schneekappe auf dem Dach + ctx.fillStyle = '#bfe3f5'; + ctx.beginPath(); + ctx.roundRect(-25, -35, 17, 10, 2); + ctx.fill(); + ctx.fillStyle = '#2b2f36'; + for (let i = 0; i < 4; i++) ctx.fillRect(-30 + i * 12, -12, 5, 3); + ctx.fillStyle = colors.pile; + ctx.beginPath(); + ctx.ellipse(-19, -38, 11, 2.5, 0, Math.PI, Math.PI * 2); + ctx.fill(); + + // Rundumlicht + ctx.fillStyle = blinkOn ? '#ffb020' : '#b86e00'; + ctx.beginPath(); + ctx.roundRect(-18, -43, 6, 5, [2, 2, 0, 0]); + ctx.fill(); + + // Räder + for (const wheelX of [-22, 10]) { + ctx.fillStyle = '#2b2f36'; + ctx.beginPath(); + ctx.arc(wheelX, -6, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#9aa3ad'; + ctx.beginPath(); + ctx.arc(wheelX, -6, 2, 0, Math.PI * 2); + ctx.fill(); + } + + // Schaufel + ctx.fillStyle = '#c9d1d9'; + ctx.beginPath(); + ctx.moveTo(28, -24); + ctx.lineTo(33, -25); + ctx.lineTo(PLOW_BLADE, 0); + ctx.lineTo(29, 0); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + }; + + /** Fahrt starten, Strecke räumen, Schneestaub aufwirbeln. */ + const movePlow = (now: number, dt: number) => { + if (!plow) { + if (now < nextPlowAt) return; + const dir = nextDir; + plow = { x: dir === 1 ? -PLOW_BLADE - 40 : width + PLOW_BLADE + 40, dir, pushed: 0 }; + // Beim nächsten Mal aus der anderen Richtung. + nextDir = dir === 1 ? -1 : 1; + nextPlowAt = now + PLOW_INTERVAL_MS; + } + + plow.x += plow.dir * PLOW_SPEED * dt; + const blade = plow.x + plow.dir * PLOW_BLADE; + const from = plow.dir === 1 ? plow.x - PLOW_HALF - COLUMN * 4 : blade; + const to = plow.dir === 1 ? blade : plow.x + PLOW_HALF + COLUMN * 4; + const firstColumn = Math.max(0, Math.ceil(from / COLUMN)); + const lastColumn = Math.min(pile.length - 1, Math.floor(to / COLUMN)); + + let cleared = 0; + for (let i = firstColumn; i <= lastColumn; i++) { + cleared += pile[i]; + pile[i] = 0; + } + plow.pushed += cleared; + + if (plow.pushed > 5 && blade > 0 && blade < width && Math.random() < dt * 14) { + spray.push({ + x: blade, + y: height - Math.min(20, 2 + Math.sqrt(plow.pushed)), + vx: plow.dir * random(40, 120), + vy: -random(60, 140), + radius: random(1.2, 2.6), + }); + } + + const gone = plow.dir === 1 ? plow.x - PLOW_HALF > width + 60 : plow.x + PLOW_HALF < -60; + if (gone) plow = null; + }; + + const drawSpray = (dt: number) => { + ctx.fillStyle = colors.flake; + ctx.strokeStyle = colors.flakeEdge; + ctx.lineWidth = 0.6; + for (let i = spray.length - 1; i >= 0; i--) { + const particle = spray[i]; + particle.vy += 600 * dt; + particle.x += particle.vx * dt; + particle.y += particle.vy * dt; + if (particle.y >= height) { + spray.splice(i, 1); + continue; + } + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + }; + + 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; + + // Die Farben hängen am Modus; getComputedStyle ist nicht umsonst. + 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 seconds = 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(seconds * 1.3 + flake.phase) * flake.sway; + const surface = height - (pile[Math.round(x / COLUMN)] ?? 0); + if (flake.y + flake.radius >= surface) { + 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(); + } + + smooth(dt); + movePlow(now, dt); + drawPile(); + if (plow) { + drawPushedHeap(plow.x + plow.dir * PLOW_BLADE, plow.dir, plow.pushed); + drawPlow(plow.x, plow.dir, seconds); + } + drawSpray(dt); + + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(frame); + window.removeEventListener('resize', resize); + }; + }, []); + + return