diff --git a/src/app/components/seasonal/themes/Christmas.css.ts b/src/app/components/seasonal/themes/Christmas.css.ts index 39ce801c8..f8cb199d2 100644 --- a/src/app/components/seasonal/themes/Christmas.css.ts +++ b/src/app/components/seasonal/themes/Christmas.css.ts @@ -25,13 +25,13 @@ export const animSnowSway = keyframes({ }); /** - * String-light breathing — bokeh orbs softly pulse in brightness and scale, - * like incandescent bulbs warming and cooling. Opacity + transform only. + * String-light twinkle — each bulb set softly brightens and dims, like + * incandescent bulbs warming and cooling. Opacity only. */ export const animBulbBreathe = keyframes({ - '0%': { transform: 'scale(0.92)', opacity: '0.55' }, - '50%': { transform: 'scale(1.08)', opacity: '0.95' }, - '100%': { transform: 'scale(0.92)', opacity: '0.55' }, + '0%': { opacity: '0.6' }, + '50%': { opacity: '1' }, + '100%': { opacity: '0.6' }, }); /** @@ -44,13 +44,3 @@ export const animAurora = keyframes({ '50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' }, '100%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' }, }); - -/** - * Vignette frost — a barely-there breathing of the cold frame so the static - * tint feels alive without distracting motion. - */ -export const animFrostPulse = keyframes({ - '0%': { opacity: '0.85' }, - '50%': { opacity: '1' }, - '100%': { opacity: '0.85' }, -}); diff --git a/src/app/components/seasonal/themes/Christmas.tsx b/src/app/components/seasonal/themes/Christmas.tsx index fad1d0a75..db9abe2ca 100644 --- a/src/app/components/seasonal/themes/Christmas.tsx +++ b/src/app/components/seasonal/themes/Christmas.tsx @@ -1,12 +1,6 @@ import React, { useMemo } from 'react'; import { SeasonalOverlayProps } from '../types'; -import { - animSnowFall, - animSnowSway, - animBulbBreathe, - animAurora, - animFrostPulse, -} from './Christmas.css'; +import { animSnowFall, animSnowSway, animBulbBreathe, animAurora } from './Christmas.css'; // Deterministic pseudo-random so the scene is identical every mount (no React // state per frame). Large primes keep the distribution well spread. @@ -15,15 +9,71 @@ const rand = (seed: number) => { return x - Math.floor(x); }; -// Warm incandescent string-light hues in oklch — gold, soft red, cool white, -// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow. -const BULB_COLORS = [ - 'oklch(0.85 0.12 85)', // warm gold - 'oklch(0.72 0.15 28)', // soft red - 'oklch(0.95 0.03 230)', // icy white - 'oklch(0.78 0.13 150)', // pine green - 'oklch(0.8 0.1 235)', // cool blue +// [Gitea #240] The string lights are a real garland now: a thin wire in +// shallow scallops with small bulbs hanging from it, all inside the top ~16px so +// they sit above the header's icons and text instead of over them (phone +// header icons start ~18px down; the bulbs end by ~15px). One 600px +// SVG tile (five scallops, five colours) repeats across the width; bulbs are +// split into two sets that twinkle alternately (opacity only). +const TILE_W = 600; +const TILE_H = 18; +const SCALLOP = 120; +const svgUrl = (body: string) => + `url("data:image/svg+xml,${encodeURIComponent( + `${body}`, + )}")`; +const GARLAND_HUES = [ + 'oklch(0.66 0.2 27)', // red + 'oklch(0.84 0.15 85)', // gold + 'oklch(0.66 0.16 150)', // green + 'oklch(0.66 0.14 245)', // blue + 'oklch(0.93 0.05 90)', // warm white ]; +const bulbsSvg = (which: 0 | 1, glow: boolean) => + GARLAND_HUES.map((hue, k) => { + if (k % 2 !== which) return ''; + const x = SCALLOP / 2 + k * SCALLOP; + if (glow) return ``; + return ( + `` + + `` + + `` + ); + }).join(''); +const GLOW_FILTER = + ""; +const WIRE = svgUrl( + ``, +); +const BULBS_A = svgUrl( + `${GLOW_FILTER}${bulbsSvg(0, true)}${bulbsSvg(0, false)}`, +); +const BULBS_B = svgUrl( + `${GLOW_FILTER}${bulbsSvg(1, true)}${bulbsSvg(1, false)}`, +); + +// A six-armed snowflake for the near layer: a blue-grey halo stroke under a +// white one, so it reads on light themes as well as dark (no CSS filter). +const CRYSTAL_ARMS = Array.from({ length: 6 }, (_, i) => i * 60) + .map( + (a) => + ``, + ) + .join(''); +const crystalUrl = `url("data:image/svg+xml,${encodeURIComponent( + `${CRYSTAL_ARMS}${CRYSTAL_ARMS}`, +)}")`; + +// Snow dot: white core with a soft blue-grey rim and shadow — visible on white +// (the rim) and on dark (the core). +const FLAKE_BG = + 'radial-gradient(circle at 38% 35%, oklch(1 0 0) 0%, oklch(0.97 0.01 230) 55%, oklch(0.9 0.03 235) 100%)'; +const FLAKE_SHADOW = '0 0 0 0.6px oklch(0.6 0.04 240 / 0.55), 0 1px 2px oklch(0.35 0.05 245 / 0.3)'; type Flake = { left: number; @@ -35,22 +85,13 @@ type Flake = { blur: number; }; -type Bulb = { - left: number; - top: number; - size: number; - color: string; - duration: number; - delay: number; -}; - export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) { // Three parallax bands of snow: far (small/slow/dim) -> near (large/fast). const flakes = useMemo(() => { const bands = [ - { count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 }, - { count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 }, - { count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 }, + { count: 16, size: [2, 3], dur: [16, 22], op: [0.5, 0.7], blur: 0.4 }, + { count: 12, size: [3, 4.5], dur: [11, 15], op: [0.65, 0.85], blur: 0 }, + { count: 8, size: [4.5, 6.5], dur: [8, 11], op: [0.8, 1], blur: 0 }, ]; const out: Flake[] = []; let s = 1; @@ -75,62 +116,53 @@ export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) { return out; }, []); - // Bokeh string lights strung along the very top edge, gently sagging. - const bulbs = useMemo(() => { - const count = 9; - const out: Bulb[] = []; - for (let i = 0; i < count; i += 1) { - const t = i / (count - 1); - // Two-segment garland sag so the lights drape rather than sit in a line. - const sag = Math.sin(t * Math.PI * 2) * 3.2; - out.push({ - left: 4 + t * 92, - top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag, - size: 12 + rand(i + 5) * 8, - color: BULB_COLORS[i % BULB_COLORS.length], - duration: 3.4 + rand(i + 2) * 2.6, - delay: -rand(i + 9) * 3, - }); - } - return out; - }, []); + // A few large six-armed crystals drifting in front. + const crystals = useMemo( + () => + Array.from({ length: 6 }, (_, i) => ({ + left: 6 + rand(i + 40) * 88, + size: 12 + rand(i + 41) * 5, + duration: 13 + rand(i + 42) * 6, + delay: -rand(i + 43) * 19, + swayDuration: 6 + rand(i + 44) * 4, + opacity: 0.55 + rand(i + 45) * 0.15, + blur: 0, + })), + [], + ); + + const garlandLayer = (image: string, delay: number): React.CSSProperties => ({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: `${TILE_H}px`, + backgroundImage: image, + backgroundRepeat: 'repeat-x', + backgroundSize: `${TILE_W}px ${TILE_H}px`, + // Start mid-scallop so the wire doesn't begin at a bulb on the far left. + backgroundPosition: '-30px 0', + animation: reduced ? 'none' : `${animBulbBreathe} 3.2s ease-in-out ${delay}s infinite`, + willChange: reduced ? undefined : 'opacity', + }); return ( <> - {/* Deep night-blue ambient wash — layered radial + linear oklch gradients - for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */} + {/* Soft night-blue wash at the top only; the bottom stays clear so the + composer isn't greyed on light themes. */}