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.
- **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
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
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
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.
- **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.
+17
View File
@@ -116,6 +116,9 @@
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.",
"REDUCE_MOTION": "Animationen reduzieren",
"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_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
"BACKGROUND_REMOVE": "Hintergrund entfernen",
@@ -1049,6 +1052,20 @@
"BN_WAVES": "Animiertes Banner: Wellen",
"BG_BOKEH": "Animierter Hintergrund: 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",
"BORDER_FINISH_25": "Silberner Avatar-Rand",
"FRAME_FINISH_50": "Smaragd-Bannerrahmen",
+17
View File
@@ -116,6 +116,9 @@
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.",
"REDUCE_MOTION": "Reduce animations",
"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_HINT": "Open a game in your library and choose \"Use as background\".",
"BACKGROUND_REMOVE": "Remove background",
@@ -1049,6 +1052,20 @@
"BN_WAVES": "Animated banner: waves",
"BG_BOKEH": "Animated background: 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",
"BORDER_FINISH_25": "Silver avatar border",
"FRAME_FINISH_50": "Emerald banner frame",
+29
View File
@@ -1,3 +1,4 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {ImageOff, Palette, RotateCcw} from 'lucide-react';
@@ -10,11 +11,27 @@ import {equippedSkin} from '../utils/rewards.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {coverUrl} from '../utils/games.js';
import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js';
export default function AppearanceCard() {
const {t} = useTranslation();
const {appearance, equipped} = usePreferences();
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 (
<Card>
@@ -41,6 +58,18 @@ export default function AppearanceCard() {
<Switch id="reduceMotion" checked={appearance.reduceMotion}
onCheckedChange={(reduceMotion) => saveAppearance({reduceMotion})}/>
</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="space-y-1">
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
+48 -4
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
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';
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('../hooks/useSaveAppearance.js', () => ({default: () => vi.fn()}));
const show = (equipped) => {
const show = (equipped, reduceMotion = false) => {
preferences.value = {
appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion: false, showBanner: true},
appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion, showBanner: true},
equipped,
};
render(<AppearanceCard/>);
};
afterEach(cleanup);
afterEach(() => {
cleanup();
localStorage.clear();
delete window.DeviceOrientationEvent;
});
describe('AppearanceCard', () => {
it('tells that the skin sets colors and corners only while a skin is worn', () => {
@@ -28,4 +32,44 @@ describe('AppearanceCard', () => {
show({});
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);
// Three groups of eight triangles on a 1600x900 canvas; a fixed pattern, so the picture is the same on every visit.
const TRIANGLES = range(3).map((group) => range(8).map((i) => {
// Four layers of triangles on a 1600x900 canvas: far and small, mid, near and big, and a wireframe. A fixed
// 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 y = (i * 137 + group * 263) % 900;
const size = 80 + ((i * 53 + group * 29) % 90);
const size = min + ((i * 53 + group * 29) % span);
return {
points: `${x},${y - size} ${x - size * 0.87},${y + size / 2} ${x + size * 0.87},${y + size / 2}`,
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 '
+ '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`
* 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
*/
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;
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)}>
{shapes(effect)}
{shapes(effect, gradientId)}
</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) {
case 'triangles':
return (
<svg className="size-full" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
{TRIANGLES.map((triangles, group) => (
<g key={group} fill="currentColor" className={group === 1 ? 'text-foreground' : undefined}>
<>
{TRIANGLES.map((triangles, layer) => (
<svg key={layer} className="fx-bleed" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
{triangles.map((triangle) => (
<polygon key={triangle.points} points={triangle.points} transform={triangle.rotate}
opacity={triangle.opacity}/>
))}
</g>
</svg>
))}
</svg>
{glints(effect)}
</>
);
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':
return range(12).map((i) => (
<span key={i} style={{
'--x': (i * 37) % 100, '--y': (i * 53 + 11) % 90,
'--dur': `${25 + ((i * 7) % 16)}s`, '--delay': `${-i * 3.1}s`,
}}/>
));
return (
<>
{range(18).map((i) => (
<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':
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':
return range(4).map((i) => <div key={i}/>);
return (
<>
{range(5).map((i) => <div key={i} className="fx-blob"/>)}
{glints(effect)}
</>
);
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':
return range(3).map((i) => (
<svg key={i} viewBox="0 0 3200 200" preserveAspectRatio="none">
<path d={WAVE} fill="currentColor"/>
</svg>
));
return (
<>
{range(4).map((i) => (
<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':
return range(16).map((i) => <span key={i}/>);
case 'bokeh':
return range(10).map((i) => (
<span key={i} style={{
'--x': (i * 29 + 7) % 90, '--y': (i * 47 + 10) % 90, '--s': 8 + ((i * 13) % 33),
'--dur': `${30 + ((i * 5) % 21)}s`, '--delay': `${-i * 4.3}s`,
}}/>
));
return (
<>
{range(16).map((i) => (
<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
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react';
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', () => {
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', () => {
const {container} = render(<EffectBackground effect="matrix"/>);
const {container} = render(<EffectBackground effect="plasma"/>);
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.
* 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.
* 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%); } }
@@ -60,9 +62,9 @@
mix-blend-mode: overlay;
background: linear-gradient(120deg,
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(--logo-violet) 45%, transparent) 60%,
color-mix(in oklab, var(--logo-violet, #7467f9) 45%, transparent) 60%,
transparent 70%);
animation: fx-slide 6s ease-in-out infinite;
}
@@ -142,9 +144,9 @@
height: 35%;
background: linear-gradient(90deg,
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(--logo-violet) 18%, transparent),
color-mix(in oklab, var(--logo-violet, #7467f9) 18%, transparent),
transparent);
animation: fx-breathe 6s ease-in-out infinite alternate;
}
@@ -166,200 +168,437 @@
/* ---- PAGE_BACKGROUND and BANNER_BACKGROUND (EffectBackground) ---- */
.fx-bg { container-type: size; }
.fx-bg > * { position: absolute; }
/* triangles: three groups of triangles drifting apart */
@keyframes fx-tri-a { to { transform: translate(60px, -40px) rotate(5deg); } }
@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 { container-type: size; --shift: 2cqmax; --glint: color-mix(in oklab, var(--primary) 70%, white); }
.dark .fx-bg { --glint: white; }
.fx-bg > * {
position: absolute;
translate: calc(var(--px, 0) * var(--depth, 0) * var(--shift)) calc(var(--py, 0) * var(--depth, 0) * var(--shift));
}
.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;
animation-duration: 60s;
--depth: 0.1;
--star: color-mix(in oklab, var(--foreground) 50%, transparent);
background-image:
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);
}
.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 */
@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 */
/* A perspective floor moving one cell (40px) toward the viewer: grid and synthwave. */
@keyframes fx-floor {
from { transform: perspective(600px) rotateX(62deg) translateY(0); }
to { transform: perspective(600px) rotateX(62deg) translateY(40px); }
}
.fx-bg-grid > :nth-child(1) {
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) {
.fx-bg > .fx-floor {
left: -100%;
top: 45%;
width: 300%;
height: 500px;
transform-origin: top center;
transform: perspective(600px) rotateX(62deg);
background-image:
repeating-linear-gradient(0deg, color-mix(in oklab, var(--primary) 35%, 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(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) 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;
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%); } }
.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;
bottom: 0;
width: 200%;
color: var(--primary);
height: 100%;
animation: fx-wave-x linear infinite;
}
.fx-bg-waves > :nth-child(1) { height: 25%; opacity: 0.08; animation-duration: 14s; }
.fx-bg-waves > :nth-child(2) { height: 32%; opacity: 0.12; animation-duration: 20s; animation-direction: reverse; }
.fx-bg-waves > :nth-child(3) { height: 40%; opacity: 0.18; animation-duration: 28s; }
.fx-bg-waves > .fx-band:nth-child(1) { --h: 25%; --depth: 0.2; --bob: 11s; color: var(--logo-blue, #0192ef); }
.fx-bg-waves > .fx-band:nth-child(2) { --h: 32%; --depth: 0.4; --bob: 9s; color: var(--logo-teal, #17c7a4); }
.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 {
0% { transform: translateY(8cqh); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(-20cqh); opacity: 0; }
}
.fx-bg-bokeh > * {
.fx-bg > .fx-bk {
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: calc(var(--s) * 1cqmin);
height: calc(var(--s) * 1cqmin);
opacity: 0.6;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 25%, transparent), transparent);
opacity: 0.8;
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-delay: var(--delay);
}
.fx-bg-bokeh > :nth-child(even) {
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent);
.fx-bk:nth-child(3n+1) { --a: 18%; --depth: 0.2; }
.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. */
@@ -372,7 +611,7 @@
transform-origin: bottom;
transform: scaleY(var(--hi));
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-delay: var(--d);
}
@@ -398,18 +637,22 @@
.fx-paused,
.fx-paused *,
.fx-paused::before,
.fx-paused::after {
.fx-paused::after,
.fx-paused *::before,
.fx-paused *::after {
animation-play-state: paused;
}
/* ---- Static mode: the system setting and the "Reduce animations" setting (utils/appearance.js) ---- */
@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::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;
}
+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) {
return (
<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>
);
}
+18 -1
View File
@@ -19,6 +19,8 @@ const wallet = (extra = {}) => ({
{code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300},
{code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450},
{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_STREAK_4', slot: 'BADGE', price: 0, track: 'FINISH_WEEKS', target: 4},
{code: 'SKIN_PASTEL', slot: 'SKIN', price: 600},
@@ -47,7 +49,11 @@ const renderPage = async (routes = {}) => {
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(() => {
cleanup();
localStorage.clear();
@@ -65,6 +71,17 @@ describe('PointsPage', () => {
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 () => {
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_WAVES: {effect: 'waves'},
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_STARFIELD: {effect: 'starfield'},
BN_PIXELS: {effect: 'pixels'},
@@ -40,6 +47,13 @@ export const LOOKS = {
BN_GRID: {effect: 'grid'},
BN_WAVES: {effect: 'waves'},
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).
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'},
+14
View File
@@ -1,4 +1,5 @@
import {describe, expect, it} from 'vitest';
import {EFFECTS} from '../components/EffectBackground.jsx';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js';
describe('effects worn', () => {
@@ -33,3 +34,16 @@ describe('equippedSkin', () => {
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);
});
});