Compare commits

..

9 commits

Author SHA1 Message Date
7f628180f0 Improve settings
All checks were successful
Check & deploy / build (pull_request) Successful in 1m9s
Check & deploy / build (push) Successful in 1m39s
2026-05-24 15:24:33 +01:00
39e19a3c64 Less strict webgpu reqs 2026-05-24 15:24:20 +01:00
a73914d0ef Restyle the app interface 2026-05-24 10:58:36 +01:00
839747304e Wire up garden controls 2026-05-24 10:58:21 +01:00
018f8c9d4d Rework garden simulation pipelines 2026-05-24 10:58:11 +01:00
c2efb33683 Add generative garden audio 2026-05-24 10:57:57 +01:00
f8294934fd Add configurable vibe presets 2026-05-24 10:57:47 +01:00
e54bddc7db Refresh app shell and site assets 2026-05-24 10:57:30 +01:00
3c21291d72 Update project tooling 2026-05-24 10:57:14 +01:00
14 changed files with 556 additions and 44 deletions

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

@ -67,7 +67,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
folder: 'Movement', folder: 'Movement',
label: 'Travel Speed', label: 'Travel Speed',
min: 10, min: 10,
max: 500, max: 250,
step: 1, step: 1,
}, },
turnSpeed: { turnSpeed: {
@ -116,6 +116,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
clarity: { clarity: {
folder: 'Look', folder: 'Look',
inverted: true,
label: 'Sharpness', label: 'Sharpness',
min: 0.00001, min: 0.00001,
max: 1, max: 1,

View file

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

View file

@ -12,6 +12,7 @@ export interface NumberControlConfig {
format?: (value: number) => string; format?: (value: number) => string;
folder: string; folder: string;
integer?: boolean; integer?: boolean;
inverted?: boolean;
label?: string; label?: string;
max?: number; max?: number;
min?: number; min?: number;

View file

@ -0,0 +1,187 @@
import type { FolderApi } from '@tweakpane/core';
import { appConfig, normalizeNumberControlValue } from '../config';
import { activeVibe, settings } from '../settings';
import { rgbColorToCss } from '../utils/rgb-color';
type PaneContainer = Pick<FolderApi, 'addFolder'>;
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
const COLOR_REACTION_STATES = [
{ id: 'follow', label: 'Move Toward', value: 1 },
{ id: 'ignore', label: 'Ignore', value: 0 },
{ id: 'avoid', label: 'Move Away', value: -1 },
] as const;
const colorReactionRows = [
{
colorIndex: 0,
label: COLOR_REACTION_LABELS[0],
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
},
{
colorIndex: 1,
label: COLOR_REACTION_LABELS[1],
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
},
{
colorIndex: 2,
label: COLOR_REACTION_LABELS[2],
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
},
] as const;
const getColorReactionStateIndex = (value: number): number =>
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
const getNextColorReactionState = (
value: number
): (typeof COLOR_REACTION_STATES)[number] => {
const index = getColorReactionStateIndex(value);
return COLOR_REACTION_STATES[
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
];
};
export class ColorReactionMatrixControl {
private readonly buttons = new Map<
ColorReactionKey,
{
element: HTMLButtonElement;
sourceColorIndex: number;
targetColorIndex: number;
}
>();
private readonly swatches: Array<{
colorIndex: number;
element: HTMLElement;
}> = [];
public constructor(private readonly onRuntimeChange: () => void) {}
public addTo(container: PaneContainer): void {
const folder = container.addFolder({
title: 'Color Behavior',
expanded: true,
});
const matrix = document.createElement('div');
matrix.className = 'color-reaction-matrix';
matrix.appendChild(this.createCorner());
colorReactionRows.forEach((row) => {
matrix.appendChild(this.createHeader(row.colorIndex, row.label));
});
colorReactionRows.forEach((row) => {
matrix.appendChild(this.createHeader(row.colorIndex, row.label));
row.keys.forEach((key, columnIndex) => {
matrix.appendChild(this.createCell(key, row.colorIndex, columnIndex));
});
});
const matrixBlade = folder.addBlade({ view: 'separator' });
matrixBlade.element.classList.add('color-reaction-matrix-blade');
matrixBlade.element.replaceChildren(matrix);
this.sync();
}
public sync(): void {
this.buttons.forEach(({ element, sourceColorIndex, targetColorIndex }, key) => {
this.syncButton(element, key, sourceColorIndex, targetColorIndex);
});
this.swatches.forEach(({ colorIndex, element }) => {
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
});
}
private createCorner(): HTMLDivElement {
const corner = document.createElement('div');
corner.className = 'color-reaction-matrix__corner';
return corner;
}
private createHeader(colorIndex: number, label: string): HTMLDivElement {
const header = document.createElement('div');
header.className = 'color-reaction-matrix__header';
header.setAttribute('aria-label', label);
header.title = label;
const swatch = document.createElement('span');
swatch.className = 'color-reaction-matrix__swatch';
this.swatches.push({ colorIndex, element: swatch });
header.appendChild(swatch);
return header;
}
private createCell(
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): HTMLDivElement {
const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell';
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return cell;
}
const button = document.createElement('button');
button.className = 'color-reaction-matrix__button';
button.type = 'button';
const icon = document.createElement('span');
icon.className = 'color-reaction-matrix__icon';
button.appendChild(icon);
button.addEventListener('click', () => {
const currentValue = normalizeNumberControlValue(settings[key], config);
const nextState = getNextColorReactionState(currentValue);
settings[key] = nextState.value;
this.syncButton(button, key, sourceColorIndex, targetColorIndex);
this.onRuntimeChange();
});
this.buttons.set(key, {
element: button,
sourceColorIndex,
targetColorIndex,
});
cell.appendChild(button);
return cell;
}
private syncButton(
button: HTMLButtonElement,
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): void {
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
}
settings[key] = normalizeNumberControlValue(settings[key], config);
const state = getColorReactionState(settings[key]);
const nextState = getNextColorReactionState(settings[key]);
const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
button.dataset.reaction = state.id;
button.setAttribute(
'aria-label',
`${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
);
button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
}
}

View file

@ -81,6 +81,16 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
return params; return params;
}; };
const getInvertedNumberControlValue = (
value: number,
config: NumberControlConfig
): number => {
if (!config.inverted || config.min === undefined || config.max === undefined) {
return value;
}
return config.min + config.max - value;
};
export class ConfigPane { export class ConfigPane {
private readonly container: HTMLDivElement; private readonly container: HTMLDivElement;
private readonly closeButton: HTMLButtonElement; private readonly closeButton: HTMLButtonElement;
@ -264,9 +274,10 @@ export class ConfigPane {
} }
settings[key] = normalizeNumberControlValue(settings[key], config); settings[key] = normalizeNumberControlValue(settings[key], config);
const bindingTarget = this.getRuntimeBindingTarget(key, config);
container container
.addBinding(settings, key, getNumberBindingParams(config)) .addBinding(bindingTarget, key, getNumberBindingParams(config))
.on('change', () => { .on('change', () => {
const nextValue = normalizeNumberControlValue(settings[key], config); const nextValue = normalizeNumberControlValue(settings[key], config);
if (nextValue !== settings[key]) { if (nextValue !== settings[key]) {
@ -277,6 +288,25 @@ export class ConfigPane {
}); });
} }
private getRuntimeBindingTarget(
key: RuntimeControlKey,
config: NumberControlConfig
): typeof settings | Record<RuntimeControlKey, number> {
if (!config.inverted) {
return settings;
}
const bindingTarget = {} as Record<RuntimeControlKey, number>;
Object.defineProperty(bindingTarget, key, {
enumerable: true,
get: () => getInvertedNumberControlValue(settings[key], config),
set: (value: number) => {
settings[key] = getInvertedNumberControlValue(value, config);
},
});
return bindingTarget;
}
private getRuntimeControlConfig( private getRuntimeControlConfig(
key: RuntimeControlKey key: RuntimeControlKey
): NumberControlConfig | undefined { ): NumberControlConfig | undefined {

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import {
getEraserSizeFromSliderRatio,
getEraserSliderRatioFromSize,
} from './eraser-size-control';
describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => {
const { max, min } = appConfig.toolbar.eraser;
expect(getEraserSizeFromSliderRatio(0)).toBe(min);
expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25);
expect(getEraserSizeFromSliderRatio(1)).toBe(max);
});
it('maps eraser size back to the inverse slider position', () => {
const { max, min } = appConfig.toolbar.eraser;
const quarterRangeSize = min + (max - min) * 0.25;
expect(getEraserSliderRatioFromSize(min)).toBe(0);
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
expect(getEraserSliderRatioFromSize(max)).toBe(1);
});
});

View file

@ -9,11 +9,28 @@ const clampEraserSize = (value: number): number => {
return Math.min(max, Math.max(min, Math.round(safeValue))); return Math.min(max, Math.max(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 => { const getEraserSizeRatio = (size: number): number => {
const { max, min } = appConfig.toolbar.eraser; const { max, min } = appConfig.toolbar.eraser;
return (size - min) / (max - min); return (clampEraserSize(size) - min) / (max - min);
}; };
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
const { max, min } = appConfig.toolbar.eraser;
return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2);
};
export const getEraserSliderRatioFromSize = (size: number): number =>
Math.sqrt(getEraserSizeRatio(size));
interface EraserSizeControlOptions { interface EraserSizeControlOptions {
getGame: () => GameLoop | null; getGame: () => GameLoop | null;
onActivate: () => void; onActivate: () => void;
@ -33,7 +50,7 @@ export class EraserSizeControl {
this.control.addEventListener('click', this.activate); this.control.addEventListener('click', this.activate);
this.slider.addEventListener('focus', this.activate); this.slider.addEventListener('focus', this.activate);
this.slider.addEventListener('input', () => { this.slider.addEventListener('input', () => {
settings.eraserSize = clampEraserSize(Number(this.slider.value)); settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
this.activate(); this.activate();
this.render(); this.render();
this.options.onChange(); this.options.onChange();
@ -46,19 +63,20 @@ export class EraserSizeControl {
settings.eraserSize = size; settings.eraserSize = size;
} }
this.slider.min = appConfig.toolbar.eraser.min.toString(); const sliderRatio = getEraserSliderRatioFromSize(size);
this.slider.max = appConfig.toolbar.eraser.max.toString(); this.slider.min = ERASER_SLIDER_MIN.toString();
this.slider.step = appConfig.toolbar.eraser.step.toString(); this.slider.max = ERASER_SLIDER_MAX.toString();
this.slider.value = size.toString(); this.slider.step = ERASER_SLIDER_STEP.toString();
this.slider.value = sliderRatio.toString();
this.slider.setAttribute('aria-valuetext', `${size}px`); this.slider.setAttribute('aria-valuetext', `${size}px`);
const ratio = getEraserSizeRatio(size); const sizeRatio = getEraserSizeRatio(size);
const scale = const scale =
appConfig.toolbar.eraser.controlScaleMin + appConfig.toolbar.eraser.controlScaleMin +
(appConfig.toolbar.eraser.controlScaleMax - (appConfig.toolbar.eraser.controlScaleMax -
appConfig.toolbar.eraser.controlScaleMin) * appConfig.toolbar.eraser.controlScaleMin) *
ratio; sizeRatio;
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`); this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.syncActiveState(); this.syncActiveState();
this.options.getGame()?.updateEraserPreview(); this.options.getGame()?.updateEraserPreview();

View file

@ -121,12 +121,15 @@ html > body {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
max-width: calc(100% - var(--normal-margin) * 2);
margin: var(--normal-margin); margin: var(--normal-margin);
z-index: 5; z-index: 5;
pre { pre {
font-size: 20px; font-size: 20px;
color: red; color: red;
white-space: pre-wrap;
overflow-wrap: anywhere;
} }
} }
} }

View file

@ -142,6 +142,7 @@
--tp-blade-value-width: min(210px, 62%); --tp-blade-value-width: min(210px, 62%);
--tp-container-unit-size: 18px; --tp-container-unit-size: 18px;
padding-bottom: 10px;
font-size: 11px; font-size: 11px;
// Tweakpane v4 internal class re-verify on upgrade. // Tweakpane v4 internal class re-verify on upgrade.

View file

@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { initializeGpu } from './initialize-gpu';
const limits = {
maxBufferSize: 1024,
maxComputeWorkgroupsPerDimension: 16,
maxStorageBufferBindingSize: 1024,
} as unknown as GPUSupportedLimits;
const createDevice = (): GPUDevice =>
({
addEventListener: vi.fn(),
lost: new Promise(() => undefined),
}) as unknown as GPUDevice;
const createAdapter = (features: Array<GPUFeatureName> = []): GPUAdapter => {
const device = createDevice();
return {
features: new Set(features),
info: {},
limits,
requestDevice: vi.fn().mockResolvedValue(device),
} as unknown as GPUAdapter;
};
const stubSecureWebGpu = (requestAdapter: GPU['requestAdapter']): void => {
vi.stubGlobal('window', { isSecureContext: true });
vi.stubGlobal('navigator', {
gpu: {
requestAdapter,
},
});
};
describe('initializeGpu', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('starts with the least demanding compatibility adapter request', async () => {
const adapter = createAdapter();
const requestAdapter = vi.fn().mockResolvedValue(adapter);
stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
await initializeGpu();
expect(requestAdapter).toHaveBeenNthCalledWith(1, {
featureLevel: 'compatibility',
});
expect(requestAdapter).toHaveBeenCalledTimes(1);
expect(adapter.requestDevice).toHaveBeenCalled();
});
it('continues trying adapters if one request throws', async () => {
const adapter = createAdapter();
const requestAdapter = vi
.fn()
.mockRejectedValueOnce(new Error('adapter request failed'))
.mockResolvedValueOnce(adapter);
stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
await expect(initializeGpu()).resolves.toBeDefined();
expect(requestAdapter).toHaveBeenCalledTimes(2);
});
it('falls back through core and high-performance adapter requests', async () => {
const adapter = createAdapter();
const requestAdapter = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(adapter);
stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
await initializeGpu();
expect(requestAdapter).toHaveBeenNthCalledWith(1, {
featureLevel: 'compatibility',
});
expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined);
expect(requestAdapter).toHaveBeenNthCalledWith(3, {
featureLevel: 'compatibility',
powerPreference: 'high-performance',
});
expect(adapter.requestDevice).toHaveBeenCalled();
});
it('requests only the core feature when the adapter exposes optional features', async () => {
const adapter = createAdapter(['core-features-and-limits', 'timestamp-query']);
const requestAdapter = vi.fn().mockResolvedValue(adapter);
stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
await initializeGpu();
expect(adapter.requestDevice).toHaveBeenCalledWith({
requiredFeatures: ['core-features-and-limits'],
});
});
});

