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();
|
||||
});
|
||||
});
|
||||
+405
-162
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user