feat(seasonal): New Year redesign (#230) — awaiting human review #472

Open
jared wants to merge 1 commits from seasonal/newyear into lotus
2 changed files with 61 additions and 81 deletions
@@ -9,28 +9,16 @@ import { keyframes } from '@vanilla-extract/css';
*/ */
/** /**
* Firework burst — a thin spark ring expands from a pinpoint, brightens, then * Firework burst — the spark burst pops open from a point, drifts down a little
* fades as it grows. Scale + opacity only; the ring is a radial-gradient border * as it spreads (gravity) and fades fast. Scale + translate + opacity only. Long
* supplied inline. Long pauses between bursts come from a low keyframe-duty: * gaps between bursts come from a low keyframe duty: invisible most of the cycle.
* the ring spends most of the cycle collapsed and invisible.
*/ */
export const animBurst = keyframes({ export const animBurst = keyframes({
'0%': { transform: 'scale(0.05)', opacity: '0' }, '0%': { transform: 'translate3d(0, 0, 0) scale(0.15)', opacity: '0' },
'4%': { transform: 'scale(0.12)', opacity: '0.95' }, '3%': { transform: 'translate3d(0, 0, 0) scale(0.4)', opacity: '1' },
'22%': { transform: 'scale(1)', opacity: '0.55' }, '14%': { transform: 'translate3d(0, 6px, 0) scale(1)', opacity: '0.75' },
'34%': { transform: 'scale(1.25)', opacity: '0' }, '24%': { transform: 'translate3d(0, 14px, 0) scale(1.1)', opacity: '0' },
'100%': { transform: 'scale(1.25)', opacity: '0' }, '100%': { transform: 'translate3d(0, 14px, 0) scale(1.1)', 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' },
}); });
/** /**
+53 -61
View File
@@ -2,7 +2,6 @@ import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types'; import { SeasonalOverlayProps } from '../types';
import { import {
animBurst, animBurst,
animCoreFlash,
animShimmer, animShimmer,
animConfettiFall, animConfettiFall,
animConfettiSway, animConfettiSway,
@@ -12,7 +11,7 @@ import {
/** /**
* New Year overlay — a midnight celebration. Layered oklch gradients sink the * 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 * champagne-gold shimmer sweeps across, confetti slivers tumble down, and
* sparkle stars twinkle. All motion is transform/opacity only. * 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 * position:fixed, or pointer-events here — kept well below opaque so chat text
* stays WCAG-AA legible. * stays WCAG-AA legible.
* *
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a frozen * REDUCED MOTION: when `reduced`, render a static but gorgeous scene (two still
* firework bloom mid-burst, scattered gold confetti, a still shimmer band) with * bursts peeking in from the top corners, scattered confetti, a still shimmer) with
* no `animation` at all. The settings preview always passes reduced=true. * 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>`, `<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) { export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
const bursts = useMemo<Burst[]>( 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) => { Array.from({ length: 7 }, (_, i) => {
const hue = BURST_HUES[i % BURST_HUES.length]; const hue = BURST_HUES[i % BURST_HUES.length];
const edge = i % 3 === 2;
const right = rand(i + 51) > 0.5;
return { return {
top: 8 + rand(i + 1) * 48, top: edge ? 22 + rand(i + 1) * 45 : -2 + rand(i + 1) * 5,
left: 8 + rand(i + 11) * 84, left: edge
size: 130 + Math.floor(rand(i + 21) * 110), ? 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], ring: hue[0],
core: hue[1], core: hue[1],
duration: 6.5 + rand(i + 31) * 4, 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 {/* Fireworks — spark bursts that pop, drift down a little and fade. In
freeze the first burst mid-bloom and drop the rest. */} reduced mode, two still bursts peek in from the top corners instead
{(reduced ? bursts.slice(0, 1) : bursts).map((b, i) => ( 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 <div
key={`b${i}`} key={`b${i}`}
aria-hidden="true" aria-hidden="true"
@@ -188,57 +222,15 @@ export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
height: `${b.size}px`, height: `${b.size}px`,
marginLeft: `${-b.size / 2}px`, marginLeft: `${-b.size / 2}px`,
marginTop: `${-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. */} {/* Twinkling sparkle stars. */}