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

+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();
});
});