diff --git a/src/app/components/seasonal/themes/LunarNewYear.css.ts b/src/app/components/seasonal/themes/LunarNewYear.css.ts index a86cb5adb..03307bdfa 100644 --- a/src/app/components/seasonal/themes/LunarNewYear.css.ts +++ b/src/app/components/seasonal/themes/LunarNewYear.css.ts @@ -9,46 +9,14 @@ import { keyframes } from '@vanilla-extract/css'; * component; this module is motion only. */ -/** - * Lantern bob — a hung lantern rises a touch and sinks again on a long, lazy - * cycle, as if buoyed by warm air. translateY + a whisper of scale only; the - * per-lantern duration/delay desynchronise the swarm. - */ -export const animLanternBob = keyframes({ - '0%': { transform: 'translate3d(0, 0, 0) scale(1)' }, - '50%': { transform: 'translate3d(0, -2.2vh, 0) scale(1.015)' }, - '100%': { transform: 'translate3d(0, 0, 0) scale(1)' }, -}); - -/** - * Lantern pendulum — a gentle rotational sway about the top mount, so each - * lantern rocks like it hangs from a string. Pairs with the bob on a different - * period to read as organic drift rather than a metronome. - */ -export const animLanternSway = keyframes({ - '0%': { transform: 'rotate(-2.4deg)' }, - '50%': { transform: 'rotate(2.4deg)' }, - '100%': { transform: 'rotate(-2.4deg)' }, -}); - -/** - * Tassel sway — the silk tassel under a lantern trails its parent's motion with - * a wider, slightly lagging swing. transformOrigin is the top of the tassel. - */ -export const animTasselSway = keyframes({ - '0%': { transform: 'rotate(5deg)' }, - '50%': { transform: 'rotate(-5deg)' }, - '100%': { transform: 'rotate(5deg)' }, -}); - /** * Lantern inner glow — the warm light inside each lantern swells and dims, like * a candle breathing. Opacity + scale only. */ export const animGlowBreathe = keyframes({ - '0%': { transform: 'scale(0.94)', opacity: '0.55' }, - '50%': { transform: 'scale(1.06)', opacity: '0.9' }, - '100%': { transform: 'scale(0.94)', opacity: '0.55' }, + '0%': { opacity: '0.7' }, + '50%': { opacity: '1' }, + '100%': { opacity: '0.7' }, }); /** @@ -77,16 +45,6 @@ export const animPetalSway = keyframes({ '100%': { transform: 'translate3d(0, 0, 0)' }, }); -/** - * Dragon drift — the gold dragon silhouette breathes and undulates almost - * imperceptibly across the scene. translate + scale + opacity only, very slow. - */ -export const animDragonDrift = keyframes({ - '0%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' }, - '50%': { transform: 'translate3d(2%, -1%, 0) scale(1.04)', opacity: '0.6' }, - '100%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' }, -}); - /** * Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so * the static base feels alive without distracting motion. @@ -107,3 +65,13 @@ export const animEmberRise = keyframes({ '80%': { opacity: '0.5' }, '100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' }, }); + +/** + * Lantern garland sway — the lantern set shifts a pixel or two side to side, as + * if in a breeze. Transform only. + */ +export const animGarlandSway = keyframes({ + '0%': { transform: 'translate3d(-1.5px, 0, 0)' }, + '50%': { transform: 'translate3d(1.5px, 0, 0)' }, + '100%': { transform: 'translate3d(-1.5px, 0, 0)' }, +}); diff --git a/src/app/components/seasonal/themes/LunarNewYear.tsx b/src/app/components/seasonal/themes/LunarNewYear.tsx index 409f0997e..b8e902523 100644 --- a/src/app/components/seasonal/themes/LunarNewYear.tsx +++ b/src/app/components/seasonal/themes/LunarNewYear.tsx @@ -1,13 +1,10 @@ import React, { useMemo } from 'react'; import { SeasonalOverlayProps } from '../types'; import { - animLanternBob, - animLanternSway, - animTasselSway, + animGarlandSway, animGlowBreathe, animPetalFall, animPetalSway, - animDragonDrift, animLacquerPulse, animEmberRise, } from './LunarNewYear.css'; @@ -31,46 +28,6 @@ const GOLD_HI = 'oklch(0.92 0.10 92)'; // data-URI so it costs a single GPU-composited layer (no DOM weight). The curve // is intentionally abstract and very subtle — a calligraphic ribbon-body with a // suggestion of a head, mane and tail arcing across the upper scene. -const dragonUri = ((): string => { - const svg = - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - // Sinuous body — a thick tapering serpentine ribbon. - `` + - // Inner highlight running along the body for a calligraphic sheen. - `` + - // Head + horn flourish at the leading end. - `` + - // Mane / whisker strokes flaring back from the head. - `` + - // Tail wisps. - `` + - ``; - return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; -})(); - -type Lantern = { - left: number; - top: number; - scale: number; - bobDuration: number; - swayDuration: number; - delay: number; - opacity: number; -}; - type Petal = { left: number; size: number; @@ -90,8 +47,6 @@ type Ember = { delay: number; }; -// A single five-petal plum blossom (gold), inline SVG so each petal sliver is -// one cheap element. Returned as a data-URI background painted on a square. const blossomUri = ((): string => { const petals = Array.from({ length: 5 }, (_, i) => { const a = (i * 72 * Math.PI) / 180; @@ -107,28 +62,56 @@ const blossomUri = ((): string => { return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; })(); -export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) { - // Paper lanterns strung across the upper third, gently staggered in depth. - const lanterns = useMemo(() => { - const slots = [ - { left: 9, top: 7, scale: 1.0 }, - { left: 27, top: 13, scale: 0.82 }, - { left: 46, top: 6, scale: 1.12 }, - { left: 64, top: 15, scale: 0.78 }, - { left: 82, top: 9, scale: 0.95 }, - { left: 92, top: 20, scale: 0.7 }, - ]; - return slots.map((s, i) => ({ - left: s.left, - top: s.top, - scale: s.scale, - bobDuration: 7 + rand(i + 1) * 4, - swayDuration: 5.5 + rand(i + 4) * 3, - delay: -rand(i + 7) * 6, - opacity: 0.78 + rand(i + 2) * 0.18, - })); - }, []); +// [Gitea #231] Lantern garland: one 480px SVG tile (four scallops) repeats +// across the width; small lanterns hang at the scallop low points, all within +// the top ~20px. +const TILE_W = 480; +const TILE_H = 20; +const SCALLOP = 120; +const tile = (body: string) => + `url("data:image/svg+xml,${encodeURIComponent( + `${body}`, + )}")`; +const CORD = tile( + ``, +); +const lanternSvg = (which: 0 | 1) => + Array.from({ length: TILE_W / SCALLOP }, (_, k) => { + if (k % 2 !== which) return ''; + const x = SCALLOP / 2 + k * SCALLOP; + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); + }).join(''); +const LANTERNS_A = tile(lanternSvg(0)); +const LANTERNS_B = tile(lanternSvg(1)); +export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) { + const garlandLayer = (image: string, animated: boolean, delay = 0): 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`, + backgroundPosition: '-20px 0', + animation: + reduced || !animated + ? 'none' + : `${animGarlandSway} 5.5s ease-in-out ${delay}s infinite, ${animGlowBreathe} 3.8s ease-in-out ${delay}s infinite`, + willChange: reduced || !animated ? undefined : 'transform, opacity', + }); // Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow, // near large/bright/fast) for depth. const petals = useMemo(() => { @@ -185,216 +168,33 @@ export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) { inset: 0, contain: 'layout paint style', backgroundImage: [ - `radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`, - `radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`, - `linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`, + `radial-gradient(120% 70% at 50% -8%, ${CRIMSON.replace(')', ' / 0.09)')} 0%, transparent 52%)`, + `linear-gradient(180deg, oklch(0.55 0.20 28 / 0.035) 0%, transparent 22%)`, ].join(','), animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`, willChange: reduced ? undefined : 'opacity', }} /> - {/* Imperial-gold dragon silhouette arcing across the upper scene — a - single composited SVG layer, blurred and screen-blended so it reads as - an ethereal gilt apparition, never a hard graphic. */} -