Add configurable vibe presets

This commit is contained in:
Andras Schmelczer 2026-05-24 10:57:47 +01:00
parent e54bddc7db
commit f8294934fd
34 changed files with 1701 additions and 341 deletions

199
src/config.ts Normal file
View file

@ -0,0 +1,199 @@
import {
createGardenAudioConfig,
DEFAULT_AUDIO_VOLUME,
} from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
export type {
GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
} from './config/types';
export const appConfig = {
audio: createGardenAudioConfig(),
analytics: {
autoCapturePageviews: true,
domain: 'fleeting.garden',
endpoint: 'https://stats.schmelczer.dev/status',
logging: import.meta.env.DEV,
},
deltaTime: {
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
},
exportSnapshot: {
bytesPerPixel: 4,
filenameExtension: 'png',
filenamePrefix: 'fleeting-garden',
filenameSuffix: '-snapshot',
mimeType: 'image/png',
rowAlignmentBytes: 256,
},
menuHider: {
bottomRevealDistancePx: 96,
desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
hideDelayMs: 3000,
},
pipelines: {
common: {
noiseChannelSeeds: [0, 1, 2, 3],
noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
noiseDrawInstanceCount: 1,
noiseDrawVertexCount: 3,
noiseHashMultiplier: 43758.5453123,
noiseHashX: 12.9898,
noiseHashY: 78.233,
noiseTextureFormat: 'r8unorm',
noiseTextureSize: 2048,
},
brush: {
maxLineCount: 240,
},
diffusion: {
minDiffusionRate: 0.000001,
},
eraser: {
maxTextureLineCount: 384,
},
},
defaultSettings,
runtimeSettings: {
controls: runtimeControls,
},
simulation: {
brushEffectFramesPerSecond: 60,
clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000,
// How long the source map continues to be diffused after a brush stroke ends.
// 600 frames at ~60 FPS ≈ 10 seconds.
sourceActiveFramesAfterWrite: 600,
intro: {
angleJitterRadians: Math.PI * 0.08,
angleEaseEnd: 1,
angleEaseStart: 0.6,
circleMaxSideRatio: 0.46,
circleMinSideRatio: 0.32,
drawHintDelayMs: 3000,
durationSeconds: 4,
entryJitterSideRatio: 0.035,
fontScaleDown: 0.94,
fontFamily: '"Open Sans", sans-serif',
initialFontHeightRatio: 0.28,
initialFontWidthRatio: 0.19,
letterSpacingEm: 0.07,
maskAlphaThreshold: 32,
maskGradientThreshold: 8,
maskMaxPixels: 1_000_000,
maskSampleDensity: 540,
maxHeightRatio: 0.25,
maxWidthRatio: 0.76,
minEntryJitterPx: 6,
minFontSizePx: 18,
minTargetJitterPx: 1,
pathEasing: 'easeOutQuad',
pathProgressEpsilon: 0.001,
radialJitterRatio: 0.35,
radialStartEpsilon: 0.001,
resizeMinimumRemainingSeconds: 1.4,
resizeSettleMs: 120,
targetDelayDistanceMultiplier: 0.12,
targetDelayMax: 0.22,
targetDelayRandomMultiplier: 0.06,
targetJitterSideRatio: 0.0035,
title: 'Fleeting',
titleColorCutLetters: [2, 5],
titleRadiusMultiplier: 1.55,
titleStrokeWidthMinPx: 6,
titleStrokeWidthRatio: 0.11,
verticalAnchor: 0.47,
},
introMoveSpeed: 280,
stroke: {
densityMultiplier: 110,
maxAgentCount: 2_400,
},
},
storage: {
audioMutedKey: 'fleeting-garden:audio-muted',
audioVolumeKey: 'fleeting-garden:audio-volume',
vibeKey: 'fleeting-garden:vibe',
},
toolbar: {
eraser: {
controlScaleMax: 1.34,
controlScaleMin: 0.74,
default: 96,
max: 480,
min: 24,
step: 1,
},
mirror: {
default: 8,
fallbackSegmentName: 'slices',
max: 12,
min: 1,
names: {
2: 'halves',
3: 'thirds',
4: 'quarters',
5: 'fifths',
6: 'sixths',
7: 'sevenths',
8: 'eighths',
9: 'ninths',
10: 'tenths',
11: 'elevenths',
12: 'twelfths',
},
offLabel: 'Mirror off',
step: 1,
},
contrast: {
backgroundOpacityMax: 0.82,
brightLuminanceThreshold: 0.32,
brightWeight: 0.65,
bytesPerSample: 4,
contrastOffset: 0.05,
linearChannelBreakpoint: 0.03928,
linearChannelDivisor: 12.92,
linearChannelGamma: 2.4,
linearChannelOffset: 0.055,
linearChannelScale: 1.055,
lowContrastThreshold: 3,
lowContrastWeight: 1.8,
luminanceBase: 0.11,
luminanceBlueWeight: 0.0722,
luminanceGreenWeight: 0.7152,
luminanceRange: 0.28,
luminanceRedWeight: 0.2126,
sampleColumns: 13,
sampleIntervalMs: 300,
sampleRows: 7,
whiteContrastNumerator: 1.05,
},
volume: {
default: DEFAULT_AUDIO_VOLUME,
max: 1,
min: 0,
step: 0.01,
},
},
tuningPane: {
showFpsOverlay: import.meta.env.DEV,
startHidden: true,
title: 'Garden Settings',
},
vibes: {
defaultVibeId,
presets: vibePresets,
},
} satisfies GardenAppConfig;

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS,
getBrushRenderQualityScale,
getRenderQualityBrushSize,
} from './brush-size';
describe('render-quality brush sizing', () => {
it('keeps brush sizes unchanged at the 7.3 MP baseline', () => {
expect(
getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS)
).toBe(21);
});
it('scales linear brush size with the square root of render area', () => {
const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4;
expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2);
expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5);
});
it('falls back to baseline scaling for invalid render areas', () => {
expect(getBrushRenderQualityScale(0)).toBe(1);
expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5);
});
});

19
src/config/brush-size.ts Normal file
View file

@ -0,0 +1,19 @@
export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3;
const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number =>
Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0
? renderAreaMegapixels
: BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS;
export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number =>
Math.sqrt(
getSafeRenderAreaMegapixels(renderAreaMegapixels) /
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS
);
export const getRenderQualityBrushSize = (
brushSize: number,
renderAreaMegapixels: number
): number =>
Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) *
getBrushRenderQualityScale(renderAreaMegapixels);

