This commit is contained in:
Andras Schmelczer 2026-05-17 17:21:49 +01:00
parent ced0ac56f3
commit d6a8f898d1
27 changed files with 760 additions and 363 deletions

View file

@ -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<string> = [];
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<string> = [];
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<string> = [];
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([]);
});

View file

@ -26,7 +26,12 @@ export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: ['--enable-unsafe-webgpu'],
},
},
},
],
});

View file

@ -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',

View file

@ -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;

View file

@ -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;
};

View file

@ -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<void> {
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;
}

View file

@ -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) => {

View file

@ -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<void> | null = null;
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
@ -15,9 +18,14 @@ export class PianoSampler {
) {}
public load(context: BaseAudioContext): Promise<void> {
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<void> | 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 = [];

View file

@ -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,

View file

@ -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<VibePreset>;
};
}

View file

@ -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<GardenAudioChord> = [
{ 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<VibePreset> = [
{
id: 'candy-rain',
id: VibeId.CandyRain,
name: 'Candy Rain',
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
backgroundColor: '#10151f',
@ -50,7 +50,7 @@ export const vibePresets: Array<VibePreset> = [
},
},
{
id: 'sunlit-moss',
id: VibeId.SunlitMoss,
name: 'Sunlit Moss',
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
backgroundColor: '#172016',
@ -80,7 +80,7 @@ export const vibePresets: Array<VibePreset> = [
},
},
{
id: 'coral-tide',
id: VibeId.CoralTide,
name: 'Coral Tide',
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
backgroundColor: '#0f1822',
@ -105,7 +105,7 @@ export const vibePresets: Array<VibePreset> = [
},
},
{
id: 'moon-orchid',
id: VibeId.MoonOrchid,
name: 'Moon Orchid',
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
backgroundColor: '#14121d',
@ -130,7 +130,7 @@ export const vibePresets: Array<VibePreset> = [
},
},
{
id: 'peach-neon',
id: VibeId.PeachNeon,
name: 'Peach Neon',
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
backgroundColor: '#191716',
@ -155,7 +155,7 @@ export const vibePresets: Array<VibePreset> = [
},
},
{
id: 'frost-bloom',
id: VibeId.FrostBloom,
name: 'Frost Bloom',
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
backgroundColor: '#101820',

View file

@ -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);

View file

@ -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;

View file

@ -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 {

View file

@ -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);

View file

@ -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,

View file

@ -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;

View file

@ -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 =>

View file

@ -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 = '';

View file

@ -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);

View file

@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CollapsiblePanelAnimator } from './collapsible-panel-animator';
type Listener = (event: Record<string, unknown>) => void;
class FakeClassList {
private readonly classes = new Set<string>();
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<string, string>();
private readonly children = new Set<FakeElement>();
private readonly listeners = new Map<string, Array<Listener>>();
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<string, unknown> = {}): 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<string, Array<Listener>>();
const fakeDocument: { activeElement: FakeElement | null } = {
activeElement: null,
};
const dispatchWindowEvent = (type: string, event: Record<string, unknown> = {}) => {
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);
});
});

View file

@ -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<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
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<string, string>,
) as Record<string, VibeId>,
})
.on('change', ({ value }) => {
if (!isVibeId(value)) {
this.refresh();
return;
}
this.options.onVibeChange(value);
this.refresh();
});

138
src/page/menu-hider.test.ts Normal file
View file

@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config';
import { MenuHider } from './menu-hider';
type Listener<T = Record<string, unknown>> = (event: T) => void;
class FakeClassList {
private readonly classes = new Set<string>();
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<string, string>();
private readonly listeners = new Map<string, Array<Listener>>();
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<string, unknown> = {}): 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<string, Array<Listener>>();
let isDesktop = true;
const dispatchWindowEvent = <T extends Record<string, unknown>>(
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);
});
});

View file

@ -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);
});
});

View file

@ -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,

View file

@ -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', () => {

View file

@ -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<VibePreset> = appConfig.vibes.presets;
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
const HEX_COLOR_PATTERN =
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[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];
};