feat(seasonal): Lunar New Year redesign (#231) — awaiting human review #473

Open
jared wants to merge 1 commits from seasonal/lunar into lotus
2 changed files with 74 additions and 306 deletions
@@ -9,46 +9,14 @@ import { keyframes } from '@vanilla-extract/css';
* component; this module is motion only. * 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 * Lantern inner glow — the warm light inside each lantern swells and dims, like
* a candle breathing. Opacity + scale only. * a candle breathing. Opacity + scale only.
*/ */
export const animGlowBreathe = keyframes({ export const animGlowBreathe = keyframes({
'0%': { transform: 'scale(0.94)', opacity: '0.55' }, '0%': { opacity: '0.7' },
'50%': { transform: 'scale(1.06)', opacity: '0.9' }, '50%': { opacity: '1' },
'100%': { transform: 'scale(0.94)', opacity: '0.55' }, '100%': { opacity: '0.7' },
}); });
/** /**
@@ -77,16 +45,6 @@ export const animPetalSway = keyframes({
'100%': { transform: 'translate3d(0, 0, 0)' }, '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 * Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so
* the static base feels alive without distracting motion. * the static base feels alive without distracting motion.
@@ -107,3 +65,13 @@ export const animEmberRise = keyframes({
'80%': { opacity: '0.5' }, '80%': { opacity: '0.5' },
'100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' }, '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)' },
});
@@ -1,13 +1,10 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types'; import { SeasonalOverlayProps } from '../types';
import { import {
animLanternBob, animGarlandSway,
animLanternSway,
animTasselSway,
animGlowBreathe, animGlowBreathe,
animPetalFall, animPetalFall,
animPetalSway, animPetalSway,
animDragonDrift,
animLacquerPulse, animLacquerPulse,
animEmberRise, animEmberRise,
} from './LunarNewYear.css'; } 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 // 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 // is intentionally abstract and very subtle — a calligraphic ribbon-body with a
// suggestion of a head, mane and tail arcing across the upper scene. // suggestion of a head, mane and tail arcing across the upper scene.
const dragonUri = ((): string => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
`<defs>` +
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
`</linearGradient>` +
`</defs>` +
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
// Sinuous body — a thick tapering serpentine ribbon.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='26' opacity='0.5'/>` +
// Inner highlight running along the body for a calligraphic sheen.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='7' opacity='0.7'/>` +
// Head + horn flourish at the leading end.
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
`stroke-width='9' opacity='0.6'/>` +
// Mane / whisker strokes flaring back from the head.
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
`stroke-width='5' opacity='0.45'/>` +
// Tail wisps.
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
`stroke-width='5' opacity='0.45'/>` +
`</g></svg>`;
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 = { type Petal = {
left: number; left: number;
size: number; size: number;
@@ -90,8 +47,6 @@ type Ember = {
delay: number; 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 blossomUri = ((): string => {
const petals = Array.from({ length: 5 }, (_, i) => { const petals = Array.from({ length: 5 }, (_, i) => {
const a = (i * 72 * Math.PI) / 180; const a = (i * 72 * Math.PI) / 180;
@@ -107,28 +62,56 @@ const blossomUri = ((): string => {
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})(); })();
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) { // [Gitea #231] Lantern garland: one 480px SVG tile (four scallops) repeats
// Paper lanterns strung across the upper third, gently staggered in depth. // across the width; small lanterns hang at the scallop low points, all within
const lanterns = useMemo<Lantern[]>(() => { // the top ~20px.
const slots = [ const TILE_W = 480;
{ left: 9, top: 7, scale: 1.0 }, const TILE_H = 20;
{ left: 27, top: 13, scale: 0.82 }, const SCALLOP = 120;
{ left: 46, top: 6, scale: 1.12 }, const tile = (body: string) =>
{ left: 64, top: 15, scale: 0.78 }, `url("data:image/svg+xml,${encodeURIComponent(
{ left: 82, top: 9, scale: 0.95 }, `<svg xmlns='http://www.w3.org/2000/svg' width='${TILE_W}' height='${TILE_H}' viewBox='0 0 ${TILE_W} ${TILE_H}'>${body}</svg>`,
{ left: 92, top: 20, scale: 0.7 }, )}")`;
]; const CORD = tile(
return slots.map((s, i) => ({ `<path d='M0 1 Q${SCALLOP / 2} 9 ${SCALLOP} 1 ${Array.from(
left: s.left, { length: TILE_W / SCALLOP - 1 },
top: s.top, (_, i) => `T${(i + 2) * SCALLOP} 1`,
scale: s.scale, ).join(' ')}' fill='none' stroke='${GOLD}' stroke-width='1.1' stroke-opacity='0.8'/>`,
bobDuration: 7 + rand(i + 1) * 4, );
swayDuration: 5.5 + rand(i + 4) * 3, const lanternSvg = (which: 0 | 1) =>
delay: -rand(i + 7) * 6, Array.from({ length: TILE_W / SCALLOP }, (_, k) => {
opacity: 0.78 + rand(i + 2) * 0.18, if (k % 2 !== which) return '';
})); const x = SCALLOP / 2 + k * SCALLOP;
}, []); return (
`<ellipse cx='${x}' cy='11.5' rx='9' ry='7' fill='${VERMILION}' opacity='0.28'/>` +
`<rect x='${x - 3}' y='5' width='6' height='1.6' rx='0.6' fill='${GOLD}'/>` +
`<ellipse cx='${x}' cy='11' rx='5.6' ry='4.4' fill='${CRIMSON}' stroke='${GOLD}' stroke-width='0.6'/>` +
`<path d='M${x - 2.4} 7.2 Q${x - 3.4} 11 ${x - 2.4} 14.8 M${x} 6.6 V15.4 M${x + 2.4} 7.2 Q${x + 3.4} 11 ${x + 2.4} 14.8' fill='none' stroke='${GOLD}' stroke-width='0.4' stroke-opacity='0.7'/>` +
`<ellipse cx='${x - 1.6}' cy='9.6' rx='1.6' ry='1.2' fill='${GOLD_HI}' opacity='0.45'/>` +
`<rect x='${x - 2.4}' y='15.2' width='4.8' height='1.4' rx='0.5' fill='${GOLD}'/>` +
`<path d='M${x} 16.6 V19.4' stroke='${GOLD}' stroke-width='0.9' stroke-linecap='round'/>`
);
}).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, // Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
// near large/bright/fast) for depth. // near large/bright/fast) for depth.
const petals = useMemo<Petal[]>(() => { const petals = useMemo<Petal[]>(() => {
@@ -185,216 +168,33 @@ export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0, inset: 0,
contain: 'layout paint style', contain: 'layout paint style',
backgroundImage: [ backgroundImage: [
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`, `radial-gradient(120% 70% at 50% -8%, ${CRIMSON.replace(')', ' / 0.09)')} 0%, transparent 52%)`,
`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.035) 0%, transparent 22%)`,
`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%)`,
].join(','), ].join(','),
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`, animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity', willChange: reduced ? undefined : 'opacity',
}} }}
/> />
{/* Imperial-gold dragon silhouette arcing across the upper scene — a {/* Warm vignette frame — a faint crimson rim, clear center. */}
single composited SVG layer, blurred and screen-blended so it reads as
an ethereal gilt apparition, never a hard graphic. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
left: '-6%',
right: '-6%',
height: '46%',
contain: 'layout paint style',
backgroundImage: dragonUri,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundSize: 'contain',
mixBlendMode: 'screen',
filter: 'blur(1.1px)',
opacity: reduced ? 0.52 : undefined,
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
backdrop-filter for a silken haze around the rim. */}
<div <div
aria-hidden="true" aria-hidden="true"
style={{ style={{
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
contain: 'layout paint style', contain: 'layout paint style',
backdropFilter: 'blur(0.4px) saturate(1.05)',
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
backgroundImage: backgroundImage:
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)', 'radial-gradient(135% 120% at 50% 40%, transparent 60%, oklch(0.55 0.16 28 / 0.05) 100%)',
}} }}
/> />
{/* The garland string the lanterns hang from — a faint warm line. */} {/* [Gitea #231] A garland of small paper lanterns on a scalloped cord, all
<div within the top ~20px so it stays above header icons and text (the old
aria-hidden="true" lanterns hung 7-20% down, over Create Room, search boxes and messages).
style={{ Two lantern sets glow and sway a little, alternately. */}
position: 'absolute', <div aria-hidden="true" style={garlandLayer(CORD, false)} />
top: 0, <div aria-hidden="true" style={garlandLayer(LANTERNS_A, true, 0)} />
left: 0, <div aria-hidden="true" style={garlandLayer(LANTERNS_B, true, -2.2)} />
right: 0,
height: '6%',
contain: 'layout paint style',
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
')',
' / 0.14)',
)} 0%, transparent 70%)`,
}}
/>
{/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
mount, an inner bob, then the lantern body (glow + ribs + caps) and a
trailing tassel. */}
{lanterns.map((l, i) => {
const W = 30 * l.scale;
const H = 38 * l.scale;
const cap = Math.max(8, W * 0.5);
return (
<div
key={`lantern-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${l.left}%`,
top: `${l.top}%`,
marginLeft: `${-W / 2}px`,
transformOrigin: 'top center',
opacity: l.opacity,
animation: reduced
? 'none'
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
<div
style={{
animation: reduced
? 'none'
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
{/* short cord from the string to the top cap */}
<div
style={{
width: '2px',
height: `${10 * l.scale}px`,
margin: '0 auto',
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
}}
/>
{/* top gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
}}
/>
{/* lantern body */}
<div
style={{
position: 'relative',
width: `${W}px`,
height: `${H}px`,
margin: `${1 * l.scale}px auto`,
borderRadius: '50% / 42%',
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
')',
' / 0.95)',
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
')',
' / 0.5)',
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
overflow: 'hidden',
}}
>
{/* breathing inner candle glow */}
<div
style={{
position: 'absolute',
left: '50%',
top: '52%',
width: `${W * 0.6}px`,
height: `${H * 0.55}px`,
marginLeft: `${-W * 0.3}px`,
marginTop: `${-H * 0.275}px`,
borderRadius: '50%',
background: `radial-gradient(circle, ${GOLD_HI.replace(
')',
' / 0.9)',
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
filter: 'blur(1px)',
animation: reduced
? 'none'
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
l.delay
}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* vertical paper ribs */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
W / 6 - 0.6
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
')',
' / 0.18)',
)} ${W / 6}px)`,
}}
/>
</div>
{/* bottom gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
}}
/>
{/* swaying silk tassel */}
<div
style={{
width: `${2 * l.scale}px`,
height: `${16 * l.scale}px`,
margin: '0 auto',
transformOrigin: 'top center',
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
borderRadius: '1px',
animation: reduced
? 'none'
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
/>
</div>
</div>
);
})}
{/* Drifting gold plum-blossom petals (motion only). Static settled {/* Drifting gold plum-blossom petals (motion only). Static settled
blossoms render below for the reduced/preview scene. */} blossoms render below for the reduced/preview scene. */}