View file

@ -0,0 +1,14 @@
import type { NumberControlConfig } from './types';
export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions',
label,
min: -1,
max: 1,
step: 1,
options: {
'Move Toward': 1,
Ignore: 0,
'Move Away': -1,
},
});

View file

@ -0,0 +1,74 @@
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
import type { GardenAppConfig } from './types';
// Mirrors the historical render-scale cap so the default render area stays
// roughly equivalent to native rendering on high-DPR phones without the
// pipeline applying its own clamp. The slider can override freely.
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
const computeDefaultInternalRenderAreaMegapixels = (): number => {
const rawDpr =
typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
? window.devicePixelRatio
: 1;
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
return Math.min(
INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
);
};
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
selectedColorIndex: 0,
introNearDistanceMin: 28,
introNearDistanceInner: 4,
introNearSensorOffsetMultiplier: 0.75,
introTargetAngleBlend: 0.2,
introProgressCutoff: 0.999,
introTurnRateMultiplier: 3.4,
introRandomTurnMultiplier: 0.18,
introStepStopDistance: 0.5,
randomTimeScale: 0.34816,
diffusionRateTrails: 0.22,
decayRateBrush: 18,
diffusionDecayRateDivisor: 1000,
diffusionNeighborDivisor: 8,
brushDecayAlphaOffset: 1.001,
brushEffectDuration: 8,
brushCurveResolution: 12,
brushCurveMinBrushRadius: 1,
brushCurveMinSegmentSpacing: 4,
brushCurveMirrorResolutionExponent: 0.5,
brushCurveSegmentBrushRadiusRatio: 0.65,
brushSmoothingMinSampleDistance: 0.5,
brushAlpha: 1,
brushDiscardThreshold: 0.02,
brushGrainNoiseScale: 22,
brushGrainNoiseOffsetX: 0.31,
brushGrainNoiseOffsetY: 0.67,
brushGrainMinStrength: 0.45,
brushGrainMaxStrength: 1,
eraserClearAlpha: 0,
eraserClearBlue: 0,
eraserClearGreen: 0,
eraserClearRed: 0,
eraserLineDistanceEpsilon: 0.0001,
eraserMaskAlphaThreshold: 0.5,
adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 50_000,
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
maxAgentCount: 1_500_000,
renderTraceNormalizationFloor: 1,
renderBrushColorBase: 1.2,
renderBrushColorStrengthMultiplier: 1.6,
};

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './normalize-runtime-settings';
import type { GardenRuntimeSettings } from './types';
describe('normalizeNumberControlValue', () => {
it('clamps and rounds numeric controls', () => {
expect(
normalizeNumberControlValue(12.6, {
folder: 'Test',
integer: true,
max: 10,
min: 0,
})
).toBe(10);
expect(
normalizeNumberControlValue(Number.NaN, {
folder: 'Test',
min: 3,
})
).toBe(3);
});
it('keeps only declared option values', () => {
expect(
normalizeNumberControlValue(2, {
folder: 'Test',
options: { off: 0, on: 2 },
})
).toBe(2);
expect(
normalizeNumberControlValue(3, {
folder: 'Test',
options: { off: 0, on: 2 },
})
).toBe(0);
});
});
describe('normalizeRuntimeSettings', () => {
it('normalizes configured runtime keys and leaves hidden keys alone', () => {
const settings = {
brushSize: 99,
selectedColorIndex: 7,
} as GardenRuntimeSettings;
expect(
normalizeRuntimeSettings(settings, {
brushSize: {
folder: 'Brush',
max: 12,
min: 1,
},
})
).toMatchObject({
brushSize: 12,
selectedColorIndex: 7,
});
});
});

View file

@ -0,0 +1,46 @@
import type {
GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
} from './types';
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
export const normalizeNumberControlValue = (
value: number,
config: NumberControlConfig
): number => {
if (config.options) {
const optionValues = Object.values(config.options);
if (optionValues.includes(value)) {
return value;
}
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
}
const min = config.min ?? Number.NEGATIVE_INFINITY;
const max = config.max ?? Number.POSITIVE_INFINITY;
const fallbackValue = config.min ?? 0;
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
const clampedValue = Math.min(max, Math.max(min, finiteValue));
return config.integer ? Math.round(clampedValue) : clampedValue;
};
export const normalizeRuntimeSettings = (
settings: GardenRuntimeSettings,
controls: RuntimeSettingControls
): GardenRuntimeSettings => {
const normalized = { ...settings };
(
Object.entries(controls) as Array<
[keyof GardenRuntimeSettings, NumberControlConfig | undefined]
>
).forEach(([key, config]) => {
if (config) {
normalized[key] = normalizeNumberControlValue(normalized[key], config);
}
});
return normalized;
};

View file

