feat(seasonal): Lunar New Year redesign (#231) — awaiting human review #473
@@ -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. */}
|
||||||
|
|||||||
Reference in New Issue
Block a user