diff --git a/index.html b/index.html
index 1414388..071ba68 100644
--- a/index.html
+++ b/index.html
@@ -4,14 +4,14 @@
@@ -22,7 +22,7 @@
@@ -35,7 +35,7 @@
@@ -46,7 +46,7 @@
"@type": "WebApplication",
"name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/",
- "description": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.",
+ "description": "Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
@@ -91,7 +91,7 @@
Fleeting Garden
- Tend it while you can. The garden returns to weather either way.
+ Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work.
Start
diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts
index a288465..866bb94 100644
--- a/src/audio/garden-audio-graph.ts
+++ b/src/audio/garden-audio-graph.ts
@@ -28,10 +28,10 @@ const graphTuning = {
latencyHint: 'interactive',
outputFilterType: 'highpass',
compressor: {
- thresholdDb: -18,
- kneeDb: 18,
- ratio: 2.1,
- attackSeconds: 0.018,
+ thresholdDb: -22,
+ kneeDb: 12,
+ ratio: 4.5,
+ attackSeconds: 0.006,
releaseSeconds: 0.18,
},
} as const;
@@ -87,10 +87,12 @@ export class GardenAudioGraph {
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
+ const outputBus = context.createGain();
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
+ outputBus.gain.value = 1;
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz;
@@ -100,15 +102,17 @@ export class GardenAudioGraph {
compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds;
- masterGain.connect(highPass);
+ // Keep peak control independent from the user's volume slider.
+ outputBus.connect(highPass);
highPass.connect(compressor);
- compressor.connect(context.destination);
+ compressor.connect(masterGain);
+ masterGain.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
- this.createDelay(context, masterGain);
- this.createBuses(context, masterGain);
+ this.createDelay(context, outputBus);
+ this.createBuses(context, outputBus);
return context;
}
@@ -224,7 +228,7 @@ export class GardenAudioGraph {
}
}
- private createDelay(context: AudioContext, masterGain: GainNode): void {
+ private createDelay(context: AudioContext, outputBus: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
@@ -250,7 +254,7 @@ export class GardenAudioGraph {
delayFeedback.connect(delayNode);
delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput);
- delayOutput.connect(masterGain);
+ delayOutput.connect(outputBus);
this.delayInput = delayInput;
this.delayNode = delayNode;
@@ -258,10 +262,10 @@ export class GardenAudioGraph {
this.delayOutput = delayOutput;
}
- private createBuses(context: AudioContext, masterGain: GainNode): void {
+ private createBuses(context: AudioContext, outputBus: GainNode): void {
const eventBus = context.createGain();
eventBus.gain.value = graphTuning.eventBusGain;
- eventBus.connect(masterGain);
+ eventBus.connect(outputBus);
this.eventBus = eventBus;
this.pianoBuses.clear();
diff --git a/src/config/eraser-size.ts b/src/config/eraser-size.ts
new file mode 100644
index 0000000..008bee7
--- /dev/null
+++ b/src/config/eraser-size.ts
@@ -0,0 +1,84 @@
+export interface CssPixelSize {
+ height: number;
+ width: number;
+}
+
+export const ERASER_SIZE_MIN = 24;
+export const ERASER_SIZE_MAX = 480;
+
+const ERASER_MAX_SHORT_SIDE_RATIO = 0.55;
+
+const getNormalizedEraserSizeMax = (maxSize: number): number => {
+ const safeMaxSize = Number.isFinite(maxSize) ? Math.floor(maxSize) : ERASER_SIZE_MAX;
+ return Math.max(ERASER_SIZE_MIN, Math.min(ERASER_SIZE_MAX, safeMaxSize));
+};
+
+export const getElementCssPixelSize = (element: Element): CssPixelSize => {
+ const rect = element.getBoundingClientRect();
+ const { clientHeight = 0, clientWidth = 0 } = element as HTMLElement;
+ return {
+ height: rect.height || clientHeight,
+ width: rect.width || clientWidth,
+ };
+};
+
+export const getEraserSizeMaxForCssSize = ({ height, width }: CssPixelSize): number => {
+ const shortestSide = Math.min(height, width);
+ if (!Number.isFinite(shortestSide) || shortestSide <= 0) {
+ return ERASER_SIZE_MAX;
+ }
+
+ return getNormalizedEraserSizeMax(
+ Math.floor(shortestSide * ERASER_MAX_SHORT_SIDE_RATIO)
+ );
+};
+
+export const clampEraserSize = (
+ value: number,
+ maxSize = ERASER_SIZE_MAX,
+ fallbackSize = ERASER_SIZE_MIN
+): number => {
+ const max = getNormalizedEraserSizeMax(maxSize);
+ const fallback = Number.isFinite(fallbackSize) ? fallbackSize : ERASER_SIZE_MIN;
+ const safeValue = Number.isFinite(value) ? value : fallback;
+ return Math.min(max, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
+};
+
+export const getEffectiveEraserSize = (
+ size: number,
+ cssSize: CssPixelSize,
+ fallbackSize = ERASER_SIZE_MIN
+): number => clampEraserSize(size, getEraserSizeMaxForCssSize(cssSize), fallbackSize);
+
+export const getEraserSizeRatio = (size: number, maxSize = ERASER_SIZE_MAX): number => {
+ const max = getNormalizedEraserSizeMax(maxSize);
+ if (max === ERASER_SIZE_MIN) {
+ return 0;
+ }
+
+ return (clampEraserSize(size, max) - ERASER_SIZE_MIN) / (max - ERASER_SIZE_MIN);
+};
+
+const ERASER_SLIDER_MIN = 0;
+const ERASER_SLIDER_MAX = 1;
+
+const clampSliderRatio = (value: number): number => {
+ const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
+ return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
+};
+
+export const getEraserSizeFromSliderRatio = (
+ sliderRatio: number,
+ maxSize = ERASER_SIZE_MAX
+): number => {
+ const max = getNormalizedEraserSizeMax(maxSize);
+ return clampEraserSize(
+ ERASER_SIZE_MIN + (max - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2,
+ max
+ );
+};
+
+export const getEraserSliderRatioFromSize = (
+ size: number,
+ maxSize = ERASER_SIZE_MAX
+): number => Math.sqrt(getEraserSizeRatio(size, maxSize));
diff --git a/src/game-loop/eraser-preview.ts b/src/game-loop/eraser-preview.ts
index fefd93c..493d497 100644
--- a/src/game-loop/eraser-preview.ts
+++ b/src/game-loop/eraser-preview.ts
@@ -1,3 +1,4 @@
+import { getEffectiveEraserSize } from '../config/eraser-size';
import { settings } from '../settings';
export class EraserPreview {
@@ -49,9 +50,15 @@ export class EraserPreview {
};
}
- if (this.previousSize !== settings.eraserSize) {
- this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
- this.previousSize = settings.eraserSize;
+ const rect = this.canvas.getBoundingClientRect();
+ const size = getEffectiveEraserSize(settings.eraserSize, {
+ height: rect.height || this.canvas.clientHeight,
+ width: rect.width || this.canvas.clientWidth,
+ });
+
+ if (this.previousSize !== size) {
+ this.element.style.setProperty('--eraser-preview-size', `${size}px`);
+ this.previousSize = size;
}
if (
@@ -63,7 +70,6 @@ export class EraserPreview {
return;
}
- const rect = this.canvas.getBoundingClientRect();
const left = `${this.previewClientPosition.x - rect.left}px`;
const top = `${this.previewClientPosition.y - rect.top}px`;
if (this.previousLeft !== left) {
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index 25d2323..d53a73c 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { createGardenAudioConfig } from '../audio/garden-audio-config';
+import { getEffectiveEraserSize, getElementCssPixelSize } from '../config/eraser-size';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
@@ -209,7 +210,11 @@ export default class GameLoop {
const runtimeSettings = { ...settings };
const introProgress = this.introPrompt.progress;
const canvasPixelRatio = this.canvasPixelRatio;
- const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
+ const eraserCssSize = getEffectiveEraserSize(
+ runtimeSettings.eraserSize,
+ getElementCssPixelSize(this.canvas)
+ );
+ const eraserPixelSize = eraserCssSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor =
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts
index 1d93778..372c63e 100644
--- a/src/page/eraser-size-control.test.ts
+++ b/src/page/eraser-size-control.test.ts
@@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest';
import {
ERASER_SIZE_MAX,
ERASER_SIZE_MIN,
+ getEffectiveEraserSize,
getEraserSizeFromSliderRatio,
+ getEraserSizeMaxForCssSize,
getEraserSliderRatioFromSize,
-} from './eraser-size-control';
+} from '../config/eraser-size';
describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => {
@@ -23,4 +25,15 @@ describe('eraser size slider mapping', () => {
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
});
+
+ it('uses a responsive max size on small canvases', () => {
+ const mobileMax = getEraserSizeMaxForCssSize({ height: 640, width: 390 });
+
+ expect(mobileMax).toBeLessThan(ERASER_SIZE_MAX);
+ expect(getEraserSizeFromSliderRatio(1, mobileMax)).toBe(mobileMax);
+ expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX, mobileMax)).toBe(1);
+ expect(getEffectiveEraserSize(ERASER_SIZE_MAX, { height: 640, width: 390 })).toBe(
+ mobileMax
+ );
+ });
});
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
index 8f93d51..beb2d0f 100644
--- a/src/page/eraser-size-control.ts
+++ b/src/page/eraser-size-control.ts
@@ -1,40 +1,25 @@
+import {
+ clampEraserSize,
+ ERASER_SIZE_MAX,
+ getElementCssPixelSize,
+ getEraserSizeFromSliderRatio,
+ getEraserSizeMaxForCssSize,
+ getEraserSizeRatio,
+ getEraserSliderRatioFromSize,
+} from '../config/eraser-size';
import type GameLoop from '../game-loop/game-loop';
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
-export const ERASER_SIZE_MIN = 24;
-export const ERASER_SIZE_MAX = 480;
-
const ERASER_CONTROL_SCALE_MIN = 0.74;
const ERASER_CONTROL_SCALE_MAX = 1.34;
-const clampEraserSize = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE;
- return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
-};
-
const ERASER_SLIDER_MIN = 0;
const ERASER_SLIDER_MAX = 1;
const ERASER_SLIDER_STEP = 0.001;
-const clampSliderRatio = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
- return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
-};
-
-const getEraserSizeRatio = (size: number): number => {
- return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
-};
-
-export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
- return clampEraserSize(
- ERASER_SIZE_MIN +
- (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
- );
-};
-
-export const getEraserSliderRatioFromSize = (size: number): number =>
- Math.sqrt(getEraserSizeRatio(size));
+const clampStoredEraserSize = (value: number): number =>
+ clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE);
interface EraserSizeControlOptions {
getGame: () => GameLoop | null;
@@ -48,6 +33,7 @@ export class EraserSizeControl {
HTMLLabelElement
);
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
+ private readonly canvas = queryRequiredElement('canvas', HTMLCanvasElement);
private isActive = false;
public constructor(private readonly options: EraserSizeControlOptions) {
@@ -55,7 +41,10 @@ export class EraserSizeControl {
this.control.addEventListener('click', this.activate);
this.slider.addEventListener('focus', this.activate);
this.slider.addEventListener('input', () => {
- settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
+ settings.eraserSize = getEraserSizeFromSliderRatio(
+ Number(this.slider.value),
+ this.getResponsiveMaxSize()
+ );
this.activate();
this.render();
this.options.onChange();
@@ -63,19 +52,21 @@ export class EraserSizeControl {
}
public render(): void {
- const size = clampEraserSize(settings.eraserSize);
- if (settings.eraserSize !== size) {
- settings.eraserSize = size;
+ const maxSize = this.getResponsiveMaxSize();
+ const storedSize = clampStoredEraserSize(settings.eraserSize);
+ if (settings.eraserSize !== storedSize) {
+ settings.eraserSize = storedSize;
}
- const sliderRatio = getEraserSliderRatioFromSize(size);
+ const size = clampEraserSize(storedSize, maxSize, DEFAULT_ERASER_SIZE);
+ const sliderRatio = getEraserSliderRatioFromSize(size, maxSize);
this.slider.min = ERASER_SLIDER_MIN.toString();
this.slider.max = ERASER_SLIDER_MAX.toString();
this.slider.step = ERASER_SLIDER_STEP.toString();
this.slider.value = sliderRatio.toString();
this.slider.setAttribute('aria-valuetext', `${size}px`);
- const sizeRatio = getEraserSizeRatio(size);
+ const sizeRatio = getEraserSizeRatio(size, maxSize);
const scale =
ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
@@ -95,6 +86,10 @@ export class EraserSizeControl {
this.options.onActivate();
};
+ private getResponsiveMaxSize(): number {
+ return getEraserSizeMaxForCssSize(getElementCssPixelSize(this.canvas));
+ }
+
private syncActiveState(): void {
this.control.classList.toggle('active', this.isActive);
this.slider.setAttribute(
diff --git a/src/style/_panels.scss b/src/style/_panels.scss
index 3e92b02..5d03b13 100644
--- a/src/style/_panels.scss
+++ b/src/style/_panels.scss
@@ -2,13 +2,10 @@
html > body > aside.control-dock > .info-page {
width: min(100%, 520px);
- max-height: min(62vh, 480px);
- max-height: min(62dvh, 480px);
+ max-height: 200vh;
+ max-height: 200dvh;
margin: 0 auto 10px;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- touch-action: pan-y;
+ overflow: hidden;
border: 1px solid rgb(255 255 255 / 46%);
border-radius: 8px;
background:
@@ -20,26 +17,12 @@ html > body > aside.control-dock > .info-page {
0 16px 42px rgb(0 0 0 / 30%),
0 2px 10px rgb(0 0 0 / 18%);
backdrop-filter: blur(16px) saturate(118%);
- scrollbar-width: thin;
- scrollbar-color: rgb(69 98 88 / 62%) transparent;
- -webkit-overflow-scrolling: touch;
transition:
max-height var(--transition-time-long),
opacity var(--transition-time-long),
transform var(--transition-time-long),
margin-bottom var(--transition-time-long);
- &::-webkit-scrollbar-track,
- &::-webkit-scrollbar {
- background-color: transparent;
- width: 6px;
- }
-
- &::-webkit-scrollbar-thumb {
- background-color: rgb(69 98 88 / 62%);
- border-radius: 8px;
- }
-
&:focus-visible {
outline: 2px solid rgb(17 56 45);
outline-offset: 3px;
@@ -225,8 +208,6 @@ html > body > aside.control-dock > .info-page {
@include on-small-screen {
width: min(100%, 520px);
- max-height: min(58vh, 500px);
- max-height: min(58dvh, 500px);
.info-page__content {
gap: 0.75rem;
@@ -244,26 +225,4 @@ html > body > aside.control-dock > .info-page {
line-height: 1.45;
}
}
-
- @media (max-height: 420px) {
- max-height: min(
- 58vh,
- max(
- 10rem,
- calc(
- 100vh - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
- )
- )
- );
- max-height: min(
- 58dvh,
- max(
- 10rem,
- calc(
- 100dvh -
- 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
- )
- )
- );
- }
}
diff --git a/src/style/common.scss b/src/style/common.scss
index 23c82aa..9996bc5 100644
--- a/src/style/common.scss
+++ b/src/style/common.scss
@@ -16,15 +16,24 @@
html {
height: 100%;
+ overscroll-behavior: none;
touch-action: manipulation;
+ user-select: none;
-webkit-font-smoothing: antialiased;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: 'Open Sans', sans-serif;
+ overscroll-behavior: none;
touch-action: manipulation;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
}
.visually-hidden {