@ -0,0 +1,147 @@
import { colorInteractionControl } from './color-interactions';
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
import type { GardenAppConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
const formatRadiansAsDegrees = (value: number): string =>
`${Math.round((value * 180) / Math.PI)} deg`;
const formatCompactNumber = (value: number): string => {
if (value >= 1_000_000) {
const millions = value / 1_000_000;
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return `${value}`;
};
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
brushSize: {
folder: 'Brush',
label: 'Brush Size',
min: 1,
max: 36,
step: 0.25,
},
spawnPerPixel: {
folder: 'Brush',
label: 'Density',
min: 0.01,
max: 0.38,
step: 0.001,
},
strokeAngleJitterRadians: {
folder: 'Brush',
format: formatRadiansAsDegrees,
label: 'Spawn Spread',
min: 0,
max: Math.PI,
step: 0.01,
},
sensorOffsetDistance: {
folder: 'Movement',
label: 'Sensor Reach',
min: 0,
max: 200,
step: 1,
},
sensorOffsetAngle: {
folder: 'Movement',
label: 'Sensor Angle',
min: 0,
max: 180,
step: 1,
},
moveSpeed: {
folder: 'Movement',
label: 'Travel Speed',
min: 10,
max: 500,
step: 1,
},
turnSpeed: {
folder: 'Movement',
label: 'Turning Speed',
min: 1,
max: 200,
step: 1,
},
forwardRotationScale: {
folder: 'Movement',
format: formatPercent,
label: 'Forward Focus',
min: 0,
max: 1,
step: 0.01,
},
turnWhenLost: {
folder: 'Movement',
label: 'Wander Turn',
min: 0,
max: Math.PI * 2,
step: 0.01,
},
individualTrailWeight: {
folder: 'Movement',
label: 'Trail Strength',
min: 0,
max: 1,
step: 0.001,
},
diffusionRateTrails: {
folder: 'Movement',
label: 'Diffusion Rate',
min: 0.01,
max: 1,
step: 0.01,
},
decayRateTrails: {
folder: 'Movement',
label: 'Trail Fade',
min: 800,
max: 1000,
step: 1,
},
clarity: {
folder: 'Look',
label: 'Sharpness',
min: 0.00001,
max: 1,
step: 0.001,
},
backgroundGrainStrength: {
folder: 'Look',
label: 'Background Grain',
min: 0,
max: 0.12,
step: 0.001,
},
maxAgentCount: {
folder: 'Performance',
format: formatCompactNumber,
integer: true,
label: 'Population Limit',
min: 0,
step: 10_000,
},
internalRenderAreaMegapixels: {
folder: 'Performance',
label: 'Render Quality (MP)',
min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
step: 0.1,
},
};

View file

@ -0,0 +1,4 @@
export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = {
min: 0.5,
max: 16.6,
} as const;

268
src/config/types.ts Normal file
View file

@ -0,0 +1,268 @@
import type {
GardenAudioConfig,
GardenAudioVibeSettings,
} from '../audio/garden-audio-config';
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
import type { RenderSettings } from '../pipelines/render/render-pipeline';
import type { RgbColor } from '../utils/rgb-color';
export interface NumberControlConfig {
format?: (value: number) => string;
folder: string;
integer?: boolean;
label?: string;
max?: number;
min?: number;
options?: Record<string, number>;
step?: number;
}
export type GardenRuntimeSettings = {
adaptiveCapInitial: number;
adaptiveCapMin: number;
backgroundGrainStrength: number;
brushCurveResolution: number;
brushCurveMinBrushRadius: number;
brushCurveMinSegmentSpacing: number;
brushCurveMirrorResolutionExponent: number;
brushCurveSegmentBrushRadiusRatio: number;
brushEffectDuration: number;
brushSmoothingMinSampleDistance: number;
eraserClearAlpha: number;
eraserClearBlue: number;
eraserClearGreen: number;
eraserClearRed: number;
eraserLineDistanceEpsilon: number;
eraserMaskAlphaThreshold: number;
eraserSize: number;
internalRenderAreaMegapixels: number;
mirrorSegmentCount: number;
maxAgentCount: number;
selectedColorIndex: number;
spawnPerPixel: number;
strokeAngleJitterRadians: number;
} & AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
type RuntimeSettingControlConfig = Partial<
Record<keyof GardenRuntimeSettings, NumberControlConfig>
>;
export type GardenVibeSettings = Pick<
GardenRuntimeSettings,
| 'backgroundGrainStrength'
| 'brushSize'
| 'clarity'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'decayRateTrails'
| 'forwardRotationScale'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'strokeAngleJitterRadians'
| 'turnSpeed'
| 'turnWhenLost'
>;
type GardenDefaultSettings = Omit<
GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>;
export enum VibeId {
AuroraMycelium = 'aurora-mycelium',
VelvetObservatory = 'velvet-observatory',
LichenSignal = 'lichen-signal',
TidepoolLantern = 'tidepool-lantern',
PaperLanternFog = 'paper-lantern-fog',
ChromePollen = 'chrome-pollen',
}
export interface VibePreset {
id: VibeId;
name: string;
colors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
settings: GardenVibeSettings;
audio: GardenAudioVibeSettings;
}
export interface GardenAppConfig {
audio: GardenAudioConfig;
analytics: {
autoCapturePageviews: boolean;
domain: string;
endpoint: string;
logging: boolean;
};
deltaTime: {
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
exportSnapshot: {
bytesPerPixel: number;
filenameExtension: string;
filenamePrefix: string;
filenameSuffix: string;
mimeType: string;
rowAlignmentBytes: number;
};
menuHider: {
bottomRevealDistancePx: number;
desktopMediaQuery: string;
hideDelayMs: number;
};
pipelines: {
common: {
noiseChannelSeeds: [number, number, number, number];
noiseClearValue: GPUColor;
noiseDrawInstanceCount: number;
noiseDrawVertexCount: number;
noiseHashMultiplier: number;
noiseHashX: number;
noiseHashY: number;
noiseTextureFormat: GPUTextureFormat;
noiseTextureSize: number;
};
brush: {
maxLineCount: number;
};
diffusion: {
minDiffusionRate: number;
};
eraser: {
maxTextureLineCount: number;
};
};
defaultSettings: GardenDefaultSettings;
runtimeSettings: {
controls: RuntimeSettingControlConfig;
};
simulation: {
brushEffectFramesPerSecond: number;
clearColor: GPUColor;
initialAgentCount: number;
sourceActiveFramesAfterWrite: number;
intro: {
angleJitterRadians: number;
angleEaseEnd: number;
angleEaseStart: number;
circleMaxSideRatio: number;
circleMinSideRatio: number;
drawHintDelayMs: number;
durationSeconds: number;
entryJitterSideRatio: number;
fontScaleDown: number;
fontFamily: string;
initialFontHeightRatio: number;
initialFontWidthRatio: number;
letterSpacingEm: number;
maskAlphaThreshold: number;
maskGradientThreshold: number;
maskMaxPixels: number;
maskSampleDensity: number;
maxHeightRatio: number;
maxWidthRatio: number;
minEntryJitterPx: number;
minFontSizePx: number;
minTargetJitterPx: number;
pathEasing: 'easeOutQuad' | 'linear';
pathProgressEpsilon: number;
radialJitterRatio: number;
radialStartEpsilon: number;
resizeMinimumRemainingSeconds: number;
resizeSettleMs: number;
targetDelayDistanceMultiplier: number;
targetDelayMax: number;
targetDelayRandomMultiplier: number;
targetJitterSideRatio: number;
title: string;
titleColorCutLetters: [number, number];
titleRadiusMultiplier: number;
titleStrokeWidthMinPx: number;
titleStrokeWidthRatio: number;
verticalAnchor: number;
};
introMoveSpeed: number;
stroke: {
densityMultiplier: number;
maxAgentCount: number;
};
};
storage: {
audioMutedKey: string;
audioVolumeKey: string;
vibeKey: string;
};
toolbar: {
eraser: {
controlScaleMax: number;
controlScaleMin: number;
default: number;
max: number;
min: number;
step: number;
};
mirror: {
default: number;
fallbackSegmentName: string;
max: number;
min: number;
names: Record<number, string>;
offLabel: string;
step: number;
};
contrast: {
backgroundOpacityMax: number;
brightLuminanceThreshold: number;
brightWeight: number;
bytesPerSample: number;
contrastOffset: number;
linearChannelBreakpoint: number;
linearChannelDivisor: number;
linearChannelGamma: number;
linearChannelOffset: number;
linearChannelScale: number;
lowContrastThreshold: number;
lowContrastWeight: number;
luminanceBase: number;
luminanceBlueWeight: number;
luminanceGreenWeight: number;
luminanceRange: number;
luminanceRedWeight: number;
sampleColumns: number;
sampleIntervalMs: number;
sampleRows: number;
whiteContrastNumerator: number;
};
volume: {
default: number;
max: number;
min: number;
step: number;
};
};
tuningPane: {
showFpsOverlay: boolean;
startHidden: boolean;
title: string;
};
vibes: {
defaultVibeId: VibeId;
presets: Array<VibePreset>;
};
}

View file

@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { runtimeControls } from './runtime-controls';
import { vibePresets } from './vibe-presets';
const FINAL_VIBE_NAMES = [
'Aurora Mycelium Copy',
'Velvet Observatory Copy',
'Lichen Signal',
'Tidepool Lantern',
'Paper Lantern Fog',
'Chrome Pollen',
];
const BLENDED_BRUSH_SIZE_MIN = 17;
const BLENDED_CLARITY_MAX = 0.56;
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
// Performance guardrails — bumping any of these is an explicit perf trade-off.
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
const HIGH_DENSITY_DECAY_LIMIT = 940;
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
describe('vibePresets', () => {
it('keeps the classic preset set distinct', () => {
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
const ids = vibePresets.map((preset) => preset.id);
expect(new Set(ids).size).toBe(vibePresets.length);
});
it('includes both blended and visibly particulate styles', () => {
const blendedNames = vibePresets
.filter(
(preset) =>
preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN &&
preset.settings.clarity <= BLENDED_CLARITY_MAX
)
.map((preset) => preset.name);
const softParticleNames = vibePresets
.filter(
(preset) =>
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
)
.map((preset) => preset.name);
expect(blendedNames).toEqual(['Tidepool Lantern']);
expect(softParticleNames).toEqual(['Chrome Pollen']);
});
it('stays inside interactive performance guardrails', () => {
const violations = vibePresets.flatMap((preset) => {
const { name, settings } = preset;
const presetViolations: Array<string> = [];
if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) {
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
}
if (settings.brushSize > MAX_BRUSH_SIZE) {
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
}
if (
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
) {
presetViolations.push(`${name} combines high density with too much persistence`);
}
return presetViolations;
});
expect(violations).toEqual([]);
});
});

366
src/config/vibe-presets.ts Normal file
View file

@ -0,0 +1,366 @@
import {
defaultGardenAudioVibeSettings,
type GardenAudioChord,
} from '../audio/garden-audio-config';
import { VibeId, type GardenVibeSettings, type VibePreset } from './types';
type ColorReactionSettings = Pick<
GardenVibeSettings,
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
>;
const colorReactions = {
auroraMycelium: {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 0,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: 0,
color3ToColor1: -1,
color3ToColor2: -1,
color3ToColor3: 1,
},
velvetObservatory: {
color1ToColor1: 1,
color1ToColor2: -1,
color1ToColor3: -1,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: -1,
color3ToColor1: -1,
color3ToColor2: -1,
color3ToColor3: 1,
},
lichenSignal: {
color1ToColor1: 0,
color1ToColor2: -1,
color1ToColor3: 1,
color2ToColor1: -1,
color2ToColor2: 0,
color2ToColor3: -1,
color3ToColor1: 1,
color3ToColor2: -1,
color3ToColor3: 1,
},
tidepoolLantern: {
color1ToColor1: 0,
color1ToColor2: 1,
color1ToColor3: 0,
color2ToColor1: 0,
color2ToColor2: 0,
color2ToColor3: 1,
color3ToColor1: 1,
color3ToColor2: 0,
color3ToColor3: 0,
},
paperLanternFog: {
color1ToColor1: 1,
color1ToColor2: 1,
color1ToColor3: 1,
color2ToColor1: 1,
color2ToColor2: 1,
color2ToColor3: 1,
color3ToColor1: 1,
color3ToColor2: 1,
color3ToColor3: 1,
},
chromePollen: {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 1,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: 0,
color3ToColor1: 1,
color3ToColor2: 0,
color3ToColor3: 1,
},
} satisfies Record<string, ColorReactionSettings>;
const musicScales = {
dorian: [0, 2, 3, 5, 7, 9, 10],
lydian: [0, 2, 4, 6, 7, 9, 11],
mixolydian: [0, 2, 4, 5, 7, 9, 10],
naturalMinor: [0, 2, 3, 5, 7, 8, 10],
} satisfies Record<string, Array<number>>;
const musicProgressions = {
aurora: [
{ rootOffset: 0, quality: 'sus2' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'sus4' },
],
chrome: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 2, quality: 'major' },
{ rootOffset: 7, quality: 'sus2' },
{ rootOffset: 9, quality: 'minor' },
],
lichen: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
],
paperLantern: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 5, quality: 'minor' },
{ rootOffset: 10, quality: 'sus4' },
],
tidepool: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
{ rootOffset: 5, quality: 'sus2' },
{ rootOffset: 9, quality: 'minor' },
],
velvet: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 5, quality: 'sus4' },
],
} satisfies Record<string, Array<GardenAudioChord>>;
export const defaultVibeId = VibeId.AuroraMycelium;
export const vibePresets: Array<VibePreset> = [
{
id: VibeId.AuroraMycelium,
name: 'Aurora Mycelium Copy',
colors: [
[251, 210, 94],
[154, 99, 255],
[255, 31, 199],
],
backgroundColor: [6, 13, 22],
settings: {
...colorReactions.auroraMycelium,
backgroundGrainStrength: 0.003,
brushSize: 8.75,
clarity: 1,
decayRateTrails: 973,
forwardRotationScale: 0.37,
individualTrailWeight: 0.053000000000000005,
moveSpeed: 144,
sensorOffsetAngle: 35,
sensorOffsetDistance: 52,
spawnPerPixel: 0.13999999999999999,
strokeAngleJitterRadians: 0.45,
turnSpeed: 13,
turnWhenLost: 0,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.12000000000000002,
bpm: 60,
rampUpIntensity: 0.7,
rampUpTime: 0.14,
noteLength: 0.8599999999999999,
notePitchOffset: -2,
brightness: 0.84,
scale: musicScales.lydian,
progression: musicProgressions.aurora,
},
},
{
id: VibeId.VelvetObservatory,
name: 'Velvet Observatory Copy',
colors: [
[178, 76, 62],
[2, 174, 255],
[213, 193, 9],
],
backgroundColor: [7, 4, 22],
settings: {
...colorReactions.velvetObservatory,
backgroundGrainStrength: 0.005,
brushSize: 9.75,
clarity: 1,
decayRateTrails: 974,
forwardRotationScale: 0,
individualTrailWeight: 0.232,
moveSpeed: 121,
sensorOffsetAngle: 24,
sensorOffsetDistance: 17,
spawnPerPixel: 0.11499999999999999,
strokeAngleJitterRadians: 0.17,
turnSpeed: 33,
turnWhenLost: 0.42,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.24000000000000002,
bpm: 72,
rampUpIntensity: 1.42,
rampUpTime: 0.07,
noteLength: 0.7,
notePitchOffset: 0,
brightness: 0.94,
scale: musicScales.naturalMinor,
progression: musicProgressions.velvet,
},
},
{
id: VibeId.LichenSignal,
name: 'Lichen Signal',
colors: [
[183, 216, 92],
[65, 166, 128],
[238, 120, 76],
],
backgroundColor: [0, 0, 0],
settings: {
...colorReactions.lichenSignal,
backgroundGrainStrength: 0.02,
brushSize: 6.5,
clarity: 0.74,
decayRateTrails: 962,
forwardRotationScale: 0.3,
individualTrailWeight: 0.052,
moveSpeed: 72,
sensorOffsetAngle: 42,
sensorOffsetDistance: 54,
spawnPerPixel: 0.16,
strokeAngleJitterRadians: 3.14,
turnSpeed: 44,
turnWhenLost: 0.92,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.13,
bpm: 68,
rampUpIntensity: 1.46,
rampUpTime: 0.1,
noteLength: 0.6,
notePitchOffset: -3,
brightness: 1.21,
scale: musicScales.dorian,
progression: musicProgressions.lichen,
},
},
{
id: VibeId.TidepoolLantern,
name: 'Tidepool Lantern',
colors: [
[30, 219, 194],
[61, 118, 255],
[255, 191, 91],
],
backgroundColor: [4, 18, 29],
settings: {
...colorReactions.tidepoolLantern,
backgroundGrainStrength: 0.018,
brushSize: 17,
clarity: 0.56,
decayRateTrails: 968,
forwardRotationScale: 0.38,
individualTrailWeight: 0.06,
moveSpeed: 88,
sensorOffsetAngle: 64,
sensorOffsetDistance: 46,
spawnPerPixel: 0.22,
strokeAngleJitterRadians: 1.8,
turnSpeed: 66,
turnWhenLost: 1.05,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.08,
bpm: 84,
rampUpIntensity: 0.95,
rampUpTime: 0.08,
noteLength: 0.46,
notePitchOffset: 0,
brightness: 0.98,
scale: musicScales.mixolydian,
progression: musicProgressions.tidepool,
},
},
{
id: VibeId.PaperLanternFog,
name: 'Paper Lantern Fog',
colors: [
[255, 176, 108],
[239, 90, 108],
[128, 213, 184],
],
backgroundColor: [30, 23, 20],
settings: {
...colorReactions.paperLanternFog,
backgroundGrainStrength: 0.038,
brushSize: 3.5,
clarity: 1,
decayRateTrails: 999,
forwardRotationScale: 0.24,
individualTrailWeight: 0.937,
moveSpeed: 28,
sensorOffsetAngle: 34,
sensorOffsetDistance: 66,
spawnPerPixel: 0.055,
strokeAngleJitterRadians: 0,
turnSpeed: 30,
turnWhenLost: 1.52,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.33,
bpm: 127,
rampUpIntensity: 0.66,
rampUpTime: 0.03,
noteLength: 0.92,
notePitchOffset: 10,
brightness: 1.42,
scale: musicScales.naturalMinor,
progression: musicProgressions.paperLantern,
},
},
{
id: VibeId.ChromePollen,
name: 'Chrome Pollen',
colors: [
[178, 34, 34],
[255, 214, 48],
[77, 240, 157],
],
backgroundColor: [7, 12, 11],
settings: {
...colorReactions.chromePollen,
backgroundGrainStrength: 0.012,
brushSize: 4.5,
clarity: 0.1,
decayRateTrails: 922,
forwardRotationScale: 0.5,
individualTrailWeight: 0.026,
moveSpeed: 86,
sensorOffsetAngle: 46,
sensorOffsetDistance: 14,
spawnPerPixel: 0.36,
strokeAngleJitterRadians: 3,
turnSpeed: 34,
turnWhenLost: 1.35,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.11,
bpm: 150,
rampUpIntensity: 2,
rampUpTime: 0.06,
noteLength: 1.8,
notePitchOffset: -12,
brightness: 0.5,
scale: musicScales.lydian,
progression: musicProgressions.chrome,
},
},
];

