feat(seasonal): Christmas redesign (#240) — awaiting human review #471
@@ -25,13 +25,13 @@ export const animSnowSway = keyframes({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* String-light breathing — bokeh orbs softly pulse in brightness and scale,
|
* String-light twinkle — each bulb set softly brightens and dims, like
|
||||||
* like incandescent bulbs warming and cooling. Opacity + transform only.
|
* incandescent bulbs warming and cooling. Opacity only.
|
||||||
*/
|
*/
|
||||||
export const animBulbBreathe = keyframes({
|
export const animBulbBreathe = keyframes({
|
||||||
'0%': { transform: 'scale(0.92)', opacity: '0.55' },
|
'0%': { opacity: '0.6' },
|
||||||
'50%': { transform: 'scale(1.08)', opacity: '0.95' },
|
'50%': { opacity: '1' },
|
||||||
'100%': { transform: 'scale(0.92)', opacity: '0.55' },
|
'100%': { opacity: '0.6' },
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,13 +44,3 @@ export const animAurora = keyframes({
|
|||||||
'50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' },
|
'50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' },
|
||||||
'100%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
|
'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' },
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { SeasonalOverlayProps } from '../types';
|
import { SeasonalOverlayProps } from '../types';
|
||||||
import {
|
import { animSnowFall, animSnowSway, animBulbBreathe, animAurora } from './Christmas.css';
|
||||||
animSnowFall,
|
|
||||||
animSnowSway,
|
|
||||||
animBulbBreathe,
|
|
||||||
animAurora,
|
|
||||||
animFrostPulse,
|
|
||||||
} from './Christmas.css';
|
|
||||||
|
|
||||||
// Deterministic pseudo-random so the scene is identical every mount (no React
|
// Deterministic pseudo-random so the scene is identical every mount (no React
|
||||||
// state per frame). Large primes keep the distribution well spread.
|
// state per frame). Large primes keep the distribution well spread.
|
||||||
@@ -15,15 +9,71 @@ const rand = (seed: number) => {
|
|||||||
return x - Math.floor(x);
|
return x - Math.floor(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Warm incandescent string-light hues in oklch — gold, soft red, cool white,
|
// [Gitea #240] The string lights are a real garland now: a thin wire in
|
||||||
// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow.
|
// shallow scallops with small bulbs hanging from it, all inside the top ~16px so
|
||||||
const BULB_COLORS = [
|
// they sit above the header's icons and text instead of over them (phone
|
||||||
'oklch(0.85 0.12 85)', // warm gold
|
// header icons start ~18px down; the bulbs end by ~15px). One 600px
|
||||||
'oklch(0.72 0.15 28)', // soft red
|
// SVG tile (five scallops, five colours) repeats across the width; bulbs are
|
||||||
'oklch(0.95 0.03 230)', // icy white
|
// split into two sets that twinkle alternately (opacity only).
|
||||||
'oklch(0.78 0.13 150)', // pine green
|
const TILE_W = 600;
|
||||||
'oklch(0.8 0.1 235)', // cool blue
|
const TILE_H = 18;
|
||||||
|
const SCALLOP = 120;
|
||||||
|
const svgUrl = (body: string) =>
|
||||||
|
`url("data:image/svg+xml,${encodeURIComponent(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' width='${TILE_W}' height='${TILE_H}' viewBox='0 0 ${TILE_W} ${TILE_H}'>${body}</svg>`,
|
||||||
|
)}")`;
|
||||||
|
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 `<ellipse cx='${x}' cy='11' rx='6' ry='6' fill='${hue}' opacity='0.55'/>`;
|
||||||
|
return (
|
||||||
|
`<rect x='${x - 1.5}' y='5.4' width='3' height='2.6' rx='0.7' fill='oklch(0.5 0.03 150)'/>` +
|
||||||
|
`<ellipse cx='${x}' cy='11.4' rx='2.7' ry='3.6' fill='${hue}'/>` +
|
||||||
|
`<ellipse cx='${x - 0.9}' cy='10' rx='0.8' ry='1.2' fill='white' opacity='0.7'/>`
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
const GLOW_FILTER =
|
||||||
|
"<filter id='g' x='-1' y='-1' width='3' height='3'><feGaussianBlur stdDeviation='2.6'/></filter>";
|
||||||
|
const WIRE = svgUrl(
|
||||||
|
`<path d='M0 1 Q${SCALLOP / 2} 10 ${SCALLOP} 1 ${Array.from(
|
||||||
|
{ length: TILE_W / SCALLOP - 1 },
|
||||||
|
(_, i) => `T${(i + 2) * SCALLOP} 1`,
|
||||||
|
).join(
|
||||||
|
' ',
|
||||||
|
)}' fill='none' stroke='oklch(0.58 0.05 150)' stroke-width='1.2' stroke-opacity='0.8'/>`,
|
||||||
|
);
|
||||||
|
const BULBS_A = svgUrl(
|
||||||
|
`${GLOW_FILTER}<g filter='url(#g)'>${bulbsSvg(0, true)}</g>${bulbsSvg(0, false)}`,
|
||||||
|
);
|
||||||
|
const BULBS_B = svgUrl(
|
||||||
|
`${GLOW_FILTER}<g filter='url(#g)'>${bulbsSvg(1, true)}</g>${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) =>
|
||||||
|
`<g transform='rotate(${a} 12 12)'><path d='M12 12V2.5M12 6l-2.6-2.4M12 6l2.6-2.4M12 9.2l-1.8-1.6M12 9.2l1.8-1.6'/></g>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const crystalUrl = `url("data:image/svg+xml,${encodeURIComponent(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g fill='none' stroke-linecap='round'><g stroke='oklch(0.55 0.05 245)' stroke-opacity='0.55' stroke-width='2.6'>${CRYSTAL_ARMS}</g><g stroke='white' stroke-width='1.2'>${CRYSTAL_ARMS}</g></g></svg>`,
|
||||||
|
)}")`;
|
||||||
|
|
||||||
|
// 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 = {
|
type Flake = {
|
||||||
left: number;
|
left: number;
|
||||||
@@ -35,22 +85,13 @@ type Flake = {
|
|||||||
blur: number;
|
blur: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Bulb = {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
size: number;
|
|
||||||
color: string;
|
|
||||||
duration: number;
|
|
||||||
delay: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
// Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
|
// Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
|
||||||
const flakes = useMemo<Flake[]>(() => {
|
const flakes = useMemo<Flake[]>(() => {
|
||||||
const bands = [
|
const bands = [
|
||||||
{ count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 },
|
{ count: 16, size: [2, 3], dur: [16, 22], op: [0.5, 0.7], blur: 0.4 },
|
||||||
{ count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 },
|
{ count: 12, size: [3, 4.5], dur: [11, 15], op: [0.65, 0.85], blur: 0 },
|
||||||
{ count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 },
|
{ count: 8, size: [4.5, 6.5], dur: [8, 11], op: [0.8, 1], blur: 0 },
|
||||||
];
|
];
|
||||||
const out: Flake[] = [];
|
const out: Flake[] = [];
|
||||||
let s = 1;
|
let s = 1;
|
||||||
@@ -75,62 +116,53 @@ export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
|||||||
return out;
|
return out;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Bokeh string lights strung along the very top edge, gently sagging.
|
// A few large six-armed crystals drifting in front.
|
||||||
const bulbs = useMemo<Bulb[]>(() => {
|
const crystals = useMemo<Flake[]>(
|
||||||
const count = 9;
|
() =>
|
||||||
const out: Bulb[] = [];
|
Array.from({ length: 6 }, (_, i) => ({
|
||||||
for (let i = 0; i < count; i += 1) {
|
left: 6 + rand(i + 40) * 88,
|
||||||
const t = i / (count - 1);
|
size: 12 + rand(i + 41) * 5,
|
||||||
// Two-segment garland sag so the lights drape rather than sit in a line.
|
duration: 13 + rand(i + 42) * 6,
|
||||||
const sag = Math.sin(t * Math.PI * 2) * 3.2;
|
delay: -rand(i + 43) * 19,
|
||||||
out.push({
|
swayDuration: 6 + rand(i + 44) * 4,
|
||||||
left: 4 + t * 92,
|
opacity: 0.55 + rand(i + 45) * 0.15,
|
||||||
top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag,
|
blur: 0,
|
||||||
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,
|
|
||||||
});
|
const garlandLayer = (image: string, delay: number): React.CSSProperties => ({
|
||||||
}
|
position: 'absolute',
|
||||||
return out;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Deep night-blue ambient wash — layered radial + linear oklch gradients
|
{/* Soft night-blue wash at the top only; the bottom stays clear so the
|
||||||
for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
|
composer isn't greyed on light themes. */}
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
contain: 'layout paint style',
|
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
'radial-gradient(120% 80% at 50% -10%, oklch(0.25 0.07 250 / 0.16) 0%, transparent 55%)',
|
'radial-gradient(120% 70% at 50% -12%, oklch(0.3 0.07 250 / 0.12) 0%, transparent 55%)',
|
||||||
'radial-gradient(90% 60% at 85% 110%, oklch(0.3 0.06 255 / 0.1) 0%, transparent 60%)',
|
'radial-gradient(70% 40% at 50% 112%, oklch(0.9 0.03 230 / 0.06) 0%, transparent 70%)',
|
||||||
'linear-gradient(180deg, oklch(0.95 0.03 230 / 0.05) 0%, transparent 22%, transparent 80%, oklch(0.22 0.07 255 / 0.08) 100%)',
|
|
||||||
].join(','),
|
].join(','),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
|
{/* Aurora shimmer high up — kept faint. */}
|
||||||
single cheap layer for a faint icy haze around the rim. */}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
contain: 'layout paint style',
|
|
||||||
backdropFilter: 'blur(0.4px) saturate(1.04)',
|
|
||||||
WebkitBackdropFilter: 'blur(0.4px) saturate(1.04)',
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(135% 120% at 50% 42%, transparent 52%, oklch(0.9 0.04 225 / 0.07) 74%, oklch(0.28 0.07 250 / 0.16) 100%)',
|
|
||||||
animation: reduced ? 'none' : `${animFrostPulse} 12s ease-in-out infinite`,
|
|
||||||
willChange: reduced ? undefined : 'opacity',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
|
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
@@ -138,99 +170,59 @@ export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
|||||||
top: '-6%',
|
top: '-6%',
|
||||||
left: '-10%',
|
left: '-10%',
|
||||||
right: '-10%',
|
right: '-10%',
|
||||||
height: '40%',
|
height: '32%',
|
||||||
contain: 'layout paint style',
|
|
||||||
mixBlendMode: 'screen',
|
mixBlendMode: 'screen',
|
||||||
filter: 'blur(26px)',
|
filter: 'blur(26px)',
|
||||||
opacity: reduced ? 0.6 : undefined,
|
opacity: reduced ? 0.45 : undefined,
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.18) 0%, transparent 70%)',
|
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.14) 0%, transparent 70%)',
|
||||||
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.16) 0%, transparent 72%)',
|
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.12) 0%, transparent 72%)',
|
||||||
'radial-gradient(50% 100% at 50% 0%, oklch(0.9 0.06 280 / 0.1) 0%, transparent 75%)',
|
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
|
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
|
||||||
willChange: reduced ? undefined : 'transform, opacity',
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* String-light wire — a faint catenary line the bulbs hang from. */}
|
{/* Snowfall (motion) — three parallax bands plus the crystals. */}
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: '14%',
|
|
||||||
contain: 'layout paint style',
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(140% 60% at 50% -30%, oklch(0.3 0.04 250 / 0.14) 0%, transparent 70%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Bokeh string lights — soft blurred orbs that breathe. */}
|
|
||||||
{bulbs.map((b, i) => (
|
|
||||||
<div
|
|
||||||
key={`bulb-${i}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: `${b.left}%`,
|
|
||||||
top: `${b.top}%`,
|
|
||||||
width: `${b.size}px`,
|
|
||||||
height: `${b.size}px`,
|
|
||||||
marginLeft: `${-b.size / 2}px`,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: `radial-gradient(circle at 38% 34%, oklch(0.98 0.02 95 / 0.95) 0%, ${b.color} 38%, transparent 72%)`,
|
|
||||||
boxShadow: `0 0 ${b.size}px ${b.size * 0.45}px ${b.color.replace(')', ' / 0.45)')}`,
|
|
||||||
filter: 'blur(0.5px)',
|
|
||||||
opacity: reduced ? 0.9 : undefined,
|
|
||||||
animation: reduced
|
|
||||||
? 'none'
|
|
||||||
: `${animBulbBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
|
|
||||||
willChange: reduced ? undefined : 'transform, opacity',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
|
|
||||||
{!reduced &&
|
{!reduced &&
|
||||||
flakes.map((f, i) => (
|
[...flakes, ...crystals].map((f, i) => {
|
||||||
<div
|
const crystal = i >= flakes.length;
|
||||||
key={`snow-${i}`}
|
return (
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: `${f.left}%`,
|
|
||||||
width: `${f.size}px`,
|
|
||||||
height: `${f.size}px`,
|
|
||||||
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
|
|
||||||
willChange: 'transform',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
|
key={`snow-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
position: 'absolute',
|
||||||
height: '100%',
|
top: 0,
|
||||||
borderRadius: '50%',
|
left: `${f.left}%`,
|
||||||
background:
|
width: `${f.size}px`,
|
||||||
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
height: `${f.size}px`,
|
||||||
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.55)',
|
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
|
||||||
opacity: f.opacity,
|
willChange: 'transform',
|
||||||
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
|
||||||
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
|
|
||||||
willChange: 'transform, opacity',
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
<div
|
||||||
))}
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
...(crystal
|
||||||
|
? { backgroundImage: crystalUrl, backgroundSize: '100% 100%' }
|
||||||
|
: { borderRadius: '50%', background: FLAKE_BG, boxShadow: FLAKE_SHADOW }),
|
||||||
|
opacity: f.opacity,
|
||||||
|
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||||
|
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Static dusting of snow for the reduced-motion / preview scene — a
|
{/* Static snow for reduced motion / the settings preview. */}
|
||||||
sparse scatter so the thumbnail still reads as snowfall. */}
|
|
||||||
{reduced &&
|
{reduced &&
|
||||||
flakes.map((f, i) => {
|
[...flakes, ...crystals].map((f, i) => {
|
||||||
const fy = rand(i + 0.5) * 96 + 2;
|
const crystal = i >= flakes.length;
|
||||||
|
const fy = rand(i + 0.5) * 88 + 8;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`snow-static-${i}`}
|
key={`snow-static-${i}`}
|
||||||
@@ -241,16 +233,24 @@ export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
|||||||
top: `${fy}%`,
|
top: `${fy}%`,
|
||||||
width: `${f.size}px`,
|
width: `${f.size}px`,
|
||||||
height: `${f.size}px`,
|
height: `${f.size}px`,
|
||||||
borderRadius: '50%',
|
...(crystal
|
||||||
background:
|
? {
|
||||||
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
backgroundImage: crystalUrl,
|
||||||
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.5)',
|
backgroundSize: '100% 100%',
|
||||||
|
transform: `rotate(${rand(i + 3) * 60}deg)`,
|
||||||
|
}
|
||||||
|
: { borderRadius: '50%', background: FLAKE_BG, boxShadow: FLAKE_SHADOW }),
|
||||||
opacity: f.opacity,
|
opacity: f.opacity,
|
||||||
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* The garland on top of everything: wire, then two twinkling bulb sets. */}
|
||||||
|
<div aria-hidden="true" style={{ ...garlandLayer(WIRE, 0), animation: 'none' }} />
|
||||||
|
<div aria-hidden="true" style={garlandLayer(BULBS_A, 0)} />
|
||||||
|
<div aria-hidden="true" style={garlandLayer(BULBS_B, -1.6)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user