View file

@ -15,7 +15,30 @@ const REQUESTED_LIMIT_NAMES = [
'maxComputeWorkgroupsPerDimension', 'maxComputeWorkgroupsPerDimension',
] as const satisfies ReadonlyArray<keyof GPUSupportedLimits>; ] as const satisfies ReadonlyArray<keyof GPUSupportedLimits>;
const getRequiredLimits = ( interface AdapterRequestAttempt {
label: string;
options?: GPURequestAdapterOptions;
}
const ADAPTER_REQUEST_ATTEMPTS: ReadonlyArray<AdapterRequestAttempt> = [
{
label: 'compatibility-default',
options: { featureLevel: 'compatibility' },
},
{
label: 'core-default',
},
{
label: 'compatibility-high-performance',
options: { featureLevel: 'compatibility', powerPreference: 'high-performance' },
},
{
label: 'core-high-performance',
options: { powerPreference: 'high-performance' },
},
] as const;
const getRelevantLimits = (
limits: GPUSupportedLimits limits: GPUSupportedLimits
): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> => ): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> =>
Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record< Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record<
@ -42,25 +65,64 @@ const getAdapterInfo = (adapter: GPUAdapter): Record<string, unknown> => {
} }
}; };
const getRequiredFeatures = (adapter: GPUAdapter): Array<GPUFeatureName> => {
const requiredFeatures: Array<GPUFeatureName> = [];
if (adapter.features.has('core-features-and-limits')) {
requiredFeatures.push('core-features-and-limits');
}
return requiredFeatures;
};
type AdapterRequestOutcome = 'adapter' | 'unavailable' | 'error';
const describeAdapterRequest = (
attempt: AdapterRequestAttempt,
outcome: AdapterRequestOutcome,
causeMessage?: string
): Record<string, unknown> => ({
label: attempt.label,
featureLevel: attempt.options?.featureLevel ?? 'core',
powerPreference: attempt.options?.powerPreference ?? 'default',
outcome,
...(causeMessage === undefined ? {} : { causeMessage }),
});
const requestAdapter = async ( const requestAdapter = async (
gpu: GPU, gpu: GPU
options?: GPURequestAdapterOptions ): Promise<{
): Promise<GPUAdapter | null> => { adapter: GPUAdapter | null;
attempts: Array<Record<string, unknown>>;
}> => {
const attempts: Array<Record<string, unknown>> = [];
for (const attempt of ADAPTER_REQUEST_ATTEMPTS) {
try { try {
return await gpu.requestAdapter(options); const adapter = await gpu.requestAdapter(attempt.options);
attempts.push(describeAdapterRequest(attempt, adapter ? 'adapter' : 'unavailable'));
if (adapter) {
return { adapter, attempts };
}
} catch (error) { } catch (error) {
throw new RuntimeError( attempts.push(describeAdapterRequest(attempt, 'error', getErrorMessage(error)));
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
'Could not request a WebGPU adapter.',
{
cause: error,
details: {
causeMessage: getErrorMessage(error),
powerPreference: options?.powerPreference ?? 'default',
},
} }
);
} }
return { adapter: null, attempts };
};
const formatAdapterAttemptSummary = (
attempts: Array<Record<string, unknown>>
): string => {
if (attempts.length === 0) {
return 'No adapter requests were attempted.';
}
return `Adapter attempts: ${attempts
.map((attempt) => `${attempt.label}: ${attempt.outcome}`)
.join('; ')}`;
}; };
export const initializeGpu = async (): Promise<GPUDevice> => { export const initializeGpu = async (): Promise<GPUDevice> => {
@ -81,35 +143,43 @@ export const initializeGpu = async (): Promise<GPUDevice> => {
}); });
} }
const adapter = const { adapter, attempts } = await requestAdapter(gpu);
(await requestAdapter(gpu, { ErrorHandler.addMetadata('webgpuAdapterRequest', {
powerPreference: 'high-performance', attempts,
})) ?? (await requestAdapter(gpu)); selectedAttempt: attempts[attempts.length - 1]?.label ?? null,
});
if (!adapter) { if (!adapter) {
throw new RuntimeError( throw new RuntimeError(
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE, ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
'WebGPU is available, but this browser could not provide a compatible GPU adapter.' [
'WebGPU is available, but this browser could not provide a compatible GPU adapter.',
formatAdapterAttemptSummary(attempts),
].join('\n'),
{
details: {
attempts,
hasNavigatorGpu: true,
isSecureContext: window.isSecureContext,
platform: navigator.platform,
userAgent: navigator.userAgent,
},
}
); );
} }
const requiredLimits = getRequiredLimits(adapter.limits); const requiredFeatures = getRequiredFeatures(adapter);
const requiredFeatures: Array<GPUFeatureName> = [];
if (adapter.features.has('timestamp-query')) {
requiredFeatures.push('timestamp-query');
}
ErrorHandler.addMetadata('webgpuAdapter', { ErrorHandler.addMetadata('webgpuAdapter', {
features: Array.from(adapter.features).sort(), features: Array.from(adapter.features).sort(),
info: getAdapterInfo(adapter), info: getAdapterInfo(adapter),
requiredFeatures, requiredFeatures,
requiredLimits, relevantLimits: getRelevantLimits(adapter.limits),
}); });
let gpuDevice: GPUDevice; let gpuDevice: GPUDevice;
try { try {
gpuDevice = await adapter.requestDevice({ gpuDevice = await adapter.requestDevice({
requiredFeatures, requiredFeatures,
requiredLimits,
}); });
} catch (error) { } catch (error) {
throw new RuntimeError( throw new RuntimeError(
@ -120,7 +190,6 @@ export const initializeGpu = async (): Promise<GPUDevice> => {
details: { details: {
causeMessage: getErrorMessage(error), causeMessage: getErrorMessage(error),
requiredFeatures, requiredFeatures,
requiredLimits,
}, },
} }
); );

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

View file

@ -11,9 +11,9 @@ describe('vibe URI handling', () => {
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe( expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
VibeId.AuroraMycelium VibeId.AuroraMycelium
); );
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe( expect(
VibeId.VelvetObservatory getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')
); ).toBe(VibeId.VelvetObservatory);
}); });
it('uses query values before path or hash fallbacks', () => { it('uses query values before path or hash fallbacks', () => {