View file

@ -1 +0,0 @@
export const isProduction: boolean = import.meta.env.PROD;

View file

@ -1,54 +1,77 @@
import { GameLoopSettings } from './game-loop/game-loop-settings';
import { AgentSettings } from './pipelines/agents/agent-settings';
import { BrushSettings } from './pipelines/brush/brush-settings';
import { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
import { RenderSettings } from './pipelines/render/render-settings';
import { persist } from './utils/persist';
import {
appConfig,
normalizeRuntimeSettings,
type GardenRuntimeSettings,
} from './config';
import { writeBrowserStorage } from './utils/browser-storage';
import { getInitialVibe, type VibePreset } from './vibes';
const initialValues: GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings = {
agentCount: 1_001_500,
const preservedRuntimeSettingKeys = [
'eraserSize',
'adaptiveCapInitial',
'adaptiveCapMin',
'internalRenderAreaMegapixels',
'maxAgentCount',
'mirrorSegmentCount',
] satisfies ReadonlyArray<keyof GardenRuntimeSettings>;
currentGenerationAggression: -5,
nextGenerationAggression: 0.2,
const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
[...color] as T;
moveSpeed: 74,
turnSpeed: 45,
sensorOffsetAngle: 31,
sensorOffsetDistance: 43,
turnWhenLost: 0.01,
const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({
...audio,
...(audio.scale ? { scale: [...audio.scale] } : {}),
...(audio.progression
? { progression: audio.progression.map((chord) => ({ ...chord })) }
: {}),
});
brushTrailWeight: 500,
individualTrailWeight: 0.05,
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
...vibe,
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
backgroundColor: cloneRgbColor(vibe.backgroundColor),
settings: { ...vibe.settings },
audio: cloneVibeAudio(vibe.audio),
});
diffusionRateTrails: 0,
decayRateTrails: 944,
diffusionRateBrush: 0.35,
decayRateBrush: 18,
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
normalizeRuntimeSettings(
{
...appConfig.defaultSettings,
eraserSize: appConfig.toolbar.eraser.default,
mirrorSegmentCount: appConfig.toolbar.mirror.default,
...vibe.settings,
},
appConfig.runtimeSettings.controls
);
clarity: 0.7,
brushSize: 12,
export let activeVibe = cloneVibePreset(getInitialVibe());
brushSizeVariation: 0.5, // hidden on the UI
startColorHue: 200,
maxAgentCountUpperLimit: Number.POSITIVE_INFINITY, // requires restart
// debug options
renderSpeed: 1,
simulatedDelayMs: 0,
export const settings: GardenRuntimeSettings = {
...buildSettings(activeVibe),
};
export const settings: { [key: string]: number } & GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings = persist({ ...initialValues });
export const resetSettings = () => {
Object.assign(settings, initialValues);
export const rememberActiveVibeSelection = (): void => {
writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
};
export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe = cloneVibePreset(vibe);
const nextSettings = buildSettings(activeVibe);
preservedRuntimeSettingKeys.forEach((key) => {
nextSettings[key] = settings[key];
});
nextSettings.selectedColorIndex = Math.min(
settings.selectedColorIndex,
activeVibe.colors.length - 1
);
Object.assign(
settings,
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
);
rememberActiveVibeSelection();
return activeVibe;
};

