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 ;
+};
diff --git a/src/index.css b/src/index.css
index ca30d18..bef88d2 100644
--- a/src/index.css
+++ b/src/index.css
@@ -426,6 +426,138 @@ html.theme-pixel.dark {
--font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace;
}
+/* ===========================================================================
+ * Snow
+ *
+ * Winterlandschaft: Eisblau auf kaltem Weiss. Der Schnee fehlt hier bewusst -
+ * er faellt in einem Canvas (components/ui/SnowWeather.tsx) und sammelt
+ * sich am unteren Rand. Die Farben der Flocken und des Schneehaufens stehen in
+ * --snow-*, damit dieselbe Mechanik in hell und dunkel funktioniert.
+ * =========================================================================== */
+html.theme-snow:not(.dark) {
+ --theme-header-image: linear-gradient(
+ 90deg,
+ rgb(250 252 255 / 0.96) 0%,
+ rgb(250 252 255 / 0.35) 30%,
+ rgb(250 252 255 / 0.3) 60%,
+ rgb(250 252 255 / 0.96) 100%
+ ),
+ url('/themes/snow-header.svg');
+ --theme-sidebar-image: linear-gradient(
+ 180deg,
+ rgb(250 252 255 / 0.97) 0%,
+ rgb(250 252 255 / 0.88) 45%,
+ rgb(250 252 255 / 0.4) 78%,
+ rgb(250 252 255 / 0.55) 100%
+ ),
+ url('/themes/snow-sidebar.svg');
+
+ --snow-flake: #f4f9ff;
+ --snow-flake-edge: rgb(90 125 160 / 0.7);
+ --snow-pile: #ffffff;
+ --snow-pile-shade: #c2d3e4;
+ --snow-pile-edge: rgb(80 115 150 / 0.75);
+
+ --color-canvas: #edf2f7;
+ --color-surface: #fafcff;
+
+ --color-brand-50: #e9f3fa;
+ --color-brand-100: #d0e5f3;
+ --color-brand-200: #a3cbe6;
+ --color-brand-300: #6eaad4;
+ --color-brand-400: #3f89bd;
+ --color-brand-500: #1f6a9e;
+ --color-brand-600: #185681;
+ --color-brand-700: #144566;
+ --color-brand-800: #10354f;
+ --color-brand-900: #0b273b;
+ --color-brand-accent: #1f6a9e;
+
+ --color-ink-50: #f1f5f9;
+ --color-ink-100: #e2e9f0;
+ --color-ink-200: #cbd6e1;
+ --color-ink-300: #97a6b6;
+ --color-ink-400: #5c6b7c;
+ --color-ink-500: #4a5868;
+ --color-ink-600: #3a4655;
+ --color-ink-700: #2c3643;
+ --color-ink-800: #1f2731;
+ --color-ink-900: #141a21;
+ --color-ink-950: #0a0e13;
+
+ --radius-sm: 0.3rem;
+ --radius-md: 0.45rem;
+ --radius-lg: 0.65rem;
+ --radius-xl: 0.85rem;
+ --radius-2xl: 1.15rem;
+
+ --shadow-card: 0 1px 2px rgb(20 26 33 / 0.05), 0 6px 20px rgb(31 106 158 / 0.08);
+ --shadow-lift: 0 12px 30px rgb(31 106 158 / 0.16);
+}
+
+/* Snow, dunkel: Winternacht. Der Schnee leuchtet, die Flaechen sind tiefblau. */
+html.theme-snow.dark {
+ --theme-header-image: linear-gradient(
+ 90deg,
+ rgb(18 26 38 / 0.95) 0%,
+ rgb(18 26 38 / 0.55) 30%,
+ rgb(18 26 38 / 0.5) 60%,
+ rgb(18 26 38 / 0.95) 100%
+ ),
+ url('/themes/snow-header.svg');
+ --theme-sidebar-image: linear-gradient(
+ 180deg,
+ rgb(18 26 38 / 0.97) 0%,
+ rgb(18 26 38 / 0.88) 45%,
+ rgb(18 26 38 / 0.55) 78%,
+ rgb(18 26 38 / 0.65) 100%
+ ),
+ url('/themes/snow-sidebar.svg');
+
+ --snow-flake: rgb(240 246 252 / 0.9);
+ /* Nachts ohne Kontur - ein dunkler Rand um weisse Flocken sieht schmutzig aus. */
+ --snow-flake-edge: transparent;
+ --snow-pile: #e9f1f9;
+ --snow-pile-shade: #a9bdd1;
+ --snow-pile-edge: rgb(255 255 255 / 0.6);
+
+ --color-canvas: #0b1119;
+ --color-surface: #121a26;
+
+ --color-brand-50: #0e1f2e;
+ --color-brand-100: #13283c;
+ --color-brand-200: #1c3d59;
+ --color-brand-300: #27567c;
+ --color-brand-400: #3571a0;
+ --color-brand-500: #2f73a8;
+ --color-brand-600: #3b80b5;
+ --color-brand-700: #92c2e6;
+ --color-brand-800: #c0dcf1;
+ --color-brand-900: #e3f0fa;
+ --color-brand-accent: #8cc6ef;
+
+ --color-ink-50: #172131;
+ --color-ink-100: #1f2a3b;
+ --color-ink-200: #2e3b4e;
+ --color-ink-300: #5b6a7e;
+ --color-ink-400: #8493a7;
+ --color-ink-500: #9fadbf;
+ --color-ink-600: #b9c5d3;
+ --color-ink-700: #d0d9e4;
+ --color-ink-800: #e4eaf1;
+ --color-ink-900: #f5f8fb;
+ --color-ink-950: #000000;
+
+ --radius-sm: 0.3rem;
+ --radius-md: 0.45rem;
+ --radius-lg: 0.65rem;
+ --radius-xl: 0.85rem;
+ --radius-2xl: 1.15rem;
+
+ --shadow-card: 0 1px 2px rgb(0 0 0 / 0.3), 0 6px 20px rgb(0 0 0 / 0.25);
+ --shadow-lift: 0 12px 32px rgb(0 0 0 / 0.4);
+}
+
@layer base {
html,
body {
diff --git a/src/lib/theme.tsx b/src/lib/theme.tsx
index 1f07963..a8eb756 100644
--- a/src/lib/theme.tsx
+++ b/src/lib/theme.tsx
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
import { authApi } from './api';
import { useAuth } from './auth';
import { THEME_PALETTES, type ThemePalette } from '../types';
+import { SnowWeather } from '../components/ui/SnowWeather';
/*
* Modus (hell/dunkel) und Design.
@@ -154,7 +155,13 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
[theme, mode, storedMode, setMode, persist]
);
- return {children};
+ return (
+
+ {children}
+ {/* Das Wetter gehört zum Design, nicht zu einer Ansicht. */}
+ {theme === 'snow' && }
+
+ );
};
export function useTheme(): ThemeContextValue {
diff --git a/src/types.ts b/src/types.ts
index 0e524aa..29f2caf 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -7,7 +7,7 @@ export type Role = 'admin' | 'user';
* übereinstimmen - das Backend weist einen unbekannten Wert ab, damit ein
* Tippfehler hier nicht still zum Standarddesign führt.
*/
-export const THEME_PALETTES = ['icon', 'halflife', 'pixel'] as const;
+export const THEME_PALETTES = ['icon', 'halflife', 'pixel', 'snow'] as const;
export type ThemePalette = (typeof THEME_PALETTES)[number];
@@ -22,6 +22,7 @@ export const THEME_PALETTE_LABELS: Record