feat(seasonal): New Year redesign (#230) — awaiting human review #472
@@ -9,28 +9,16 @@ import { keyframes } from '@vanilla-extract/css';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Firework burst — a thin spark ring expands from a pinpoint, brightens, then
|
||||
* fades as it grows. Scale + opacity only; the ring is a radial-gradient border
|
||||
* supplied inline. Long pauses between bursts come from a low keyframe-duty:
|
||||
* the ring spends most of the cycle collapsed and invisible.
|
||||
* Firework burst — the spark burst pops open from a point, drifts down a little
|
||||
* as it spreads (gravity) and fades fast. Scale + translate + opacity only. Long
|
||||
* gaps between bursts come from a low keyframe duty: invisible most of the cycle.
|
||||
*/
|
||||
export const animBurst = keyframes({
|
||||
'0%': { transform: 'scale(0.05)', opacity: '0' },
|
||||
'4%': { transform: 'scale(0.12)', opacity: '0.95' },
|
||||
'22%': { transform: 'scale(1)', opacity: '0.55' },
|
||||
'34%': { transform: 'scale(1.25)', opacity: '0' },
|
||||
'100%': { transform: 'scale(1.25)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Burst core flash — the bright pinpoint at a firework's origin pops just before
|
||||
* the ring blooms, then quickly dims. Pairs with animBurst on the same cadence.
|
||||
*/
|
||||
export const animCoreFlash = keyframes({
|
||||
'0%': { transform: 'scale(0.2)', opacity: '0' },
|
||||
'3%': { transform: 'scale(1)', opacity: '1' },
|
||||
'14%': { transform: 'scale(0.6)', opacity: '0' },
|
||||
'100%': { transform: 'scale(0.6)', opacity: '0' },
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(0.15)', opacity: '0' },
|
||||
'3%': { transform: 'translate3d(0, 0, 0) scale(0.4)', opacity: '1' },
|
||||
'14%': { transform: 'translate3d(0, 6px, 0) scale(1)', opacity: '0.75' },
|
||||
'24%': { transform: 'translate3d(0, 14px, 0) scale(1.1)', opacity: '0' },
|
||||
'100%': { transform: 'translate3d(0, 14px, 0) scale(1.1)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animBurst,
|
||||
animCoreFlash,
|
||||
animShimmer,
|
||||
animConfettiFall,
|
||||
animConfettiSway,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
|
||||
/**
|
||||
* New Year overlay — a midnight celebration. Layered oklch gradients sink the
|
||||
* app into a deep navy night; fireworks bloom as expanding spark rings, a
|
||||
* app into a deep navy night; fireworks pop as spark bursts at the edges, a
|
||||
* champagne-gold shimmer sweeps across, confetti slivers tumble down, and
|
||||
* sparkle stars twinkle. All motion is transform/opacity only.
|
||||
*
|
||||
@@ -26,8 +25,8 @@ import {
|
||||
* position:fixed, or pointer-events here — kept well below opaque so chat text
|
||||
* stays WCAG-AA legible.
|
||||
*
|
||||
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a frozen
|
||||
* firework bloom mid-burst, scattered gold confetti, a still shimmer band) with
|
||||
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (two still
|
||||
* bursts peeking in from the top corners, scattered confetti, a still shimmer) with
|
||||
* no `animation` at all. The settings preview always passes reduced=true.
|
||||
*/
|
||||
|
||||
@@ -89,16 +88,44 @@ const sparkleUri = (color: string) =>
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 L14 10 L24 12 L14 14 L12 24 L10 14 L0 12 L10 10 Z' fill='${color}'/></svg>`,
|
||||
)}")`;
|
||||
|
||||
// [Gitea #230] A firework is spark rays + ember dots radiating from a point
|
||||
// (not a solid ring), drawn once as an SVG and scaled/faded by the keyframes.
|
||||
const burstUri = (ring: string, core: string) => {
|
||||
const rays = Array.from({ length: 14 }, (_, k) => {
|
||||
const a = (k / 14) * Math.PI * 2 + (k % 2) * 0.12;
|
||||
const [c, si] = [Math.cos(a), Math.sin(a)];
|
||||
const r1 = k % 2 ? 18 : 14;
|
||||
const r2 = k % 2 ? 38 : 44;
|
||||
const p = (r: number) => `${(50 + c * r).toFixed(1)} ${(50 + si * r).toFixed(1)}`;
|
||||
return (
|
||||
`<path d='M${p(r1)} L${p(r2)}' stroke='${ring}' stroke-width='1.6' stroke-linecap='round' stroke-opacity='0.85'/>` +
|
||||
`<circle cx='${(50 + c * (r2 + 4)).toFixed(1)}' cy='${(50 + si * (r2 + 4)).toFixed(1)}' r='1.5' fill='${core}'/>`
|
||||
);
|
||||
}).join('');
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='6' fill='${core}' opacity='0.5'/>${rays}</svg>`,
|
||||
)}")`;
|
||||
};
|
||||
|
||||
export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
const bursts = useMemo<Burst[]>(
|
||||
() =>
|
||||
// Bursts cluster in the upper two-thirds of the sky, away from typical text.
|
||||
// [Gitea #230] Bursts go off in the top band or along the side edges,
|
||||
// away from the reading column (top ones centred at the very top edge so they
|
||||
// fan down through the header row and fade), and are kept small; the old ones were
|
||||
// large rings anywhere in the upper two-thirds, over message text.
|
||||
Array.from({ length: 7 }, (_, i) => {
|
||||
const hue = BURST_HUES[i % BURST_HUES.length];
|
||||
const edge = i % 3 === 2;
|
||||
const right = rand(i + 51) > 0.5;
|
||||
return {
|
||||
top: 8 + rand(i + 1) * 48,
|
||||
left: 8 + rand(i + 11) * 84,
|
||||
size: 130 + Math.floor(rand(i + 21) * 110),
|
||||
top: edge ? 22 + rand(i + 1) * 45 : -2 + rand(i + 1) * 5,
|
||||
left: edge
|
||||
? right
|
||||
? 92 + rand(i + 11) * 6
|
||||
: 2 + rand(i + 11) * 6
|
||||
: 6 + rand(i + 11) * 88,
|
||||
size: 90 + Math.floor(rand(i + 21) * 60),
|
||||
ring: hue[0],
|
||||
core: hue[1],
|
||||
duration: 6.5 + rand(i + 31) * 4,
|
||||
@@ -174,9 +201,16 @@ export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Fireworks — expanding spark rings + a core flash. In reduced mode we
|
||||
freeze the first burst mid-bloom and drop the rest. */}
|
||||
{(reduced ? bursts.slice(0, 1) : bursts).map((b, i) => (
|
||||
{/* Fireworks — spark bursts that pop, drift down a little and fade. In
|
||||
reduced mode, two still bursts peek in from the top corners instead
|
||||
of one frozen over the timeline. */}
|
||||
{(reduced
|
||||
? [
|
||||
{ ...bursts[0], top: -4, left: 1, size: 160 },
|
||||
{ ...bursts[1], top: -3, left: 99, size: 130 },
|
||||
]
|
||||
: bursts
|
||||
).map((b, i) => (
|
||||
<div
|
||||
key={`b${i}`}
|
||||
aria-hidden="true"
|
||||
@@ -188,57 +222,15 @@ export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
height: `${b.size}px`,
|
||||
marginLeft: `${-b.size / 2}px`,
|
||||
marginTop: `${-b.size / 2}px`,
|
||||
backgroundImage: burstUri(b.ring, b.core),
|
||||
backgroundSize: '100% 100%',
|
||||
opacity: reduced ? 0.35 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
>
|
||||
{/* Spark ring */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '50%',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
background: `radial-gradient(circle, transparent 56%, ${b.ring} 64%, transparent 74%)`,
|
||||
transform: reduced ? 'scale(0.82)' : undefined,
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
{/* Inner secondary ring for a fuller bloom */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '18%',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, transparent 50%, ${b.ring} 60%, transparent 72%)`,
|
||||
transform: reduced ? 'scale(0.7)' : undefined,
|
||||
opacity: reduced ? 0.4 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBurst} ${b.duration}s ease-out ${b.delay + 0.12}s infinite`,
|
||||
}}
|
||||
/>
|
||||
{/* Core flash */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: '14%',
|
||||
height: '14%',
|
||||
marginLeft: '-7%',
|
||||
marginTop: '-7%',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${b.core} 0%, transparent 70%)`,
|
||||
transform: reduced ? 'scale(0.9)' : undefined,
|
||||
opacity: reduced ? 0.85 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animCoreFlash} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Twinkling sparkle stars. */}
|
||||
|
||||
Reference in New Issue
Block a user