diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 8a9b30f..86a6c02 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,5 +1,34 @@ import { expect, test, type Page } from '@playwright/test'; +const canvasName = 'Interactive generative garden canvas'; + +const isLocalUrl = (url: string) => { + const { hostname } = new URL(url); + return hostname === '127.0.0.1' || hostname === 'localhost'; +}; + +const collectLocalBrowserFailures = (page: Page) => { + const failures: Array = []; + + page.on('requestfailed', (request) => { + if (!isLocalUrl(request.url())) { + return; + } + + const failure = request.failure(); + failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); + }); + page.on('response', (response) => { + if (response.status() < 400 || !isLocalUrl(response.url())) { + return; + } + + failures.push(`${response.status()} ${response.url()}`); + }); + + return failures; +}; + const disableWebGpu = async (page: Page) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'gpu', { @@ -9,223 +38,98 @@ const disableWebGpu = async (page: Page) => { }); }; -const getFirstSwatchColor = (page: Page) => - page - .locator('.color-swatch') - .first() - .evaluate((element) => getComputedStyle(element).backgroundColor); - -const getGardenBackground = (page: Page) => - page.evaluate(() => - document.documentElement.style.getPropertyValue('--garden-background').trim() - ); - -test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { - const browserFailures: Array = []; - - page.on('requestfailed', (request) => { - const failure = request.failure(); - browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); - }); - page.on('response', (response) => { - if (response.status() >= 400) { - browserFailures.push(`${response.status()} ${response.url()}`); +test('starts the WebGPU garden and accepts drawing input', async ({ page }) => { + const browserFailures = collectLocalBrowserFailures(page); + const consoleErrors: Array = []; + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); } }); - await disableWebGpu(page); + await page.addInitScript((expectedCanvasName) => { + const captureState = { count: 0 }; + Object.defineProperty(window, '__fleetingGardenPointerCaptures', { + configurable: true, + value: captureState, + }); + + const originalSetPointerCapture = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = function setPointerCapture(pointerId) { + if ( + this instanceof HTMLCanvasElement && + this.getAttribute('aria-label') === expectedCanvasName + ) { + captureState.count += 1; + } + + return originalSetPointerCapture.call(this, pointerId); + }; + }, canvasName); await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/, { + timeout: 30_000, + }); - await expect(page).toHaveTitle('Fleeting Garden'); - await expect( - page.getByRole('img', { name: 'Interactive generative garden canvas' }) - ).toBeVisible(); + await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); - await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); - await page.getByRole('button', { name: 'About' }).click(); - await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); + const canvas = page.getByRole('img', { name: canvasName }); + await expect(canvas).toBeVisible(); + const canvasSize = await canvas.evaluate((element) => { + const canvasElement = element as HTMLCanvasElement; + return { + height: canvasElement.height, + width: canvasElement.width, + }; + }); + expect(canvasSize.width).toBeGreaterThan(0); + expect(canvasSize.height).toBeGreaterThan(0); + + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (!box) { + return; + } + + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, { + steps: 16, + }); + await page.mouse.up(); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __fleetingGardenPointerCaptures?: { count: number }; + } + ).__fleetingGardenPointerCaptures?.count ?? 0 + ) + ) + .toBeGreaterThan(0); + + expect(consoleErrors).toEqual([]); expect(browserFailures).toEqual([]); }); -test('keeps fallback controls interactive and accessible', async ({ page }) => { - await disableWebGpu(page); +test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => { + const browserFailures = collectLocalBrowserFailures(page); + await disableWebGpu(page); await page.goto('/'); + + await expect(page).toHaveTitle('Fleeting Garden'); + await expect(page.getByRole('img', { name: canvasName })).toBeVisible(); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); await expect(page.locator('body')).not.toHaveClass(/is-loading/); - const aboutButton = page.getByRole('button', { name: 'About' }); - const aboutPanel = page.locator('#info-panel'); - await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); - await aboutButton.click(); - await expect(aboutButton).toHaveAttribute('aria-expanded', 'true'); - await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false'); - await expect(aboutPanel).not.toHaveAttribute('inert', ''); - await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); - await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true'); - await expect(aboutPanel).toHaveAttribute('inert', ''); - - const settingsButton = page.locator('button.settings'); - await expect(settingsButton).toHaveAttribute('aria-label', 'Show config overlay'); - await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); - await settingsButton.click(); - await expect(settingsButton).toHaveAttribute('aria-expanded', 'true'); - await expect(settingsButton).toHaveAttribute('aria-label', 'Hide config overlay'); - await expect(page.locator('.config-pane')).toBeVisible(); - await expect(page.locator('.config-pane')).toContainText('Runtime'); - await expect(page.locator('.color-reaction-matrix')).toBeVisible(); - - const colorReaction = page.getByLabel('Color 1 agents reacting to color 2'); - await colorReaction.selectOption('-1'); - await expect(colorReaction).toHaveValue('-1'); - await settingsButton.click(); - await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); - - const soundButton = page.locator('button.sound'); - const volumeSlider = page.getByLabel('Master volume'); - await expect(volumeSlider).toHaveValue('0.42'); - await volumeSlider.evaluate((input) => { - const slider = input as HTMLInputElement; - slider.value = '0.25'; - slider.dispatchEvent(new Event('input', { bubbles: true })); - }); - await expect(volumeSlider).toHaveValue('0.25'); - await expect(volumeSlider).toHaveAttribute('aria-valuetext', '25%'); - await expect(soundButton).toHaveAttribute('aria-pressed', 'false'); - await soundButton.click(); - await expect(soundButton).toHaveAttribute('aria-pressed', 'true'); - await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio'); - await page.reload(); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); - await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true'); - await expect(page.getByLabel('Master volume')).toHaveValue('0.25'); - await expect(page.getByLabel('Master volume')).toHaveAttribute( - 'aria-valuetext', - 'Muted, 25%' - ); - - const initialSwatchColor = await getFirstSwatchColor(page); - const initialBackground = await getGardenBackground(page); - await page.getByRole('button', { name: 'Next vibe' }).click(); - await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor); - await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground); - - await page.getByRole('button', { name: 'Draw colour 2' }).click(); - await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/); - await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/); - - const mirrorSlider = page.locator('.mirror-segment-slider'); - await mirrorSlider.evaluate((input) => { - const slider = input as HTMLInputElement; - slider.value = '3'; - slider.dispatchEvent(new Event('input', { bubbles: true })); - }); - await expect(page.locator('.mirror-segment-control')).toHaveAttribute( - 'title', - '3 thirds' - ); - await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/); -}); - -test('keeps the fallback shell usable on mobile', async ({ page }) => { - await page.setViewportSize({ height: 844, width: 390 }); - await disableWebGpu(page); - - await page.goto('/'); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); - - const canvasBox = await page - .getByRole('img', { name: 'Interactive generative garden canvas' }) - .boundingBox(); - expect(canvasBox?.width).toBeGreaterThan(0); - expect(canvasBox?.height).toBeGreaterThan(0); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); - await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); - - const aboutButtonReceivesPointer = await page - .getByRole('button', { name: 'About' }) - .evaluate((button) => { - const rect = button.getBoundingClientRect(); - const target = document.elementFromPoint( - rect.left + rect.width / 2, - rect.top + rect.height / 2 - ); - - return button === target || button.contains(target); - }); - - expect(aboutButtonReceivesPointer).toBe(true); -}); - -test('hides the bottom dock after the cursor leaves fullscreen controls', async ({ - page, -}) => { - await disableWebGpu(page); - - await page.goto('/'); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - - await page.getByRole('button', { name: 'Enter fullscreen' }).click(); - await expect - .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) - .toBe(true); - - await page.mouse.move(640, 120); - await page.evaluate(() => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - }); - - await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, { - timeout: 6000, - }); - await expect(page.locator('.garden-controls')).not.toBeVisible(); - await expect - .poll(() => - page - .locator('aside.control-dock') - .evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight) - ) - .toBe(true); - - await page.mouse.move(640, 700); - await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); - await expect(page.locator('.garden-controls')).toBeVisible(); - await expect - .poll(() => - page - .locator('aside.control-dock') - .evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight) - ) - .toBe(true); -}); - -test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => { - await page.setViewportSize({ height: 844, width: 390 }); - await disableWebGpu(page); - - await page.goto('/'); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - - await page.getByRole('button', { name: 'Enter fullscreen' }).click(); - await expect - .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) - .toBe(true); - - await page.mouse.move(195, 120); - await page.evaluate(() => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - }); - await page.waitForTimeout(5200); - - await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); - await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); + const fallback = page.getByRole('alert'); + await expect(fallback).toContainText('Fleeting Garden needs WebGPU'); + await expect(fallback).toContainText('webgpu-unsupported'); + expect(browserFailures).toEqual([]); }); diff --git a/playwright.config.ts b/playwright.config.ts index 76ea7b9..e62296a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,7 +26,12 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + launchOptions: { + args: ['--enable-unsafe-webgpu'], + }, + }, }, ], }); diff --git a/src/analytics.ts b/src/analytics.ts index d58a65a..f025389 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -4,6 +4,8 @@ import { type PlausibleEventOptions, } from '@plausible-analytics/tracker'; +import type { VibeId } from './vibes'; + let isInitialized = false; const track = (eventName: string, options: PlausibleEventOptions = {}) => { @@ -37,7 +39,7 @@ export const trackVibeChange = ({ vibeName, source, }: { - vibeId: string; + vibeId: VibeId; vibeName: string; source: string; }) => { @@ -50,7 +52,7 @@ export const trackVibeChange = ({ }); }; -export const trackExport = ({ vibeId }: { vibeId: string }) => { +export const trackExport = ({ vibeId }: { vibeId: VibeId }) => { track('Export', { props: { format: 'png', diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index 5b5129a..78abfd5 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -94,8 +94,6 @@ interface GardenAudioGenerativePianoConfig { }; brushPhrase: { initialMotifOffset: number; - energyRetain: number; - maniaRetain: number; energyDecaySeconds: number; maniaDecaySeconds: number; fadeMinimumLifetimeSeconds: number; @@ -165,7 +163,6 @@ interface GardenAudioGenerativePianoConfig { min: number; max: number; }; - styleRotationMinSeconds: number; stylePanOffsetScale: number; lowpass: { midiBase: number; @@ -174,7 +171,6 @@ interface GardenAudioGenerativePianoConfig { expressionBase: number; expressionWeight: number; }; - styleRotationSeconds: number; styleRotationBars: number; chordBars: number; supportBarSpacing: number; @@ -189,7 +185,6 @@ interface GardenAudioGenerativePianoConfig { noteScoreChordToneWeight: number; noteScoreRepeatPenalty: number; gestureAccentMinIntervalSeconds: number; - strokeAccentMinIntervalSeconds: number; strokeAccentMinSteps: number; strokeAccentThreshold: number; stingerDurationSeconds: number; @@ -315,8 +310,6 @@ export interface GardenAudioConfig { }; }; input: { - distanceWindowForFullActivityPixels: number; - distanceWindowSeconds: number; fallbackFrameSeconds: number; fullActivitySpeed: number; activityNoiseFloorSpeed: number; diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts index cf5f2e2..75522fd 100644 --- a/src/audio/garden-audio-input.ts +++ b/src/audio/garden-audio-input.ts @@ -1,6 +1,8 @@ import type { GardenAudioConfig } from './garden-audio-config'; import type { GardenAudioStroke } from './garden-audio-types'; +const fallbackNormalizationPixels = 1000; + export interface GardenAudioStrokeMetrics { distancePixels: number; elapsedSeconds: number; @@ -16,8 +18,7 @@ export const getStrokeMetrics = ( const dy = stroke.to[1] - stroke.from[1]; const distancePixels = Math.hypot(dx, dy); const elapsedSeconds = getElapsedSeconds(stroke, inputConfig); - const normalizedDistance = - distancePixels / getStrokeNormalizationPixels(stroke, inputConfig); + const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke); return { distancePixels, @@ -42,10 +43,7 @@ const getElapsedSeconds = ( return inputConfig.fallbackFrameSeconds; }; -const getStrokeNormalizationPixels = ( - stroke: GardenAudioStroke, - inputConfig: GardenAudioConfig['input'] -): number => { +const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => { const width = stroke.canvasSize?.[0]; const height = stroke.canvasSize?.[1]; if ( @@ -59,5 +57,5 @@ const getStrokeNormalizationPixels = ( return Math.max(1, Math.min(width, height)); } - return Math.max(1, inputConfig.distanceWindowForFullActivityPixels); + return fallbackNormalizationPixels; }; diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index af4e9f4..ff8e6bd 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,6 +1,6 @@ import { clamp01 } from '../utils/clamp'; import { ErrorHandler, Severity } from '../utils/error-handler'; -import { VibePreset } from '../vibes'; +import type { VibeId, VibePreset } from '../vibes'; import { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; import { GardenAudioGestureState } from './garden-audio-gesture-state'; @@ -22,6 +22,8 @@ export type { GardenAudioStroke, } from './garden-audio-types'; +type AudioLifecycle = 'idle' | 'started' | 'destroyed'; + export class GardenAudio { private readonly graph: GardenAudioGraph; private readonly piano: PianoSampler; @@ -30,13 +32,11 @@ export class GardenAudio { private readonly gestureState: GardenAudioGestureState; private readonly pianoEngine: GenerativePianoEngine; - private currentVibeId: string | null = null; - private hasStarted = false; - private isDestroyed = false; + private currentVibeId: VibeId | null = null; + private lifecycle: AudioLifecycle = 'idle'; private isMuted = false; - private masterVolume: number; private isGestureActive = false; - private hasQueuedPianoLoad = false; + private masterVolume: number; private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; @@ -51,7 +51,7 @@ export class GardenAudio { } public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { - if (this.isDestroyed || this.isMuted) { + if (this.lifecycle === 'destroyed' || this.isMuted) { return; } @@ -81,7 +81,11 @@ export class GardenAudio { if (resumePromise) { void resumePromise .then(() => { - if (this.graph.context === context && !this.isDestroyed && !this.isMuted) { + if ( + this.graph.context === context && + this.lifecycle !== 'destroyed' && + !this.isMuted + ) { this.graph.unlock(); this.graph.setMasterGain(this.masterVolume, startupRampSeconds); } @@ -94,17 +98,16 @@ export class GardenAudio { }); } - this.hasStarted = true; + this.lifecycle = 'started'; this.applyVibe(vibe); this.pianoEngine.prime(context.currentTime); this.graph.setMasterGain(this.masterVolume, startupRampSeconds); - if (!this.hasQueuedPianoLoad) { - this.hasQueuedPianoLoad = true; - void this.piano - .load(context) + const pianoLoad = this.piano.loadIfIdle(context); + if (pianoLoad) { + void pianoLoad .then(() => { - if (this.graph.context === context && !this.isDestroyed) { + if (this.graph.context === context && this.lifecycle !== 'destroyed') { this.pianoEngine.cue(context.currentTime); } }) @@ -126,7 +129,7 @@ export class GardenAudio { context && (context.state === 'running' || options.userGesture === true) && !this.isMuted && - !this.isDestroyed && + this.lifecycle !== 'destroyed' && didChangeVibe ) { this.playVibeChangeStinger(vibe); @@ -173,7 +176,7 @@ export class GardenAudio { public update(snapshot: GardenAudioSnapshot): void { const context = this.graph.context; - if (!this.hasStarted || !context || this.isMuted) { + if (this.lifecycle !== 'started' || !context || this.isMuted) { return; } @@ -195,7 +198,7 @@ export class GardenAudio { } public stroke(stroke: GardenAudioStroke): void { - if (this.isDestroyed || this.isMuted) { + if (this.lifecycle === 'destroyed' || this.isMuted) { return; } @@ -230,7 +233,7 @@ export class GardenAudio { } public async destroy(): Promise { - this.isDestroyed = true; + this.lifecycle = 'destroyed'; await this.graph.close(); this.piano.reset(); @@ -238,9 +241,7 @@ export class GardenAudio { this.gestureState.reset(); this.pianoEngine.reset(); this.currentVibeId = null; - this.hasStarted = false; this.isGestureActive = false; - this.hasQueuedPianoLoad = false; this.lastEraserAt = Number.NEGATIVE_INFINITY; this.lastVibeStingerAt = Number.NEGATIVE_INFINITY; } diff --git a/src/audio/piano-sampler.test.ts b/src/audio/piano-sampler.test.ts index e8403b6..6e61743 100644 --- a/src/audio/piano-sampler.test.ts +++ b/src/audio/piano-sampler.test.ts @@ -109,6 +109,47 @@ describe('PianoSampler', () => { expect(calls.bufferSourcesStarted).toBe(1); }); + it('only queues a piano load when the sampler is idle', async () => { + const context = new FakeAudioContext() as unknown as AudioContext; + const sampler = await makeSampler(context); + const fetch = vi.fn(async () => { + return { + arrayBuffer: async () => new ArrayBuffer(8), + ok: true, + } as Response; + }); + vi.stubGlobal('fetch', fetch); + + const firstLoad = sampler.loadIfIdle(context); + const secondLoad = sampler.loadIfIdle(context); + + expect(firstLoad).toBeInstanceOf(Promise); + expect(secondLoad).toBeNull(); + + await firstLoad; + + expect(sampler.loadIfIdle(context)).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(sampleCount); + }); + + it('allows loading to be retried after a load failure', async () => { + const context = new FakeAudioContext() as unknown as AudioContext; + const sampler = await makeSampler(context); + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error('load failed')) + .mockResolvedValue({ + arrayBuffer: async () => new ArrayBuffer(8), + ok: true, + } as Response); + vi.stubGlobal('fetch', fetch); + + await expect(sampler.loadIfIdle(context)).rejects.toThrow('load failed'); + await expect(sampler.loadIfIdle(context)).resolves.toBeUndefined(); + + expect(fetch).toHaveBeenCalledTimes(sampleCount * 2); + }); + it('stays silent when no decoded sample is available', () => { const context = new FakeAudioContext() as unknown as AudioContext; return makeSampler(context).then((sampler) => { diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index ba8d771..e8314c5 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -4,7 +4,10 @@ import { GardenAudioGraph } from './garden-audio-graph'; import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types'; import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples'; +type PianoLoadState = 'idle' | 'loading' | 'loaded'; + export class PianoSampler { + private loadState: PianoLoadState = 'idle'; private sampleLoadPromise: Promise | null = null; private samples: Array = []; private activeVoices: Array = []; @@ -15,9 +18,14 @@ export class PianoSampler { ) {} public load(context: BaseAudioContext): Promise { + if (this.loadState === 'loaded') { + return Promise.resolve(); + } + const loadedSamples = getLoadedPianoSamples(); if (loadedSamples) { this.setSamples(loadedSamples); + this.loadState = 'loaded'; return Promise.resolve(); } @@ -25,13 +33,29 @@ export class PianoSampler { return this.sampleLoadPromise; } - this.sampleLoadPromise = loadPianoSamples(context).then((samples) => { - this.setSamples(samples); - }); + this.loadState = 'loading'; + this.sampleLoadPromise = loadPianoSamples(context) + .then((samples) => { + this.setSamples(samples); + this.loadState = 'loaded'; + }) + .catch((error) => { + this.loadState = 'idle'; + this.sampleLoadPromise = null; + throw error; + }); return this.sampleLoadPromise; } + public loadIfIdle(context: BaseAudioContext): Promise | null { + if (this.loadState !== 'idle') { + return null; + } + + return this.load(context); + } + public play({ midi, velocity, @@ -158,6 +182,7 @@ export class PianoSampler { } public reset(): void { + this.loadState = 'idle'; this.sampleLoadPromise = null; this.samples = []; this.activeVoices = []; diff --git a/src/config.ts b/src/config.ts index 4427c33..de6a9c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,8 @@ import { defaultVibeId, vibePresets } from './config/vibe-presets'; const defaultAudioMasterVolume = 0.42; +export { VibeId } from './config/types'; + export type { GardenAppConfig, GardenRuntimeSettings, @@ -56,7 +58,7 @@ export const appConfig = { tailStopExtraSeconds: 0.05, voiceStealFadeSeconds: 0.025, voiceStealStopSeconds: 0.05, - sampleBaseUrl: `${import.meta.env.BASE_URL}audio/piano/`, + sampleBaseUrl: `${import.meta.env.BASE_URL}audio/`, preloadDecode: { channels: 1, frames: 1, @@ -126,8 +128,6 @@ export const appConfig = { }, }, input: { - distanceWindowForFullActivityPixels: 140, - distanceWindowSeconds: 0.5, fallbackFrameSeconds: 1 / 60, fullActivitySpeed: 0.86, activityNoiseFloorSpeed: 0.025, @@ -267,8 +267,6 @@ export const appConfig = { }, brushPhrase: { initialMotifOffset: -1, - energyRetain: 0.94, - maniaRetain: 0.92, energyDecaySeconds: 0.72, maniaDecaySeconds: 0.54, fadeMinimumLifetimeSeconds: 0.001, @@ -338,7 +336,6 @@ export const appConfig = { min: -3, max: 3, }, - styleRotationMinSeconds: 0.001, stylePanOffsetScale: 0.35, lowpass: { midiBase: 48, @@ -347,7 +344,6 @@ export const appConfig = { expressionBase: 0.58, expressionWeight: 0.32, }, - styleRotationSeconds: 8, styleRotationBars: 2, chordBars: 4, supportBarSpacing: 2, @@ -362,7 +358,6 @@ export const appConfig = { noteScoreChordToneWeight: 0.75, noteScoreRepeatPenalty: 3.2, gestureAccentMinIntervalSeconds: 2.5, - strokeAccentMinIntervalSeconds: 3.2, strokeAccentMinSteps: 12, strokeAccentThreshold: 0.58, stingerSpacingSeconds: 0.08, diff --git a/src/config/types.ts b/src/config/types.ts index 8b0e1c2..19fd7da 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -64,8 +64,17 @@ type GardenDefaultSettings = Omit< keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount' >; +export enum VibeId { + CandyRain = 'candy-rain', + SunlitMoss = 'sunlit-moss', + CoralTide = 'coral-tide', + MoonOrchid = 'moon-orchid', + PeachNeon = 'peach-neon', + FrostBloom = 'frost-bloom', +} + export interface VibePreset { - id: string; + id: VibeId; name: string; colors: [string, string, string]; backgroundColor: string; @@ -248,7 +257,7 @@ export interface GardenAppConfig { title: string; }; vibes: { - defaultVibeId: string; + defaultVibeId: VibeId; presets: Array; }; } diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index 40fbfe3..9885ab6 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -1,5 +1,5 @@ import type { GardenAudioChord } from '../audio/garden-audio-config'; -import type { VibePreset } from './types'; +import { VibeId, type VibePreset } from './types'; const majorProgression: Array = [ { rootOffset: 0, quality: 'major' }, @@ -21,11 +21,11 @@ const mixolydianPentatonic = [0, 2, 4, 7, 10]; const dorianHexatonic = [0, 2, 3, 5, 7, 10]; const darkMinorPentatonic = [0, 2, 3, 7, 10]; -export const defaultVibeId = 'candy-rain'; +export const defaultVibeId = VibeId.CandyRain; export const vibePresets: Array = [ { - id: 'candy-rain', + id: VibeId.CandyRain, name: 'Candy Rain', colors: ['#ff5da2', '#36d7d0', '#ffd84d'], backgroundColor: '#10151f', @@ -50,7 +50,7 @@ export const vibePresets: Array = [ }, }, { - id: 'sunlit-moss', + id: VibeId.SunlitMoss, name: 'Sunlit Moss', colors: ['#83d483', '#f6d76b', '#5ec1a1'], backgroundColor: '#172016', @@ -80,7 +80,7 @@ export const vibePresets: Array = [ }, }, { - id: 'coral-tide', + id: VibeId.CoralTide, name: 'Coral Tide', colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'], backgroundColor: '#0f1822', @@ -105,7 +105,7 @@ export const vibePresets: Array = [ }, }, { - id: 'moon-orchid', + id: VibeId.MoonOrchid, name: 'Moon Orchid', colors: ['#c993ff', '#7dd8ff', '#f0f4ff'], backgroundColor: '#14121d', @@ -130,7 +130,7 @@ export const vibePresets: Array = [ }, }, { - id: 'peach-neon', + id: VibeId.PeachNeon, name: 'Peach Neon', colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'], backgroundColor: '#191716', @@ -155,7 +155,7 @@ export const vibePresets: Array = [ }, }, { - id: 'frost-bloom', + id: VibeId.FrostBloom, name: 'Frost Bloom', colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'], backgroundColor: '#101820', diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts index 64a770d..6cce50a 100644 --- a/src/game-loop/agent-population.test.ts +++ b/src/game-loop/agent-population.test.ts @@ -19,6 +19,8 @@ vi.hoisted(() => { const originalBrushSize = settings.brushSize; const originalSelectedColorIndex = settings.selectedColorIndex; const originalSpawnPerPixel = settings.spawnPerPixel; +const originalStrokeSpawnSpreadBrushSizeMultiplier = + settings.strokeSpawnSpreadBrushSizeMultiplier; const createPopulation = () => { const pipeline = { @@ -51,12 +53,16 @@ describe('AgentPopulation adaptive budget', () => { settings.brushSize = 1; settings.selectedColorIndex = 0; settings.spawnPerPixel = 1; + settings.strokeSpawnSpreadBrushSizeMultiplier = 1; }); afterEach(() => { settings.brushSize = originalBrushSize; settings.selectedColorIndex = originalSelectedColorIndex; settings.spawnPerPixel = originalSpawnPerPixel; + settings.strokeSpawnSpreadBrushSizeMultiplier = + originalStrokeSpawnSpreadBrushSizeMultiplier; + vi.restoreAllMocks(); }); it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => { @@ -102,6 +108,25 @@ describe('AgentPopulation adaptive budget', () => { expect(population.activeAgentCount).toBe(maxAgentCount); }); + it('scales stroke spawn spread by device pixel ratio', () => { + settings.brushSize = 10; + const writeAgents = vi.fn(); + const pipeline = { + maxAgentCount: 10_000_000, + writeAgents, + resizeAgents: vi.fn(), + compactAgents: vi.fn(), + } as unknown as AgentGenerationPipeline; + const population = new AgentPopulation(pipeline, 0, () => 2); + vi.spyOn(Math, 'random').mockReturnValue(1); + + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(0, 0)); + + const firstBatch = writeAgents.mock.calls[0][1] as Float32Array; + expect(firstBatch[0]).toBe(10); + expect(firstBatch[1]).toBe(10); + }); + it('decreases the cap and active count slowly when FPS falls below the threshold', () => { const population = createPopulation(); setPopulationActiveCount(population, 1_000_000); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index b65b2be..89f40da 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -3,6 +3,7 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../config'; import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent'; import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { getSafeDevicePixelRatio } from '../pipelines/brush/brush-pipeline'; import { settings } from '../settings'; import { createIntroTitleAgents } from './intro-title-agents'; @@ -19,7 +20,8 @@ export class AgentPopulation { public constructor( private readonly pipeline: AgentGenerationPipeline, - private readonly introSeed = Math.floor(Math.random() * 0xffffffff) + private readonly introSeed = Math.floor(Math.random() * 0xffffffff), + private readonly getDevicePixelRatio = () => 1 ) { this.adaptiveCap = this.clampAdaptiveCap( appConfig.simulation.budget.adaptiveCapInitial @@ -121,7 +123,10 @@ export class AgentPopulation { baseAngle + (Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians; const base = i * AGENT_FLOAT_COUNT; - const spread = settings.brushSize * settings.strokeSpawnSpreadBrushSizeMultiplier; + const spread = + settings.brushSize * + getSafeDevicePixelRatio(this.getDevicePixelRatio()) * + settings.strokeSpawnSpreadBrushSizeMultiplier; this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread; this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread; this.strokeAgentData[base + 2] = angle; diff --git a/src/game-loop/export-4k-renderer.ts b/src/game-loop/export-4k-renderer.ts index 32375d2..4aafa01 100644 --- a/src/game-loop/export-4k-renderer.ts +++ b/src/game-loop/export-4k-renderer.ts @@ -1,5 +1,6 @@ import { appConfig } from '../config'; import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import type { VibeId } from '../vibes'; import { estimateExport4KMemory, getAspectFitExport4KDimensions, @@ -15,7 +16,7 @@ interface Export4KRendererOptions { getSourceSize: () => { width: number; height: number }; getColorTextureView: () => GPUTextureView; getSourceTextureView: () => GPUTextureView; - getVibeId: () => string; + getVibeId: () => VibeId; } export class Export4KRenderer { diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index d285719..4acd14b 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -20,6 +20,7 @@ interface FrameParameters extends RenderInputs { deltaTime: number; canvasSize: vec2; activeAgentCount: number; + devicePixelRatio: number; introProgress: number; selectedColorIndex: number; isErasing: boolean; @@ -99,6 +100,7 @@ export class GameLoopResources { deltaTime, canvasSize, activeAgentCount, + devicePixelRatio, introProgress, selectedColorIndex, channelColors, @@ -123,6 +125,7 @@ export class GameLoopResources { }); this.brushPipeline.setParameters({ ...settings, + devicePixelRatio, selectedColorIndex, }); this.diffusionPipeline.setParameters(settings); diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index dcf46b6..3ba7ee3 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -49,7 +49,8 @@ export default class GameLoop { this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device); this.agentPopulation = new AgentPopulation( this.resources.agentGenerationPipeline, - this.seedValue + this.seedValue, + () => this.devicePixelRatio ); this.agentPopulation.initializeIntroAgents(this.canvasSize); this.pointerInput = new GardenPointerInput({ @@ -155,7 +156,8 @@ export default class GameLoop { const { channelColors, backgroundColor } = this.renderInputs.get(); const introProgress = this.introPrompt.progress; - const eraserPixelSize = settings.eraserSize * this.devicePixelRatio; + const devicePixelRatio = this.devicePixelRatio; + const eraserPixelSize = settings.eraserSize * devicePixelRatio; const isErasing = this.pointerInput.isEraseMode; const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; this.renderInputs.updateAccentColor(accentColor); @@ -169,6 +171,7 @@ export default class GameLoop { deltaTime, canvasSize: this.canvasSize, activeAgentCount: this.agentPopulation.activeAgentCount, + devicePixelRatio, introProgress, selectedColorIndex: settings.selectedColorIndex, isErasing, diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts index a8057aa..a22a715 100644 --- a/src/game-loop/pointer-input.test.ts +++ b/src/game-loop/pointer-input.test.ts @@ -88,7 +88,9 @@ const makeSwipePipeline = () => ({ clearSwipes: vi.fn(), }); -const createPointerInput = async () => { +const createPointerInput = async ({ + devicePixelRatio = 1, +}: { devicePixelRatio?: number } = {}) => { const { GardenPointerInput } = await import('./pointer-input'); const { settings: runtimeSettings } = await import('../settings'); const canvas = new FakeCanvas(); @@ -117,7 +119,7 @@ const createPointerInput = async () => { eraserAgentPipeline, eraserPreview, eraserTexturePipeline, - getDevicePixelRatio: () => 1, + getDevicePixelRatio: () => devicePixelRatio, getMirrorSegmentCount: () => 1, onEraseGestureEnded, onStartDrawing, @@ -277,6 +279,30 @@ describe('GardenPointerInput drawing startup', () => { expect(toPoint(stroke.to)).toEqual([40, 50]); }); + it('keeps pointer geometry in backing pixels on high-DPR canvases', async () => { + const { audio, brushPipeline, canvas } = await createPointerInput({ + devicePixelRatio: 2, + }); + + canvas.dispatchPointerEvent('pointerdown', { + clientX: 10, + clientY: 20, + pointerId: 9, + timeStamp: 100, + }); + canvas.dispatchPointerEvent('pointermove', { + clientX: 40, + clientY: 50, + pointerId: 9, + timeStamp: 150, + }); + + const firstStroke = audio.stroke.mock.calls[0][0]; + expect(toPoint(firstStroke.from)).toEqual([20, 40]); + expect(toPoint(firstStroke.to)).toEqual([80, 100]); + expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[0][0])).toEqual([20, 40]); + }); + it('caps curve tessellation with the brush curve resolution setting', async () => { const { brushPipeline, canvas, runtimeSettings } = await createPointerInput(); runtimeSettings.brushCurveResolution = 2; diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index 699610b..fa80996 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -2,7 +2,10 @@ import { vec2 } from 'gl-matrix'; import { GardenAudio } from '../audio/garden-audio'; import { appConfig } from '../config'; -import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; +import { + BrushPipeline, + getSafeDevicePixelRatio, +} from '../pipelines/brush/brush-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; import { activeVibe, settings } from '../settings'; @@ -201,7 +204,9 @@ export class GardenPointerInput { private getCanvasPointerPosition(event: PointerEvent): vec2 { const rect = this.canvas.getBoundingClientRect(); - const devicePixelRatio = this.options.getDevicePixelRatio(); + const devicePixelRatio = getSafeDevicePixelRatio( + this.options.getDevicePixelRatio() + ); return vec2.fromValues( (event.clientX - rect.left) * devicePixelRatio, (event.clientY - rect.top) * devicePixelRatio @@ -213,7 +218,8 @@ export class GardenPointerInput { this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1]; if ( previousSample !== undefined && - vec2.squaredDistance(previousSample, position) <= getBrushSmoothingDistanceSquared() + vec2.squaredDistance(previousSample, position) <= + getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio()) ) { return; } @@ -247,12 +253,13 @@ export class GardenPointerInput { private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void { const curveLength = vec2.distance(start, control) + vec2.distance(control, end); + const devicePixelRatio = getSafeDevicePixelRatio(this.options.getDevicePixelRatio()); const brushRadius = Math.max( - settings.brushCurveMinBrushRadius, - settings.brushSize / 2 + settings.brushCurveMinBrushRadius * devicePixelRatio, + (settings.brushSize * devicePixelRatio) / 2 ); const segmentSpacing = Math.max( - settings.brushCurveMinSegmentSpacing, + settings.brushCurveMinSegmentSpacing * devicePixelRatio, brushRadius * settings.brushCurveSegmentBrushRadiusRatio ); const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount()); @@ -292,7 +299,7 @@ export class GardenPointerInput { if ( this.lastSmoothedBrushPosition !== null && vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) > - getBrushSmoothingDistanceSquared() + getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio()) ) { this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample); } @@ -374,11 +381,11 @@ const getBrushCurveResolution = (): number => { return Math.max(1, Math.floor(resolution)); }; -const getBrushSmoothingDistanceSquared = (): number => { +const getBrushSmoothingDistanceSquared = (devicePixelRatio?: number): number => { const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance) ? settings.brushSmoothingMinSampleDistance : appConfig.defaultSettings.brushSmoothingMinSampleDistance; - return Math.max(0, distance) ** 2; + return Math.max(0, distance * getSafeDevicePixelRatio(devicePixelRatio)) ** 2; }; const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean => diff --git a/src/game-loop/render-input-cache.ts b/src/game-loop/render-input-cache.ts index 11969d5..6e72c16 100644 --- a/src/game-loop/render-input-cache.ts +++ b/src/game-loop/render-input-cache.ts @@ -1,9 +1,9 @@ import { activeVibe } from '../settings'; -import { hexToRgb } from '../vibes'; +import { hexToRgb, type VibeId } from '../vibes'; import { RenderInputs } from './game-loop-types'; export class RenderInputCache { - private cachedVibeId: string | null = null; + private cachedVibeId: VibeId | null = null; private cachedRenderInputs?: RenderInputs; private previousAccentColor = ''; diff --git a/src/index.ts b/src/index.ts index 44cb627..b624a08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -308,38 +308,6 @@ const main = async () => { elements.infoElement, elements.aside ); - configPane = new ConfigPane({ - settingsButton: elements.settingsButton, - onConfigChange: () => { - game?.onVibeChanged(); - syncRuntimeUi(); - }, - onOpenChange: () => undefined, - onRuntimeChange: syncRuntimeUi, - onRuntimeReset: () => { - resetSettings(); - game?.onVibeChanged(); - syncRuntimeUi(); - }, - onRestart: () => game?.destroy(), - onVibeChange: (vibeId) => { - const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId); - if (!vibe) { - return; - } - - const activePreset = applyVibeSettings(vibe); - trackVibeChange({ - vibeId: activePreset.id, - vibeName: activePreset.name, - source: 'settings', - }); - game?.onVibeChanged(); - syncRuntimeUi(); - game?.playVibeChangeAudio(false); - }, - }); - infoPageHandler.onOpen = configPane.close.bind(configPane); new MenuHider( elements.aside, @@ -488,6 +456,38 @@ const main = async () => { }); setLoadingStage('Connecting to GPU…', 0.1); const gpu = await initializeGpu(); + configPane = new ConfigPane({ + settingsButton: elements.settingsButton, + onConfigChange: () => { + game?.onVibeChanged(); + syncRuntimeUi(); + }, + onOpenChange: () => undefined, + onRuntimeChange: syncRuntimeUi, + onRuntimeReset: () => { + resetSettings(); + game?.onVibeChanged(); + syncRuntimeUi(); + }, + onRestart: () => game?.destroy(), + onVibeChange: (vibeId) => { + const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId); + if (!vibe) { + return; + } + + const activePreset = applyVibeSettings(vibe); + trackVibeChange({ + vibeId: activePreset.id, + vibeName: activePreset.name, + source: 'settings', + }); + game?.onVibeChanged(); + syncRuntimeUi(); + game?.playVibeChangeAudio(false); + }, + }); + infoPageHandler.onOpen = configPane.close.bind(configPane); setLoadingStage('Loading fonts…', 0.3); await fontsReady; setLoadingStage('Loading piano samples…', 0.45); diff --git a/src/page/collapsible-panel-animator.test.ts b/src/page/collapsible-panel-animator.test.ts new file mode 100644 index 0000000..6d32a4e --- /dev/null +++ b/src/page/collapsible-panel-animator.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CollapsiblePanelAnimator } from './collapsible-panel-animator'; + +type Listener = (event: Record) => void; + +class FakeClassList { + private readonly classes = new Set(); + + public add(className: string): void { + this.classes.add(className); + } + + public contains(className: string): boolean { + return this.classes.has(className); + } + + public remove(className: string): void { + this.classes.delete(className); + } + + public toggle(className: string, force?: boolean): boolean { + const shouldAdd = force ?? !this.classes.has(className); + if (shouldAdd) { + this.add(className); + } else { + this.remove(className); + } + return shouldAdd; + } +} + +class FakeElement { + public readonly classList = new FakeClassList(); + public inert = false; + + private readonly attributes = new Map(); + private readonly children = new Set(); + private readonly listeners = new Map>(); + + public addChild(child: FakeElement): void { + this.children.add(child); + } + + public addEventListener(type: string, listener: Listener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + public contains(target: unknown): boolean { + return target === this || this.children.has(target as FakeElement); + } + + public dispatch(type: string, event: Record = {}): void { + this.listeners.get(type)?.forEach((listener) => listener({ target: this, ...event })); + } + + public focus(): void { + fakeDocument.activeElement = this; + } + + public getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + public setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } +} + +const windowListeners = new Map>(); +const fakeDocument: { activeElement: FakeElement | null } = { + activeElement: null, +}; + +const dispatchWindowEvent = (type: string, event: Record = {}) => { + windowListeners.get(type)?.forEach((listener) => listener(event)); +}; + +describe('CollapsiblePanelAnimator', () => { + beforeEach(() => { + windowListeners.clear(); + fakeDocument.activeElement = null; + vi.stubGlobal('HTMLElement', FakeElement); + vi.stubGlobal('document', fakeDocument); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('window', { + addEventListener: (type: string, listener: Listener) => { + windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]); + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('syncs About panel accessibility when toggled and closed with Escape', () => { + const button = new FakeElement(); + const panel = new FakeElement(); + const dock = new FakeElement(); + dock.addChild(button); + dock.addChild(panel); + + new CollapsiblePanelAnimator( + button as unknown as HTMLButtonElement, + panel as unknown as HTMLElement, + dock as unknown as HTMLElement + ); + + expect(button.getAttribute('aria-expanded')).toBe('false'); + expect(panel.getAttribute('aria-hidden')).toBe('true'); + expect(panel.inert).toBe(true); + expect(panel.classList.contains('hidden')).toBe(true); + + fakeDocument.activeElement = button; + button.dispatch('click'); + + expect(button.getAttribute('aria-expanded')).toBe('true'); + expect(button.classList.contains('active')).toBe(true); + expect(panel.getAttribute('aria-hidden')).toBe('false'); + expect(panel.inert).toBe(false); + expect(panel.classList.contains('hidden')).toBe(false); + expect(fakeDocument.activeElement).toBe(panel); + + const preventDefault = vi.fn(); + dispatchWindowEvent('keydown', { key: 'Escape', preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(button.getAttribute('aria-expanded')).toBe('false'); + expect(panel.getAttribute('aria-hidden')).toBe('true'); + expect(panel.inert).toBe(true); + expect(fakeDocument.activeElement).toBe(button); + }); +}); diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index f18be6c..84831c0 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -7,7 +7,7 @@ import { type NumberControlConfig, } from '../config'; import { activeVibe, settings } from '../settings'; -import { VIBE_PRESETS } from '../vibes'; +import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes'; type PaneContainer = Pick; type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number]; @@ -43,7 +43,7 @@ interface ConfigPaneOptions { onRestart: () => void; onRuntimeChange: () => void; onRuntimeReset: () => void; - onVibeChange: (vibeId: string) => void; + onVibeChange: (vibeId: VibeId) => void; settingsButton: HTMLButtonElement; } @@ -97,7 +97,7 @@ export class ConfigPane { colorIndex: number; element: HTMLElement; }> = []; - private readonly state = { + private readonly state: { activeVibeId: VibeId } = { activeVibeId: activeVibe.id, }; @@ -172,9 +172,13 @@ export class ConfigPane { label: 'active vibe', options: Object.fromEntries( VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id]) - ) as Record, + ) as Record, }) .on('change', ({ value }) => { + if (!isVibeId(value)) { + this.refresh(); + return; + } this.options.onVibeChange(value); this.refresh(); }); diff --git a/src/page/menu-hider.test.ts b/src/page/menu-hider.test.ts new file mode 100644 index 0000000..4f5cffd --- /dev/null +++ b/src/page/menu-hider.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { appConfig } from '../config'; +import { MenuHider } from './menu-hider'; + +type Listener> = (event: T) => void; + +class FakeClassList { + private readonly classes = new Set(); + + public add(className: string): void { + this.classes.add(className); + } + + public contains(className: string): boolean { + return this.classes.has(className); + } + + public remove(className: string): void { + this.classes.delete(className); + } +} + +class FakeDockElement { + public readonly classList = new FakeClassList(); + public inert = false; + + private readonly attributes = new Map(); + private readonly listeners = new Map>(); + + public addEventListener(type: string, listener: Listener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + public contains(target: unknown): boolean { + return target === this; + } + + public dispatch(type: string, event: Record = {}): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } + + public getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + public getBoundingClientRect(): DOMRect { + return { + bottom: 720, + height: 120, + left: 0, + right: 1280, + toJSON: () => ({}), + top: 600, + width: 1280, + x: 0, + y: 600, + } as DOMRect; + } + + public setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } +} + +const windowListeners = new Map>(); +let isDesktop = true; + +const dispatchWindowEvent = >( + type: string, + event: T +) => { + windowListeners.get(type)?.forEach((listener) => listener(event)); +}; + +describe('MenuHider', () => { + beforeEach(() => { + vi.useFakeTimers(); + windowListeners.clear(); + isDesktop = true; + + vi.stubGlobal('document', { + activeElement: null, + addEventListener: vi.fn(), + documentElement: { + clientHeight: 720, + }, + }); + vi.stubGlobal('window', { + addEventListener: (type: string, listener: Listener) => { + windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]); + }, + clearTimeout, + innerHeight: 720, + matchMedia: () => ({ + addEventListener: vi.fn(), + matches: isDesktop, + }), + setTimeout, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('hides the dock after the desktop fullscreen pointer leaves it', () => { + const dock = new FakeDockElement(); + + new MenuHider(dock as unknown as HTMLElement, () => true); + dock.dispatch('pointerleave'); + vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs); + + expect(dock.classList.contains('menu-hidden')).toBe(true); + expect(dock.getAttribute('aria-hidden')).toBe('true'); + expect(dock.inert).toBe(true); + + dispatchWindowEvent('pointermove', { clientX: 640, clientY: 710 }); + + expect(dock.classList.contains('menu-hidden')).toBe(false); + expect(dock.getAttribute('aria-hidden')).toBe('false'); + expect(dock.inert).toBe(false); + }); + + it('keeps the dock visible outside the desktop auto-hide breakpoint', () => { + isDesktop = false; + const dock = new FakeDockElement(); + + new MenuHider(dock as unknown as HTMLElement, () => true); + dock.dispatch('pointerleave'); + vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs); + + expect(dock.classList.contains('menu-hidden')).toBe(false); + expect(dock.getAttribute('aria-hidden')).toBe('false'); + expect(dock.inert).toBe(false); + }); +}); diff --git a/src/pipelines/brush/brush-pipeline.test.ts b/src/pipelines/brush/brush-pipeline.test.ts new file mode 100644 index 0000000..889a2bf --- /dev/null +++ b/src/pipelines/brush/brush-pipeline.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { getSafeDevicePixelRatio, setBrushUniformValues } from './brush-pipeline'; + +const brushSettings = { + brushAlpha: 0.75, + brushCoarseNoiseScale: 100, + brushDiscardThreshold: 0.02, + brushFeatherRatio: 0.25, + brushGrainMaxStrength: 1, + brushGrainMinStrength: 0.4, + brushGrainNoiseOffsetX: 0.1, + brushGrainNoiseOffsetY: 0.2, + brushGrainNoiseScale: 25, + brushMinimumFeather: 2, + brushSize: 10, + brushSizeVariation: 0.5, + selectedColorIndex: 1, +}; + +describe('brush pipeline parameters', () => { + it('scales pixel-space brush uniforms by device pixel ratio', () => { + const uniformValues = new Float32Array(16); + + setBrushUniformValues(uniformValues, { + ...brushSettings, + devicePixelRatio: 2, + }); + + expect(uniformValues[0]).toBe(10); + expect(uniformValues[1]).toBe(5); + expect(uniformValues[3]).toBe(4); + expect(uniformValues[5]).toBe(1); + expect(uniformValues[8]).toBe(200); + expect(uniformValues[9]).toBe(50); + expect(uniformValues[15]).toBe(19); + }); + + it('falls back to a 1x pixel ratio for invalid values', () => { + expect(getSafeDevicePixelRatio(0)).toBe(1); + expect(getSafeDevicePixelRatio(Number.NaN)).toBe(1); + expect(getSafeDevicePixelRatio(undefined)).toBe(1); + expect(getSafeDevicePixelRatio(1.5)).toBe(1.5); + }); +}); diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index f99b60c..dd70285 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -15,6 +15,66 @@ interface LineSegment { to: vec2; } +interface BrushParameterSettings extends BrushSettings { + devicePixelRatio?: number; + selectedColorIndex: number; +} + +export const getSafeDevicePixelRatio = (devicePixelRatio: number | undefined): number => + typeof devicePixelRatio === 'number' && + Number.isFinite(devicePixelRatio) && + devicePixelRatio > 0 + ? devicePixelRatio + : 1; + +export const setBrushUniformValues = ( + target: Float32Array, + { + brushSize, + brushSizeVariation, + brushAlpha, + brushFeatherRatio, + brushMinimumFeather, + brushDiscardThreshold, + brushCoarseNoiseScale, + brushGrainNoiseScale, + brushGrainNoiseOffsetX, + brushGrainNoiseOffsetY, + brushGrainMinStrength, + brushGrainMaxStrength, + selectedColorIndex, + devicePixelRatio, + }: BrushParameterSettings +): void => { + const pixelRatio = getSafeDevicePixelRatio(devicePixelRatio); + const brushRadius = (brushSize * pixelRatio) / 2; + const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation); + const brushMinimumFeatherPixels = brushMinimumFeather * pixelRatio; + const brushFeather = Math.max( + brushMinimumFeatherPixels, + brushRadius * brushFeatherRatio + ); + const brushGeometryRadius = + brushRadius + Math.max(0, brushRadiusVariation) + brushFeather; + + target[0] = brushRadius; + target[1] = brushRadiusVariation; + target[2] = brushFeatherRatio; + target[3] = brushMinimumFeatherPixels; + target[4] = selectedColorIndex === 0 ? 1 : 0; + target[5] = selectedColorIndex === 1 ? 1 : 0; + target[6] = selectedColorIndex === 2 ? 1 : 0; + target[7] = brushAlpha; + target[8] = brushCoarseNoiseScale * pixelRatio; + target[9] = brushGrainNoiseScale * pixelRatio; + target[10] = brushGrainNoiseOffsetX; + target[11] = brushGrainNoiseOffsetY; + target[12] = brushDiscardThreshold; + target[13] = brushGrainMinStrength; + target[14] = brushGrainMaxStrength; + target[15] = brushGeometryRadius; +}; + export class BrushPipeline { private static readonly UNIFORM_COUNT = 16; private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount; @@ -87,43 +147,8 @@ export class BrushPipeline { this.actualSegments.length = 0; } - public setParameters({ - brushSize, - brushSizeVariation, - brushAlpha, - brushFeatherRatio, - brushMinimumFeather, - brushDiscardThreshold, - brushCoarseNoiseScale, - brushGrainNoiseScale, - brushGrainNoiseOffsetX, - brushGrainNoiseOffsetY, - brushGrainMinStrength, - brushGrainMaxStrength, - selectedColorIndex, - }: BrushSettings & { selectedColorIndex: number }) { - const brushRadius = brushSize / 2; - const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation); - const brushFeather = Math.max(brushMinimumFeather, brushRadius * brushFeatherRatio); - const brushGeometryRadius = - brushRadius + Math.max(0, brushRadiusVariation) + brushFeather; - - this.uniformValues[0] = brushRadius; - this.uniformValues[1] = brushRadiusVariation; - this.uniformValues[2] = brushFeatherRatio; - this.uniformValues[3] = brushMinimumFeather; - this.uniformValues[4] = selectedColorIndex === 0 ? 1 : 0; - this.uniformValues[5] = selectedColorIndex === 1 ? 1 : 0; - this.uniformValues[6] = selectedColorIndex === 2 ? 1 : 0; - this.uniformValues[7] = brushAlpha; - this.uniformValues[8] = brushCoarseNoiseScale; - this.uniformValues[9] = brushGrainNoiseScale; - this.uniformValues[10] = brushGrainNoiseOffsetX; - this.uniformValues[11] = brushGrainNoiseOffsetY; - this.uniformValues[12] = brushDiscardThreshold; - this.uniformValues[13] = brushGrainMinStrength; - this.uniformValues[14] = brushGrainMaxStrength; - this.uniformValues[15] = brushGeometryRadius; + public setParameters(parameters: BrushParameterSettings) { + setBrushUniformValues(this.uniformValues, parameters); writeFloat32BufferIfChanged( this.device, this.uniforms, diff --git a/src/vibes.test.ts b/src/vibes.test.ts index 198c87d..6a6cde5 100644 --- a/src/vibes.test.ts +++ b/src/vibes.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { gardenAudioConfig } from './audio/garden-audio-config'; -import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes'; +import { getInitialVibe, hexToRgb, VIBE_PRESETS, VibeId } from './vibes'; const originalLocalStorage = globalThis.localStorage; @@ -29,9 +29,9 @@ describe('vibe selection', () => { }); it('uses a valid stored vibe id', () => { - setBrowserVibeState({ storedVibeId: 'sunlit-moss' }); + setBrowserVibeState({ storedVibeId: VibeId.SunlitMoss }); - expect(getInitialVibe().id).toBe('sunlit-moss'); + expect(getInitialVibe().id).toBe(VibeId.SunlitMoss); }); it('falls back to the default preset for an unknown stored vibe id', () => { diff --git a/src/vibes.ts b/src/vibes.ts index 5b6bd47..42db194 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -1,9 +1,11 @@ -import { appConfig, type VibePreset } from './config'; +import { appConfig, type VibeId, type VibePreset } from './config'; import { readBrowserStorage } from './utils/browser-storage'; +export { VibeId } from './config'; export type { VibePreset } from './config'; export const VIBE_PRESETS: Array = appConfig.vibes.presets; +const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id)); const HEX_COLOR_PATTERN = /^#?(?[0-9a-f]{2})(?[0-9a-f]{2})(?[0-9a-f]{2})$/i; @@ -18,11 +20,14 @@ export const hexToRgb = (hex: string): [number, number, number] => { return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255]; }; +export const isVibeId = (value: unknown): value is VibeId => + typeof value === 'string' && VIBE_IDS.has(value as VibeId); + export const getInitialVibe = (): VibePreset => { - const id = readBrowserStorage(appConfig.storage.vibeKey); - return ( - VIBE_PRESETS.find((vibe) => vibe.id === id) ?? - VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ?? - VIBE_PRESETS[0] - ); + const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey); + const initialVibeId = isVibeId(storedVibeId) + ? storedVibeId + : appConfig.vibes.defaultVibeId; + + return VIBE_PRESETS.find((vibe) => vibe.id === initialVibeId) ?? VIBE_PRESETS[0]; };