View file

@ -0,0 +1,18 @@
export const readBrowserStorage = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
export const writeBrowserStorage = (key: string, value: string): void => {
try {
localStorage.setItem(key, value);
} catch (error) {
console.warn(
'Storage can be unavailable in private browsing or embedded contexts.',
error
);
}
};

View file

@ -1,4 +0,0 @@
export const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
export const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));

View file

@ -1,9 +0,0 @@
export const exponentialDecay = ({
accumulator,
nextValue,
biasOfNextValue,
}: {
accumulator: number;
nextValue: number;
biasOfNextValue: number;
}) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue;

View file

@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { formatNumber } from './format-number';
describe('formatNumber', () => {
it('renders integers without decimals', () => {
expect(formatNumber(42)).toBe('42 ');
});
it('renders fractional values with two decimals', () => {
expect(formatNumber(3.14159)).toBe('3.14 ');
});
it('renders thousands compactly', () => {
expect(formatNumber(2500)).toBe('2.5 thousand ');
});
it('renders millions compactly', () => {
expect(formatNumber(1_500_000)).toBe('1.5 million ');
});
it('appends the unit when provided', () => {
expect(formatNumber(5, 'agents')).toBe('5 agents');
expect(formatNumber(2_000_000, 'agents')).toBe('2.0 million agents');
});
});

