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:
co-authored by
Claude Opus 5
parent
af3ca33743
commit
77b61b4a04
@@ -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
@@ -10,8 +10,8 @@
|
||||
*/
|
||||
|
||||
/*
|
||||
* Vier Farbwelten, jede in hell und dunkel. Sie haengen an einer Klasse am
|
||||
* <html> (theme-pixel, theme-outdoor, theme-cyberpunk); ohne Klasse gilt
|
||||
* 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
|
||||
* perinco. Der Modus bleibt die vorhandene Klasse "dark".
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
* 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 {
|
||||
html,
|
||||
body {
|
||||
@@ -662,6 +800,29 @@ html.theme-cyberpunk:not(.dark) {
|
||||
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;
|
||||
* die negative Laufweite der Ueberschriften wuerde die Buchstaben ineinander
|
||||
|
||||
+14
-2
@@ -10,11 +10,13 @@ import React, {
|
||||
import { authApi } from './api';
|
||||
import { useAuth } from './auth';
|
||||
import { THEME_PALETTES, type ThemePalette } from '../types';
|
||||
import { RainyWeather } from '../components/RainyWeather';
|
||||
|
||||
/*
|
||||
* 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>
|
||||
*
|
||||
* 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]
|
||||
);
|
||||
|
||||
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 {
|
||||
|
||||
+2
-1
@@ -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'] as const;
|
||||
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy'] as const;
|
||||
|
||||
export type ThemePalette = (typeof THEME_PALETTES)[number];
|
||||
|
||||
@@ -101,6 +101,7 @@ export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: st
|
||||
name: 'Cyberpunk',
|
||||
hint: 'Neon in der Nacht: scharfe Kanten, violettes Leuchten, Chakra Petch',
|
||||
},
|
||||
rainy: { name: 'Rainy', hint: 'Regenwetter: Nieselregen, Wolken mit Wetterleuchten, Schieferblau' },
|
||||
};
|
||||
|
||||
export interface AppUser {
|
||||
|
||||
Reference in New Issue
Block a user