feat(effects): polish 8 effects and add 7 new engines with parallax
Build, Test & Push Frontend / quality-check (push) Successful in 32s
Build, Test & Push Frontend / docker (push) Successful in 26s

Synthwave, fireflies, snow, sakura, nebula: CSS/SVG. Matrix and
constellation: canvas. Existing engines gain depth, color, glow, and
parallax (mouse and device tilt, opt-in on iOS)
This commit is contained in:
lobo committed 2026-10-03 17:31:19 +02:00
1 parent d7810fbf94
commit ed5df8c40d
21 files changed
+1510 -211

No files matched your search

+2 -2
View File
@@ -33,11 +33,11 @@ To Be Played is split into three repositories:
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform. trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and - **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG, in `effects.css`), banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings),
shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more
play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS
variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still with the variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still (canvas: one static frame, no loop) with the
system setting "reduce motion" or the "Reduce animations" switch in the settings. system setting "reduce motion" or the "Reduce animations" switch in the settings.
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and - **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users. finished games per year of all users.
+17
View File
@@ -116,6 +116,9 @@
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.", "WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.",
"REDUCE_MOTION": "Animationen reduzieren", "REDUCE_MOTION": "Animationen reduzieren",
"REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.", "REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.",
"TILT": "Effekte neigen",
"TILT_HINT": "Hintergründe folgen, wenn du das Handy neigst. Das Handy fragt nach der Erlaubnis.",
"TILT_DENIED": "Erlaubnis abgelehnt. Erlaube den Bewegungszugriff in Safari und versuche es erneut.",
"BACKGROUND": "Hintergrund", "BACKGROUND": "Hintergrund",
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.", "BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
"BACKGROUND_REMOVE": "Hintergrund entfernen", "BACKGROUND_REMOVE": "Hintergrund entfernen",
@@ -1049,6 +1052,20 @@
"BN_WAVES": "Animiertes Banner: Wellen", "BN_WAVES": "Animiertes Banner: Wellen",
"BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter", "BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter",
"BN_BOKEH": "Animiertes Banner: Bokeh-Lichter", "BN_BOKEH": "Animiertes Banner: Bokeh-Lichter",
"BG_SYNTHWAVE": "Animierter Hintergrund: Synthwave-Sonnenuntergang",
"BN_SYNTHWAVE": "Animiertes Banner: Synthwave-Sonnenuntergang",
"BG_FIREFLIES": "Animierter Hintergrund: Glühwürmchen",
"BN_FIREFLIES": "Animiertes Banner: Glühwürmchen",
"BG_SNOW": "Animierter Hintergrund: Schnee",
"BN_SNOW": "Animiertes Banner: Schnee",
"BG_SAKURA": "Animierter Hintergrund: Kirschblüten",
"BN_SAKURA": "Animiertes Banner: Kirschblüten",
"BG_NEBULA": "Animierter Hintergrund: Nebel-Strudel",
"BN_NEBULA": "Animiertes Banner: Nebel-Strudel",
"BG_MATRIX": "Animierter Hintergrund: Matrix-Regen",
"BN_MATRIX": "Animiertes Banner: Matrix-Regen",
"BG_CONSTELLATION": "Animierter Hintergrund: Sternbild",
"BN_CONSTELLATION": "Animiertes Banner: Sternbild",
"BADGE_FINISH_10": "Beender-Abzeichen", "BADGE_FINISH_10": "Beender-Abzeichen",
"BORDER_FINISH_25": "Silberner Avatar-Rand", "BORDER_FINISH_25": "Silberner Avatar-Rand",
"FRAME_FINISH_50": "Smaragd-Bannerrahmen", "FRAME_FINISH_50": "Smaragd-Bannerrahmen",
+17
View File
@@ -116,6 +116,9 @@
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.", "WIDE_LAYOUT_HINT": "Uses more of a wide screen.",
"REDUCE_MOTION": "Reduce animations", "REDUCE_MOTION": "Reduce animations",
"REDUCE_MOTION_HINT": "Shop effects and confetti stand still, also on other profiles.", "REDUCE_MOTION_HINT": "Shop effects and confetti stand still, also on other profiles.",
"TILT": "Tilt effects",
"TILT_HINT": "Backgrounds follow when you tilt your phone. Your phone asks for permission.",
"TILT_DENIED": "Permission was refused. Allow motion access in Safari, then try again.",
"BACKGROUND": "Background", "BACKGROUND": "Background",
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".", "BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
"BACKGROUND_REMOVE": "Remove background", "BACKGROUND_REMOVE": "Remove background",
@@ -1049,6 +1052,20 @@
"BN_WAVES": "Animated banner: waves", "BN_WAVES": "Animated banner: waves",
"BG_BOKEH": "Animated background: bokeh", "BG_BOKEH": "Animated background: bokeh",
"BN_BOKEH": "Animated banner: bokeh", "BN_BOKEH": "Animated banner: bokeh",
"BG_SYNTHWAVE": "Animated background: synthwave sunset",
"BN_SYNTHWAVE": "Animated banner: synthwave sunset",
"BG_FIREFLIES": "Animated background: fireflies",
"BN_FIREFLIES": "Animated banner: fireflies",
"BG_SNOW": "Animated background: snow",
"BN_SNOW": "Animated banner: snow",
"BG_SAKURA": "Animated background: cherry blossoms",
"BN_SAKURA": "Animated banner: cherry blossoms",
"BG_NEBULA": "Animated background: nebula",
"BN_NEBULA": "Animated banner: nebula",
"BG_MATRIX": "Animated background: matrix rain",
"BN_MATRIX": "Animated banner: matrix rain",
"BG_CONSTELLATION": "Animated background: constellation",
"BN_CONSTELLATION": "Animated banner: constellation",
"BADGE_FINISH_10": "Finisher badge", "BADGE_FINISH_10": "Finisher badge",
"BORDER_FINISH_25": "Silver avatar border", "BORDER_FINISH_25": "Silver avatar border",
"FRAME_FINISH_50": "Emerald banner frame", "FRAME_FINISH_50": "Emerald banner frame",
+29
View File
@@ -1,3 +1,4 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ImageOff, Palette, RotateCcw} from 'lucide-react'; import {ImageOff, Palette, RotateCcw} from 'lucide-react';
@@ -10,11 +11,27 @@ import {equippedSkin} from '../utils/rewards.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {coverUrl} from '../utils/games.js'; import {coverUrl} from '../utils/games.js';
import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js';
export default function AppearanceCard() { export default function AppearanceCard() {
const {t} = useTranslation(); const {t} = useTranslation();
const {appearance, equipped} = usePreferences(); const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance(); const saveAppearance = useSaveAppearance();
const [tilt, setTilt] = useState(tiltAllowed);
const [tiltRefused, setTiltRefused] = useState(false);
// requestTilt() starts inside the tap, as iOS demands.
const changeTilt = (on) => {
if (!on) {
disableTilt();
setTilt(false);
return;
}
requestTilt().then((granted) => {
setTilt(granted);
setTiltRefused(!granted);
});
};
return ( return (
<Card> <Card>
@@ -41,6 +58,18 @@ export default function AppearanceCard() {
<Switch id="reduceMotion" checked={appearance.reduceMotion} <Switch id="reduceMotion" checked={appearance.reduceMotion}
onCheckedChange={(reduceMotion) => saveAppearance({reduceMotion})}/> onCheckedChange={(reduceMotion) => saveAppearance({reduceMotion})}/>
</div> </div>
{tiltNeedsPermission() && (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="tilt">{t('SETTINGS.TILT')}</Label>
<p className="text-xs text-muted-foreground">
{t(tiltRefused ? 'SETTINGS.TILT_DENIED' : 'SETTINGS.TILT_HINT')}
</p>
</div>
<Switch id="tilt" checked={tilt} disabled={appearance.reduceMotion}
onCheckedChange={changeTilt}/>
</div>
)}
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label> <Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
+48 -4
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import AppearanceCard from './AppearanceCard.jsx'; import AppearanceCard from './AppearanceCard.jsx';
const preferences = vi.hoisted(() => ({value: null})); const preferences = vi.hoisted(() => ({value: null}));
@@ -8,15 +8,19 @@ vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../context/PreferencesContext.jsx', () => ({usePreferences: () => preferences.value})); vi.mock('../context/PreferencesContext.jsx', () => ({usePreferences: () => preferences.value}));
vi.mock('../hooks/useSaveAppearance.js', () => ({default: () => vi.fn()})); vi.mock('../hooks/useSaveAppearance.js', () => ({default: () => vi.fn()}));
const show = (equipped) => { const show = (equipped, reduceMotion = false) => {
preferences.value = { preferences.value = {
appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion: false, showBanner: true}, appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion, showBanner: true},
equipped, equipped,
}; };
render(<AppearanceCard/>); render(<AppearanceCard/>);
}; };
afterEach(cleanup); afterEach(() => {
cleanup();
localStorage.clear();
delete window.DeviceOrientationEvent;
});
describe('AppearanceCard', () => { describe('AppearanceCard', () => {
it('tells that the skin sets colors and corners only while a skin is worn', () => { it('tells that the skin sets colors and corners only while a skin is worn', () => {
@@ -28,4 +32,44 @@ describe('AppearanceCard', () => {
show({}); show({});
expect(screen.queryByText('SETTINGS.SKIN_OVERRIDES')).toBeNull(); expect(screen.queryByText('SETTINGS.SKIN_OVERRIDES')).toBeNull();
}); });
describe('tilt effects', () => {
const stubPermission = (answer) => {
window.DeviceOrientationEvent = {requestPermission: vi.fn().mockResolvedValue(answer)};
return window.DeviceOrientationEvent.requestPermission;
};
it('has no tilt switch where the browser needs no permission', () => {
show({});
expect(document.querySelector('#tilt')).toBeNull();
});
it('asks for permission on the tap, then shows the switch on and stores it', async () => {
const request = stubPermission('granted');
show({});
fireEvent.click(document.querySelector('#tilt'));
expect(request).toHaveBeenCalledTimes(1);
await waitFor(() => expect(document.querySelector('#tilt').getAttribute('aria-checked')).toBe('true'));
expect(localStorage.getItem('tbp_tilt')).toBe('true');
});
it('stays off and tells why when the permission is refused', async () => {
stubPermission('denied');
show({});
fireEvent.click(document.querySelector('#tilt'));
await waitFor(() => expect(screen.getByText('SETTINGS.TILT_DENIED')).toBeTruthy());
expect(document.querySelector('#tilt').getAttribute('aria-checked')).toBe('false');
});
it('is disabled while animations are reduced', () => {
stubPermission('granted');
show({}, true);
expect(document.querySelector('#tilt').disabled).toBe(true);
});
});
}); });
+179 -37
View File
@@ -1,18 +1,34 @@
import {cn} from '@/lib/utils'; import {useEffect, useId, useRef} from 'react';
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh', 'equalizer']; import {cn} from '@/lib/utils';
import useReducedMotion from '../hooks/useReducedMotion.js';
import {watchParallax} from '../utils/parallax.js';
import EffectCanvas from './EffectCanvas.jsx';
import {LOGO_COLORS} from './Logo.jsx';
export const EFFECTS = [
'triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh', 'equalizer',
'synthwave', 'fireflies', 'snow', 'sakura', 'nebula', 'matrix', 'constellation',
];
const range = (count) => Array.from({length: count}, (_, i) => i); const range = (count) => Array.from({length: count}, (_, i) => i);
// Three groups of eight triangles on a 1600x900 canvas; a fixed pattern, so the picture is the same on every visit. // Four layers of triangles on a 1600x900 canvas: far and small, mid, near and big, and a wireframe. A fixed
const TRIANGLES = range(3).map((group) => range(8).map((i) => { // pattern, so the picture is the same on every visit.
const TRIANGLE_LAYERS = [
{count: 8, min: 40, span: 30, opacity: 0.05, step: 0.01},
{count: 8, min: 80, span: 50, opacity: 0.08, step: 0.02},
{count: 6, min: 140, span: 80, opacity: 0.12, step: 0.03},
{count: 5, min: 90, span: 70, opacity: 0.65, step: 0},
];
const TRIANGLES = TRIANGLE_LAYERS.map(({count, min, span, opacity, step}, group) => range(count).map((i) => {
const x = (i * 211 + group * 97) % 1600; const x = (i * 211 + group * 97) % 1600;
const y = (i * 137 + group * 263) % 900; const y = (i * 137 + group * 263) % 900;
const size = 80 + ((i * 53 + group * 29) % 90); const size = min + ((i * 53 + group * 29) % span);
return { return {
points: `${x},${y - size} ${x - size * 0.87},${y + size / 2} ${x + size * 0.87},${y + size / 2}`, points: `${x},${y - size} ${x - size * 0.87},${y + size / 2} ${x + size * 0.87},${y + size / 2}`,
rotate: `rotate(${i * 40} ${x} ${y})`, rotate: `rotate(${i * 40} ${x} ${y})`,
opacity: 0.06 + ((i + group) % 4) * 0.04, opacity: opacity + (i % 4) * step,
}; };
})); }));
@@ -20,67 +36,193 @@ const TRIANGLES = range(3).map((group) => range(8).map((i) => {
const WAVE = 'M0 100 C200 30 600 30 800 100 C1000 170 1400 170 1600 100 C1800 30 2200 30 2400 100 ' const WAVE = 'M0 100 C200 30 600 30 800 100 C1000 170 1400 170 1600 100 C1800 30 2200 30 2400 100 '
+ 'C2600 170 3000 170 3200 100 L3200 200 L0 200 Z'; + 'C2600 170 3000 170 3200 100 L3200 200 L0 200 Z';
// Glint spots per engine: [x %, y %, delay s].
const GLINTS = {
triangles: [[18, 20, 0], [44, 60, -0.8], [72, 28, -1.6], [88, 70, -2.4]],
starfield: [[12, 20, 0], [33, 62, -0.4], [51, 14, -0.8], [68, 48, -1.2], [84, 26, -1.6], [92, 78, -2], [22, 84, -2.4], [60, 90, -2.8]],
pixels: [[20, 30, 0], [55, 60, -1], [82, 22, -2]],
mesh: [[22, 25, 0], [75, 35, -1], [48, 75, -2]],
grid: [[10, 8, 0], [25, 28, -0.5], [42, 14, -1], [58, 30, -1.5], [76, 10, -2], [90, 26, -2.5]],
waves: [[14, 68, 0], [32, 64, -0.6], [50, 70, -1.2], [68, 64, -1.8], [86, 68, -2.4]],
bokeh: [[16, 30, 0], [52, 70, -1], [84, 24, -2]],
synthwave: [[10, 8, 0], [28, 22, -0.6], [64, 12, -1.2], [80, 28, -1.8], [92, 10, -2.4]],
nebula: [[18, 22, 0], [78, 18, -0.9], [24, 78, -1.8], [86, 72, -2.7]],
};
// [smallest, spread] of a bokeh circle in cqmin, per tier: far, mid, near.
const BOKEH_SIZES = [[6, 7], [12, 13], [26, 15]];
const [violet, blue, teal] = LOGO_COLORS.map(([name, hex]) => `var(${name}, ${hex})`);
const GLINT_COLORS = [violet, blue, teal, 'var(--primary)'];
const COLORS = ['var(--primary)', teal, violet, blue];
const color = (i) => COLORS[(i + Math.floor(i / 4)) % 4];
const glints = (effect, colored = false) => GLINTS[effect].map(([x, y, d], i) => (
<span key={`g${i}`} className="fx-glint"
style={{'--x': `${x}%`, '--y': `${y}%`, '--d': `${d}s`, ...(colored && {'--glint': GLINT_COLORS[i % 4]})}}/>
));
/** /**
* The markup of an animated page or banner background (CSS in effects.css). Fills its parent: pass `absolute inset-0` * The markup of an animated page or banner background (CSS in effects.css). Fills its parent: pass `absolute inset-0`
* or `fixed inset-0`. Nothing here is read by a screen reader. * or `fixed inset-0`. Nothing here is read by a screen reader. Its layers follow the mouse and the phone tilt unless
* `parallax` is false or motion is reduced.
* *
* @param effect one of EFFECTS; anything else renders nothing * @param effect one of EFFECTS; anything else renders nothing
*/ */
export default function EffectBackground({effect, className}) { export default function EffectBackground({effect, className, parallax = true}) {
const ref = useRef(null);
const reduce = useReducedMotion();
const gradientId = useId().replace(/:/g, '');
useEffect(() => (parallax && !reduce && ref.current ? watchParallax(ref.current) : undefined), [parallax, reduce]);
if (!EFFECTS.includes(effect)) return null; if (!EFFECTS.includes(effect)) return null;
return ( return (
<div aria-hidden="true" data-effect={effect} <div ref={ref} aria-hidden="true" data-effect={effect}
className={cn('fx fx-bg pointer-events-none overflow-hidden', `fx-bg-${effect}`, className)}> className={cn('fx fx-bg pointer-events-none overflow-hidden', `fx-bg-${effect}`, className)}>
{shapes(effect)} {shapes(effect, gradientId)}
</div> </div>
); );
} }
function shapes(effect) { // Wanderers (fireflies) and fallers (snow, sakura) are <span><i/></span>; every value comes from (i * prime) % n.
const wanderer = (i) => (
<span key={i} style={{
'--x': (i * 29 + 7) % 90, '--y': (i * 41 + 12) % 80, '--c': color(i),
'--dur': `${7 + ((i * 3) % 7)}s`, '--dur2': `${5 + ((i * 5) % 7)}s`, '--blink': `${1.8 + ((i * 7) % 12) / 5}s`,
'--delay': `${-i * 1.7}s`,
}}><i/></span>
);
const faller = (slow) => (i) => (
<span key={i} style={{
'--x': (i * 37 + 5) % 97, '--y': (i * 53 + 7) % 100,
'--dur': `${[26, 18, 12][i % 3] * slow + (i % 4)}s`, '--sway': `${3 + ((i * 5) % 4)}s`,
'--delay': `${-((i * 7.3) % 26)}s`,
}}><i/></span>
);
function shapes(effect, gradientId) {
switch (effect) { switch (effect) {
case 'triangles': case 'triangles':
return ( return (
<svg className="size-full" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice"> <>
{TRIANGLES.map((triangles, group) => ( {TRIANGLES.map((triangles, layer) => (
<g key={group} fill="currentColor" className={group === 1 ? 'text-foreground' : undefined}> <svg key={layer} className="fx-bleed" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
{triangles.map((triangle) => ( {triangles.map((triangle) => (
<polygon key={triangle.points} points={triangle.points} transform={triangle.rotate} <polygon key={triangle.points} points={triangle.points} transform={triangle.rotate}
opacity={triangle.opacity}/> opacity={triangle.opacity}/>
))} ))}
</g> </svg>
))} ))}
</svg> {glints(effect)}
</>
); );
case 'starfield': case 'starfield':
return range(9).map((i) => <span key={i}/>); return (
<>
{range(3).map((i) => <span key={i} className="fx-bleed fx-tile"/>)}
{glints(effect, true)}
<span className="fx-shoot"/>
</>
);
case 'pixels': case 'pixels':
return range(12).map((i) => ( return (
<span key={i} style={{ <>
'--x': (i * 37) % 100, '--y': (i * 53 + 11) % 90, {range(18).map((i) => (
'--dur': `${25 + ((i * 7) % 16)}s`, '--delay': `${-i * 3.1}s`, <span key={i} className="fx-px" style={{
}}/> '--x': (i * 37) % 100, '--y': (i * 53 + 11) % 90,
)); '--dur': `${[40, 30, 20][i % 3] + ((i * 7) % 5)}s`, '--delay': `${-i * 3.1}s`,
}}/>
))}
{glints(effect)}
</>
);
case 'aurora': case 'aurora':
return range(2).map((i) => <div key={i}/>); return (
<>
<span className="fx-bleed fx-tile"/>
{range(3).map((i) => <div key={i} className="fx-ribbon"><div/></div>)}
</>
);
case 'mesh': case 'mesh':
return range(4).map((i) => <div key={i}/>); return (
<>
{range(5).map((i) => <div key={i} className="fx-blob"/>)}
{glints(effect)}
</>
);
case 'grid': case 'grid':
return range(2).map((i) => <div key={i}/>); return (
<>
<div className="fx-sky"/>
<div className="fx-floor"/>
<div className="fx-horizon"/>
{glints(effect)}
</>
);
case 'waves': case 'waves':
return range(3).map((i) => ( return (
<svg key={i} viewBox="0 0 3200 200" preserveAspectRatio="none"> <>
<path d={WAVE} fill="currentColor"/> {range(4).map((i) => (
</svg> <div key={i} className="fx-band">
)); <svg viewBox="0 0 3200 200" preserveAspectRatio="none">
<defs>
<linearGradient id={`${gradientId}w${i}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity="0.5"/>
<stop offset="1" stopColor="currentColor" stopOpacity="0.2"/>
</linearGradient>
</defs>
<path d={WAVE} fill={`url(#${gradientId}w${i})`}/>
</svg>
</div>
))}
{glints(effect)}
</>
);
case 'equalizer': case 'equalizer':
return range(16).map((i) => <span key={i}/>); return range(16).map((i) => <span key={i}/>);
case 'bokeh': case 'bokeh':
return range(10).map((i) => ( return (
<span key={i} style={{ <>
'--x': (i * 29 + 7) % 90, '--y': (i * 47 + 10) % 90, '--s': 8 + ((i * 13) % 33), {range(16).map((i) => (
'--dur': `${30 + ((i * 5) % 21)}s`, '--delay': `${-i * 4.3}s`, <span key={i} className={cn('fx-bk', i % 4 === 3 && 'fx-ring')} style={{
}}/> '--x': (i * 29 + 7) % 90, '--y': (i * 47 + 10) % 90,
)); '--s': BOKEH_SIZES[i % 3][0] + ((i * 13) % BOKEH_SIZES[i % 3][1]),
'--c': color(i),
'--dur': `${30 + ((i * 5) % 21)}s`, '--delay': `${-i * 4.3}s`,
}}/>
))}
{glints(effect)}
</>
);
case 'synthwave':
return (
<>
<div className="fx-sky"/>
<div className="fx-halo"/>
<div className="fx-sun"/>
<div className="fx-floor"/>
{glints(effect)}
</>
);
case 'fireflies':
return range(14).map(wanderer);
case 'snow':
return range(24).map(faller(1));
case 'sakura':
return range(24).map(faller(1.25));
case 'nebula':
return (
<>
<span className="fx-bleed fx-tile"/>
<div className="fx-disc"/>
<div className="fx-disc"/>
<div className="fx-core"/>
{glints(effect)}
</>
);
case 'matrix':
case 'constellation':
return <EffectCanvas effect={effect}/>;
} }
} }
+64 -4
View File
@@ -1,9 +1,19 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render} from '@testing-library/react'; import {act, cleanup, render} from '@testing-library/react';
import EffectBackground, {EFFECTS} from './EffectBackground.jsx'; import EffectBackground, {EFFECTS} from './EffectBackground.jsx';
afterEach(cleanup); // jsdom has no canvas: the canvas engines render, draw nothing.
beforeEach(() => vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.documentElement.removeAttribute('data-reduce-motion');
});
const pointerListeners = (spy) => spy.mock.calls.filter(([type]) => type === 'pointermove');
describe('EffectBackground', () => { describe('EffectBackground', () => {
it.each(EFFECTS)('renders %s hidden from screen readers with its engine class and shapes', (effect) => { it.each(EFFECTS)('renders %s hidden from screen readers with its engine class and shapes', (effect) => {
@@ -19,8 +29,58 @@ describe('EffectBackground', () => {
}); });
it('renders nothing for an unknown effect', () => { it('renders nothing for an unknown effect', () => {
const {container} = render(<EffectBackground effect="matrix"/>); const {container} = render(<EffectBackground effect="plasma"/>);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
it('attaches one pointermove listener for the mounted effect and removes it on unmount', () => {
const add = vi.spyOn(window, 'addEventListener');
const remove = vi.spyOn(window, 'removeEventListener');
const {unmount} = render(<EffectBackground effect="snow"/>);
expect(pointerListeners(add)).toHaveLength(1);
unmount();
expect(pointerListeners(remove)).toHaveLength(1);
});
it('attaches nothing with parallax off', () => {
const add = vi.spyOn(window, 'addEventListener');
render(<EffectBackground effect="snow" parallax={false}/>);
expect(pointerListeners(add)).toHaveLength(0);
});
it('attaches nothing while the system asks for reduced motion', () => {
vi.stubGlobal('matchMedia', () => ({matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn()}));
const add = vi.spyOn(window, 'addEventListener');
render(<EffectBackground effect="snow"/>);
expect(pointerListeners(add)).toHaveLength(0);
});
it('detaches when the reduce animations setting turns on while mounted', async () => {
const remove = vi.spyOn(window, 'removeEventListener');
render(<EffectBackground effect="snow"/>);
await act(async () => {
document.documentElement.setAttribute('data-reduce-motion', '');
await Promise.resolve();
});
expect(pointerListeners(remove)).toHaveLength(1);
});
// REVIEW-FINDING: COLORS/GLINT_COLORS pass var(--logo-*) without a fallback as inline --c/--glint; with the default
// violet accent no --logo-* is set, so 12/16 bokeh circles, 10/14 fireflies and 6/8 starfield glints render nothing.
it.each(['bokeh', 'fireflies', 'starfield'])('reviewFinding_%sInlineLogoColorsHaveAFallback', (effect) => {
const {container} = render(<EffectBackground effect={effect}/>);
const colors = [...container.querySelectorAll('[style]')]
.flatMap((el) => [el.style.getPropertyValue('--c'), el.style.getPropertyValue('--glint')])
.filter((value) => value.includes('--logo-'));
expect(colors.length).toBeGreaterThan(0);
expect(colors.filter((value) => !/var\(--logo-[a-z]+,\s*#/.test(value))).toEqual([]);
});
}); });
+75
View File
@@ -0,0 +1,75 @@
import {useEffect, useRef} from 'react';
import useInView from '../hooks/useInView.js';
import useReducedMotion from '../hooks/useReducedMotion.js';
import {CANVAS_EFFECTS, readPalette, seededRandom} from '../utils/canvasEffects.js';
/** Canvas background engine. Animates only on screen and with motion allowed, else shows one static frame. */
export default function EffectCanvas({effect}) {
const ref = useRef(null);
const reduce = useReducedMotion();
const inView = useInView(ref, true);
// Setup runs per effect only; the simulation state lives in a ref so scrolling or a motion change keeps it.
const run = useRef(null);
useEffect(() => {
const canvas = ref.current;
const context = canvas.getContext?.('2d');
const engine = CANVAS_EFFECTS[effect];
if (!context || !engine) return undefined;
const random = seededRandom(7);
const current = {engine, random, state: undefined, palette: readPalette(canvas)};
const paint = () => engine.draw(context, current.state, current.palette);
current.paint = paint;
run.current = current;
const size = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
current.state = engine.create(width, height, random);
paint();
};
size();
const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(size);
resizeObserver?.observe(canvas);
const themeObserver = new MutationObserver(() => {
current.palette = readPalette(canvas);
paint();
});
themeObserver.observe(document.documentElement, {
attributes: true, attributeFilter: ['class', 'data-accent', 'data-skin'],
});
return () => {
run.current = null;
resizeObserver?.disconnect();
themeObserver.disconnect();
};
}, [effect]);
useEffect(() => {
const current = run.current;
if (!current || reduce || !inView) return undefined;
const {engine, random, paint} = current;
let handle = 0;
let last = performance.now();
const frame = (now) => {
if (now - last >= 1000 / engine.fps) {
engine.step(current.state, Math.min((now - last) / 1000, 0.1), random);
paint();
last = now;
}
handle = requestAnimationFrame(frame);
};
handle = requestAnimationFrame(frame);
return () => cancelAnimationFrame(handle);
}, [effect, reduce, inView]);
return <canvas ref={ref} className="fx-bleed" style={{'--depth': 0.5}}/>;
}
+78
View File
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react';
import EffectCanvas from './EffectCanvas.jsx';
const context = new Proxy({}, {get: (target, name) => (target[name] ??= vi.fn()), set: () => true});
let callbacks;
let raf;
let cancel;
beforeEach(() => {
callbacks = [];
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
raf = vi.fn(() => 1);
cancel = vi.fn();
vi.stubGlobal('requestAnimationFrame', raf);
vi.stubGlobal('cancelAnimationFrame', cancel);
vi.stubGlobal('IntersectionObserver', class {
constructor(callback) { callbacks.push(callback); }
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
for (const name of Object.keys(context)) delete context[name];
});
const reduced = () => vi.stubGlobal('matchMedia', () => ({matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn()}));
describe('EffectCanvas', () => {
it('requests animation frames while motion is allowed', () => {
render(<EffectCanvas effect="constellation"/>);
expect(raf).toHaveBeenCalledTimes(1);
expect(context.clearRect).toHaveBeenCalled();
});
it('draws one static frame and runs no loop under reduced motion', () => {
reduced();
render(<EffectCanvas effect="matrix"/>);
expect(context.clearRect).toHaveBeenCalled();
expect(raf).not.toHaveBeenCalled();
});
it('stops the loop when the canvas leaves the screen', () => {
const {container} = render(<EffectCanvas effect="matrix"/>);
expect(raf).toHaveBeenCalledTimes(1);
act(() => callbacks[0]([{target: container.querySelector('canvas'), isIntersecting: false}]));
expect(cancel).toHaveBeenCalled();
expect(raf).toHaveBeenCalledTimes(1);
});
it('cancels the loop on unmount', () => {
const {unmount} = render(<EffectCanvas effect="matrix"/>);
cancel.mockClear();
unmount();
expect(cancel).toHaveBeenCalled();
});
it('renders the canvas without a 2d context', () => {
HTMLCanvasElement.prototype.getContext.mockReturnValue(null);
const {container} = render(<EffectCanvas effect="matrix"/>);
expect(container.querySelector('canvas')).not.toBeNull();
expect(raf).not.toHaveBeenCalled();
});
});
+405 -162
View File
@@ -3,6 +3,8 @@
* Every effect looks good at frame 0, because that is the static picture: no animation, no motion. * Every effect looks good at frame 0, because that is the static picture: no animation, no motion.
* Colors come from --primary, --foreground and --logo-*, so an effect follows the accent and works in both modes. * Colors come from --primary, --foreground and --logo-*, so an effect follows the accent and works in both modes.
* Backgrounds are a size container, so cq units size and move the shapes in a page and in a banner alike. * Backgrounds are a size container, so cq units size and move the shapes in a page and in a banner alike.
* Parallax: utils/parallax.js sets --px/--py on the root and every child moves by its own --depth (0..1) with the
* `translate` property, which stacks with the keyframe `transform`. Never set `translate` on a child of .fx-bg.
*/ */
@keyframes fx-sweep { 0% { transform: translateX(-100%); } 35%, 100% { transform: translateX(100%); } } @keyframes fx-sweep { 0% { transform: translateX(-100%); } 35%, 100% { transform: translateX(100%); } }
@@ -60,9 +62,9 @@
mix-blend-mode: overlay; mix-blend-mode: overlay;
background: linear-gradient(120deg, background: linear-gradient(120deg,
transparent 30%, transparent 30%,
color-mix(in oklab, var(--logo-teal) 45%, transparent) 40%, color-mix(in oklab, var(--logo-teal, #17c7a4) 45%, transparent) 40%,
color-mix(in oklab, var(--primary) 45%, transparent) 50%, color-mix(in oklab, var(--primary) 45%, transparent) 50%,
color-mix(in oklab, var(--logo-violet) 45%, transparent) 60%, color-mix(in oklab, var(--logo-violet, #7467f9) 45%, transparent) 60%,
transparent 70%); transparent 70%);
animation: fx-slide 6s ease-in-out infinite; animation: fx-slide 6s ease-in-out infinite;
} }
@@ -142,9 +144,9 @@
height: 35%; height: 35%;
background: linear-gradient(90deg, background: linear-gradient(90deg,
transparent, transparent,
color-mix(in oklab, var(--logo-teal) 18%, transparent), color-mix(in oklab, var(--logo-teal, #17c7a4) 18%, transparent),
color-mix(in oklab, var(--primary) 18%, transparent), color-mix(in oklab, var(--primary) 18%, transparent),
color-mix(in oklab, var(--logo-violet) 18%, transparent), color-mix(in oklab, var(--logo-violet, #7467f9) 18%, transparent),
transparent); transparent);
animation: fx-breathe 6s ease-in-out infinite alternate; animation: fx-breathe 6s ease-in-out infinite alternate;
} }
@@ -166,200 +168,437 @@
/* ---- PAGE_BACKGROUND and BANNER_BACKGROUND (EffectBackground) ---- */ /* ---- PAGE_BACKGROUND and BANNER_BACKGROUND (EffectBackground) ---- */
.fx-bg { container-type: size; } .fx-bg { container-type: size; --shift: 2cqmax; --glint: color-mix(in oklab, var(--primary) 70%, white); }
.fx-bg > * { position: absolute; } .dark .fx-bg { --glint: white; }
.fx-bg > * {
/* triangles: three groups of triangles drifting apart */ position: absolute;
@keyframes fx-tri-a { to { transform: translate(60px, -40px) rotate(5deg); } } translate: calc(var(--px, 0) * var(--depth, 0) * var(--shift)) calc(var(--py, 0) * var(--depth, 0) * var(--shift));
@keyframes fx-tri-b { to { transform: translate(-70px, 30px) rotate(-6deg); } }
@keyframes fx-tri-c { to { transform: translate(40px, 50px) rotate(4deg); } }
.fx-bg-triangles { color: var(--primary); }
.fx-bg-triangles g { transform-box: fill-box; transform-origin: center; }
.fx-bg-triangles g:nth-child(1) { animation: fx-tri-a 45s ease-in-out infinite alternate; }
.fx-bg-triangles g:nth-child(2) { animation: fx-tri-b 58s ease-in-out infinite alternate; }
.fx-bg-triangles g:nth-child(3) { animation: fx-tri-c 70s ease-in-out infinite alternate; }
/* starfield: three tiled layers (a tile of --t px moves up by one tile, so the loop is seamless) and 6 twinkles */
@keyframes fx-drift { to { transform: translateY(calc(var(--t) * -1)); } }
.fx-bg-starfield { --star: color-mix(in oklab, var(--foreground) 70%, transparent); }
.fx-bg-starfield > :nth-child(-n+3) {
inset: 0;
bottom: calc(var(--t) * -1);
background-size: var(--t) var(--t);
will-change: transform;
animation: fx-drift linear infinite;
} }
.fx-bg-starfield > :nth-child(1) { /* A full-bleed layer that moves gets this, so its edge never shows. */
.fx-bg > .fx-bleed { inset: calc(var(--shift) * var(--bleed, 1) * -1); }
.fx-bg > svg.fx-bleed, .fx-bg > canvas {
width: calc(100% + 2 * var(--shift) * var(--bleed, 1));
height: calc(100% + 2 * var(--shift) * var(--bleed, 1));
}
.fx-bg > .fx-glint {
--depth: 0.6;
left: var(--x);
top: var(--y);
width: var(--s, 10px);
height: var(--s, 10px);
background: var(--glint);
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
transform: scale(0.6);
animation: fx-twinkle 3.2s ease-in-out infinite;
animation-delay: var(--d);
}
/* Star tiles (a tile of --t px moves up by one tile, so the loop is seamless): starfield, aurora and nebula. */
@keyframes fx-drift { to { transform: translateY(calc(var(--t) * -1)); } }
.fx-bg > .fx-tile {
bottom: calc((var(--shift) + var(--t)) * -1);
background-size: var(--t) var(--t);
}
.fx-bg-aurora > .fx-tile, .fx-bg-nebula > .fx-tile, .fx-bg-starfield > .fx-tile:nth-child(1) {
--t: 90px; --t: 90px;
animation-duration: 60s; --depth: 0.1;
--star: color-mix(in oklab, var(--foreground) 50%, transparent);
background-image: background-image:
radial-gradient(circle at 20% 30%, var(--star) 0 1px, transparent 1.5px), radial-gradient(circle at 20% 30%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 70% 80%, var(--star) 0 1px, transparent 1.5px); radial-gradient(circle at 70% 80%, var(--star) 0 1px, transparent 1.5px);
} }
.fx-bg-starfield > :nth-child(2) {
--t: 140px;
animation-duration: 90s;
background-image:
radial-gradient(circle at 15% 65%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 55% 20%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 85% 75%, var(--star) 0 1px, transparent 1.5px);
}
.fx-bg-starfield > :nth-child(3) {
--t: 220px;
animation-duration: 120s;
background-image:
radial-gradient(circle at 30% 40%, var(--star) 0 1.5px, transparent 2px),
radial-gradient(circle at 75% 85%, var(--star) 0 1.5px, transparent 2px);
}
.fx-bg-starfield > :nth-child(n+4) {
left: var(--x);
top: var(--y);
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--star);
box-shadow: 0 0 6px var(--star);
opacity: 0.65;
animation: fx-breathe 3s ease-in-out infinite alternate;
animation-delay: var(--d);
}
.fx-bg-starfield > :nth-child(4) { --x: 12%; --y: 20%; --d: 0s; }
.fx-bg-starfield > :nth-child(5) { --x: 33%; --y: 62%; --d: -0.6s; }
.fx-bg-starfield > :nth-child(6) { --x: 51%; --y: 14%; --d: -1.2s; }
.fx-bg-starfield > :nth-child(7) { --x: 68%; --y: 48%; --d: -1.8s; }
.fx-bg-starfield > :nth-child(8) { --x: 84%; --y: 26%; --d: -2.4s; }
.fx-bg-starfield > :nth-child(9) { --x: 92%; --y: 78%; --d: -0.3s; }
/* pixels: squares rise and turn; fade in at the base spot, out at the top, so the loop shows no jump */ /* A perspective floor moving one cell (40px) toward the viewer: grid and synthwave. */
@keyframes fx-float {
0% { transform: translateY(0) rotate(0); opacity: 0; }
10%, 90% { opacity: 0.8; }
100% { transform: translateY(calc(var(--y) * -1cqh - 10px)) rotate(180deg); opacity: 0; }
}
.fx-bg-pixels > * {
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: 4px;
height: 4px;
opacity: 0.8;
background: var(--primary);
animation: fx-float var(--dur) linear infinite;
animation-delay: var(--delay);
}
.fx-bg-pixels > :nth-child(4n+2) { background: var(--logo-violet); }
.fx-bg-pixels > :nth-child(4n+3) { background: var(--logo-blue); }
.fx-bg-pixels > :nth-child(4n) { background: var(--logo-teal); }
/* aurora: two soft ribbons waving across the top (elliptic gradients have no hard edge) */
@keyframes fx-wave-a {
from { transform: rotate(-8deg) translateX(-10%) scaleY(1); }
to { transform: rotate(-8deg) translateX(10%) scaleY(1.3); }
}
.fx-bg-aurora > * {
left: -30%;
width: 160%;
height: 40%;
transform: rotate(-8deg);
will-change: transform;
animation: fx-wave-a 20s ease-in-out infinite alternate;
}
.fx-bg-aurora > :nth-child(1) {
top: -5%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 28%, transparent), transparent);
}
.fx-bg-aurora > :nth-child(2) {
top: 12%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 24%, transparent), transparent);
animation-duration: 26s;
animation-direction: alternate-reverse;
}
/* mesh: four big blobs in the corners; the gradient edge is the blur, a filter would cost every frame */
@keyframes fx-blob {
from { transform: translate(-8%, -6%) scale(0.9); }
to { transform: translate(8%, 6%) scale(1.1); }
}
.fx-bg-mesh > * {
width: 60cqmax;
height: 60cqmax;
will-change: transform;
animation: fx-blob 25s ease-in-out infinite alternate;
}
.fx-bg-mesh > :nth-child(1) {
left: -15%; top: -20%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 30%, transparent), transparent);
}
.fx-bg-mesh > :nth-child(2) {
right: -15%; top: -10%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 26%, transparent), transparent);
animation-duration: 28s;
}
.fx-bg-mesh > :nth-child(3) {
left: -10%; bottom: -25%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-violet) 26%, transparent), transparent);
animation-duration: 32s;
}
.fx-bg-mesh > :nth-child(4) {
right: -20%; bottom: -20%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 22%, transparent), transparent);
animation-duration: 35s;
}
/* grid: a perspective floor moving one cell (40px) toward the viewer; the height keeps all points in front of the camera */
@keyframes fx-floor { @keyframes fx-floor {
from { transform: perspective(600px) rotateX(62deg) translateY(0); } from { transform: perspective(600px) rotateX(62deg) translateY(0); }
to { transform: perspective(600px) rotateX(62deg) translateY(40px); } to { transform: perspective(600px) rotateX(62deg) translateY(40px); }
} }
.fx-bg-grid > :nth-child(1) { .fx-bg > .fx-floor {
inset: 0 0 auto;
height: 45%;
background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--primary) 25%, transparent));
}
.fx-bg-grid > :nth-child(2) {
left: -100%; left: -100%;
top: 45%;
width: 300%; width: 300%;
height: 500px; height: 500px;
transform-origin: top center; transform-origin: top center;
transform: perspective(600px) rotateX(62deg); transform: perspective(600px) rotateX(62deg);
background-image: background-image:
repeating-linear-gradient(0deg, color-mix(in oklab, var(--primary) 35%, transparent) 0 1px, transparent 1px 40px), repeating-linear-gradient(0deg, color-mix(in oklab, var(--logo-violet, #7467f9) 40%, transparent) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, color-mix(in oklab, var(--primary) 35%, transparent) 0 1px, transparent 1px 40px); repeating-linear-gradient(90deg, color-mix(in oklab, var(--primary) 40%, transparent) 0 1px, transparent 1px 40px);
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 40%);
mask-image: linear-gradient(to bottom, transparent, #000 40%);
will-change: transform; will-change: transform;
animation: fx-floor 2s linear infinite; animation: fx-floor 2s linear infinite;
} }
/* waves: three bands 2 container widths wide (one period = one width), moving one width sideways */ /* triangles: four SVG layers drifting apart (the <svg> moves, so it is composited; a <g> would repaint) */
@keyframes fx-tri-a { to { transform: translate(2cqw, -1.5cqh) rotate(1deg); } }
@keyframes fx-tri-b { to { transform: translate(-2.5cqw, 1cqh) rotate(-1.5deg); } }
@keyframes fx-tri-c { to { transform: translate(1.5cqw, 2cqh) rotate(1deg); } }
@keyframes fx-tri-d { to { transform: translate(-1.5cqw, -2cqh) rotate(-1deg); } }
.fx-bg-triangles { --bleed: 2.5; color: var(--primary); }
.fx-bg-triangles > svg:nth-child(1) { --depth: 0.15; animation: fx-tri-a 70s ease-in-out infinite alternate; }
.fx-bg-triangles > svg:nth-child(2) { --depth: 0.4; animation: fx-tri-b 58s ease-in-out infinite alternate; }
.fx-bg-triangles > svg:nth-child(3) { --depth: 0.9; animation: fx-tri-c 45s ease-in-out infinite alternate; }
.fx-bg-triangles > svg:nth-child(4) { --depth: 0.65; animation: fx-tri-d 52s ease-in-out infinite alternate; }
.fx-bg-triangles > svg:nth-child(2) { color: var(--foreground); }
.fx-bg-triangles polygon:nth-child(4n+1) { fill: var(--primary); }
.fx-bg-triangles polygon:nth-child(4n+2) { fill: var(--logo-violet, #7467f9); }
.fx-bg-triangles polygon:nth-child(4n+3) { fill: var(--logo-blue, #0192ef); }
.fx-bg-triangles polygon:nth-child(4n) { fill: var(--logo-teal, #17c7a4); }
.fx-bg-triangles > svg:nth-child(2) polygon { fill: var(--foreground); }
.fx-bg-triangles > svg:nth-child(4) polygon { fill: none; stroke: var(--primary); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
/* starfield: three tiled layers, 8 glints and one shooting star */
.fx-bg-starfield > .fx-tile { will-change: transform; animation: fx-drift linear infinite; }
.fx-bg-starfield > .fx-tile:nth-child(1) { --depth: 0.15; animation-duration: 60s; }
.fx-bg-starfield > .fx-tile:nth-child(2) {
--t: 140px;
--depth: 0.45;
--star: color-mix(in oklab, var(--logo-blue, #0192ef) 40%, var(--foreground) 60%);
animation-duration: 90s;
background-image:
radial-gradient(circle at 15% 65%, var(--star) 0 1.5px, transparent 2px),
radial-gradient(circle at 55% 20%, var(--star) 0 1.5px, transparent 2px),
radial-gradient(circle at 85% 75%, var(--star) 0 1.5px, transparent 2px);
}
.fx-bg-starfield > .fx-tile:nth-child(3) {
--t: 220px;
--depth: 0.85;
animation-duration: 120s;
background-image:
radial-gradient(circle at 30% 40%, var(--primary) 0 2.2px, transparent 2.8px),
radial-gradient(circle at 30% 40%, color-mix(in oklab, var(--primary) 30%, transparent) 0 5px, transparent 6px),
radial-gradient(circle at 75% 85%, var(--logo-teal, #17c7a4) 0 2.2px, transparent 2.8px);
}
@keyframes fx-shoot {
0% { transform: rotate(28deg) translateX(0); opacity: 0; }
1.5% { opacity: 1; }
8%, 100% { transform: rotate(28deg) translateX(60cqmax); opacity: 0; }
}
.fx-bg-starfield > .fx-shoot {
--depth: 0.3;
left: 10%;
top: 12%;
width: 120px;
height: 2px;
opacity: 0;
background: linear-gradient(to right, transparent, white);
animation: fx-shoot 9s linear infinite;
}
/* pixels: squares in three tiers rise and turn; fade in at the base spot, out at the top, so the loop shows no jump */
@keyframes fx-float {
0% { transform: translateY(0) rotate(0); opacity: 0; }
10%, 90% { opacity: var(--o); }
100% { transform: translateY(calc(var(--y) * -1cqh - 10px)) rotate(180deg); opacity: 0; }
}
.fx-bg > .fx-px {
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: var(--w);
height: var(--w);
opacity: var(--o);
background: var(--c);
animation: fx-float var(--dur) linear infinite;
animation-delay: var(--delay);
}
.fx-px:nth-child(3n+1) { --w: 2px; --o: 0.5; --depth: 0.2; }
.fx-px:nth-child(3n+2) { --w: 4px; --o: 0.75; --depth: 0.5; }
.fx-px:nth-child(3n) { --w: 7px; --o: 0.95; --depth: 0.9; color: var(--c); box-shadow: 0 0 8px currentColor; }
.fx-px:nth-child(4n+1) { --c: var(--primary); }
.fx-px:nth-child(4n+2) { --c: var(--logo-violet, #7467f9); }
.fx-px:nth-child(4n+3) { --c: var(--logo-blue, #0192ef); }
.fx-px:nth-child(4n) { --c: var(--logo-teal, #17c7a4); }
.fx-px:nth-child(6n) { clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%); box-shadow: none; }
/* aurora: three soft ribbons waving across the top, each with curtain streaks; the wrapper breathes, the ribbon waves */
@keyframes fx-wave-a {
from { transform: rotate(-8deg) translateX(-10%) scaleY(1); }
to { transform: rotate(-8deg) translateX(10%) scaleY(1.3); }
}
.fx-bg-aurora > .fx-ribbon {
left: -30%;
width: 160%;
height: 40%;
animation: fx-breathe var(--b) ease-in-out infinite alternate;
}
.fx-bg-aurora > .fx-ribbon > div {
position: absolute;
inset: 0;
transform: rotate(-8deg);
-webkit-mask-image: repeating-linear-gradient(90deg, #000 0 3px, rgb(0 0 0 / 0.55) 3px 7px);
mask-image: repeating-linear-gradient(90deg, #000 0 3px, rgb(0 0 0 / 0.55) 3px 7px);
will-change: transform;
animation: fx-wave-a 20s ease-in-out infinite alternate;
}
.fx-bg-aurora > .fx-ribbon:nth-child(2) { --b: 7s; --depth: 0.3; top: -5%; }
.fx-bg-aurora > .fx-ribbon:nth-child(3) { --b: 9s; --depth: 0.5; top: 12%; }
.fx-bg-aurora > .fx-ribbon:nth-child(4) { --b: 11s; --depth: 0.7; top: 26%; height: 30%; }
.fx-bg-aurora > .fx-ribbon:nth-child(2) > div {
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 30%, transparent), transparent);
}
.fx-bg-aurora > .fx-ribbon:nth-child(3) > div {
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal, #17c7a4) 26%, transparent), transparent);
animation-duration: 26s;
animation-direction: alternate-reverse;
}
.fx-bg-aurora > .fx-ribbon:nth-child(4) > div {
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-violet, #7467f9) 26%, transparent), transparent);
animation-duration: 32s;
}
/* mesh: five big blobs with two-tone stops; the gradient edge is the blur, a filter would cost every frame */
@keyframes fx-blob {
from { transform: translate(-8%, -6%) scale(0.9); }
to { transform: translate(8%, 6%) scale(1.1); }
}
.fx-bg-mesh > .fx-blob {
width: 60cqmax;
height: 60cqmax;
will-change: transform;
animation: fx-blob 25s ease-in-out infinite alternate;
background: radial-gradient(closest-side,
color-mix(in oklab, var(--a) 30%, transparent), color-mix(in oklab, var(--b) 14%, transparent) 55%, transparent);
}
.fx-bg-mesh > .fx-blob:nth-child(1) { --a: var(--primary); --b: var(--logo-violet, #7467f9); --depth: 0.25; left: -15%; top: -20%; }
.fx-bg-mesh > .fx-blob:nth-child(2) { --a: var(--logo-teal, #17c7a4); --b: var(--logo-blue, #0192ef); --depth: 0.35; right: -15%; top: -10%; animation-duration: 28s; }
.fx-bg-mesh > .fx-blob:nth-child(3) { --a: var(--logo-violet, #7467f9); --b: var(--primary); --depth: 0.45; left: -10%; bottom: -25%; animation-duration: 32s; }
.fx-bg-mesh > .fx-blob:nth-child(4) { --a: var(--logo-blue, #0192ef); --b: var(--logo-teal, #17c7a4); --depth: 0.55; right: -20%; bottom: -20%; animation-duration: 35s; }
.fx-bg-mesh > .fx-blob:nth-child(5) {
--a: var(--primary);
--b: var(--logo-teal, #17c7a4);
--depth: 0.9;
width: 30cqmax;
height: 30cqmax;
left: calc(50% - 15cqmax);
top: calc(50% - 15cqmax);
animation: fx-glow 9s ease-in-out infinite alternate;
}
/* grid: sky, floor, horizon line */
.fx-bg-grid > .fx-sky {
--depth: 0.1;
inset: 0 0 auto;
height: 45%;
background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--primary) 25%, transparent));
}
.fx-bg-grid > .fx-floor { --depth: 0.4; top: 45%; }
.fx-bg-grid > .fx-horizon {
--depth: 0.2;
left: 0;
right: 0;
top: 45%;
height: 2px;
background: var(--logo-teal, #17c7a4);
box-shadow: 0 0 10px 1px var(--logo-teal, #17c7a4);
}
/* waves: four bands 2 container widths wide (one period = one width), sliding one width sideways and bobbing */
@keyframes fx-wave-x { to { transform: translateX(-50%); } } @keyframes fx-wave-x { to { transform: translateX(-50%); } }
.fx-bg-waves > svg { @keyframes fx-bob { from { transform: translateY(-1.5cqh); } to { transform: translateY(1.5cqh); } }
.fx-bg-waves > .fx-band {
left: calc(var(--shift) * -1);
right: calc(var(--shift) * -1);
bottom: calc(var(--shift) * -1);
height: var(--h);
opacity: 0.8;
animation: fx-bob var(--bob) ease-in-out infinite alternate;
}
.fx-bg-waves > .fx-band > svg {
position: absolute;
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 200%; width: 200%;
color: var(--primary); height: 100%;
animation: fx-wave-x linear infinite; animation: fx-wave-x linear infinite;
} }
.fx-bg-waves > :nth-child(1) { height: 25%; opacity: 0.08; animation-duration: 14s; } .fx-bg-waves > .fx-band:nth-child(1) { --h: 25%; --depth: 0.2; --bob: 11s; color: var(--logo-blue, #0192ef); }
.fx-bg-waves > :nth-child(2) { height: 32%; opacity: 0.12; animation-duration: 20s; animation-direction: reverse; } .fx-bg-waves > .fx-band:nth-child(2) { --h: 32%; --depth: 0.4; --bob: 9s; color: var(--logo-teal, #17c7a4); }
.fx-bg-waves > :nth-child(3) { height: 40%; opacity: 0.18; animation-duration: 28s; } .fx-bg-waves > .fx-band:nth-child(3) { --h: 38%; --depth: 0.6; --bob: 13s; color: var(--logo-violet, #7467f9); }
.fx-bg-waves > .fx-band:nth-child(4) { --h: 44%; --depth: 0.9; --bob: 8s; color: var(--primary); }
.fx-bg-waves > .fx-band:nth-child(1) > svg { animation-duration: 14s; }
.fx-bg-waves > .fx-band:nth-child(2) > svg { animation-duration: 20s; animation-direction: reverse; }
.fx-bg-waves > .fx-band:nth-child(3) > svg { animation-duration: 24s; }
.fx-bg-waves > .fx-band:nth-child(4) > svg { animation-duration: 28s; animation-direction: reverse; }
/* bokeh: soft circles drifting up */ /* bokeh: soft circles and rings in three tiers drifting up (--c color, --a alpha) */
@keyframes fx-bokeh { @keyframes fx-bokeh {
0% { transform: translateY(8cqh); opacity: 0; } 0% { transform: translateY(8cqh); opacity: 0; }
50% { opacity: 1; } 50% { opacity: 1; }
100% { transform: translateY(-20cqh); opacity: 0; } 100% { transform: translateY(-20cqh); opacity: 0; }
} }
.fx-bg-bokeh > * { .fx-bg > .fx-bk {
left: calc(var(--x) * 1%); left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%); top: calc(var(--y) * 1%);
width: calc(var(--s) * 1cqmin); width: calc(var(--s) * 1cqmin);
height: calc(var(--s) * 1cqmin); height: calc(var(--s) * 1cqmin);
opacity: 0.6; opacity: 0.8;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 25%, transparent), transparent); background: radial-gradient(closest-side, color-mix(in oklab, var(--c) var(--a), transparent), transparent);
animation: fx-bokeh var(--dur) ease-in-out infinite; animation: fx-bokeh var(--dur) ease-in-out infinite;
animation-delay: var(--delay); animation-delay: var(--delay);
} }
.fx-bg-bokeh > :nth-child(even) { .fx-bk:nth-child(3n+1) { --a: 18%; --depth: 0.2; }
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent); .fx-bk:nth-child(3n+2) { --a: 28%; --depth: 0.5; }
.fx-bk:nth-child(3n) { --a: 38%; --depth: 0.9; }
.fx-bk.fx-ring {
background: radial-gradient(closest-side,
color-mix(in oklab, var(--c) 12%, transparent) 62%, color-mix(in oklab, var(--c) 45%, transparent) 90%, transparent);
}
/* synthwave: striped sun on the horizon, sky glow, scrolling floor */
.fx-bg-synthwave > .fx-sky {
--depth: 0.1;
inset: 0;
background: linear-gradient(to bottom, transparent,
color-mix(in oklab, var(--logo-violet, #7467f9) 30%, transparent) 60%, color-mix(in oklab, var(--primary) 35%, transparent));
}
.fx-bg-synthwave > .fx-halo {
--depth: 0.25;
left: calc(50% - 35cqmin);
top: calc(45% - 35cqmin);
width: 70cqmin;
height: 70cqmin;
background: radial-gradient(closest-side, rgb(251 146 60 / 0.35), transparent);
animation: fx-breathe 6s ease-in-out infinite alternate;
}
.fx-bg-synthwave > .fx-sun {
--depth: 0.25;
left: calc(50% - 19cqmin);
top: calc(45% - 38cqmin);
width: 38cqmin;
height: 38cqmin;
border-radius: 50%;
background: linear-gradient(#fde047, #fb923c 45%, var(--primary));
-webkit-mask-image: linear-gradient(#000 52%, transparent 52% 56%, #000 56% 64%, transparent 64% 69%, #000 69% 76%, transparent 76% 82%, #000 82%);
mask-image: linear-gradient(#000 52%, transparent 52% 56%, #000 56% 64%, transparent 64% 69%, #000 69% 76%, transparent 76% 82%, #000 82%);
animation: fx-sun 7s ease-in-out infinite alternate;
}
@keyframes fx-sun { from { transform: scale(1); } to { transform: scale(1.03); } }
.fx-bg-synthwave > .fx-floor { --depth: 0.5; top: 45%; }
html:not(.dark) .fx-bg-synthwave > .fx-sky {
background: linear-gradient(to bottom, transparent,
color-mix(in oklab, var(--logo-violet, #7467f9) 15%, transparent) 60%, color-mix(in oklab, var(--primary) 18%, transparent));
}
/* fireflies: the outer span wanders sideways, the inner one up and down, so the path is a curve; the halo blinks */
@keyframes fx-wander-x { from { transform: translateX(-6cqw); } to { transform: translateX(6cqw); } }
@keyframes fx-wander-y { from { transform: translateY(-5cqh); } to { transform: translateY(5cqh); } }
@keyframes fx-blink { from { opacity: 0.25; } to { opacity: 1; } }
.fx-bg-fireflies > span {
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: 0;
height: 0;
animation: fx-wander-x var(--dur) ease-in-out infinite alternate;
animation-delay: var(--delay);
}
.fx-bg-fireflies > span > i {
position: absolute;
left: -2px;
top: -2px;
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in oklab, var(--c) 70%, white);
animation: fx-wander-y var(--dur2) ease-in-out infinite alternate;
animation-delay: var(--delay);
}
.fx-bg-fireflies > span > i::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: var(--h);
height: var(--h);
margin: calc(var(--h) / -2) 0 0 calc(var(--h) / -2);
opacity: 0.8;
background: radial-gradient(closest-side, var(--c) 20%, color-mix(in oklab, var(--c) 35%, transparent) 45%, transparent);
animation: fx-blink var(--blink) ease-in-out infinite alternate;
animation-delay: var(--delay);
}
.fx-bg-fireflies > span:nth-child(3n+1) { --h: 10px; --depth: 0.3; }
.fx-bg-fireflies > span:nth-child(3n+2) { --h: 16px; --depth: 0.6; }
.fx-bg-fireflies > span:nth-child(3n) { --h: 26px; --depth: 1; }
html:not(.dark) .fx-bg-fireflies > span > i { background: color-mix(in oklab, var(--c) 70%, var(--foreground) 30%); }
html:not(.dark) .fx-bg-fireflies > span > i::before {
background: radial-gradient(closest-side, color-mix(in oklab, var(--c) 75%, transparent) 20%, color-mix(in oklab, var(--c) 25%, transparent) 45%, transparent);
}
/* snow and sakura: the outer span falls (the base transform is its spread-out static place), the inner one sways */
@keyframes fx-fall { from { transform: translateY(-10cqh); } to { transform: translateY(115cqh); } }
@keyframes fx-sway { from { transform: translateX(-2cqw) rotate(-15deg); } to { transform: translateX(2cqw) rotate(15deg); } }
@keyframes fx-petal { from { transform: translateX(-3cqw) rotate(0); } to { transform: translateX(3cqw) rotate(300deg); } }
.fx-bg-snow > span, .fx-bg-sakura > span {
left: calc(var(--x) * 1%);
top: 0;
transform: translateY(calc(var(--y) * 1cqh));
animation: fx-fall var(--dur) linear infinite;
animation-delay: var(--delay);
}
.fx-bg-snow > span > i, .fx-bg-sakura > span > i {
display: block;
width: var(--w);
height: var(--w);
animation: fx-sway var(--sway) ease-in-out infinite alternate;
animation-delay: var(--delay);
}
.fx-bg-snow { --flake: white; }
html:not(.dark) .fx-bg-snow { --flake: color-mix(in oklab, var(--logo-blue, #0192ef) 45%, white); }
.fx-bg-snow > span > i { background: radial-gradient(closest-side, var(--flake), transparent); }
.fx-bg-sakura > span > i {
border-radius: 100% 0 100% 0;
background: linear-gradient(135deg, #fbcfe8, #f9a8d4);
animation-name: fx-petal;
}
.fx-bg-sakura > span:nth-child(5n) > i { background: color-mix(in oklab, #f9a8d4 60%, var(--primary) 40%); }
.fx-bg-snow > span:nth-child(3n+1), .fx-bg-sakura > span:nth-child(3n+1) { --depth: 0.2; --w: 3px; }
.fx-bg-snow > span:nth-child(3n+2), .fx-bg-sakura > span:nth-child(3n+2) { --depth: 0.5; --w: 5px; }
.fx-bg-snow > span:nth-child(3n), .fx-bg-sakura > span:nth-child(3n) { --depth: 0.9; --w: 8px; }
.fx-bg-sakura > span:nth-child(3n+1) { --w: 6px; }
.fx-bg-sakura > span:nth-child(3n+2) { --w: 9px; }
.fx-bg-sakura > span:nth-child(3n) { --w: 13px; }
/* nebula: two discs turning against each other, a breathing core, faint stars (discs are capped at 90cqmax:
a turning layer is rastered at its full size) */
@keyframes fx-turn { to { transform: rotate(360deg); } }
@keyframes fx-turn-back { to { transform: rotate(-360deg); } }
.fx-bg-nebula > .fx-disc {
left: calc(50% - var(--r));
top: calc(50% - var(--r));
width: calc(var(--r) * 2);
height: calc(var(--r) * 2);
border-radius: 50%;
will-change: transform;
}
.fx-bg-nebula > .fx-disc:nth-child(2) {
--r: 45cqmax;
--depth: 0.3;
background: conic-gradient(from 0deg,
color-mix(in oklab, var(--primary) 30%, transparent), color-mix(in oklab, var(--logo-violet, #7467f9) 25%, transparent),
color-mix(in oklab, var(--logo-teal, #17c7a4) 22%, transparent), color-mix(in oklab, var(--logo-blue, #0192ef) 25%, transparent),
color-mix(in oklab, var(--primary) 30%, transparent));
-webkit-mask-image: radial-gradient(closest-side, #000 20%, transparent);
mask-image: radial-gradient(closest-side, #000 20%, transparent);
animation: fx-turn 140s linear infinite;
}
.fx-bg-nebula > .fx-disc:nth-child(3) {
--r: 35cqmax;
--depth: 0.55;
background:
radial-gradient(circle at 30% 35%, color-mix(in oklab, var(--logo-violet, #7467f9) 30%, transparent), transparent 40%),
radial-gradient(circle at 70% 40%, color-mix(in oklab, var(--logo-teal, #17c7a4) 26%, transparent), transparent 38%),
radial-gradient(circle at 50% 75%, color-mix(in oklab, var(--primary) 28%, transparent), transparent 40%);
animation: fx-turn-back 100s linear infinite;
}
.fx-bg-nebula > .fx-core {
--depth: 0.8;
left: calc(50% - 11cqmin);
top: calc(50% - 11cqmin);
width: 22cqmin;
height: 22cqmin;
background: radial-gradient(closest-side, rgb(255 255 255 / 0.35), color-mix(in oklab, var(--primary) 25%, transparent) 40%, transparent);
animation: fx-glow 8s ease-in-out infinite alternate;
}
html:not(.dark) .fx-bg-nebula > .fx-core {
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 35%, transparent), color-mix(in oklab, var(--primary) 25%, transparent) 40%, transparent);
} }
/* equalizer: 16 bars at the bottom, scaleY only. The heights are fixed, so frame 0 is a static skyline. */ /* equalizer: 16 bars at the bottom, scaleY only. The heights are fixed, so frame 0 is a static skyline. */
@@ -372,7 +611,7 @@
transform-origin: bottom; transform-origin: bottom;
transform: scaleY(var(--hi)); transform: scaleY(var(--hi));
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
background: linear-gradient(to top, color-mix(in oklab, var(--primary) 35%, transparent), color-mix(in oklab, var(--logo-teal) 12%, transparent)); background: linear-gradient(to top, color-mix(in oklab, var(--primary) 35%, transparent), color-mix(in oklab, var(--logo-teal, #17c7a4) 12%, transparent));
animation: fx-bar var(--dur) ease-in-out infinite alternate; animation: fx-bar var(--dur) ease-in-out infinite alternate;
animation-delay: var(--d); animation-delay: var(--d);
} }
@@ -398,18 +637,22 @@
.fx-paused, .fx-paused,
.fx-paused *, .fx-paused *,
.fx-paused::before, .fx-paused::before,
.fx-paused::after { .fx-paused::after,
.fx-paused *::before,
.fx-paused *::after {
animation-play-state: paused; animation-play-state: paused;
} }
/* ---- Static mode: the system setting and the "Reduce animations" setting (utils/appearance.js) ---- */ /* ---- Static mode: the system setting and the "Reduce animations" setting (utils/appearance.js) ---- */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.fx, .fx *, .fx::before, .fx::after { animation: none !important; } .fx, .fx *, .fx::before, .fx::after, .fx *::before, .fx *::after { animation: none !important; }
} }
html[data-reduce-motion] .fx, html[data-reduce-motion] .fx,
html[data-reduce-motion] .fx *, html[data-reduce-motion] .fx *,
html[data-reduce-motion] .fx::before, html[data-reduce-motion] .fx::before,
html[data-reduce-motion] .fx::after { html[data-reduce-motion] .fx::after,
html[data-reduce-motion] .fx *::before,
html[data-reduce-motion] .fx *::after {
animation: none !important; animation: none !important;
} }
+35
View File
@@ -0,0 +1,35 @@
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {describe, expect, it} from 'vitest';
const src = path.resolve(import.meta.dirname);
const effects = readFileSync(path.join(src, 'effects.css'), 'utf8');
const style = readFileSync(path.join(src, 'style.css'), 'utf8');
// The first block of style.css (":root, [data-skin="none"]") holds the tokens every accent and mode starts from.
const rootBlock = style.slice(style.indexOf(':root'), style.indexOf('}', style.indexOf(':root')));
describe('effects.css', () => {
// REVIEW-FINDING: with the default violet accent no --logo-* is set, so every color-mix with var(--logo-*) is
// invalid at computed-value time: mesh, nebula discs, grid/synthwave floor and light snow render nothing.
it('reviewFinding_logoColorsResolveWithTheDefaultAccent', () => {
const unresolved = [...effects.matchAll(/var\((--logo-[a-z]+)\)/g)]
.map(([, name]) => name)
.filter((name) => !rootBlock.includes(`${name}:`));
expect([...new Set(unresolved)]).toEqual([]);
});
// REVIEW-FINDING: the static-mode rules stop `.fx *` and `.fx::before`, but not a pseudo-element of a child,
// so the 14 firefly halos (`span > i::before`, fx-blink) keep blinking under reduced motion.
it('reviewFinding_staticModeStopsPseudoElementsOfChildren', () => {
const animatedChildPseudos = [...effects.matchAll(/^([^{}\n]*\.fx-bg[^{}\n]* [^{}\n]*::(?:before|after))\s*\{[^}]*animation:/gm)];
const media = effects.slice(effects.indexOf('@media (prefers-reduced-motion: reduce)'));
const setting = effects.slice(effects.indexOf('html[data-reduce-motion] .fx,'));
expect(animatedChildPseudos.length).toBeGreaterThan(0);
for (const block of [media, setting]) {
expect(block).toMatch(/\.fx \*::before/);
expect(block).toMatch(/\.fx \*::after/);
}
});
});
+18
View File
@@ -0,0 +1,18 @@
import {useSyncExternalStore} from 'react';
import {reduceMotion} from '../utils/appearance.js';
// The system setting (matchMedia) and the "Reduce animations" setting (data-reduce-motion on <html>) can flip live.
const subscribe = (callback) => {
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)');
query?.addEventListener?.('change', callback);
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, {attributes: true, attributeFilter: ['data-reduce-motion']});
return () => {
query?.removeEventListener?.('change', callback);
observer.disconnect();
};
};
export default function useReducedMotion() {
return useSyncExternalStore(subscribe, reduceMotion, () => false);
}
+44
View File
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, renderHook} from '@testing-library/react';
import useReducedMotion from './useReducedMotion.js';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
document.documentElement.removeAttribute('data-reduce-motion');
});
describe('useReducedMotion', () => {
it('is false by default', () => {
expect(renderHook(() => useReducedMotion()).result.current).toBe(false);
});
it('turns true when the reduce animations setting is set while mounted', async () => {
const {result} = renderHook(() => useReducedMotion());
await act(async () => {
document.documentElement.setAttribute('data-reduce-motion', '');
await Promise.resolve();
});
expect(result.current).toBe(true);
});
it('is true while the system asks for reduced motion and follows its change event', () => {
let matches = true;
let listener;
vi.stubGlobal('matchMedia', () => ({
get matches() { return matches; },
addEventListener: (type, callback) => { listener = callback; },
removeEventListener: vi.fn(),
}));
const {result} = renderHook(() => useReducedMotion());
expect(result.current).toBe(true);
matches = false;
act(() => listener());
expect(result.current).toBe(false);
});
});
+1 -1
View File
@@ -180,7 +180,7 @@ function Preview({code}) {
if (look.effect) { if (look.effect) {
return ( return (
<span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-background', !inView && 'fx-paused')}> <span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-background', !inView && 'fx-paused')}>
<EffectBackground effect={look.effect} className="absolute inset-0"/> <EffectBackground effect={look.effect} parallax={false} className="absolute inset-0"/>
</span> </span>
); );
} }
+18 -1
View File
@@ -19,6 +19,8 @@ const wallet = (extra = {}) => ({
{code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300}, {code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300},
{code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450}, {code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450},
{code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300}, {code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300},
{code: 'BG_SNOW', slot: 'PAGE_BACKGROUND', price: 400},
{code: 'BN_MATRIX', slot: 'BANNER_BACKGROUND', price: 300},
{code: 'BADGE_FINISH_10', slot: 'BADGE', price: 0, track: 'GAMES_FINISHED', target: 10}, {code: 'BADGE_FINISH_10', slot: 'BADGE', price: 0, track: 'GAMES_FINISHED', target: 10},
{code: 'BADGE_STREAK_4', slot: 'BADGE', price: 0, track: 'FINISH_WEEKS', target: 4}, {code: 'BADGE_STREAK_4', slot: 'BADGE', price: 0, track: 'FINISH_WEEKS', target: 4},
{code: 'SKIN_PASTEL', slot: 'SKIN', price: 600}, {code: 'SKIN_PASTEL', slot: 'SKIN', price: 600},
@@ -47,7 +49,11 @@ const renderPage = async (routes = {}) => {
await screen.findByText('POINTS.SHOP'); await screen.findByText('POINTS.SHOP');
}; };
beforeEach(() => localStorage.setItem('tbp_session', '1')); beforeEach(() => {
localStorage.setItem('tbp_session', '1');
// jsdom has no canvas: the matrix preview renders, draws nothing.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
});
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
localStorage.clear(); localStorage.clear();
@@ -65,6 +71,17 @@ describe('PointsPage', () => {
expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull(); expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
}); });
it('previews the new CSS and canvas looks without parallax listeners', async () => {
const add = vi.spyOn(window, 'addEventListener');
await renderPage();
const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]');
expect(card('PAGE_BACKGROUND').querySelector('[data-effect="snow"]')).not.toBeNull();
expect(card('BANNER_BACKGROUND').querySelector('[data-effect="matrix"] canvas')).not.toBeNull();
expect(add.mock.calls.filter(([type]) => type === 'pointermove')).toHaveLength(0);
add.mockRestore();
});
it('shows a locked unlock item with its progress, a progress bar and no price or Buy button', async () => { it('shows a locked unlock item with its progress, a progress bar and no price or Buy button', async () => {
await renderPage(); await renderPage();
+131
View File
@@ -0,0 +1,131 @@
import {LOGO_COLORS} from '../components/Logo.jsx';
// The drawers of the canvas page and banner backgrounds (components/EffectCanvas.jsx). Plain objects with no canvas
// or DOM, so the logic is testable; sizes are css px. `create` fills the first frame, which is also the static picture.
/** mulberry32: the same seed gives the same sequence, so the static picture is the same on every visit. */
export const seededRandom = (seed) => () => {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
/** Colors of the element's own skin and accent (a public profile sets them on a wrapper), logo hex as fallback. */
export function readPalette(element) {
const style = getComputedStyle(element);
const read = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
return {
primary: read('--primary', LOGO_COLORS[0][1]),
foreground: read('--foreground', '#fafafa'),
logo: LOGO_COLORS.map(([name, hex]) => read(name, hex)),
};
}
// Half-width katakana and digits.
const GLYPHS = Array.from({length: 56}, (_, i) => (i < 46 ? String.fromCharCode(0xFF66 + i) : String(i - 46)));
const TRAIL = 14;
const glyph = (random) => GLYPHS[Math.floor(random() * GLYPHS.length)];
const matrix = {
fps: 20,
create(width, height, random) {
const gap = height < 360 ? 12 : 16;
const rows = Math.ceil(height / gap);
const columns = Array.from({length: Math.floor(width / gap)}, () => ({
head: random() * (rows + TRAIL),
speed: 6 + random() * 8,
glyphs: Array.from({length: rows}, () => glyph(random)),
}));
return {gap, rows, columns};
},
step(state, seconds, random) {
for (const column of state.columns) {
column.head += column.speed * seconds;
if (column.head - TRAIL > state.rows) column.head = -random() * 8;
for (let n = 0; n < 2 + Math.floor(random() * 2); n++) {
column.glyphs[Math.floor(random() * state.rows)] = glyph(random);
}
}
},
draw(context, state, palette) {
const {gap, rows, columns} = state;
context.clearRect(0, 0, columns.length * gap + gap, rows * gap + gap);
context.font = `${gap}px ui-monospace, monospace`;
context.textBaseline = 'top';
columns.forEach((column, index) => {
const head = Math.floor(column.head);
const trail = index % 6 === 5 ? palette.logo[2] : palette.primary;
for (let k = 0; k < TRAIL; k++) {
const row = head - k;
if (row < 0 || row >= rows) continue;
context.globalAlpha = k === 0 ? 0.9 : (1 - k / TRAIL) * 0.55;
context.fillStyle = k === 0 ? palette.foreground : trail;
context.fillText(column.glyphs[row], index * gap, row * gap);
}
});
context.globalAlpha = 1;
},
};
const LINK = 110;
const TIERS = [{r: 1, speed: 6, alpha: 0.35}, {r: 1.6, speed: 12, alpha: 0.6}, {r: 2.4, speed: 20, alpha: 0.9}];
const constellation = {
fps: 30,
create(width, height, random) {
const count = Math.min(80, Math.max(18, Math.round((width * height) / 16000)));
const points = Array.from({length: count}, (_, i) => {
const tier = TIERS[i % 3];
const angle = random() * Math.PI * 2;
return {
x: random() * width, y: random() * height,
vx: Math.cos(angle) * tier.speed, vy: Math.sin(angle) * tier.speed,
tier, color: i % 4, twinkle: i % 5 === 4, phase: random() * Math.PI * 2,
};
});
return {width, height, points, t: 0};
},
step(state, seconds) {
state.t += seconds;
for (const p of state.points) {
p.x = (p.x + p.vx * seconds + state.width) % state.width;
p.y = (p.y + p.vy * seconds + state.height) % state.height;
}
},
draw(context, state, palette) {
const {points, width, height, t} = state;
context.clearRect(0, 0, width, height);
context.strokeStyle = palette.primary;
context.lineWidth = 1;
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const d = Math.hypot(points[i].x - points[j].x, points[i].y - points[j].y);
if (d >= LINK) continue;
context.globalAlpha = (1 - d / LINK) * 0.35;
context.beginPath();
context.moveTo(points[i].x, points[i].y);
context.lineTo(points[j].x, points[j].y);
context.stroke();
}
}
const colors = [palette.logo[0], palette.logo[1], palette.logo[2], palette.primary];
for (const p of points) {
const alpha = p.tier.alpha * (p.twinkle ? 0.6 + 0.4 * Math.sin(t * 2 + p.phase) : 1);
context.fillStyle = colors[p.color];
if (p.tier.r > 2) {
context.globalAlpha = 0.15;
context.beginPath();
context.arc(p.x, p.y, p.tier.r * 3, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = alpha;
context.beginPath();
context.arc(p.x, p.y, p.tier.r, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
},
};
export const CANVAS_EFFECTS = {matrix, constellation};
+85
View File
@@ -0,0 +1,85 @@
import {describe, expect, it, vi} from 'vitest';
import {CANVAS_EFFECTS, readPalette, seededRandom} from './canvasEffects.js';
const fakeContext = () => new Proxy({}, {get: (target, name) => (target[name] ??= vi.fn()), set: () => true});
const palette = {primary: '#111', foreground: '#eee', logo: ['#1', '#2', '#3']};
describe('seededRandom', () => {
it('gives the same sequence for the same seed', () => {
const take = (random) => Array.from({length: 5}, random);
expect(take(seededRandom(7))).toEqual(take(seededRandom(7)));
expect(take(seededRandom(7))).not.toEqual(take(seededRandom(8)));
});
});
describe('matrix', () => {
const {matrix} = CANVAS_EFFECTS;
it('has a column every 16 px with its head inside the rain', () => {
const state = matrix.create(320, 400, seededRandom(7));
expect(state.columns).toHaveLength(Math.floor(320 / 16));
for (const column of state.columns) {
expect(column.head).toBeGreaterThanOrEqual(0);
expect(column.head).toBeLessThanOrEqual(state.rows + 14);
}
});
it('draws glyphs with fillText and keeps its heads in range while it steps', () => {
const random = seededRandom(7);
const state = matrix.create(320, 400, random);
for (let i = 0; i < 500; i++) matrix.step(state, 0.1, random);
const context = fakeContext();
matrix.draw(context, state, palette);
expect(context.fillText).toHaveBeenCalled();
for (const column of state.columns) expect(column.head).toBeLessThanOrEqual(state.rows + 14 + 1);
});
});
describe('constellation', () => {
const {constellation} = CANVAS_EFFECTS;
it('scales the point count with the area between 18 and 80', () => {
expect(constellation.create(320, 200, seededRandom(7)).points).toHaveLength(18);
expect(constellation.create(4000, 3000, seededRandom(7)).points).toHaveLength(80);
});
it('keeps every point inside the box and draws arcs and lines', () => {
const random = seededRandom(7);
const state = constellation.create(320, 200, random);
for (let i = 0; i < 500; i++) constellation.step(state, 0.1, random);
for (const p of state.points) {
expect(p.x).toBeGreaterThanOrEqual(0);
expect(p.x).toBeLessThanOrEqual(320);
expect(p.y).toBeGreaterThanOrEqual(0);
expect(p.y).toBeLessThanOrEqual(200);
}
const context = fakeContext();
constellation.draw(context, state, palette);
expect(context.arc).toHaveBeenCalled();
expect(context.stroke).toHaveBeenCalled();
});
});
describe('readPalette', () => {
it('falls back to the logo hex and the default colors when the variables are empty', () => {
vi.stubGlobal('getComputedStyle', () => ({getPropertyValue: () => ''}));
const result = readPalette({});
expect(result.logo).toEqual(['#7467f9', '#0192ef', '#17c7a4']);
expect(result.primary).toBe('#7467f9');
expect(result.foreground).toBe('#fafafa');
vi.unstubAllGlobals();
});
it('reads the element variables when they are set', () => {
vi.stubGlobal('getComputedStyle', () => ({getPropertyValue: (name) => (name === '--primary' ? ' #abc ' : '')}));
expect(readPalette({}).primary).toBe('#abc');
vi.unstubAllGlobals();
});
});
+99
View File
@@ -0,0 +1,99 @@
import {storage} from './storage.js';
// Writes --px/--py (-1..1) from the mouse or the phone tilt on each effect root; effects.css moves the layers.
export const TILT_STORAGE_KEY = 'tbp_tilt';
/** iOS and iPadOS Safari ask for a permission before they send orientation events. */
export const tiltNeedsPermission = () => typeof window.DeviceOrientationEvent?.requestPermission === 'function';
export const tiltAllowed = () => !tiltNeedsPermission() || storage.get(TILT_STORAGE_KEY) === 'true';
const roots = new Set();
const target = {x: 0, y: 0};
const current = {x: 0, y: 0};
let frame = 0;
let tiltListening = false;
const clamp = (value) => Math.max(-1, Math.min(1, value));
function onPointer(event) {
if (event.pointerType !== 'mouse') return;
target.x = (event.clientX / window.innerWidth) * 2 - 1;
target.y = (event.clientY / window.innerHeight) * 2 - 1;
schedule();
}
function onTilt(event) {
if (event.gamma == null || event.beta == null) return;
let x = event.gamma / 30;
let y = (event.beta - 45) / 30;
const angle = window.screen?.orientation?.angle;
if (angle === 90) [x, y] = [y, -x];
else if (angle === 270) [x, y] = [-y, x];
target.x = clamp(x);
target.y = clamp(y);
schedule();
}
function tick() {
frame = 0;
current.x += (target.x - current.x) * 0.12;
current.y += (target.y - current.y) * 0.12;
for (const root of roots) {
root.style.setProperty('--px', current.x.toFixed(3));
root.style.setProperty('--py', current.y.toFixed(3));
}
if (Math.abs(target.x - current.x) >= 0.002 || Math.abs(target.y - current.y) >= 0.002) schedule();
}
function schedule() {
if (!frame && roots.size) frame = requestAnimationFrame(tick);
}
function listenTilt() {
if (tiltListening || !roots.size) return;
tiltListening = true;
window.addEventListener('deviceorientation', onTilt, {passive: true});
}
function stopTilt() {
if (!tiltListening) return;
tiltListening = false;
window.removeEventListener('deviceorientation', onTilt);
}
/** Call it inside a tap (iOS). Stores the answer and resolves to whether tilt is granted. */
export async function requestTilt() {
let answer;
try {
answer = await window.DeviceOrientationEvent.requestPermission();
} catch {
answer = 'denied';
}
const granted = answer === 'granted';
storage.set(TILT_STORAGE_KEY, String(granted));
if (granted) listenTilt();
return granted;
}
export function disableTilt() {
storage.set(TILT_STORAGE_KEY, 'false');
stopTilt();
}
/** Starts moving the layers of `element`; returns the stop function. */
export function watchParallax(element) {
roots.add(element);
if (roots.size === 1) window.addEventListener('pointermove', onPointer, {passive: true});
if (tiltAllowed()) listenTilt();
return () => {
roots.delete(element);
element.style.removeProperty('--px');
element.style.removeProperty('--py');
if (roots.size) return;
window.removeEventListener('pointermove', onPointer);
stopTilt();
cancelAnimationFrame(frame);
frame = 0;
target.x = target.y = current.x = current.y = 0;
};
}
+137
View File
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {disableTilt, requestTilt, TILT_STORAGE_KEY, tiltAllowed, watchParallax} from './parallax.js';
const listeners = (spy, type) => spy.mock.calls.filter(([name]) => name === type);
const pointer = (pointerType, clientX, clientY) => {
const event = new Event('pointermove');
Object.assign(event, {pointerType, clientX, clientY});
window.dispatchEvent(event);
};
const tilt = (gamma, beta) => {
const event = new Event('deviceorientation');
Object.assign(event, {gamma, beta});
window.dispatchEvent(event);
};
const makeRoot = () => document.createElement('div');
let add;
let remove;
let stops;
beforeEach(() => {
localStorage.clear();
vi.stubGlobal('requestAnimationFrame', (callback) => { callback(); return 1; });
vi.stubGlobal('cancelAnimationFrame', vi.fn());
add = vi.spyOn(window, 'addEventListener');
remove = vi.spyOn(window, 'removeEventListener');
stops = [];
});
afterEach(() => {
stops.forEach((stop) => stop());
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete window.DeviceOrientationEvent;
});
const watch = (root) => { const stop = watchParallax(root); stops.push(stop); return stop; };
describe('parallax', () => {
it('attaches one pointermove listener for two roots', () => {
watch(makeRoot());
watch(makeRoot());
expect(listeners(add, 'pointermove')).toHaveLength(1);
});
it('writes --px and --py on every root for a mouse and ignores touch', () => {
const a = makeRoot();
const b = makeRoot();
watch(a);
watch(b);
pointer('touch', 0, 0);
expect(a.style.getPropertyValue('--px')).toBe('');
pointer('mouse', window.innerWidth, 0);
expect(Number(a.style.getPropertyValue('--px'))).toBeGreaterThan(0);
expect(Number(a.style.getPropertyValue('--py'))).toBeLessThan(0);
expect(b.style.getPropertyValue('--px')).toBe(a.style.getPropertyValue('--px'));
});
it('removes the listener and the variables when the last root stops', () => {
const a = makeRoot();
const b = makeRoot();
const stopA = watch(a);
const stopB = watch(b);
pointer('mouse', window.innerWidth, window.innerHeight);
stopA();
expect(a.style.getPropertyValue('--px')).toBe('');
expect(listeners(remove, 'pointermove')).toHaveLength(0);
stopB();
expect(listeners(remove, 'pointermove')).toHaveLength(1);
});
it('listens for tilt at once where no permission is needed', () => {
watch(makeRoot());
expect(tiltAllowed()).toBe(true);
expect(listeners(add, 'deviceorientation')).toHaveLength(1);
});
it('drives both variables to 1, clamped, from a tilt, and ignores null angles', () => {
const root = makeRoot();
watch(root);
tilt(null, null);
expect(root.style.getPropertyValue('--px')).toBe('');
tilt(60, 120);
for (let i = 0; i < 60; i++) tilt(60, 120);
expect(Number(root.style.getPropertyValue('--px'))).toBeCloseTo(1, 1);
expect(Number(root.style.getPropertyValue('--py'))).toBeCloseTo(1, 1);
});
describe('where iOS asks for permission', () => {
const stubPermission = (answer) => {
window.DeviceOrientationEvent = {requestPermission: vi.fn().mockResolvedValue(answer)};
};
it('listens for tilt only after a granted request, and stores it', async () => {
stubPermission('granted');
watch(makeRoot());
expect(tiltAllowed()).toBe(false);
expect(listeners(add, 'deviceorientation')).toHaveLength(0);
await expect(requestTilt()).resolves.toBe(true);
expect(listeners(add, 'deviceorientation')).toHaveLength(1);
expect(localStorage.getItem(TILT_STORAGE_KEY)).toBe('true');
});
it('stays off when the request is denied or throws', async () => {
stubPermission('denied');
watch(makeRoot());
await expect(requestTilt()).resolves.toBe(false);
window.DeviceOrientationEvent = {requestPermission: vi.fn().mockRejectedValue(new Error('no'))};
await expect(requestTilt()).resolves.toBe(false);
expect(listeners(add, 'deviceorientation')).toHaveLength(0);
expect(localStorage.getItem(TILT_STORAGE_KEY)).toBe('false');
});
it('removes the tilt listener when it is switched off', async () => {
stubPermission('granted');
watch(makeRoot());
await requestTilt();
disableTilt();
expect(listeners(remove, 'deviceorientation')).toHaveLength(1);
expect(localStorage.getItem(TILT_STORAGE_KEY)).toBe('false');
});
});
});
+14
View File
@@ -32,6 +32,13 @@ export const LOOKS = {
BG_GRID: {effect: 'grid'}, BG_GRID: {effect: 'grid'},
BG_WAVES: {effect: 'waves'}, BG_WAVES: {effect: 'waves'},
BG_BOKEH: {effect: 'bokeh'}, BG_BOKEH: {effect: 'bokeh'},
BG_SYNTHWAVE: {effect: 'synthwave'},
BG_FIREFLIES: {effect: 'fireflies'},
BG_SNOW: {effect: 'snow'},
BG_SAKURA: {effect: 'sakura'},
BG_NEBULA: {effect: 'nebula'},
BG_MATRIX: {effect: 'matrix'},
BG_CONSTELLATION: {effect: 'constellation'},
BN_TRIANGLES: {effect: 'triangles'}, BN_TRIANGLES: {effect: 'triangles'},
BN_STARFIELD: {effect: 'starfield'}, BN_STARFIELD: {effect: 'starfield'},
BN_PIXELS: {effect: 'pixels'}, BN_PIXELS: {effect: 'pixels'},
@@ -40,6 +47,13 @@ export const LOOKS = {
BN_GRID: {effect: 'grid'}, BN_GRID: {effect: 'grid'},
BN_WAVES: {effect: 'waves'}, BN_WAVES: {effect: 'waves'},
BN_BOKEH: {effect: 'bokeh'}, BN_BOKEH: {effect: 'bokeh'},
BN_SYNTHWAVE: {effect: 'synthwave'},
BN_FIREFLIES: {effect: 'fireflies'},
BN_SNOW: {effect: 'snow'},
BN_SAKURA: {effect: 'sakura'},
BN_NEBULA: {effect: 'nebula'},
BN_MATRIX: {effect: 'matrix'},
BN_CONSTELLATION: {effect: 'constellation'},
// Unlock-only items (Core: earned by progress, never sold). // Unlock-only items (Core: earned by progress, never sold).
BADGE_FINISH_10: {Icon: Flag, className: 'bg-emerald-500/15 text-emerald-500'}, BADGE_FINISH_10: {Icon: Flag, className: 'bg-emerald-500/15 text-emerald-500'},
BORDER_FINISH_25: {className: 'ring-4 ring-slate-300 ring-offset-2 ring-offset-background'}, BORDER_FINISH_25: {className: 'ring-4 ring-slate-300 ring-offset-2 ring-offset-background'},
+14
View File
@@ -1,4 +1,5 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {EFFECTS} from '../components/EffectBackground.jsx';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js'; import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js';
describe('effects worn', () => { describe('effects worn', () => {
@@ -33,3 +34,16 @@ describe('equippedSkin', () => {
expect(equippedSkin(undefined)).toBeNull(); expect(equippedSkin(undefined)).toBeNull();
}); });
}); });
describe('LOOKS backgrounds', () => {
it('gives every page background a banner with the same engine, except the unlock-only equalizer', () => {
for (const [code, look] of Object.entries(LOOKS)) {
if (code.startsWith('BG_')) expect(LOOKS[`BN_${code.slice(3)}`]?.effect, code).toBe(look.effect);
if (code.startsWith('BN_') && code !== 'BN_EQUALIZER') expect(LOOKS[`BG_${code.slice(3)}`], code).toBeDefined();
}
});
it('only names engines that EffectBackground renders', () => {
for (const look of Object.values(LOOKS)) if (look.effect) expect(EFFECTS).toContain(look.effect);
});
});