diff --git a/README.md b/README.md index b06e557..275dce3 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ To Be Played is split into three repositories: trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform. - **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a - banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG, in `effects.css`), + banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings), shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS - variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still with the + variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still (canvas: one static frame, no loop) with the system setting "reduce motion" or the "Reduce animations" switch in the settings. - **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and finished games per year of all users. diff --git a/public/i18n/de.json b/public/i18n/de.json index 37cac48..c90c647 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -116,6 +116,9 @@ "WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.", "REDUCE_MOTION": "Animationen reduzieren", "REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.", + "TILT": "Effekte neigen", + "TILT_HINT": "Hintergründe folgen, wenn du das Handy neigst. Das Handy fragt nach der Erlaubnis.", + "TILT_DENIED": "Erlaubnis abgelehnt. Erlaube den Bewegungszugriff in Safari und versuche es erneut.", "BACKGROUND": "Hintergrund", "BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.", "BACKGROUND_REMOVE": "Hintergrund entfernen", @@ -1049,6 +1052,20 @@ "BN_WAVES": "Animiertes Banner: Wellen", "BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter", "BN_BOKEH": "Animiertes Banner: Bokeh-Lichter", + "BG_SYNTHWAVE": "Animierter Hintergrund: Synthwave-Sonnenuntergang", + "BN_SYNTHWAVE": "Animiertes Banner: Synthwave-Sonnenuntergang", + "BG_FIREFLIES": "Animierter Hintergrund: Glühwürmchen", + "BN_FIREFLIES": "Animiertes Banner: Glühwürmchen", + "BG_SNOW": "Animierter Hintergrund: Schnee", + "BN_SNOW": "Animiertes Banner: Schnee", + "BG_SAKURA": "Animierter Hintergrund: Kirschblüten", + "BN_SAKURA": "Animiertes Banner: Kirschblüten", + "BG_NEBULA": "Animierter Hintergrund: Nebel-Strudel", + "BN_NEBULA": "Animiertes Banner: Nebel-Strudel", + "BG_MATRIX": "Animierter Hintergrund: Matrix-Regen", + "BN_MATRIX": "Animiertes Banner: Matrix-Regen", + "BG_CONSTELLATION": "Animierter Hintergrund: Sternbild", + "BN_CONSTELLATION": "Animiertes Banner: Sternbild", "BADGE_FINISH_10": "Beender-Abzeichen", "BORDER_FINISH_25": "Silberner Avatar-Rand", "FRAME_FINISH_50": "Smaragd-Bannerrahmen", diff --git a/public/i18n/en.json b/public/i18n/en.json index a218735..043bbd7 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -116,6 +116,9 @@ "WIDE_LAYOUT_HINT": "Uses more of a wide screen.", "REDUCE_MOTION": "Reduce animations", "REDUCE_MOTION_HINT": "Shop effects and confetti stand still, also on other profiles.", + "TILT": "Tilt effects", + "TILT_HINT": "Backgrounds follow when you tilt your phone. Your phone asks for permission.", + "TILT_DENIED": "Permission was refused. Allow motion access in Safari, then try again.", "BACKGROUND": "Background", "BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".", "BACKGROUND_REMOVE": "Remove background", @@ -1049,6 +1052,20 @@ "BN_WAVES": "Animated banner: waves", "BG_BOKEH": "Animated background: bokeh", "BN_BOKEH": "Animated banner: bokeh", + "BG_SYNTHWAVE": "Animated background: synthwave sunset", + "BN_SYNTHWAVE": "Animated banner: synthwave sunset", + "BG_FIREFLIES": "Animated background: fireflies", + "BN_FIREFLIES": "Animated banner: fireflies", + "BG_SNOW": "Animated background: snow", + "BN_SNOW": "Animated banner: snow", + "BG_SAKURA": "Animated background: cherry blossoms", + "BN_SAKURA": "Animated banner: cherry blossoms", + "BG_NEBULA": "Animated background: nebula", + "BN_NEBULA": "Animated banner: nebula", + "BG_MATRIX": "Animated background: matrix rain", + "BN_MATRIX": "Animated banner: matrix rain", + "BG_CONSTELLATION": "Animated background: constellation", + "BN_CONSTELLATION": "Animated banner: constellation", "BADGE_FINISH_10": "Finisher badge", "BORDER_FINISH_25": "Silver avatar border", "FRAME_FINISH_50": "Emerald banner frame", diff --git a/src/components/AppearanceCard.jsx b/src/components/AppearanceCard.jsx index b6d0b40..2ea57f4 100644 --- a/src/components/AppearanceCard.jsx +++ b/src/components/AppearanceCard.jsx @@ -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 ( @@ -41,6 +58,18 @@ export default function AppearanceCard() { saveAppearance({reduceMotion})}/> + {tiltNeedsPermission() && ( +
+
+ +

