feat(design): neues Theme Rainy mit Nieselregen und Wetterleuchten

Schieferblau in hell und dunkel. Ein paar Tropfen fallen per Canvas
ueber den Bildschirm und zerspritzen am unteren Rand; die Wolken der
oberen Leiste leuchten alle 7-18 s auf (Maske = Wolken-SVG). 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 17:35:31 +02:00
co-authored by Claude Opus 5
parent af3ca33743
commit 77b61b4a04
11 changed files with 617 additions and 15 deletions
+28 -2
View File
@@ -138,7 +138,7 @@ Die Darstellung hat **zwei unabhängige Achsen**, beide als Klasse am `<html>`:
| Achse | Klasse | Werte | | Achse | Klasse | Werte |
| --- | --- | --- | | --- | --- | --- |
| Farbwelt | `theme-perinco`, `theme-pixel`, `theme-outdoor`, `theme-cyberpunk` | perinco ist der Standard | | Farbwelt | `theme-perinco`, `theme-pixel`, `theme-outdoor`, `theme-cyberpunk`, `theme-rainy` | perinco ist der Standard |
| Modus | `dark` bzw. keine | hell, dunkel, oder der Systemeinstellung folgen | | Modus | `dark` bzw. keine | hell, dunkel, oder der Systemeinstellung folgen |
`src/index.css` tauscht darin nur Variablenwerte: `canvas`, `surface`, `ink-*`, `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 Alle Themes außer perinco legen eine Grafik hinter die obere Leiste und die
Seitenleiste: 8-bit-Himmel und Steinmauer, blauer Himmel und Wiese, Seitenleiste: 8-bit-Himmel und Steinmauer, blauer Himmel und Wiese,
Neonröhren und regnerische Gasse. Die Dateien liegen als **SVG** in Neonröhren und regnerische Gasse, Regenwolken und nasse Hügel. Die Dateien liegen als **SVG** in
`public/themes/` und sind von Hand gezeichnet - stilisierte Grafik statt Foto, `public/themes/` und sind von Hand gezeichnet - stilisierte Grafik statt Foto,
damit sie in jeder Auflösung scharf bleibt und wenige Kilobyte wiegt. damit sie in jeder Auflösung scharf bleibt und wenige Kilobyte wiegt.
@@ -212,6 +212,32 @@ Das ist kein Umweg, sondern erledigt zwei Dinge zugleich:
> nur Licht - über Dunkel leuchtet das Neon, über Weiss bleiben blasse farbige > nur Licht - über Dunkel leuchtet das Neon, über Weiss bleiben blasse farbige
> Linien. Wer eine Grafik ergänzt: **keine deckenden Flächen**, nur Motiv. > Linien. Wer eine Grafik ergänzt: **keine deckenden Flächen**, nur Motiv.
### Rainy: das Theme mit Wetter
Rainy ist das einzige Theme mit Bewegung. 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.
| Effekt | Umsetzung |
| --- | --- |
| **Nieselregen** | Ein `<canvas>` über dem ganzen Fenster. Alle 220–650 ms startet ein Tropfen (höchstens 14 gleichzeitig), fällt leicht schräg und zerspritzt am unteren Fensterrand in 3–6 Spritzer mit Schwerkraft plus einen kleinen Ring. Farbe aus `--rain-color` |
| **Wetterleuchten** | Ein Element, das beim Blitz exakt über `.app-header` gelegt und per Web Animations API kurz eingeblendet wird (flackernd, 0,9 s, alle 7–18 s). Das Licht ist ein Schein um eine zufällige Stelle in der Mitte der Leiste |
Der Trick beim Blitz ist die **Maske**: `.rainy-flash` benutzt
`rainy-header.svg` - dieselbe Datei wie das Hintergrundbild, gleich skaliert -
als `mask-image`. Das Licht erscheint dadurch nur auf den Wolken selbst. Die
Grafik darf deshalb **nur Wolken** enthalten, keinen Himmel: sonst leuchtet die
ganze Leiste. Im Dunkelmodus hellt der Blitz per `mix-blend-mode: screen` auf,
im hellen Modus deckt er normal mit warmem Weiss (`--flash-blend`,
`--flash-color`) - `screen` macht aus hellem Grau nur etwas helleres Grau.
Beide Ebenen haben `pointer-events: none` und `z-[90]`, liegen also unter den
Dialogen (`z-[100]`). Bei `prefers-reduced-motion: reduce` startet weder Regen
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.
### Was ein neues Theme können muss ### Was ein neues Theme können muss
Die Rollen der Skalen sind über die ganze App gleich; ein Theme, das sie Die Rollen der Skalen sind über die ganze App gleich; ein Theme, das sie
+14 -5
View File
@@ -66,7 +66,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
zusammen - hilfreich auf kleinen Bildschirmen. zusammen - hilfreich auf kleinen Bildschirmen.
* Rechts oben stehen zwei Schalter. Der rechte wechselt zwischen **hell und * Rechts oben stehen zwei Schalter. Der rechte wechselt zwischen **hell und
dunkel**; ein **Doppelklick** darauf folgt wieder der Systemeinstellung. dunkel**; ein **Doppelklick** darauf folgt wieder der Systemeinstellung.
* Links daneben steht das **Design**. Es gibt vier zur Wahl, jedes in hell und * Links daneben steht das **Design**. Es gibt fünf zur Wahl, jedes in hell und
dunkel: dunkel:
| Design | Aussehen | | Design | Aussehen |
@@ -75,6 +75,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
| **Pixel Art** | 8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften | | **Pixel Art** | 8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften |
| **Outdoor** | Wanderkarte: Sandpapier, Bergsee-Türkis, weiche Ecken | | **Outdoor** | Wanderkarte: Sandpapier, Bergsee-Türkis, weiche Ecken |
| **Cyberpunk** | Neon in der Nacht: scharfe Kanten, violettes Leuchten, Blaustahl | | **Cyberpunk** | Neon in der Nacht: scharfe Kanten, violettes Leuchten, Blaustahl |
| **Rainy** | Regenwetter: Schieferblau, leichter Nieselregen, Wolken mit Wetterleuchten |
> **Die Wahl gehört dir, nicht dem Rechner.** Sie wird an deinem Benutzerkonto > **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 > gespeichert. Meldest du dich an einem anderen Rechner an, ist dein Design
@@ -85,11 +86,19 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
haben bis zu acht Spalten, und eine Pixelschrift wäre darin nicht mehr zu haben bis zu acht Spalten, und eine Pixelschrift wäre darin nicht mehr zu
lesen. lesen.
Die drei Designs neben perinco haben zusätzlich eine **Grafik** hinter der Die Designs neben perinco haben zusätzlich eine **Grafik** hinter der
oberen Leiste und der Seitenleiste: 8-bit-Himmel und Steinmauer, blauer Himmel oberen Leiste und der Seitenleiste: 8-bit-Himmel und Steinmauer, blauer Himmel
und Wiese, Neonröhren und Regen. Sie liegt hinter einem Schleier, damit die und Wiese, Neonröhren und Regen, Regenwolken und nasse Hügel. Sie liegt hinter
Schrift darauf lesbar bleibt, und ist dort am deutlichsten, wo die Leisten frei einem Schleier, damit die Schrift darauf lesbar bleibt, und ist dort am
sind - in der Mitte oben und unten links. perinco bleibt einfarbig. 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.
![Dieselbe Ansicht im dunklen Design](bilder/aus-mails-dunkel.png) ![Dieselbe Ansicht im dunklen Design](bilder/aus-mails-dunkel.png)
* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und * Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
+1 -1
View File
@@ -295,7 +295,7 @@ Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
| Datei | Hält | Eigenheit | | Datei | Hält | Eigenheit |
| --- | --- | --- | | --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prüft beim Start das gespeicherte Token gegen `/auth/me` | | `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, Hintergrundgrafiken in `public/themes/`) 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, 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 |
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt | | `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 | | `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App |
+4 -1
View File
@@ -26,7 +26,10 @@
var palette = localStorage.getItem('perinco_crm_palette'); var palette = localStorage.getItem('perinco_crm_palette');
// Whitelist, damit ein manipulierter Eintrag keine Klasse setzt. // Whitelist, damit ein manipulierter Eintrag keine Klasse setzt.
if (palette === 'pixel' || palette === 'outdoor' || palette === 'cyberpunk') { if (palette === 'pixel' ||
palette === 'outdoor' ||
palette === 'cyberpunk' ||
palette === 'rainy') {
document.documentElement.classList.add('theme-' + palette); document.documentElement.classList.add('theme-' + palette);
} }
} catch (e) { } catch (e) {
+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 80" width="1600" height="80" preserveAspectRatio="xMidYMid slice">
<!--
Rainy, obere Leiste: eine Wolkendecke.
Die Datei hat zwei Aufgaben. Sie ist das Hintergrundbild der Leiste, und sie
ist die Maske fuer das Wetterleuchten (components/RainyWeather.tsx): der
Blitz leuchtet nur dort, wo hier deckend gezeichnet ist. Deshalb gibt es
keine Flaeche und keinen Himmel, nur Wolken auf durchsichtigem Grund - sonst
leuchtete beim Blitz die ganze Leiste statt der Wolken.
Drei Lagen von hinten nach vorn: hell und weich, mittel, dunkel und
tiefhaengend. Die Unterkanten sind flach wie bei echten Regenwolken.
-->
<defs>
<filter id="weich" x="-10%" y="-40%" width="120%" height="180%">
<feGaussianBlur stdDeviation="2.2" />
</filter>
</defs>
<!-- Hintere Lage: hell, verschwommen -->
<g fill="#a9b6c6" opacity="0.55" filter="url(#weich)">
<ellipse cx="90" cy="22" rx="110" ry="20" />
<ellipse cx="330" cy="16" rx="140" ry="22" />
<ellipse cx="610" cy="24" rx="130" ry="20" />
<ellipse cx="880" cy="14" rx="150" ry="22" />
<ellipse cx="1150" cy="22" rx="130" ry="20" />
<ellipse cx="1410" cy="16" rx="150" ry="22" />
</g>
<!-- Mittlere Lage -->
<g fill="#8394aa" opacity="0.7" filter="url(#weich)">
<path d="M150 44 C 150 30 170 22 186 26 C 194 10 222 6 238 18 C 252 6 282 10 288 28 C 306 26 318 36 314 46 Z" />
<path d="M500 42 C 498 28 520 20 536 26 C 546 8 580 4 596 20 C 612 8 646 14 650 30 C 670 28 684 38 678 46 Z" />
<path d="M760 40 C 760 26 780 18 796 24 C 806 8 836 4 850 18 C 866 8 894 12 898 28 C 914 28 924 36 920 44 Z" />
<path d="M1060 44 C 1058 30 1078 22 1094 28 C 1104 10 1138 6 1154 22 C 1170 10 1204 16 1208 32 C 1226 30 1238 40 1232 48 Z" />
<path d="M1360 42 C 1360 28 1380 20 1396 26 C 1406 10 1438 6 1452 20 C 1468 10 1498 14 1502 30 C 1518 30 1530 38 1524 46 Z" />
</g>
<!-- Vordere Lage: dunkel, tief, flache Unterkante -->
<g fill="#5d6d82" opacity="0.85">
<path d="M300 56 C 296 42 314 32 332 38 C 340 20 374 16 390 32 C 402 20 436 22 440 40 C 460 38 474 48 468 58 Z" />
<path d="M640 60 C 636 46 656 36 674 42 C 684 24 720 20 736 36 C 750 24 786 28 790 46 C 810 44 824 54 818 62 Z" />
<path d="M960 54 C 956 40 976 30 994 36 C 1004 18 1038 14 1054 30 C 1068 18 1102 22 1106 40 C 1124 38 1138 48 1132 56 Z" />
<path d="M1220 60 C 1218 46 1236 38 1252 42 C 1262 26 1292 22 1306 36 C 1320 26 1350 30 1352 46 C 1368 46 1378 54 1374 62 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+54
View File
@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 900" width="260" height="900" preserveAspectRatio="xMidYMax slice">
<!--
Rainy, Seitenleiste: nasse Huegel mit Pfuetzen, von unten hereinwachsend.
Wie bei den anderen Themes sitzt das Motiv unten - oben liegen die
Menuepunkte unter einem dichten Schleier (index.css). Der Regen selbst ist
nicht gezeichnet, er faellt lebendig ueber den Bildschirm; hier stehen nur
die Ringe, die er in den Pfuetzen hinterlaesst.
-->
<defs>
<linearGradient id="dunst" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8fa1b6" stop-opacity="0" />
<stop offset="1" stop-color="#8fa1b6" stop-opacity="0.45" />
</linearGradient>
<filter id="weich" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="12" />
</filter>
</defs>
<!-- Nebel ueber dem Boden -->
<rect y="540" width="260" height="360" fill="url(#dunst)" filter="url(#weich)" />
<!-- Hintere Huegel -->
<path d="M0 740 C 50 700 100 712 140 724 C 180 736 220 706 260 712 L260 900 L0 900 Z" fill="#7d8ea3" opacity="0.45" />
<!-- Vordere Huegel -->
<path d="M0 792 C 60 770 110 786 160 778 C 200 772 236 782 260 776 L260 900 L0 900 Z" fill="#56667b" opacity="0.8" />
<!-- Pfuetzen: der Himmel spiegelt sich hell darin -->
<g fill="#b9c7d6" opacity="0.45">
<ellipse cx="62" cy="838" rx="42" ry="7" />
<ellipse cx="184" cy="858" rx="52" ry="8" />
</g>
<!-- Ringe, die der Regen in den Pfuetzen zieht -->
<g fill="none" stroke="#eef3f8" stroke-width="1.2" opacity="0.4">
<ellipse cx="52" cy="838" rx="12" ry="2.4" />
<ellipse cx="52" cy="838" rx="22" ry="4" opacity="0.5" />
<ellipse cx="80" cy="838" rx="6" ry="1.4" />
<ellipse cx="170" cy="858" rx="16" ry="3" />
<ellipse cx="170" cy="858" rx="28" ry="5" opacity="0.45" />
<ellipse cx="206" cy="857" rx="8" ry="1.8" />
</g>
<!-- Graeser am Pfuetzenrand, dunkel und nass -->
<g stroke="#3e4b5c" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35">
<path d="M14 830 Q 10 808 20 794" />
<path d="M22 832 Q 28 810 22 796" />
<path d="M118 848 Q 112 824 124 810" />
<path d="M128 850 Q 134 826 128 812" />
<path d="M238 846 Q 232 824 244 810" />
<path d="M248 848 Q 254 826 250 812" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -48,7 +48,7 @@ export function normalizeAvatar(value) {
* Standard gesetzt: sonst faellt ein Tippfehler im Frontend nie auf, die * Standard gesetzt: sonst faellt ein Tippfehler im Frontend nie auf, die
* Oberflaeche zeigt weiter das alte Design und niemand weiss, warum. * Oberflaeche zeigt weiter das alte Design und niemand weiss, warum.
*/ */
export const THEMES = ['perinco', 'pixel', 'outdoor', 'cyberpunk']; export const THEMES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy'];
export const THEME_MODES = ['light', 'dark']; export const THEME_MODES = ['light', 'dark'];
/** Farbwelt; null bzw. '' bedeutet "perinco", der Standard. */ /** Farbwelt; null bzw. '' bedeutet "perinco", der Standard. */
+290
View File
@@ -0,0 +1,290 @@
import React, { useEffect, useRef } from 'react';
/*
* Das Wetter des Themes "Rainy": Nieselregen ueber dem Bildschirm und
* Wetterleuchten in den Wolken der oberen Leiste.
*
* Beides liegt ueber der ganzen Oberflaeche, faengt aber keinen Klick ab
* (pointer-events: none) und bleibt unter den Dialogen (z-[100]) - ein Tropfen,
* der ueber ein Formular laeuft, ist Stimmung, einer, der den Speichern-Knopf
* verdeckt, waere ein Fehler.
*
* Bewusst sparsam: es sind nie mehr als eine Handvoll Tropfen gleichzeitig
* unterwegs, und es blitzt nur alle paar Sekunden. Ein CRM, in dem es schuettet,
* lenkt ab. Wer im Betriebssystem "Bewegung reduzieren" eingestellt hat, bekommt
* weder Regen noch Blitz; die Wolken in der Leiste bleiben als Grafik stehen.
*
* Der Regen ist ein Canvas und keine CSS-Animation: der Aufprall haengt davon
* ab, wo der Tropfen unten ankommt, und die Spritzer fliegen mit Schwerkraft -
* beides ist in Keyframes nicht sauber zu haben.
*/
interface Drop {
x: number;
y: number;
/** Geschwindigkeit in px/s */
vx: number;
vy: number;
length: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
/** Restlebenszeit in s */
life: number;
maxLife: number;
size: number;
}
interface Ripple {
x: number;
y: number;
life: number;
maxLife: number;
width: number;
}
/** Abstand zwischen zwei neuen Tropfen in ms - daraus folgt "ein paar". */
const SPAWN_MIN_MS = 220;
const SPAWN_MAX_MS = 650;
/** Obergrenze, falls der Tab lange im Hintergrund war. */
const MAX_DROPS = 14;
const GRAVITY = 1400;
/** Pause zwischen zwei Blitzen in ms. */
const FLASH_MIN_MS = 7000;
const FLASH_MAX_MS = 18000;
function random(min: number, max: number) {
return min + Math.random() * (max - min);
}
function prefersReducedMotion() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
/** Farbe aus index.css (--rain-color), damit hell und dunkel passen. */
function rainColor() {
const value = getComputedStyle(document.documentElement).getPropertyValue('--rain-color').trim();
return value || 'rgb(120 140 165 / 0.5)';
}
export const RainyWeather: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const flashRef = useRef<HTMLDivElement>(null);
// --- Regen -----------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx || prefersReducedMotion()) return;
let width = 0;
let height = 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);
};
resize();
window.addEventListener('resize', resize);
const drops: Drop[] = [];
const particles: Particle[] = [];
const ripples: Ripple[] = [];
let nextSpawn = 0;
let last = performance.now();
let frame = 0;
// Die Farbe haengt am Modus; sie wird gelegentlich nachgelesen statt in
// jedem Frame, getComputedStyle ist nicht umsonst.
let color = rainColor();
let colorReadAt = 0;
const spawn = () => {
if (drops.length >= MAX_DROPS) return;
// Leichter Wind von rechts: die Tropfen fallen etwas schraeg.
const vy = random(620, 900);
drops.push({
x: random(0, width + 60),
y: random(-80, -20),
vx: -vy * 0.08,
vy,
length: random(9, 17),
});
};
const splash = (x: number) => {
const count = Math.round(random(3, 6));
for (let i = 0; i < count; i++) {
const life = random(0.25, 0.45);
particles.push({
x,
y: height - 1,
vx: random(-110, 110),
vy: -random(110, 240),
life,
maxLife: life,
size: random(0.8, 1.6),
});
}
const life = random(0.3, 0.45);
ripples.push({ x, y: height - 1.5, life, maxLife: life, width: random(10, 18) });
};
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;
if (now >= nextSpawn) {
spawn();
nextSpawn = now + random(SPAWN_MIN_MS, SPAWN_MAX_MS);
}
if (now - colorReadAt > 500) {
color = rainColor();
colorReadAt = now;
}
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineCap = 'round';
// Tropfen
ctx.lineWidth = 1.2;
for (let i = drops.length - 1; i >= 0; i--) {
const drop = drops[i];
drop.x += drop.vx * dt;
drop.y += drop.vy * dt;
if (drop.y >= height) {
drops.splice(i, 1);
if (drop.x >= 0 && drop.x <= width) splash(drop.x);
continue;
}
const scale = drop.length / drop.vy;
ctx.beginPath();
ctx.moveTo(drop.x, drop.y);
ctx.lineTo(drop.x - drop.vx * scale, drop.y - drop.vy * scale);
ctx.stroke();
}
// Spritzer
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt;
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}
p.vy += GRAVITY * dt;
p.x += p.vx * dt;
p.y = Math.min(p.y + p.vy * dt, height - 0.5);
ctx.globalAlpha = p.life / p.maxLife;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
// Kleiner Ring am Boden, wo der Tropfen aufgekommen ist
ctx.lineWidth = 1;
for (let i = ripples.length - 1; i >= 0; i--) {
const r = ripples[i];
r.life -= dt;
if (r.life <= 0) {
ripples.splice(i, 1);
continue;
}
const progress = 1 - r.life / r.maxLife;
ctx.globalAlpha = (1 - progress) * 0.8;
ctx.beginPath();
ctx.ellipse(r.x, r.y, 2 + r.width * progress, 1 + 1.5 * progress, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', resize);
};
}, []);
// --- Wetterleuchten --------------------------------------------------------
useEffect(() => {
const flash = flashRef.current;
if (!flash || prefersReducedMotion() || typeof flash.animate !== 'function') return;
let timer = 0;
let cancelled = false;
const schedule = () => {
if (cancelled) return;
timer = window.setTimeout(strike, random(FLASH_MIN_MS, FLASH_MAX_MS));
};
const strike = () => {
/*
* Die Leiste wird bei jedem Blitz neu vermessen statt einmal: sie ist
* nicht immer da (Anmeldeseite) und aendert ihre Breite mit der
* Seitenleiste. Das Overlay bekommt exakt ihre Masse, damit die Maske
* (dieselbe SVG wie das Hintergrundbild, gleich skaliert) auf den Wolken
* sitzt.
*/
const header = document.querySelector<HTMLElement>('.app-header');
if (!header || document.hidden) {
schedule();
return;
}
const rect = header.getBoundingClientRect();
flash.style.left = `${rect.left}px`;
flash.style.top = `${rect.top}px`;
flash.style.width = `${rect.width}px`;
flash.style.height = `${rect.height}px`;
// Nur in der Mitte, wo der Schleier die Wolken durchlaesst - am Rand
// stuende der Blitz auf dem Titel bzw. den Schaltern.
flash.style.setProperty('--flash-x', `${random(28, 72)}%`);
// Kurz, zurueck, noch einmal heller, langes Ausklingen - so flackert
// Wetterleuchten.
const animation = flash.animate(
[
{ opacity: 0 },
{ opacity: 0.75, offset: 0.06 },
{ opacity: 0.15, offset: 0.14 },
{ opacity: 1, offset: 0.22 },
{ opacity: 0.35, offset: 0.45 },
{ opacity: 0 },
],
{ duration: 900, easing: 'ease-out' }
);
animation.onfinish = schedule;
};
// Der erste Blitz kommt frueher, damit man das Theme beim Umschalten erlebt.
timer = window.setTimeout(strike, random(1500, 3500));
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, []);
return (
<>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-[90] h-full w-full"
/>
<div ref={flashRef} aria-hidden="true" className="rainy-flash pointer-events-none fixed z-[90]" />
</>
);
};
+163 -2
View File
@@ -10,8 +10,8 @@
*/ */
/* /*
* Vier Farbwelten, jede in hell und dunkel. Sie haengen an einer Klasse am * Fuenf Farbwelten, jede in hell und dunkel. Sie haengen an einer Klasse am
* <html> (theme-pixel, theme-outdoor, theme-cyberpunk); ohne Klasse gilt * <html> (theme-pixel, theme-outdoor, theme-cyberpunk, theme-rainy); ohne Klasse gilt
* perinco. Der Modus bleibt die vorhandene Klasse "dark". * perinco. Der Modus bleibt die vorhandene Klasse "dark".
* *
* Warum das ohne Aenderung an den Komponenten geht: im Markup steht nirgends * Warum das ohne Aenderung an den Komponenten geht: im Markup steht nirgends
@@ -590,6 +590,144 @@ html.theme-cyberpunk:not(.dark) {
--font-display: 'Chakra Petch', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif; --font-display: 'Chakra Petch', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
} }
/* ===========================================================================
* Rainy - Regenwetter.
*
* Das einzige Theme mit Bewegung: Nieselregen faellt ueber den Bildschirm und
* die Wolken in der oberen Leiste leuchten ab und zu auf. Das macht nicht CSS,
* sondern components/RainyWeather.tsx; hier stehen nur die Farben, darunter
* --rain-color fuer die Tropfen.
*
* Die Farbwelt ist Schieferblau auf kuehlem Grau - nass, aber nicht trist.
* Schrift bleibt Nunito Sans: das Wetter ist Auffaelligkeit genug.
* =========================================================================== */
html.theme-rainy:not(.dark) {
/* Tagsueber bedeckt: der Schleier ist in der Mitte duenn genug, dass die
grauen Wolken tragen und ein Blitz darauf sichtbar wird. */
--theme-header-image: linear-gradient(
90deg,
rgb(248 250 252 / 0.96) 0%,
rgb(248 250 252 / 0.35) 30%,
rgb(248 250 252 / 0.3) 60%,
rgb(248 250 252 / 0.96) 100%
),
url('/themes/rainy-header.svg');
--theme-sidebar-image: linear-gradient(
180deg,
rgb(248 250 252 / 0.97) 0%,
rgb(248 250 252 / 0.88) 45%,
rgb(248 250 252 / 0.4) 78%,
rgb(248 250 252 / 0.55) 100%
),
url('/themes/rainy-sidebar.svg');
--rain-color: rgb(62 90 128 / 0.5);
/* Auf hellem Grund macht "screen" aus Grau nur etwas helleres Grau - der
Blitz waere kaum zu sehen. Tagsueber deckt er deshalb normal und bekommt
einen warmen Stich, damit die Wolke sichtbar aufgluehen kann. */
--flash-color: rgb(255 244 196);
--flash-blend: normal;
--color-canvas: #e8ecf1;
--color-surface: #f8fafc;
--color-brand-50: #eaf1f7;
--color-brand-100: #d3e2ee;
--color-brand-200: #a9c5dc;
--color-brand-300: #7ba5c6;
--color-brand-400: #4f84ad;
--color-brand-500: #2f6690;
--color-brand-600: #255477;
--color-brand-700: #1e4461;
--color-brand-800: #17354c;
--color-brand-900: #112839;
--color-brand-accent: #2f6690;
--color-ink-50: #f1f4f7;
--color-ink-100: #e3e8ee;
--color-ink-200: #cdd5de;
--color-ink-300: #9aa6b4;
--color-ink-400: #5f6b7a;
--color-ink-500: #4d5866;
--color-ink-600: #3d4753;
--color-ink-700: #2f3741;
--color-ink-800: #222931;
--color-ink-900: #161b21;
--color-ink-950: #0c0f13;
--radius-sm: 0.25rem;
--radius-md: 0.4rem;
--radius-lg: 0.55rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--shadow-card: 0 1px 2px rgb(22 27 33 / 0.05), 0 6px 20px rgb(47 102 144 / 0.08);
--shadow-lift: 0 12px 30px rgb(47 102 144 / 0.16);
}
/*
* Rainy, dunkel: Gewitternacht. Tintenblaue Flaechen, die Wolken treten erst
* beim Blitz richtig hervor.
*/
html.theme-rainy.dark {
--theme-header-image: linear-gradient(
90deg,
rgb(21 27 36 / 0.95) 0%,
rgb(21 27 36 / 0.45) 30%,
rgb(21 27 36 / 0.4) 60%,
rgb(21 27 36 / 0.95) 100%
),
url('/themes/rainy-header.svg');
--theme-sidebar-image: linear-gradient(
180deg,
rgb(21 27 36 / 0.97) 0%,
rgb(21 27 36 / 0.88) 45%,
rgb(21 27 36 / 0.5) 78%,
rgb(21 27 36 / 0.62) 100%
),
url('/themes/rainy-sidebar.svg');
--rain-color: rgb(170 195 225 / 0.45);
--flash-color: rgb(225 236 255);
--flash-blend: screen;
--color-canvas: #0d1117;
--color-surface: #151b24;
--color-brand-50: #10202e;
--color-brand-100: #15293b;
--color-brand-200: #1f3d57;
--color-brand-300: #2b5577;
--color-brand-400: #3b6f99;
--color-brand-500: #3a72a0;
--color-brand-600: #447fae;
--color-brand-700: #8fb8da;
--color-brand-800: #bcd5ea;
--color-brand-900: #e1edf7;
--color-brand-accent: #7fb2dc;
--color-ink-50: #1a212b;
--color-ink-100: #222b37;
--color-ink-200: #313c4a;
--color-ink-300: #5d6a7b;
--color-ink-400: #8593a4;
--color-ink-500: #a0adbc;
--color-ink-600: #b9c4d0;
--color-ink-700: #d0d8e1;
--color-ink-800: #e4e9ef;
--color-ink-900: #f4f7fa;
--color-ink-950: #000000;
--radius-sm: 0.25rem;
--radius-md: 0.4rem;
--radius-lg: 0.55rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--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 { @layer base {
html, html,
body { body {
@@ -662,6 +800,29 @@ html.theme-cyberpunk:not(.dark) {
background-size: cover; background-size: cover;
} }
/*
* Wetterleuchten im Theme Rainy (components/RainyWeather.tsx legt das
* Element exakt ueber die obere Leiste und blendet es kurz ein).
*
* Der Trick ist die Maske: sie ist dieselbe Wolken-SVG wie das
* Hintergrundbild, gleich skaliert. Das Licht - ein Schein um eine zufaellige
* Stelle (--flash-x) - erscheint deshalb nur auf den Wolken selbst, nicht auf
* der ganzen Leiste. So leuchten die Wolken von innen auf.
*/
.rainy-flash {
opacity: 0;
background: radial-gradient(
ellipse 30% 200% at var(--flash-x, 50%) 40%,
var(--flash-color, #fff) 0%,
transparent 70%
);
mix-blend-mode: var(--flash-blend, screen);
mask-image: url('/themes/rainy-header.svg');
mask-size: cover;
mask-position: center;
mask-repeat: no-repeat;
}
/* /*
* Pixelschrift hat eine feste Zeichenbreite und traegt ihre Sperrung selbst; * Pixelschrift hat eine feste Zeichenbreite und traegt ihre Sperrung selbst;
* die negative Laufweite der Ueberschriften wuerde die Buchstaben ineinander * die negative Laufweite der Ueberschriften wuerde die Buchstaben ineinander
+14 -2
View File
@@ -10,11 +10,13 @@ import React, {
import { authApi } from './api'; import { authApi } from './api';
import { useAuth } from './auth'; import { useAuth } from './auth';
import { THEME_PALETTES, type ThemePalette } from '../types'; import { THEME_PALETTES, type ThemePalette } from '../types';
import { RainyWeather } from '../components/RainyWeather';
/* /*
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen: * Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
* *
* Farbwelt (palette) perinco | pixel | outdoor -> Klasse theme-* am <html> * Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy
* -> Klasse theme-* am <html>
* Modus (mode) hell | dunkel -> Klasse dark am <html> * Modus (mode) hell | dunkel -> Klasse dark am <html>
* *
* Beide gehoeren zum **Benutzer** und nicht zum Browser: sie stehen in * Beide gehoeren zum **Benutzer** und nicht zum Browser: sie stehen in
@@ -220,7 +222,17 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
[theme, preference, palette, storeMode, setPalette] [theme, preference, palette, storeMode, setPalette]
); );
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; /*
* Rainy ist das einzige Theme 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.
*/
return (
<ThemeContext.Provider value={value}>
{children}
{palette === 'rainy' && <RainyWeather />}
</ThemeContext.Provider>
);
}; };
export function useTheme(): ThemeContextValue { export function useTheme(): ThemeContextValue {
+2 -1
View File
@@ -88,7 +88,7 @@ export type Role = 'admin' | 'user';
* Tippfehler hier nicht still zum Standarddesign fuehrt. * Tippfehler hier nicht still zum Standarddesign fuehrt.
* server/test/userFields.test.mjs prueft, dass beide Listen gleich bleiben. * server/test/userFields.test.mjs prueft, dass beide Listen gleich bleiben.
*/ */
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk'] as const; export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy'] as const;
export type ThemePalette = (typeof THEME_PALETTES)[number]; export type ThemePalette = (typeof THEME_PALETTES)[number];
@@ -101,6 +101,7 @@ export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: st
name: 'Cyberpunk', name: 'Cyberpunk',
hint: 'Neon in der Nacht: scharfe Kanten, violettes Leuchten, Chakra Petch', hint: 'Neon in der Nacht: scharfe Kanten, violettes Leuchten, Chakra Petch',
}, },
rainy: { name: 'Rainy', hint: 'Regenwetter: Nieselregen, Wolken mit Wetterleuchten, Schieferblau' },
}; };
export interface AppUser { export interface AppUser {