feat: Theme Snow aus Pulse übernommen

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 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-10-01 10:45:35 +02:00
co-authored by Claude Opus 5.5
parent 1e273ddd92
commit a24fd938a2
10 changed files with 724 additions and 6 deletions
+2 -1
View File
@@ -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
+10 -1
View File
@@ -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 `<html>` tauscht in `src/index.css` die Werte von `canvas`,
+1 -1
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 80" width="1600" height="80" preserveAspectRatio="xMidYMid slice">
<!--
Snow, obere Leiste: verschneite Bergkette mit Tannen.
Wie bei den anderen Themes ohne deckenden Himmel - die Flaeche kommt aus dem
Theme, hier steht nur Motiv. Die Berge sind blaugrau mit weissen Kuppen,
davor eine Reihe Tannen, deren Zweige Schnee tragen. Der Schneefall selbst
ist nicht gezeichnet, er faellt lebendig (components/SnowWeather.tsx).
-->
<!-- Ferne Berge -->
<path d="M0 80 L0 52 L90 30 L170 46 L260 18 L360 44 L450 26 L540 48 L640 22 L740 46 L830 28 L930 50 L1030 20 L1130 44 L1220 30 L1320 48 L1420 24 L1520 44 L1600 34 L1600 80 Z" fill="#9fb3c8" opacity="0.45" />
<!-- Schneekuppen der fernen Berge -->
<g fill="#ffffff" opacity="0.9">
<path d="M260 18 L236 28 L250 27 L262 32 L274 26 L286 29 Z" />
<path d="M640 22 L614 32 L630 31 L642 36 L654 30 L668 33 Z" />
<path d="M1030 20 L1004 30 L1020 29 L1032 34 L1044 28 L1058 31 Z" />
<path d="M1420 24 L1394 34 L1410 33 L1422 38 L1434 32 L1448 35 Z" />
</g>
<!-- Nahe Huegel, verschneit -->
<path d="M0 80 L0 66 C 120 56 220 62 340 58 C 480 54 560 64 700 60 C 840 56 940 64 1080 58 C 1220 54 1340 64 1460 60 C 1520 58 1570 62 1600 60 L1600 80 Z" fill="#e8f0f8" opacity="0.9" />
<!-- Tannen in drei Etagen, jede mit Schneekappe -->
<g fill="#4d6478" opacity="0.85">
<rect x="442.8" y="66.0" width="2.4" height="3.0" />
<rect x="465.1" y="68.0" width="1.7" height="2.1" />
<rect x="774.7" y="64.0" width="2.6" height="3.4" />
<rect x="799.1" y="66.0" width="1.8" height="2.2" />
<rect x="1162.8" y="64.0" width="2.4" height="3.0" />
<rect x="1185.2" y="66.0" width="1.7" height="2.1" />
<rect x="1511.0" y="66.0" width="1.9" height="2.4" />
<path d="M444 28.0 L449.0 45.1 L439.0 45.1 Z" />
<path d="M444 38.6 L452.4 55.4 L435.6 55.4 Z" />
<path d="M444 48.9 L456.0 66.0 L432.0 66.0 Z" />
<path d="M466 42.0 L469.6 53.7 L462.4 53.7 Z" />
<path d="M466 49.3 L471.9 60.7 L460.1 60.7 Z" />
<path d="M466 56.3 L474.5 68.0 L457.5 68.0 Z" />
<path d="M776 22.0 L781.5 40.9 L770.5 40.9 Z" />
<path d="M776 33.8 L785.1 52.2 L766.9 52.2 Z" />
<path d="M776 45.1 L789.0 64.0 L763.0 64.0 Z" />
<path d="M800 38.0 L803.8 50.6 L796.2 50.6 Z" />
<path d="M800 45.8 L806.3 58.2 L793.7 58.2 Z" />
<path d="M800 53.4 L809.0 66.0 L791.0 66.0 Z" />
<path d="M1164 26.0 L1169.0 43.1 L1159.0 43.1 Z" />
<path d="M1164 36.6 L1172.4 53.4 L1155.6 53.4 Z" />
<path d="M1164 46.9 L1176.0 64.0 L1152.0 64.0 Z" />
<path d="M1186 40.0 L1189.6 51.7 L1182.4 51.7 Z" />
<path d="M1186 47.3 L1192.0 58.7 L1180.0 58.7 Z" />
<path d="M1186 54.3 L1194.5 66.0 L1177.5 66.0 Z" />
<path d="M1512 36.0 L1516.0 49.5 L1508.0 49.5 Z" />
<path d="M1512 44.4 L1518.7 57.6 L1505.3 57.6 Z" />
<path d="M1512 52.5 L1521.5 66.0 L1502.5 66.0 Z" />
</g>
<g fill="#ffffff" opacity="0.95">
<path d="M444 28.0 L446.3 35.7 Q444 33.6 441.7 35.7 Z" />
<path d="M444 38.6 L447.8 46.2 Q444 44.2 440.2 46.2 Z" />
<path d="M444 48.9 L449.4 56.6 Q444 54.5 438.6 56.6 Z" />
<path d="M466 42.0 L467.6 47.3 Q466 45.9 464.4 47.3 Z" />
<path d="M466 49.3 L468.7 54.4 Q466 53.1 463.3 54.4 Z" />
<path d="M466 56.3 L469.8 61.6 Q466 60.2 462.2 61.6 Z" />
<path d="M776 22.0 L778.5 30.5 Q776 28.2 773.5 30.5 Z" />
<path d="M776 33.8 L780.1 42.1 Q776 39.9 771.9 42.1 Z" />
<path d="M776 45.1 L781.9 53.6 Q776 51.3 770.1 53.6 Z" />
<path d="M800 38.0 L801.7 43.7 Q800 42.2 798.3 43.7 Z" />
<path d="M800 45.8 L802.8 51.4 Q800 49.9 797.2 51.4 Z" />
<path d="M800 53.4 L804.0 59.1 Q800 57.6 796.0 59.1 Z" />
<path d="M1164 26.0 L1166.3 33.7 Q1164 31.6 1161.7 33.7 Z" />
<path d="M1164 36.6 L1167.8 44.2 Q1164 42.2 1160.2 44.2 Z" />
<path d="M1164 46.9 L1169.4 54.6 Q1164 52.5 1158.6 54.6 Z" />
<path d="M1186 40.0 L1187.6 45.3 Q1186 43.9 1184.4 45.3 Z" />
<path d="M1186 47.3 L1188.7 52.4 Q1186 51.1 1183.3 52.4 Z" />
<path d="M1186 54.3 L1189.8 59.6 Q1186 58.2 1182.2 59.6 Z" />
<path d="M1512 36.0 L1513.8 42.1 Q1512 40.5 1510.2 42.1 Z" />
<path d="M1512 44.4 L1515.0 50.3 Q1512 48.8 1509.0 50.3 Z" />
<path d="M1512 52.5 L1516.3 58.6 Q1512 57.0 1507.7 58.6 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+65
View File
@@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 900" width="260" height="900" preserveAspectRatio="xMidYMax slice">
<!--
Snow, Seitenleiste: verschneiter Waldrand, von unten hereinwachsend.
Das Motiv sitzt unten, oben liegt der Schleier ueber den Menuepunkten
(index.css). Die Schneeverwehung ganz unten ist bewusst flach und hell: dort
stehen Name und Rolle des Benutzers, und darueber haeuft sich zusaetzlich
der fallende Schnee an.
-->
<defs>
<linearGradient id="dunst" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#c9d8e8" stop-opacity="0" />
<stop offset="1" stop-color="#c9d8e8" stop-opacity="0.5" />
</linearGradient>
<filter id="weich" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="12" />
</filter>
</defs>
<rect y="540" width="260" height="360" fill="url(#dunst)" filter="url(#weich)" />
<!-- Tannen hinten: blass -->
<g fill="#8aa0b5" opacity="0.4">
<rect x="43.2" y="770.0" width="5.6" height="8.0" />
<rect x="180.9" y="760.0" width="6.2" height="8.8" />
<path d="M46 670.0 L57.8 715.0 L34.2 715.0 Z" />
<path d="M46 698.0 L65.6 742.0 L26.4 742.0 Z" />
<path d="M46 725.0 L74.0 770.0 L18.0 770.0 Z" />
<path d="M184 650.0 L197.0 699.5 L171.0 699.5 Z" />
<path d="M184 680.8 L205.7 729.2 L162.3 729.2 Z" />
<path d="M184 710.5 L215.0 760.0 L153.0 760.0 Z" />
</g>
<g fill="#ffffff" opacity="0.6">
<path d="M46 670.0 L51.3 690.2 Q46 684.9 40.7 690.2 Z" />
<path d="M46 698.0 L54.8 717.8 Q46 712.5 37.2 717.8 Z" />
<path d="M46 725.0 L58.6 745.2 Q46 739.9 33.4 745.2 Z" />
<path d="M184 650.0 L189.9 672.3 Q184 666.3 178.1 672.3 Z" />
<path d="M184 680.8 L193.8 702.6 Q184 696.8 174.2 702.6 Z" />
<path d="M184 710.5 L197.9 732.8 Q184 726.8 170.1 732.8 Z" />
</g>
<!-- Tannen vorn, mit Schneelast -->
<g fill="#4d6478" opacity="0.65">
<rect x="100.5" y="800.0" width="7.0" height="9.6" />
<rect x="223.3" y="808.0" width="5.4" height="7.2" />
<path d="M104 680.0 L118.7 734.0 L89.3 734.0 Z" />
<path d="M104 713.6 L128.5 766.4 L79.5 766.4 Z" />
<path d="M104 746.0 L139.0 800.0 L69.0 800.0 Z" />
<path d="M226 718.0 L237.3 758.5 L214.7 758.5 Z" />
<path d="M226 743.2 L244.9 782.8 L207.1 782.8 Z" />
<path d="M226 767.5 L253.0 808.0 L199.0 808.0 Z" />
</g>
<g fill="#ffffff" opacity="0.9">
<path d="M104 680.0 L110.6 704.3 Q104 697.8 97.4 704.3 Z" />
<path d="M104 713.6 L115.0 737.4 Q104 731.0 93.0 737.4 Z" />
<path d="M104 746.0 L119.8 770.3 Q104 763.8 88.2 770.3 Z" />
<path d="M226 718.0 L231.1 736.2 Q226 731.4 220.9 736.2 Z" />
<path d="M226 743.2 L234.5 761.0 Q226 756.3 217.5 761.0 Z" />
<path d="M226 767.5 L238.2 785.7 Q226 780.9 213.8 785.7 Z" />
</g>
<!-- Schneeverwehung -->
<path d="M0 800 C 50 784 110 796 160 788 C 200 782 236 792 260 786 L260 900 L0 900 Z" fill="#f2f7fc" opacity="0.8" />
<path d="M0 800 C 50 784 110 796 160 788 C 200 782 236 792 260 786" fill="none" stroke="#b9cadb" stroke-width="1.5" opacity="0.6" />
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+1 -1
View File
@@ -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 };
+424
View File
@@ -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<HTMLCanvasElement>(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 <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none fixed inset-0 z-[45] h-full w-full" />;
};
+132
View File
@@ -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 {
+8 -1
View File
@@ -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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
return (
<ThemeContext.Provider value={value}>
{children}
{/* Das Wetter gehört zum Design, nicht zu einer Ansicht. */}
{theme === 'snow' && <SnowWeather />}
</ThemeContext.Provider>
);
};
export function useTheme(): ThemeContextValue {
+2 -1
View File
@@ -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<ThemePalette, { name: string; hint: st
name: 'Pixel Art',
hint: '8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften',
},
snow: { name: 'Snow', hint: 'Winter: Schneefall, der sich unten sammelt, Eisblau' },
};
export interface AppUser {