+ {t(tiltRefused ? 'SETTINGS.TILT_DENIED' : 'SETTINGS.TILT_HINT')} +

+
+ +
+ )}
diff --git a/src/components/AppearanceCard.test.jsx b/src/components/AppearanceCard.test.jsx index f56e7f5..4751eb4 100644 --- a/src/components/AppearanceCard.test.jsx +++ b/src/components/AppearanceCard.test.jsx @@ -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(); }; -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); + }); + }); }); diff --git a/src/components/EffectBackground.jsx b/src/components/EffectBackground.jsx index 763514c..9e4560c 100644 --- a/src/components/EffectBackground.jsx +++ b/src/components/EffectBackground.jsx @@ -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) => ( + +)); + /** * 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 ( - ); } -function shapes(effect) { +// Wanderers (fireflies) and fallers (snow, sakura) are ; every value comes from (i * prime) % n. +const wanderer = (i) => ( + +); + +const faller = (slow) => (i) => ( + +); + +function shapes(effect, gradientId) { switch (effect) { case 'triangles': return ( - - {TRIANGLES.map((triangles, group) => ( - + <> + {TRIANGLES.map((triangles, layer) => ( + {triangles.map((triangle) => ( ))} - + ))} - + {glints(effect)} + ); case 'starfield': - return range(9).map((i) => ); + return ( + <> + {range(3).map((i) => )} + {glints(effect, true)} + + + ); case 'pixels': - return range(12).map((i) => ( - - )); + return ( + <> + {range(18).map((i) => ( + + ))} + {glints(effect)} + + ); case 'aurora': - return range(2).map((i) =>
); + return ( + <> + + {range(3).map((i) =>
)} + + ); case 'mesh': - return range(4).map((i) =>
); + return ( + <> + {range(5).map((i) =>
)} + {glints(effect)} + + ); case 'grid': - return range(2).map((i) =>
); + return ( + <> +
+
+
+ {glints(effect)} + + ); case 'waves': - return range(3).map((i) => ( - - - - )); + return ( + <> + {range(4).map((i) => ( +
+ + + + + + + + + +
+ ))} + {glints(effect)} + + ); case 'equalizer': return range(16).map((i) => ); case 'bokeh': - return range(10).map((i) => ( - - )); + return ( + <> + {range(16).map((i) => ( + + ))} + {glints(effect)} + + ); + case 'synthwave': + return ( + <> +
+
+
+
+ {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 ( + <> + +
+
+
+ {glints(effect)} + + ); + case 'matrix': + case 'constellation': + return ; } } diff --git a/src/components/EffectBackground.test.jsx b/src/components/EffectBackground.test.jsx index 104d157..4864a4e 100644 --- a/src/components/EffectBackground.test.jsx +++ b/src/components/EffectBackground.test.jsx @@ -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(); + const {container} = render(); 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(); + + expect(pointerListeners(add)).toHaveLength(1); + unmount(); + expect(pointerListeners(remove)).toHaveLength(1); + }); + + it('attaches nothing with parallax off', () => { + const add = vi.spyOn(window, 'addEventListener'); + render(); + + 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(); + + expect(pointerListeners(add)).toHaveLength(0); + }); + + it('detaches when the reduce animations setting turns on while mounted', async () => { + const remove = vi.spyOn(window, 'removeEventListener'); + render(); + + 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(); + + 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([]); + }); }); diff --git a/src/components/EffectCanvas.jsx b/src/components/EffectCanvas.jsx new file mode 100644 index 0000000..af8b269 --- /dev/null +++ b/src/components/EffectCanvas.jsx @@ -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 ; +} diff --git a/src/components/EffectCanvas.test.jsx b/src/components/EffectCanvas.test.jsx new file mode 100644 index 0000000..b9a51cd --- /dev/null +++ b/src/components/EffectCanvas.test.jsx @@ -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(); + + expect(raf).toHaveBeenCalledTimes(1); + expect(context.clearRect).toHaveBeenCalled(); + }); + + it('draws one static frame and runs no loop under reduced motion', () => { + reduced(); + render(); + + expect(context.clearRect).toHaveBeenCalled(); + expect(raf).not.toHaveBeenCalled(); + }); + + it('stops the loop when the canvas leaves the screen', () => { + const {container} = render(); + 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(); + cancel.mockClear(); + + unmount(); + + expect(cancel).toHaveBeenCalled(); + }); + + it('renders the canvas without a 2d context', () => { + HTMLCanvasElement.prototype.getContext.mockReturnValue(null); + + const {container} = render(); + + expect(container.querySelector('canvas')).not.toBeNull(); + expect(raf).not.toHaveBeenCalled(); + }); +}); diff --git a/src/effects.css b/src/effects.css index 467e3bc..be516af 100644 --- a/src/effects.css +++ b/src/effects.css @@ -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 moves, so it is composited; a 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; } diff --git a/src/effects.test.js b/src/effects.test.js new file mode 100644 index 0000000..ec8c7d8 --- /dev/null +++ b/src/effects.test.js @@ -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/); + } + }); +}); diff --git a/src/hooks/useReducedMotion.js b/src/hooks/useReducedMotion.js new file mode 100644 index 0000000..0d5269b --- /dev/null +++ b/src/hooks/useReducedMotion.js @@ -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 ) 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); +} diff --git a/src/hooks/useReducedMotion.test.jsx b/src/hooks/useReducedMotion.test.jsx new file mode 100644 index 0000000..39caf67 --- /dev/null +++ b/src/hooks/useReducedMotion.test.jsx @@ -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); + }); +}); diff --git a/src/pages/PointsPage.jsx b/src/pages/PointsPage.jsx index 703ed88..9f0353c 100644 --- a/src/pages/PointsPage.jsx +++ b/src/pages/PointsPage.jsx @@ -180,7 +180,7 @@ function Preview({code}) { if (look.effect) { return ( - + ); } diff --git a/src/pages/PointsPage.test.jsx b/src/pages/PointsPage.test.jsx index 15d22cd..062f1fa 100644 --- a/src/pages/PointsPage.test.jsx +++ b/src/pages/PointsPage.test.jsx @@ -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(); diff --git a/src/utils/canvasEffects.js b/src/utils/canvasEffects.js new file mode 100644 index 0000000..672ccca --- /dev/null +++ b/src/utils/canvasEffects.js @@ -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}; diff --git a/src/utils/canvasEffects.test.js b/src/utils/canvasEffects.test.js new file mode 100644 index 0000000..d365a12 --- /dev/null +++ b/src/utils/canvasEffects.test.js @@ -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(); + }); +}); diff --git a/src/utils/parallax.js b/src/utils/parallax.js new file mode 100644 index 0000000..347f2e1 --- /dev/null +++ b/src/utils/parallax.js @@ -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; + }; +} diff --git a/src/utils/parallax.test.js b/src/utils/parallax.test.js new file mode 100644 index 0000000..115802d --- /dev/null +++ b/src/utils/parallax.test.js @@ -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'); + }); + }); +}); diff --git a/src/utils/rewards.js b/src/utils/rewards.js index ebed8d5..f5b32c3 100644 --- a/src/utils/rewards.js +++ b/src/utils/rewards.js @@ -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'}, diff --git a/src/utils/rewards.test.js b/src/utils/rewards.test.js index adb6fd6..a650cd9 100644 --- a/src/utils/rewards.test.js +++ b/src/utils/rewards.test.js @@ -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); + }); +});