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) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-17 18:02:06 +02:00
co-authored by Claude Opus 5
parent 77b61b4a04
commit 831429d559
11 changed files with 606 additions and 23 deletions
+268
View File
@@ -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<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-[90] h-full w-full"
/>
);
};
+139 -2
View File
@@ -10,8 +10,8 @@
*/
/*
* Fuenf Farbwelten, jede in hell und dunkel. Sie haengen an einer Klasse am
* <html> (theme-pixel, theme-outdoor, theme-cyberpunk, theme-rainy); ohne Klasse gilt
* Sechs Farbwelten, jede in hell und dunkel. Sie haengen an einer Klasse am
* <html> (theme-pixel, theme-outdoor, theme-cyberpunk, theme-rainy, theme-snow); ohne Klasse gilt
* perinco. Der Modus bleibt die vorhandene Klasse "dark".
*
* Warum das ohne Aenderung an den Komponenten geht: im Markup steht nirgends
@@ -728,6 +728,143 @@ html.theme-rainy.dark {
--shadow-lift: 0 12px 32px rgb(0 0 0 / 0.4);
}
/* ===========================================================================
* Snow - Winter.
*
* Zweites Theme mit Bewegung: Schnee faellt und sammelt sich am unteren
* Fensterrand (components/SnowWeather.tsx). Hier stehen die Farben, dazu die
* --snow-* Variablen fuer Flocken und Schneedecke.
*
* Eisblau auf fast weissem Grund. Weisse Flocken waeren darauf unsichtbar,
* deshalb bekommen sie im hellen Modus eine zarte blaugraue Kontur, und die
* Schneedecke eine Schattierung nach unten.
* =========================================================================== */
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. Tiefes Nachtblau, der Schnee leuchtet darauf von
* selbst und braucht keine Kontur.
*/
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);
--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 {
+4 -2
View File
@@ -11,11 +11,12 @@ import { authApi } from './api';
import { useAuth } from './auth';
import { THEME_PALETTES, type ThemePalette } from '../types';
import { RainyWeather } from '../components/RainyWeather';
import { SnowWeather } from '../components/SnowWeather';
/*
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
*
* Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy
* Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy | snow
* -> Klasse theme-* am <html>
* Modus (mode) hell | dunkel -> Klasse dark am <html>
*
@@ -223,7 +224,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
);
/*
* Rainy ist das einzige Theme mit Bewegung. Das Wetter haengt hier und nicht
* Rainy und Snow sind die Themes mit Bewegung. Das Wetter haengt hier und nicht
* im Layout, damit es auch auf der Anmeldeseite regnet - und damit es beim
* Wechsel auf ein anderes Theme sofort samt Animationsschleife verschwindet.
*/
@@ -231,6 +232,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
<ThemeContext.Provider value={value}>
{children}
{palette === 'rainy' && <RainyWeather />}
{palette === 'snow' && <SnowWeather />}
</ThemeContext.Provider>
);
};
+2 -1
View File
@@ -88,7 +88,7 @@ export type Role = 'admin' | 'user';
* Tippfehler hier nicht still zum Standarddesign fuehrt.
* server/test/userFields.test.mjs prueft, dass beide Listen gleich bleiben.
*/
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy'] as const;
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow'] as const;
export type ThemePalette = (typeof THEME_PALETTES)[number];
@@ -102,6 +102,7 @@ export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: st
hint: 'Neon in der Nacht: scharfe Kanten, violettes Leuchten, Chakra Petch',
},
rainy: { name: 'Rainy', hint: 'Regenwetter: Nieselregen, Wolken mit Wetterleuchten, Schieferblau' },
snow: { name: 'Snow', hint: 'Winter: Schneefall, der sich unten sammelt, Eisblau' },
};
export interface AppUser {