View file

@ -1,11 +0,0 @@
export const formatNumber = (value: number, unit = ''): string => {
if (value >= 1e6) {
return `${(value / 1e6).toFixed(1)} million ${unit}`;
}
if (value >= 1e3) {
return `${(value / 1e3).toFixed(1)} thousand ${unit}`;
}
return `${value === Math.floor(value) ? value : value.toFixed(2)} ${unit}`;
};

View file

@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest';
import { hsl } from './hsl';
describe('hsl', () => {
it('produces pure red at hue 0', () => {
const [r, g, b] = hsl(0, 100, 50);
expect(r).toBeCloseTo(1);
expect(g).toBeCloseTo(0);
expect(b).toBeCloseTo(0);
});
it('produces pure green at hue 120', () => {
const [r, g, b] = hsl(120, 100, 50);
expect(r).toBeCloseTo(0);
expect(g).toBeCloseTo(1);
expect(b).toBeCloseTo(0);
});
it('produces pure blue at hue 240', () => {
const [r, g, b] = hsl(240, 100, 50);
expect(r).toBeCloseTo(0);
expect(g).toBeCloseTo(0);
expect(b).toBeCloseTo(1);
});
it('produces gray at saturation 0', () => {
const [r, g, b] = hsl(180, 0, 50);
expect(r).toBeCloseTo(0.5);
expect(g).toBeCloseTo(0.5);
expect(b).toBeCloseTo(0.5);
});
it('produces black at lightness 0', () => {
const [r, g, b] = hsl(0, 100, 0);
expect(r).toBe(0);
expect(g).toBe(0);
expect(b).toBe(0);
});
it('produces white at lightness 100', () => {
const [r, g, b] = hsl(0, 100, 100);
expect(r).toBeCloseTo(1);
expect(g).toBeCloseTo(1);
expect(b).toBeCloseTo(1);
});
});

View file

@ -1,35 +0,0 @@
import { vec3 } from 'gl-matrix';
import { rgb } from './rgb';
export const hsl = (hue: number, saturation: number, lightness: number): vec3 => {
hue /= 360;
saturation /= 100;
lightness /= 100;
let r: number, g: number, b: number;
if (saturation == 0) {
r = g = b = lightness;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q =
lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
r = hue2rgb(p, q, hue + 1 / 3);
g = hue2rgb(p, q, hue);
b = hue2rgb(p, q, hue - 1 / 3);
}
return rgb(r, g, b);
};

View file

