diff --git a/src/config/normalize-runtime-settings.test.ts b/src/config/normalize-runtime-settings.test.ts new file mode 100644 index 0000000..259ff17 --- /dev/null +++ b/src/config/normalize-runtime-settings.test.ts @@ -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, + }); + }); +}); diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index e07a259..e6b8a70 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -67,7 +67,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { folder: 'Movement', label: 'Travel Speed', min: 10, - max: 500, + max: 250, step: 1, }, turnSpeed: { @@ -116,6 +116,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { clarity: { folder: 'Look', + inverted: true, label: 'Sharpness', min: 0.00001, max: 1, diff --git a/src/config/runtime-setting-bounds.ts b/src/config/runtime-setting-bounds.ts new file mode 100644 index 0000000..5400912 --- /dev/null +++ b/src/config/runtime-setting-bounds.ts @@ -0,0 +1,4 @@ +export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = { + min: 0.5, + max: 16.6, +} as const; diff --git a/src/config/types.ts b/src/config/types.ts index 456dec8..6f9aa29 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -12,6 +12,7 @@ export interface NumberControlConfig { format?: (value: number) => string; folder: string; integer?: boolean; + inverted?: boolean; label?: string; max?: number; min?: number; diff --git a/src/page/color-reaction-matrix-control.ts b/src/page/color-reaction-matrix-control.ts new file mode 100644 index 0000000..ca78cbb --- /dev/null +++ b/src/page/color-reaction-matrix-control.ts @@ -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; +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`; + } +} diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index 3e6c397..8bb7f28 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -81,6 +81,16 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => { 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 { private readonly container: HTMLDivElement; private readonly closeButton: HTMLButtonElement; @@ -264,9 +274,10 @@ export class ConfigPane { } settings[key] = normalizeNumberControlValue(settings[key], config); + const bindingTarget = this.getRuntimeBindingTarget(key, config); container - .addBinding(settings, key, getNumberBindingParams(config)) + .addBinding(bindingTarget, key, getNumberBindingParams(config)) .on('change', () => { const nextValue = normalizeNumberControlValue(settings[key], config); if (nextValue !== settings[key]) { @@ -277,6 +288,25 @@ export class ConfigPane { }); } + private getRuntimeBindingTarget( + key: RuntimeControlKey, + config: NumberControlConfig + ): typeof settings | Record { + if (!config.inverted) { + return settings; + } + + const bindingTarget = {} as Record; + Object.defineProperty(bindingTarget, key, { + enumerable: true, + get: () => getInvertedNumberControlValue(settings[key], config), + set: (value: number) => { + settings[key] = getInvertedNumberControlValue(value, config); + }, + }); + return bindingTarget; + } + private getRuntimeControlConfig( key: RuntimeControlKey ): NumberControlConfig | undefined { diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts new file mode 100644 index 0000000..6b2bbe8 --- /dev/null +++ b/src/page/eraser-size-control.test.ts @@ -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); + }); +}); diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts index 1099821..2c0e8a8 100644 --- a/src/page/eraser-size-control.ts +++ b/src/page/eraser-size-control.ts @@ -9,11 +9,28 @@ const clampEraserSize = (value: number): number => { 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 { 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 { getGame: () => GameLoop | null; onActivate: () => void; @@ -33,7 +50,7 @@ export class EraserSizeControl { this.control.addEventListener('click', this.activate); this.slider.addEventListener('focus', this.activate); this.slider.addEventListener('input', () => { - settings.eraserSize = clampEraserSize(Number(this.slider.value)); + settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value)); this.activate(); this.render(); this.options.onChange(); @@ -46,19 +63,20 @@ export class EraserSizeControl { settings.eraserSize = size; } - this.slider.min = appConfig.toolbar.eraser.min.toString(); - this.slider.max = appConfig.toolbar.eraser.max.toString(); - this.slider.step = appConfig.toolbar.eraser.step.toString(); - this.slider.value = size.toString(); + const sliderRatio = getEraserSliderRatioFromSize(size); + 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 ratio = getEraserSizeRatio(size); + const sizeRatio = getEraserSizeRatio(size); const scale = appConfig.toolbar.eraser.controlScaleMin + (appConfig.toolbar.eraser.controlScaleMax - appConfig.toolbar.eraser.controlScaleMin) * - ratio; - this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`); + sizeRatio; + this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`); this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); this.syncActiveState(); this.options.getGame()?.updateEraserPreview(); diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss index abf8824..f32be5d 100644 --- a/src/style/_app-shell.scss +++ b/src/style/_app-shell.scss @@ -121,12 +121,15 @@ html > body { position: absolute; top: 0; left: 0; + max-width: calc(100% - var(--normal-margin) * 2); margin: var(--normal-margin); z-index: 5; pre { font-size: 20px; color: red; + white-space: pre-wrap; + overflow-wrap: anywhere; } } } diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss index ad5402f..ad7ae31 100644 --- a/src/style/_config-pane.scss +++ b/src/style/_config-pane.scss @@ -142,6 +142,7 @@ --tp-blade-value-width: min(210px, 62%); --tp-container-unit-size: 18px; + padding-bottom: 10px; font-size: 11px; // Tweakpane v4 internal class — re-verify on upgrade. diff --git a/src/utils/graphics/initialize-gpu.test.ts b/src/utils/graphics/initialize-gpu.test.ts new file mode 100644 index 0000000..44bbd91 --- /dev/null +++ b/src/utils/graphics/initialize-gpu.test.ts @@ -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 = []): 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'], + }); + }); +}); diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts index 9108060..1fdc9bf 100644 --- a/src/utils/graphics/initialize-gpu.ts +++ b/src/utils/graphics/initialize-gpu.ts @@ -15,7 +15,30 @@ const REQUESTED_LIMIT_NAMES = [ 'maxComputeWorkgroupsPerDimension', ] as const satisfies ReadonlyArray; -const getRequiredLimits = ( +interface AdapterRequestAttempt { + label: string; + options?: GPURequestAdapterOptions; +} + +const ADAPTER_REQUEST_ATTEMPTS: ReadonlyArray = [ + { + 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 ): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> => Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record< @@ -42,25 +65,64 @@ const getAdapterInfo = (adapter: GPUAdapter): Record => { } }; -const requestAdapter = async ( - gpu: GPU, - options?: GPURequestAdapterOptions -): Promise => { - try { - return await gpu.requestAdapter(options); - } catch (error) { - throw new RuntimeError( - ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE, - 'Could not request a WebGPU adapter.', - { - cause: error, - details: { - causeMessage: getErrorMessage(error), - powerPreference: options?.powerPreference ?? 'default', - }, - } - ); +const getRequiredFeatures = (adapter: GPUAdapter): Array => { + const requiredFeatures: Array = []; + + 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 => ({ + label: attempt.label, + featureLevel: attempt.options?.featureLevel ?? 'core', + powerPreference: attempt.options?.powerPreference ?? 'default', + outcome, + ...(causeMessage === undefined ? {} : { causeMessage }), +}); + +const requestAdapter = async ( + gpu: GPU +): Promise<{ + adapter: GPUAdapter | null; + attempts: Array>; +}> => { + const attempts: Array> = []; + + for (const attempt of ADAPTER_REQUEST_ATTEMPTS) { + try { + const adapter = await gpu.requestAdapter(attempt.options); + attempts.push(describeAdapterRequest(attempt, adapter ? 'adapter' : 'unavailable')); + + if (adapter) { + return { adapter, attempts }; + } + } catch (error) { + attempts.push(describeAdapterRequest(attempt, 'error', getErrorMessage(error))); + } + } + + return { adapter: null, attempts }; +}; + +const formatAdapterAttemptSummary = ( + attempts: Array> +): 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 => { @@ -81,35 +143,43 @@ export const initializeGpu = async (): Promise => { }); } - const adapter = - (await requestAdapter(gpu, { - powerPreference: 'high-performance', - })) ?? (await requestAdapter(gpu)); + const { adapter, attempts } = await requestAdapter(gpu); + ErrorHandler.addMetadata('webgpuAdapterRequest', { + attempts, + selectedAttempt: attempts[attempts.length - 1]?.label ?? null, + }); if (!adapter) { throw new RuntimeError( 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: Array = []; - if (adapter.features.has('timestamp-query')) { - requiredFeatures.push('timestamp-query'); - } + const requiredFeatures = getRequiredFeatures(adapter); ErrorHandler.addMetadata('webgpuAdapter', { features: Array.from(adapter.features).sort(), info: getAdapterInfo(adapter), requiredFeatures, - requiredLimits, + relevantLimits: getRelevantLimits(adapter.limits), }); let gpuDevice: GPUDevice; try { gpuDevice = await adapter.requestDevice({ requiredFeatures, - requiredLimits, }); } catch (error) { throw new RuntimeError( @@ -120,7 +190,6 @@ export const initializeGpu = async (): Promise => { details: { causeMessage: getErrorMessage(error), requiredFeatures, - requiredLimits, }, } ); diff --git a/src/vibe-registry.ts b/src/vibe-registry.ts new file mode 100644 index 0000000..0b0ae54 --- /dev/null +++ b/src/vibe-registry.ts @@ -0,0 +1,7 @@ +import { appConfig } from './config'; +import type { VibeId, VibePreset } from './config/types'; + +export const VIBE_PRESETS: Array = appConfig.vibes.presets; + +export const getVibeById = (vibeId: VibeId): VibePreset | undefined => + VIBE_PRESETS.find((vibe) => vibe.id === vibeId); diff --git a/src/vibe-uri.test.ts b/src/vibe-uri.test.ts index fe55d5e..72c04f2 100644 --- a/src/vibe-uri.test.ts +++ b/src/vibe-uri.test.ts @@ -11,9 +11,9 @@ describe('vibe URI handling', () => { expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe( VibeId.AuroraMycelium ); - expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe( - VibeId.VelvetObservatory - ); + expect( + getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy') + ).toBe(VibeId.VelvetObservatory); }); it('uses query values before path or hash fallbacks', () => {