Public Access
feat(effects): polish 8 effects and add 7 new engines with parallax
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:
1 parent
d7810fbf94
commit
ed5df8c40d
21 files changed
+1510
-211
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}/>;
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}}/>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user