@ -1,63 +0,0 @@
import { describe, expect, it } from 'vitest';
import { clamp, clamp01 } from './clamp';
import { exponentialDecay } from './exponential-decay';
import { mix } from './mix';
describe('clamp', () => {
it('returns value when within bounds', () => {
expect(clamp(5, 0, 10)).toBe(5);
});
it('clamps below to lower bound', () => {
expect(clamp(-3, 0, 10)).toBe(0);
});
it('clamps above to upper bound', () => {
expect(clamp(42, 0, 10)).toBe(10);
});
});
describe('clamp01', () => {
it('passes through values in [0, 1]', () => {
expect(clamp01(0.25)).toBe(0.25);
});
it('clamps negatives to 0', () => {
expect(clamp01(-1)).toBe(0);
});
it('clamps above 1 to 1', () => {
expect(clamp01(2)).toBe(1);
});
});
describe('mix', () => {
it('returns from at q=0', () => {
expect(mix(10, 20, 0)).toBe(10);
});
it('returns to at q=1', () => {
expect(mix(10, 20, 1)).toBe(20);
});
it('interpolates at q=0.5', () => {
expect(mix(10, 20, 0.5)).toBe(15);
});
it('extrapolates outside [0, 1]', () => {
expect(mix(0, 10, 2)).toBe(20);
expect(mix(0, 10, -1)).toBe(-10);
});
});
describe('exponentialDecay', () => {
it('returns nextValue when bias is 1', () => {
expect(exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 1 })).toBe(
10
);
});
it('returns accumulator when bias is 0', () => {
expect(exponentialDecay({ accumulator: 5, nextValue: 10, biasOfNextValue: 0 })).toBe(
5
);
});
it('blends with given bias', () => {
expect(
exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 0.25 })
).toBe(2.5);
});
});

29
src/utils/math.ts Normal file
View file

@ -0,0 +1,29 @@
export const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
export const clamp01 = (value: number): number => clamp(value, 0, 1);
export const mix = (from: number, to: number, amount: number): number =>
from + (to - from) * amount;
export const mixAngle = (from: number, to: number, amount: number): number => {
const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from));
return from + delta * amount;
};
export const approach = (
current: number,
target: number,
elapsedSeconds: number,
timeConstantSeconds: number
): number => {
const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds));
return mix(current, target, amount);
};
export const smoothstep = (edge0: number, edge1: number, value: number): number => {
const amount = clamp01((value - edge0) / (edge1 - edge0));
return amount * amount * (3 - 2 * amount);
};
export const easeOutQuad = (value: number): number => value * (2 - value);

View file

@ -1 +0,0 @@
export const mix = (from: number, to: number, q: number) => from + (to - from) * q;

View file

@ -1,39 +0,0 @@
export const persist = <T extends Record<string, number>>(wrapee: T): T => {
const keys = Object.keys(wrapee);
keys.sort();
const keysToShortKeys = Object.fromEntries(keys.map((key) => [key, key]));
const params = new URLSearchParams(window.location.search);
const newParams = new URLSearchParams();
keys.forEach((key) => {
if (params.has(keysToShortKeys[key])) {
(wrapee as any)[key] = Number(params.get(keysToShortKeys[key]));
newParams.set(keysToShortKeys[key], params.get(keysToShortKeys[key])!);
}
});
window.history.replaceState(
{},
'',
`${window.location.pathname}?${newParams.toString()}`
);
return new Proxy(wrapee, {
set: (target, key: string, value: number) => {
const params = new URLSearchParams(window.location.search);
params.set(keysToShortKeys[key], value.toString());
(target as any)[key] = value;
window.history.replaceState(
{},
'',
`${window.location.pathname}?${params.toString()}`
);
return true;
},
});
};

View file

@ -1,41 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { Random } from './random';
describe('Random', () => {
beforeEach(() => {
Random.seed = 42;
});
it('produces values in [0, 1)', () => {
for (let i = 0; i < 1000; i++) {
const v = Random.getRandom();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it('is deterministic for the same seed', () => {
Random.seed = 42;
const a = Array.from({ length: 8 }, () => Random.getRandom());
Random.seed = 42;
const b = Array.from({ length: 8 }, () => Random.getRandom());
expect(a).toEqual(b);
});
it('produces different sequences for different seeds', () => {
Random.seed = 1;
const a = Array.from({ length: 4 }, () => Random.getRandom());
Random.seed = 2;
const b = Array.from({ length: 4 }, () => Random.getRandom());
expect(a).not.toEqual(b);
});
it('randomBetween stays within [from, to)', () => {
for (let i = 0; i < 1000; i++) {
const v = Random.randomBetween(-10, 10);
expect(v).toBeGreaterThanOrEqual(-10);
expect(v).toBeLessThan(10);
}
});
});

View file

@ -1,23 +0,0 @@
// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript, Mulberry32
export abstract class Random {
private static _seed = 42;
public static set seed(value: number) {
Random._seed = value;
}
public static getRandomInt(): number {
let t = (Random._seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
}
public static getRandom(): number {
return Random.getRandomInt() / 4294967296;
}
public static randomBetween(from: number, to: number): number {
return from + Random.getRandom() * (to - from);
}
}

42
src/utils/rgb-color.ts Normal file
View file

@ -0,0 +1,42 @@
export type RgbColor = [red: number, green: number, blue: number];
const RGB_CHANNEL_MAX = 255;
const toFiniteRgbChannel = (value: number): number =>
Number.isFinite(value) ? value : 0;
const clampRgbChannel = (value: number): number =>
Math.min(RGB_CHANNEL_MAX, Math.max(0, Math.round(toFiniteRgbChannel(value))));
export const rgbColorToCss = ([red, green, blue]: RgbColor): string =>
`rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`;
export const rgbColorToHex = ([red, green, blue]: RgbColor): string =>
`#${[red, green, blue]
.map((channel) => clampRgbChannel(channel).toString(16).padStart(2, '0'))
.join('')}`;
export const hexColorToRgbColor = (value: string): RgbColor | null => {
const match = value.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!match) {
return null;
}
const shorthandOrHex = match[1];
const hex =
shorthandOrHex.length === 3
? shorthandOrHex
.split('')
.map((channel) => `${channel}${channel}`)
.join('')
: shorthandOrHex;
return [
Number.parseInt(hex.slice(0, 2), 16),
Number.parseInt(hex.slice(2, 4), 16),
Number.parseInt(hex.slice(4, 6), 16),
];
};
export const rgbChannelToUnit = (value: number): number =>
Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX));

View file

@ -1,3 +0,0 @@
import { vec3 } from 'gl-matrix';
export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b);

View file

@ -1,3 +0,0 @@
export const sleep = (ms: number): Promise<void> => {
return new Promise<void>((resolve, _) => setTimeout(resolve, ms));
};

7
src/vibe-registry.ts Normal file
View file

@ -0,0 +1,7 @@
import { appConfig } from './config';
import type { VibeId, VibePreset } from './config/types';
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);

54
src/vibe-uri.test.ts Normal file
View file

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { VibeId } from './config/types';
import { createVibeUri, getVibeIdFromUri } from './vibe-uri';
describe('vibe URI handling', () => {
it('loads vibes from slug IDs and display names', () => {
expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe(
VibeId.AuroraMycelium
);
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
VibeId.AuroraMycelium
);
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe(
VibeId.VelvetObservatory
);
});
it('uses query values before path or hash fallbacks', () => {
expect(
getVibeIdFromUri(
'https://example.test/chrome-pollen?vibe=lichen-signal#vibe=aurora-mycelium'
)
).toBe(VibeId.LichenSignal);
});
it('accepts explicit path segments and hash fallbacks', () => {
expect(getVibeIdFromUri('https://example.test/vibes/tidepool-lantern')).toBe(
VibeId.TidepoolLantern
);
expect(getVibeIdFromUri('https://example.test/#paper-lantern-fog')).toBe(
VibeId.PaperLanternFog
);
});
it('ignores unknown or malformed vibe values', () => {
expect(getVibeIdFromUri('https://example.test/?vibe=missing')).toBeNull();
expect(getVibeIdFromUri('https://example.test/?vibe=%E0%A4%A')).toBeNull();
expect(getVibeIdFromUri('not a url')).toBeNull();
});
it('creates a canonical query URI without dropping other URL parts', () => {
expect(
createVibeUri('https://example.test/garden?debug=1#panel', VibeId.ChromePollen)
).toBe('/garden?debug=1&vibe=chrome-pollen#panel');
expect(
createVibeUri(
'https://example.test/garden?vibe=aurora-mycelium&debug=1',
VibeId.LichenSignal
)
).toBe('/garden?vibe=lichen-signal&debug=1');
});
});

148
src/vibe-uri.ts Normal file
View file

@ -0,0 +1,148 @@
import type { VibeId } from './config/types';
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
const VIBE_URI_QUERY_PARAM = 'vibe';
const FALLBACK_URL_ORIGIN = 'https://fleeting.garden';
const slugifyVibeName = (value: string): string =>
value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const safeDecodeURIComponent = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const normalizeVibeIdentifier = (value: string): string =>
slugifyVibeName(safeDecodeURIComponent(value).replace(/^[#/\\?\s]+|[/\\?\s]+$/g, ''));
const vibeIdByIdentifier = new Map<string, VibeId>();
for (const vibe of VIBE_PRESETS) {
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id);
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id);
}
const toUrl = (url: string | URL): URL | null => {
try {
return new URL(url, FALLBACK_URL_ORIGIN);
} catch {
return null;
}
};
const resolveVibeId = (value: string | null | undefined): VibeId | null => {
if (!value) {
return null;
}
return vibeIdByIdentifier.get(normalizeVibeIdentifier(value)) ?? null;
};
const getHashSearchParam = (hash: string): string | null => {
const hashValue = hash.replace(/^#/, '');
if (!hashValue.includes('=')) {
return null;
}
const searchText = hashValue.startsWith('?') ? hashValue.slice(1) : hashValue;
try {
return new URLSearchParams(searchText).get(VIBE_URI_QUERY_PARAM);
} catch {
return null;
}
};
const getPathVibeCandidates = (pathname: string): Array<string> => {
const segments = pathname.split('/').map(safeDecodeURIComponent).filter(Boolean);
const explicitVibeIndex = segments.findIndex((segment) =>
['vibe', 'vibes'].includes(segment.toLowerCase())
);
return [
explicitVibeIndex >= 0 ? segments[explicitVibeIndex + 1] : undefined,
segments.at(-1),
].filter((candidate): candidate is string => typeof candidate === 'string');
};
export const getVibeIdFromUri = (url: string | URL): VibeId | null => {
const parsedUrl = toUrl(url);
if (!parsedUrl) {
return null;
}
const candidates = [
parsedUrl.searchParams.get(VIBE_URI_QUERY_PARAM),
getHashSearchParam(parsedUrl.hash),
...getPathVibeCandidates(parsedUrl.pathname),
parsedUrl.hash.replace(/^#/, ''),
];
for (const candidate of candidates) {
const vibeId = resolveVibeId(candidate);
if (vibeId) {
return vibeId;
}
}
return null;
};
export const getCurrentUriVibeId = (): VibeId | null => {
if (typeof window === 'undefined') {
return null;
}
return getVibeIdFromUri(window.location.href);
};
const getVibeSlug = (vibeId: VibeId): string => {
const vibe = getVibeById(vibeId);
return vibe ? vibe.id : vibeId;
};
export const createVibeUri = (url: string | URL, vibeId: VibeId): string => {
const parsedUrl = toUrl(url);
if (!parsedUrl) {
return `?${VIBE_URI_QUERY_PARAM}=${encodeURIComponent(getVibeSlug(vibeId))}`;
}
parsedUrl.searchParams.set(VIBE_URI_QUERY_PARAM, getVibeSlug(vibeId));
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
};
export const writeCurrentVibeUri = (
vibeId: VibeId,
mode: 'push' | 'replace' = 'replace'
): void => {
if (typeof window === 'undefined') {
return;
}
const nextUri = createVibeUri(window.location.href, vibeId);
const currentUri = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (nextUri === currentUri) {
return;
}
const nextState =
typeof window.history.state === 'object' && window.history.state !== null
? { ...window.history.state, vibeId }
: { vibeId };
if (mode === 'push') {
window.history.pushState(nextState, '', nextUri);
return;
}
window.history.replaceState(nextState, '', nextUri);
};

26
src/vibes.ts Normal file
View file

@ -0,0 +1,26 @@
import { appConfig } from './config';
import { VibeId, type VibePreset } from './config/types';
import { readBrowserStorage } from './utils/browser-storage';
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri';
export { VibeId };
export { getVibeById, VIBE_PRESETS };
export type { VibePreset };
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
const isVibeId = (value: unknown): value is VibeId =>
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
export const getInitialVibe = (): VibePreset => {
const uriVibeId = getCurrentUriVibeId();
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
const storedOrLegacyVibeId = isVibeId(storedVibeId)
? storedVibeId
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
const initialVibeId =
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
};