From 282e6a0b128f667c72fcb08f2751b69f68a13ad3 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Sun, 19 Jul 2026 13:42:33 +0200 Subject: [PATCH 1/4] feat: add source-backed Studio patch editing --- .gitignore | 1 + .pi/npm/.gitignore | 2 + .pi/settings.json | 5 + README.md | 27 +- example/classic-bass.ts | 13 +- packages/cli/src/cli.ts | 67 +- packages/cli/src/patch-preview.ts | 36 + packages/cli/src/patch-runtime.ts | 75 +- packages/cli/src/source-file.ts | 95 + packages/cli/src/studio-document.ts | 449 +++++ packages/cli/src/studio-history.ts | 42 + packages/cli/test/cli.test.ts | 15 +- packages/cli/test/patch-preview.test.ts | 49 + packages/cli/test/patch-runtime.test.ts | 137 +- packages/cli/test/source-file.test.ts | 56 + packages/cli/test/studio-document.test.ts | 223 ++ packages/cli/test/studio-history.test.ts | 23 + packages/schema/src/editor.ts | 76 + packages/schema/src/index.ts | 1 + packages/studio/client/index.html | 14 + packages/studio/client/src/app.test.tsx | 172 ++ packages/studio/client/src/favicon.svg | 4 + packages/studio/client/src/keyboard.test.ts | 29 + packages/studio/client/src/keyboard.ts | 21 + packages/studio/client/src/main.tsx | 899 +++++++++ packages/studio/client/src/studio.css | 458 +++++ packages/studio/package.json | 22 +- packages/studio/public/app.js | 210 -- packages/studio/public/index.html | 81 - packages/studio/public/styles.css | 316 --- packages/studio/src/edit-types.ts | 95 + packages/studio/src/index.ts | 34 +- packages/studio/src/protocol-validate.ts | 274 +++ packages/studio/src/protocol.ts | 55 + packages/studio/src/server.ts | 177 +- packages/studio/src/source/analyze.ts | 550 +++++ packages/studio/src/source/apply-edit.ts | 321 +++ packages/studio/src/source/index.ts | 3 + packages/studio/src/source/parse.ts | 11 + packages/studio/src/source/unwrap.ts | 93 + packages/studio/test/browser-app.test.ts | 184 +- packages/studio/test/protocol.test.ts | 88 + packages/studio/test/server.test.ts | 197 +- packages/studio/test/source-analyze.test.ts | 98 + packages/studio/test/source-edit.test.ts | 109 + packages/studio/tsconfig.json | 12 +- packages/studio/vite.config.ts | 15 + pnpm-lock.yaml | 2015 ++++++++++++++++++- 48 files changed, 7016 insertions(+), 933 deletions(-) create mode 100644 .pi/npm/.gitignore create mode 100644 .pi/settings.json create mode 100644 packages/cli/src/patch-preview.ts create mode 100644 packages/cli/src/source-file.ts create mode 100644 packages/cli/src/studio-document.ts create mode 100644 packages/cli/src/studio-history.ts create mode 100644 packages/cli/test/patch-preview.test.ts create mode 100644 packages/cli/test/source-file.test.ts create mode 100644 packages/cli/test/studio-document.test.ts create mode 100644 packages/cli/test/studio-history.test.ts create mode 100644 packages/schema/src/editor.ts create mode 100644 packages/studio/client/index.html create mode 100644 packages/studio/client/src/app.test.tsx create mode 100644 packages/studio/client/src/favicon.svg create mode 100644 packages/studio/client/src/keyboard.test.ts create mode 100644 packages/studio/client/src/keyboard.ts create mode 100644 packages/studio/client/src/main.tsx create mode 100644 packages/studio/client/src/studio.css delete mode 100644 packages/studio/public/app.js delete mode 100644 packages/studio/public/index.html delete mode 100644 packages/studio/public/styles.css create mode 100644 packages/studio/src/edit-types.ts create mode 100644 packages/studio/src/protocol-validate.ts create mode 100644 packages/studio/src/protocol.ts create mode 100644 packages/studio/src/source/analyze.ts create mode 100644 packages/studio/src/source/apply-edit.ts create mode 100644 packages/studio/src/source/index.ts create mode 100644 packages/studio/src/source/parse.ts create mode 100644 packages/studio/src/source/unwrap.ts create mode 100644 packages/studio/test/protocol.test.ts create mode 100644 packages/studio/test/source-analyze.test.ts create mode 100644 packages/studio/test/source-edit.test.ts create mode 100644 packages/studio/vite.config.ts diff --git a/.gitignore b/.gitignore index 02ff1a9..5f50de1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ packages/*/node_modules/ packages/native/target/ packages/native/*.node +packages/studio/dist/ .pi-subagents/ .DS_Store *.log diff --git a/.pi/npm/.gitignore b/.pi/npm/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/.pi/npm/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 0000000..ac48a2c --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,5 @@ +{ + "packages": [ + "npm:@noice-tech/pi-changelog" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index f8d4747..aba513b 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,30 @@ Patch programs are control-rate automation: JavaScript never runs in the audio c ## Studio -Studio binds only to loopback with a random per-process capability URL. It visualizes the current signal chain, canonical values, program time, voice, and runtime status. It does not read or rewrite source files and exposes no patch editor or remote hosting surface. +Studio binds only to loopback with a random per-process capability URL. Its React interface combines the playable keyboard with an Ableton-style serial device rack, device browser, and inspector. Supported edits are written back to the one TypeScript entry file passed to the CLI; Studio is never a remote hosting surface and the browser cannot choose a path or send source code. + +Literal scalar controls preview the sound while you drag. Releasing the pointer, pressing Enter, or leaving the input commits one validated source edit and creates one process-local undo entry. Adding, removing, replacing, or reordering oscillator/filter/LFO/effect blocks commits immediately. Studio compares source content and file identity immediately before an atomic replacement and verifies the result, so ordinary stale edits fail with a visible conflict. Portable filesystems do not provide an indivisible content-hash-and-replace primitive; a truly simultaneous external rename in the final replacement interval can still race, so avoid saving from an editor during the instant Studio commits. + +### Editable TypeScript + +Studio deliberately understands a small source grammar rather than guessing at arbitrary TypeScript: + +- A static patch must be a directly default-exported object literal, optionally wrapped in `satisfies`, `as`, or parentheses. +- A `PatchProgram` may return one direct object literal, either as a concise arrow body or one function-scope `return`. Local statements before that return are allowed. +- Direct literal numbers, strings, booleans, objects, and dense arrays are editable. Missing optional literal fields appear as defaults and materialize only when changed. +- Variables, calls, arithmetic, conditional values, spreads, computed keys, imported patch objects, and indirect exports remain audible and visible but are marked **Computed** and read-only with a source location. +- Studio only rewrites the entry file. Symbolic links and non-`.ts`/`.tsx` entry files are playback-only. + +For example, `resonance` is editable while the authored cutoff animation remains code-only: + +```ts +filter: { + cutoffHz: 200 + movement * 2_400, // Computed in code + resonance: 0.45, // Editable literal +} +``` + +Undo and redo exist only for Studio transactions in the current process. An external file change clears affected Studio history rather than risking a snapshot overwrite. Disconnect invalidates queued browser edits; an atomic commit that has already begun is allowed to finish and shutdown drains it. Invalid external saves continue to retain the previous valid sound through the normal hot-reload behavior. Use physical computer-key positions: @@ -92,7 +115,7 @@ Use physical computer-key positions: `A` begins at Ableton's displayed `C3`. New notes retrigger the envelope and filter LFO. Patchwave uses last-pressed priority: releasing the active key falls back to the most recently held key without retriggering, and releasing the final key starts the amplitude release. Leaving, hiding, or disconnecting the studio safely releases all notes. Velocity is not yet modeled. -Static patches become playable through a transient runtime frequency copy; the TypeScript file is never changed. Patch programs control their returned pitch directly, so use `voice.frequencyHz` when keyboard pitch should drive the source. +Keyboard performance never rewrites pitch into source: static patches use a transient runtime frequency copy. Patch programs control their returned pitch directly, so use `voice.frequencyHz` when keyboard pitch should drive the source. Studio parameter edits are a separate, explicit source transaction; a held keyboard note is never persisted as the authored `frequencyHz`. ## Source diff --git a/example/classic-bass.ts b/example/classic-bass.ts index fe9d170..0ae8abf 100644 --- a/example/classic-bass.ts +++ b/example/classic-bass.ts @@ -11,13 +11,20 @@ export default { { waveform: "sine", level: 0.75 }, ], filter: { - cutoffHz: 700, - resonance: 0.35, + mode: "bandpass", + cutoffHz: 609, + resonance: 0.31, }, ampEnvelope: { attackSeconds: 0.005, releaseSeconds: 0.18, }, }, - effects: [{ type: "saturator", driveDb: 12, outputGainDb: -5, mix: 0.55 }], + effects: [{ type: "saturator", driveDb: 24.6 }, { + type: "stereoDelay", + timeSeconds: 0.906, + feedback: 0.32, + damping: 0.16, + mix: 0.25, + }], } satisfies Patch; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4c0fca4..1777b85 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,6 +5,7 @@ import { KeyboardState, startStudioServer, type StudioInput, + type StudioKeyboardInput, type StudioServer, type StudioServerOptions, } from "@patchwave/studio"; @@ -12,6 +13,7 @@ import chokidar from "chokidar"; import { loadPatchModule, type PatchModule } from "./load-patch.js"; import { NativeDispatcher, type RuntimeAudioEngine } from "./native-dispatcher.js"; import { PatchRuntime } from "./patch-runtime.js"; +import { StudioDocumentController } from "./studio-document.js"; const RELOAD_DEBOUNCE_MS = 100; const RUNTIME_ERROR_POLL_MS = 250; @@ -72,6 +74,7 @@ export async function runCli(dependencies: RunCliDependencies): Promise let studio: StudioServer | undefined; let dispatcher: NativeDispatcher | undefined; let runtime: PatchRuntime | undefined; + let document: StudioDocumentController | undefined; let debounceTimer: ReturnType | undefined; let runtimeErrorTimer: ReturnType | undefined; let signalHandlersInstalled = false; @@ -79,6 +82,7 @@ export async function runCli(dependencies: RunCliDependencies): Promise let exitCode = 0; let reloadRunning = false; let reloadQueued = false; + let reliableDocumentKey = ""; const keyboard = new KeyboardState(); let resolveShutdown: () => void = () => undefined; const shutdownPromise = new Promise((resolvePromise) => { @@ -98,8 +102,21 @@ export async function runCli(dependencies: RunCliDependencies): Promise }; const handleSigint = (): void => requestShutdown(0); const handleSigterm = (): void => requestShutdown(0); - const state = (): unknown => ({ ...runtime?.snapshot(), keyboard: keyboard.snapshot() }); + const state = (): unknown => ({ + ...runtime?.snapshot(), + keyboard: keyboard.snapshot(), + document: document?.snapshot(), + }); const publish = (): void => studio?.publish(state()); + const publishDocument = (): void => { + publish(); + const snapshot = document?.snapshot(); + if (!snapshot) return; + const key = `${snapshot.revision}|${snapshot.phase}|${snapshot.canUndo}|${snapshot.canRedo}|${snapshot.diagnostic ?? ""}`; + if (key === reliableDocumentKey) return; + reliableDocumentKey = key; + studio?.send({ type: "document", protocol: 1, document: snapshot }); + }; const releaseAll = (): void => { keyboard.releaseAll(); runtime?.releaseVoice(); @@ -146,6 +163,15 @@ export async function runCli(dependencies: RunCliDependencies): Promise onProgramError: (message) => logger.error(`Patch program: ${message}`), }); + document = await StudioDocumentController.create({ + path: configPath, + getCanonical: () => runtime!.snapshot().patch, + preview: (input) => + runtime!.previewField(input.gestureId, input.baseRevision, input.path, input.value), + cancelPreview: (gestureId) => runtime!.cancelPreview(gestureId), + onChange: publishDocument, + }); + try { activeEngine.start(); } catch (error) { @@ -155,6 +181,7 @@ export async function runCli(dependencies: RunCliDependencies): Promise studio = await createStudio({ onInput: (input) => { if (shutdownRequested) return; + if (isEditInput(input)) return document!.handle(input); const action = applyStudioInput(keyboard, input); if (!runtime?.handleKeyboard(action)) { logger.error("Studio input could not be applied; releasing the voice."); @@ -163,7 +190,10 @@ export async function runCli(dependencies: RunCliDependencies): Promise } publish(); }, - onControllerClosed: releaseAll, + onControllerClosed: () => { + document?.cancelAllPreviews(); + releaseAll(); + }, getState: state, }); @@ -173,15 +203,38 @@ export async function runCli(dependencies: RunCliDependencies): Promise try { do { reloadQueued = false; + let loadedRevision: string | undefined; try { + loadedRevision = await document!.currentRevision().catch(() => undefined); + const trackRevision = loadedRevision !== undefined; + if (trackRevision) document?.markReloading(); const candidate = await load(configPath); if (shutdownRequested) return; - if (await runtime!.stageReload(candidate)) { + if (trackRevision) { + const revisionAfterLoad = await document!.currentRevision(); + if (revisionAfterLoad !== loadedRevision) { + reloadQueued = true; + continue; + } + } + const accepted = await runtime!.stageReload(candidate); + if (trackRevision) { + const stillCurrent = await document!.refreshAfterReload(accepted, loadedRevision!); + if (!stillCurrent) { + reloadQueued = true; + continue; + } + } + if (accepted) { logger.log(`Reloaded ${candidate.kind} patch at frame ${runtime!.snapshot().frame}`); } else { logger.error("Patch reload was rejected. Keeping the previous patch program."); } } catch (error) { + const currentRevision = document!.snapshot().writable + ? await document!.currentRevision().catch(() => loadedRevision) + : undefined; + if (currentRevision) await document!.refreshAfterReload(false, currentRevision); logger.error( `Patch reload failed: ${errorMessage(error)}. Keeping the previous patch program.`, ); @@ -243,7 +296,9 @@ export async function runCli(dependencies: RunCliDependencies): Promise } finally { if (debounceTimer) clearTimeout(debounceTimer); if (runtimeErrorTimer) clearInterval(runtimeErrorTimer); + document?.cancelAllPreviews(); releaseAll(); + await document?.drain(); runtime?.stop(); if (studio) { try { @@ -278,7 +333,11 @@ export async function runCli(dependencies: RunCliDependencies): Promise return exitCode; } -function applyStudioInput(keyboard: KeyboardState, input: StudioInput) { +function isEditInput(input: StudioInput): input is Exclude { + return !["keyDown", "keyUp", "releaseAll"].includes(input.type); +} + +function applyStudioInput(keyboard: KeyboardState, input: StudioKeyboardInput) { switch (input.type) { case "keyDown": return keyboard.keyDown(input.code); diff --git a/packages/cli/src/patch-preview.ts b/packages/cli/src/patch-preview.ts new file mode 100644 index 0000000..6abcb93 --- /dev/null +++ b/packages/cli/src/patch-preview.ts @@ -0,0 +1,36 @@ +import { validatePatch } from "@patchwave/schema"; +import type { PatchFieldPath, PatchScalar } from "@patchwave/studio"; +import type { CanonicalPatch, LoadedPatch } from "./load-patch.js"; +import { loadedPatch } from "./load-patch.js"; + +export type PatchPreview = Readonly<{ + gestureId: string; + baseRevision: string; + path: PatchFieldPath; + value: PatchScalar; +}>; + +export function applyPatchPreview( + patch: CanonicalPatch, + preview: PatchPreview | undefined, +): LoadedPatch { + if (!preview) return loadedPatch(patch); + const candidate: any = structuredClone(patch); + let target: any = candidate; + for (const segment of preview.path.slice(0, -1)) { + target = target?.[segment as any]; + if (target === null || target === undefined || typeof target !== "object") { + throw new Error("Preview target no longer exists in the patch"); + } + } + target[preview.path.at(-1) as any] = preview.value; + return loadedPatch(validatePatch(authorFromCanonical(candidate))); +} + +function authorFromCanonical(patch: any): any { + // validatePatch rejects canonical null optionals, so remove only those absence markers. + const clone = structuredClone(patch); + if (clone.source.filter === null) delete clone.source.filter; + else if (clone.source.filter?.cutoffLfo === null) delete clone.source.filter.cutoffLfo; + return clone; +} diff --git a/packages/cli/src/patch-runtime.ts b/packages/cli/src/patch-runtime.ts index 039bc9c..ac45811 100644 --- a/packages/cli/src/patch-runtime.ts +++ b/packages/cli/src/patch-runtime.ts @@ -1,4 +1,4 @@ -import type { KeyboardAction } from "@patchwave/studio"; +import type { KeyboardAction, PatchFieldPath, PatchScalar } from "@patchwave/studio"; import type { PatchProgramContext, PatchProgramVoice } from "@patchwave/schema"; import { CONTROL_FPS, ControlClock, type ControlClockOptions } from "./control-clock.js"; import { @@ -9,6 +9,7 @@ import { type PatchModule, } from "./load-patch.js"; import { NativeDispatcher, type NativeOperationHandle } from "./native-dispatcher.js"; +import { applyPatchPreview, type PatchPreview } from "./patch-preview.js"; export type PatchRuntimeSnapshot = Readonly<{ mode: "static" | "program"; @@ -46,6 +47,7 @@ export class PatchRuntime { | { resolve: (accepted: boolean) => void; operation?: NativeOperationHandle } | undefined; #voiceOperations = new Set(); + #preview: PatchPreview | undefined; #stopped = false; constructor(options: PatchRuntimeOptions) { @@ -73,7 +75,10 @@ export class PatchRuntime { this.#stopped = true; this.#clock.stop(); this.#dispatcher.clearSteadyPatch(); + this.#preview = undefined; this.#cancelPendingReload(); + this.#dispatcher.cancelOperations(this.#voiceOperations); + this.#voiceOperations.clear(); this.#staging = false; this.#stagedActions = []; } @@ -92,6 +97,36 @@ export class PatchRuntime { }); } + previewField( + gestureId: string, + baseRevision: string, + path: PatchFieldPath, + value: PatchScalar, + ): boolean { + if (this.#stopped || this.#staging) return false; + const previous = this.#preview; + this.#preview = Object.freeze({ gestureId, baseRevision, path, value }); + const candidate = this.#currentCandidate(this.#clock.frame()); + if (!candidate) { + this.#preview = previous; + return false; + } + this.#dispatcher.setSteadyPatch(candidate.serialized, () => this.#accept(candidate)); + this.#onState(); + return true; + } + + cancelPreview(gestureId?: string): boolean { + if (!this.#preview || (gestureId && this.#preview.gestureId !== gestureId)) return true; + this.#preview = undefined; + if (this.#stopped || this.#staging) return true; + const candidate = this.#currentCandidate(this.#clock.frame()); + if (!candidate) return false; + this.#dispatcher.setSteadyPatch(candidate.serialized, () => this.#accept(candidate)); + this.#onState(); + return true; + } + handleKeyboard(action: KeyboardAction): boolean { if (this.#stopped) return false; if (this.#staging) { @@ -120,10 +155,7 @@ export class PatchRuntime { return true; } - const candidate = - this.#module.kind === "static" - ? staticAtFrequency(this.#module.patch, this.#voice.frequencyHz) - : this.#evaluate(this.#clock.frame(), this.#module, this.#topology); + const candidate = this.#currentCandidate(this.#clock.frame()); if (!candidate) return false; const accepted = action.type === "noteOn" @@ -138,6 +170,7 @@ export class PatchRuntime { if (this.#stopped) return; this.#voice = frozenVoice({ frequencyHz: this.#voice.frequencyHz, gate: false }); this.#dispatcher.clearSteadyPatch(); + this.#preview = undefined; const reload = this.#pendingReload; this.#pendingReload = undefined; @@ -149,7 +182,11 @@ export class PatchRuntime { this.#voiceOperations.clear(); reload?.resolve(false); + // Safety release always remains first. The source-derived parameter image follows it + // without reopening the gate, so a disconnected static preview cannot linger. this.#enqueueNoteOff(() => this.#onState()); + const source = this.#currentCandidate(this.#clock.frame()); + if (source) this.#dispatcher.setSteadyPatch(source.serialized, () => this.#accept(source)); this.#onState(); } @@ -162,6 +199,18 @@ export class PatchRuntime { this.#fail(error); return Promise.resolve(false); } + try { + candidate = applyPatchPreview(candidate.patch, this.#preview); + } catch (error) { + if (!this.#preview) { + this.#fail(error); + return Promise.resolve(false); + } + // The source may validly remove the field currently being previewed. A stale + // authoring overlay must never veto that otherwise valid reload. + this.#preview = undefined; + candidate = evaluatePatchModule(module, context(this.#clock.frame(), this.#voice)); + } if (module.kind === "static" && this.#voice.gate) { candidate = staticAtFrequency(candidate.patch, this.#voice.frequencyHz); } @@ -277,12 +326,26 @@ export class PatchRuntime { #evaluate(frame: number, module: PatchModule, expectedTopology: string): LoadedPatch | undefined { try { - const candidate = evaluatePatchModule(module, context(frame, this.#voice)); + let candidate = evaluatePatchModule(module, context(frame, this.#voice)); if (topologyKey(candidate.patch) !== expectedTopology) { throw new Error( "PatchProgram changed structure; oscillator count, effect count, and ordered effect kinds must remain stable", ); } + candidate = applyPatchPreview(candidate.patch, this.#preview); + this.#clearError(); + return candidate; + } catch (error) { + this.#fail(error); + return undefined; + } + } + + #currentCandidate(frame: number): LoadedPatch | undefined { + if (this.#module.kind === "program") return this.#evaluate(frame, this.#module, this.#topology); + try { + let candidate = applyPatchPreview(this.#module.patch, this.#preview); + if (this.#voice.gate) candidate = staticAtFrequency(candidate.patch, this.#voice.frequencyHz); this.#clearError(); return candidate; } catch (error) { diff --git a/packages/cli/src/source-file.ts b/packages/cli/src/source-file.ts new file mode 100644 index 0000000..d7a393d --- /dev/null +++ b/packages/cli/src/source-file.ts @@ -0,0 +1,95 @@ +import { createHash, randomBytes } from "node:crypto"; +import { chmod, lstat, open, readFile, realpath, rename, rm } from "node:fs/promises"; +import { dirname, extname, join, resolve } from "node:path"; + +export const STUDIO_SOURCE_MAX_BYTES = 1_048_576; + +export function sourceRevision(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export type SourceFileSnapshot = Readonly<{ + bytes: Buffer; + revision: string; + mode: number; + device: bigint; + inode: bigint; + parentRealPath: string; +}>; + +export async function readWritableSource(path: string): Promise { + const lexicalPath = resolve(path); + const resolvedPath = await realpath(path); + if (resolvedPath !== lexicalPath) + throw new Error("Studio write-back is disabled for symbolic-link path components"); + const parentRealPath = await realpath(dirname(path)); + if (parentRealPath !== resolve(dirname(path))) + throw new Error("Studio write-back is disabled for symbolic-link path components"); + const info = await lstat(path, { bigint: true }); + if (info.isSymbolicLink()) throw new Error("Studio write-back is disabled for symbolic links"); + if (!info.isFile()) throw new Error("Studio write-back requires a regular file"); + if (![".ts", ".tsx"].includes(extname(path))) + throw new Error("Studio write-back supports .ts and .tsx entry files"); + if (info.size > BigInt(STUDIO_SOURCE_MAX_BYTES)) + throw new Error("Patch source is too large for Studio write-back"); + const bytes = await readFile(path); + if (bytes.byteLength > STUDIO_SOURCE_MAX_BYTES) + throw new Error("Patch source is too large for Studio write-back"); + const afterRead = await lstat(path, { bigint: true }); + if (afterRead.dev !== info.dev || afterRead.ino !== info.ino) throw new SourceConflictError(); + return { + bytes, + revision: sourceRevision(bytes), + mode: Number(info.mode & 0o777n), + device: info.dev, + inode: info.ino, + parentRealPath, + }; +} + +export async function atomicReplaceSource( + path: string, + expectedRevision: string, + bytes: Buffer, +): Promise { + if (bytes.byteLength > STUDIO_SOURCE_MAX_BYTES) + throw new Error("Patch source is too large for Studio write-back"); + const current = await readWritableSource(path); + if (current.revision !== expectedRevision) throw new SourceConflictError(); + const temporary = join( + dirname(path), + `.patchwave-${process.pid}-${randomBytes(8).toString("hex")}.tmp`, + ); + let handle: Awaited> | undefined; + try { + handle = await open(temporary, "wx", current.mode); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + await chmod(temporary, current.mode); + const beforeRename = await readWritableSource(path); + if ( + beforeRename.revision !== expectedRevision || + beforeRename.device !== current.device || + beforeRename.inode !== current.inode || + beforeRename.parentRealPath !== current.parentRealPath + ) + throw new SourceConflictError(); + await rename(temporary, path); + const written = await readWritableSource(path); + const revision = sourceRevision(bytes); + if (written.revision !== revision) + throw new Error("Studio could not verify the completed source replacement"); + return revision; + } finally { + await handle?.close().catch(() => undefined); + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +export class SourceConflictError extends Error { + constructor() { + super("The patch changed outside Studio; no source was written"); + } +} diff --git a/packages/cli/src/studio-document.ts b/packages/cli/src/studio-document.ts new file mode 100644 index 0000000..4f787c2 --- /dev/null +++ b/packages/cli/src/studio-document.ts @@ -0,0 +1,449 @@ +import { basename } from "node:path"; +import { + analyzeSource, + applySourceEdit, + parseSource, + pathKey, + type PatchEditOperation, + type StudioDocumentPhase, + type StudioDocumentSnapshot, + type StudioEditInput, + type StudioEditResult, +} from "@patchwave/studio"; +import type { CanonicalPatch } from "./load-patch.js"; +import { + atomicReplaceSource, + readWritableSource, + sourceRevision, + SourceConflictError, +} from "./source-file.js"; +import { StudioHistory } from "./studio-history.js"; + +export type StudioDocumentControllerOptions = { + path: string; + getCanonical: () => CanonicalPatch; + preview: (input: Extract) => boolean; + cancelPreview: (gestureId?: string) => boolean; + onChange: () => void; +}; + +export class StudioDocumentController { + readonly #path: string; + readonly #fileLabel: string; + readonly #getCanonical: () => CanonicalPatch; + readonly #preview: StudioDocumentControllerOptions["preview"]; + readonly #cancelPreview: StudioDocumentControllerOptions["cancelPreview"]; + readonly #onChange: () => void; + readonly #history = new StudioHistory(); + #snapshot: StudioDocumentSnapshot; + #queue: Promise = Promise.resolve(); + #generation = 0; + #expectedOwnRevision: string | undefined; + #activeGesture: string | undefined; + #activePreviewPath: string | undefined; + + private constructor(options: StudioDocumentControllerOptions, snapshot: StudioDocumentSnapshot) { + this.#path = options.path; + this.#fileLabel = basename(options.path); + this.#getCanonical = options.getCanonical; + this.#preview = options.preview; + this.#cancelPreview = options.cancelPreview; + this.#onChange = options.onChange; + this.#snapshot = snapshot; + } + + static async create(options: StudioDocumentControllerOptions): Promise { + let snapshot: StudioDocumentSnapshot; + try { + const source = await readWritableSource(options.path); + const analysis = analyzeSource( + parseSource(source.bytes.toString("utf8")), + options.getCanonical(), + basename(options.path), + ); + snapshot = documentSnapshot( + source.revision, + analysis, + basename(options.path), + "ready", + true, + null, + false, + false, + ); + } catch (error) { + snapshot = { + revision: sourceRevision(Buffer.alloc(0)), + mode: "unknown", + writable: false, + fileLabel: basename(options.path), + diagnostic: errorMessage(error), + phase: "error", + bindings: [], + editableStructure: { oscillators: false, filter: false, cutoffLfo: false, effects: false }, + canUndo: false, + canRedo: false, + }; + } + return new StudioDocumentController(options, snapshot); + } + + snapshot(): StudioDocumentSnapshot { + return this.#snapshot; + } + + handle(input: StudioEditInput): Promise { + const generation = this.#generation; + const run = async () => { + if (generation !== this.#generation) + return this.#result(input.requestId, "rejected", "Studio controller session ended"); + return this.#handle(input, generation); + }; + const next = this.#queue.then(run, run); + this.#queue = next.then( + () => undefined, + () => undefined, + ); + return next; + } + + async currentRevision(): Promise { + return (await readWritableSource(this.#path)).revision; + } + + async drain(): Promise { + await this.#queue; + } + + async refreshAfterReload(accepted: boolean, loadedRevision: string): Promise { + let matchesLoadedRevision = false; + await this.#enqueue(async () => { + try { + const source = await readWritableSource(this.#path); + matchesLoadedRevision = source.revision === loadedRevision; + if (!matchesLoadedRevision) { + const laterOwn = source.revision === this.#expectedOwnRevision; + if (!laterOwn) { + this.#history.clear(); + this.#expectedOwnRevision = undefined; + } + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#cancelPreview(); + this.#snapshot = { + ...this.#snapshot, + revision: source.revision, + phase: "reloading", + diagnostic: "Source changed while audio was reloading; loading the newer revision.", + canUndo: this.#history.canUndo, + canRedo: this.#history.canRedo, + }; + this.#onChange(); + return; + } + + const own = + source.revision === this.#expectedOwnRevision || + source.revision === this.#snapshot.revision; + if (!own || !accepted) { + if (!own) this.#history.clear(); + } + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#cancelPreview(); + this.#expectedOwnRevision = undefined; + const analysis = analyzeSource( + parseSource(source.bytes.toString("utf8")), + this.#getCanonical(), + this.#fileLabel, + ); + this.#snapshot = documentSnapshot( + source.revision, + analysis, + this.#fileLabel, + accepted ? "audio-accepted" : "error", + true, + accepted ? null : "Source was saved, but audio rejected the reload.", + this.#history.canUndo, + this.#history.canRedo, + ); + } catch (error) { + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#expectedOwnRevision = undefined; + this.#history.clear(); + this.#cancelPreview(); + this.#snapshot = { + ...this.#snapshot, + writable: false, + phase: "error", + diagnostic: errorMessage(error), + canUndo: false, + canRedo: false, + }; + } + this.#onChange(); + }); + return matchesLoadedRevision; + } + + markReloading(): void { + this.#snapshot = { ...this.#snapshot, phase: "reloading" }; + this.#onChange(); + } + + cancelAllPreviews(): void { + this.#generation += 1; + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#cancelPreview(); + if (this.#snapshot.phase === "previewing") { + this.#snapshot = { ...this.#snapshot, phase: "ready" }; + this.#onChange(); + } + } + + async #handle(input: StudioEditInput, generation: number): Promise { + try { + if (input.type === "preview") return this.#previewInput(input); + if (input.type === "cancelPreview") return this.#cancelInput(input); + if (!this.#snapshot.writable) + return this.#result( + input.requestId, + "rejected", + this.#snapshot.diagnostic ?? "Source is read-only", + ); + if (input.baseRevision !== this.#snapshot.revision) return this.#conflict(input.requestId); + if (input.type === "commit") return await this.#commit(input, generation); + return await this.#historyEdit(input, generation); + } catch (error) { + if (error instanceof SourceConflictError) return this.#conflict(input.requestId); + this.#cancelPreview(); + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#snapshot = { ...this.#snapshot, phase: "error", diagnostic: errorMessage(error) }; + this.#onChange(); + return this.#result(input.requestId, "rejected", errorMessage(error)); + } + } + + #previewInput(input: Extract): StudioEditResult { + if (!this.#snapshot.writable || input.baseRevision !== this.#snapshot.revision) + return this.#conflict(input.requestId); + const binding = this.#snapshot.bindings.find( + (item) => pathKey(item.path) === pathKey(input.path), + ); + if (!binding || binding.sourceForm.kind === "computed") + return this.#result(input.requestId, "rejected", "This field is computed in code"); + if (this.#activeGesture && this.#activeGesture !== input.gestureId) { + this.#cancelPreview(this.#activeGesture); + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#snapshot = { ...this.#snapshot, phase: "ready" }; + } + if (!this.#preview(input)) { + this.#onChange(); + return this.#result(input.requestId, "rejected", "Audio preview queue is busy"); + } + this.#activeGesture = input.gestureId; + this.#activePreviewPath = pathKey(input.path); + this.#snapshot = { ...this.#snapshot, phase: "previewing" }; + this.#onChange(); + return this.#result(input.requestId, "previewed"); + } + + #cancelInput(input: Extract): StudioEditResult { + if (this.#activeGesture !== input.gestureId) { + return this.#result(input.requestId, "rejected", "That preview gesture is no longer active"); + } + this.#cancelPreview(input.gestureId); + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#snapshot = { ...this.#snapshot, phase: "ready" }; + this.#onChange(); + return this.#result(input.requestId, "cancelled"); + } + + async #commit( + input: Extract, + generation: number, + ): Promise { + if ( + this.#activeGesture && + (input.gestureId !== this.#activeGesture || + input.operation.type !== "setField" || + pathKey(input.operation.path) !== this.#activePreviewPath) + ) { + return this.#result( + input.requestId, + "rejected", + "Finish or cancel the active parameter gesture first", + ); + } + this.#snapshot = { ...this.#snapshot, phase: "writing", diagnostic: null }; + this.#onChange(); + const before = await readWritableSource(this.#path); + if (before.revision !== input.baseRevision) return this.#conflict(input.requestId); + let output: string; + try { + output = applySourceEdit( + before.bytes.toString("utf8"), + this.#getCanonical(), + this.#fileLabel, + input.operation, + ); + } catch (error) { + if (errorMessage(error) !== "Edit did not change the source file") throw error; + this.#cancelPreview(input.gestureId ?? undefined); + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#snapshot = { ...this.#snapshot, phase: "ready" }; + this.#onChange(); + return this.#result(input.requestId, "cancelled", "No source change was needed"); + } + const afterBytes = Buffer.from(output); + if (generation !== this.#generation) { + this.#snapshot = { ...this.#snapshot, phase: "ready" }; + this.#onChange(); + return this.#result(input.requestId, "rejected", "Studio controller session ended"); + } + const afterRevision = await atomicReplaceSource(this.#path, before.revision, afterBytes); + this.#history.push({ + label: operationLabel(input.operation), + before: before.bytes, + beforeRevision: before.revision, + after: afterBytes, + afterRevision, + }); + this.#expectedOwnRevision = afterRevision; + this.#activeGesture = input.gestureId ?? undefined; + this.#activePreviewPath = + input.operation.type === "setField" ? pathKey(input.operation.path) : undefined; + const analysis = analyzeSource(parseSource(output), this.#getCanonical(), this.#fileLabel); + this.#snapshot = documentSnapshot( + afterRevision, + analysis, + this.#fileLabel, + "source-written", + true, + null, + this.#history.canUndo, + this.#history.canRedo, + ); + this.#onChange(); + return this.#result(input.requestId, "committed"); + } + + async #historyEdit( + input: Extract, + generation: number, + ): Promise { + const undo = input.type === "undo"; + const entry = undo ? this.#history.peekUndo() : this.#history.peekRedo(); + if (!entry) return this.#result(input.requestId, "rejected", `Nothing to ${input.type}`); + const expected = undo ? entry.afterRevision : entry.beforeRevision; + const bytes = undo ? entry.before : entry.after; + const current = await readWritableSource(this.#path); + if (current.revision !== expected || input.baseRevision !== expected) + return this.#conflict(input.requestId); + if (generation !== this.#generation) + return this.#result(input.requestId, "rejected", "Studio controller session ended"); + const revision = await atomicReplaceSource(this.#path, expected, bytes); + if (undo) this.#history.didUndo(); + else this.#history.didRedo(); + this.#expectedOwnRevision = revision; + this.#cancelPreview(); + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + const analysis = analyzeSource( + parseSource(bytes.toString("utf8")), + this.#getCanonical(), + this.#fileLabel, + ); + this.#snapshot = documentSnapshot( + revision, + analysis, + this.#fileLabel, + "source-written", + true, + null, + this.#history.canUndo, + this.#history.canRedo, + ); + this.#onChange(); + return this.#result(input.requestId, "committed"); + } + + #conflict(requestId: string): StudioEditResult { + this.#cancelPreview(); + this.#activeGesture = undefined; + this.#activePreviewPath = undefined; + this.#history.clear(); + this.#snapshot = { + ...this.#snapshot, + phase: "conflict", + canUndo: false, + canRedo: false, + diagnostic: "The patch changed outside Studio. Refreshing from source.", + }; + this.#onChange(); + return this.#result(requestId, "conflict", this.#snapshot.diagnostic ?? undefined); + } + + #result( + requestId: string, + status: StudioEditResult["status"], + message?: string, + ): StudioEditResult { + return { + type: "editResult", + protocol: 1, + requestId, + status, + revision: this.#snapshot.revision, + ...(message ? { message } : {}), + document: this.#snapshot, + }; + } + + #enqueue(task: () => Promise): Promise { + const next = this.#queue.then(task, task); + this.#queue = next.then( + () => undefined, + () => undefined, + ); + return next; + } +} + +function documentSnapshot( + revision: string, + analysis: ReturnType, + fileLabel: string, + phase: StudioDocumentPhase, + writable: boolean, + diagnostic: string | null, + canUndo: boolean, + canRedo: boolean, +): StudioDocumentSnapshot { + return { + revision, + mode: analysis.mode, + writable: writable && analysis.mode !== "unknown", + fileLabel, + diagnostic: diagnostic ?? analysis.diagnostic, + phase, + bindings: analysis.bindings, + editableStructure: analysis.editableStructure, + canUndo, + canRedo, + }; +} +function operationLabel(operation: PatchEditOperation): string { + if (operation.type === "setField") return `Set ${operation.path.join(".")}`; + if (operation.type === "resetField") return `Reset ${operation.path.join(".")}`; + return operation.type.replace(/([A-Z])/g, " $1").toLowerCase(); +} +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/cli/src/studio-history.ts b/packages/cli/src/studio-history.ts new file mode 100644 index 0000000..435d830 --- /dev/null +++ b/packages/cli/src/studio-history.ts @@ -0,0 +1,42 @@ +export type StudioHistoryEntry = Readonly<{ + label: string; + before: Buffer; + beforeRevision: string; + after: Buffer; + afterRevision: string; +}>; + +export class StudioHistory { + #undo: StudioHistoryEntry[] = []; + #redo: StudioHistoryEntry[] = []; + + get canUndo(): boolean { + return this.#undo.length > 0; + } + get canRedo(): boolean { + return this.#redo.length > 0; + } + push(entry: StudioHistoryEntry): void { + this.#undo.push(entry); + if (this.#undo.length > 100) this.#undo.shift(); + this.#redo = []; + } + peekUndo(): StudioHistoryEntry | undefined { + return this.#undo.at(-1); + } + peekRedo(): StudioHistoryEntry | undefined { + return this.#redo.at(-1); + } + didUndo(): void { + const entry = this.#undo.pop(); + if (entry) this.#redo.push(entry); + } + didRedo(): void { + const entry = this.#redo.pop(); + if (entry) this.#undo.push(entry); + } + clear(): void { + this.#undo = []; + this.#redo = []; + } +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 685feda..d2a4331 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -80,6 +80,7 @@ class FakeStudio implements StudioServer { publish(state: unknown): void { this.states.push(state); } + send(): void {} disconnect(): void { this.disconnected = true; } @@ -208,6 +209,8 @@ test("controller close cancels a backpressured reload and staged note before saf harness.studioOptions!.onControllerClosed(); engine.accept = true; await waitUntil(() => engine.accepted.includes("off")); + harness.signals.emit("SIGTERM"); + assert.equal(await harness.promise, 0); assert.equal(engine.noteOns.length, 0); assert.equal( @@ -218,10 +221,14 @@ test("controller close cancels a backpressured reload and staged note before saf ), false, ); - assert.equal(engine.accepted.at(-1), "off"); - - harness.signals.emit("SIGTERM"); - assert.equal(await harness.promise, 0); + const offIndex = engine.accepted.indexOf("off"); + const restoredIndex = engine.accepted.findIndex( + (call, index) => + index > offIndex && + call.startsWith("update:") && + JSON.parse(call.slice("update:".length)).source.frequencyHz === 110, + ); + assert.ok(offIndex >= 0 && restoredIndex > offIndex); }); test("runtime-error polling reports failure and shuts down", async () => { diff --git a/packages/cli/test/patch-preview.test.ts b/packages/cli/test/patch-preview.test.ts new file mode 100644 index 0000000..22851eb --- /dev/null +++ b/packages/cli/test/patch-preview.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validatePatch } from "@patchwave/schema"; +import { applyPatchPreview } from "../src/patch-preview.js"; + +const patch = validatePatch({ + source: { + frequencyHz: 110, + oscillators: [{ waveform: "saw" }], + filter: { cutoffHz: 1200, resonance: 0.2 }, + }, +}); + +test("applies a validated scalar overlay without mutating its source patch", () => { + const loaded = applyPatchPreview(patch, { + gestureId: "g", + baseRevision: "a", + path: ["source", "filter", "resonance"], + value: 0.7, + }); + assert.equal(loaded.patch.source.filter?.resonance, 0.7); + assert.equal(patch.source.filter?.resonance, 0.2); +}); + +test("rejects invalid preview ranges and missing targets", () => { + assert.throws( + () => + applyPatchPreview(patch, { + gestureId: "g", + baseRevision: "a", + path: ["source", "filter", "resonance"], + value: 2, + }), + /must be <= 1/, + ); + const withoutFilter = validatePatch({ + source: { frequencyHz: 110, oscillators: [{ waveform: "sine" }] }, + }); + assert.throws( + () => + applyPatchPreview(withoutFilter, { + gestureId: "g", + baseRevision: "a", + path: ["source", "filter", "resonance"], + value: 0.5, + }), + /no longer exists/, + ); +}); diff --git a/packages/cli/test/patch-runtime.test.ts b/packages/cli/test/patch-runtime.test.ts index c583b0f..f45183d 100644 --- a/packages/cli/test/patch-runtime.test.ts +++ b/packages/cli/test/patch-runtime.test.ts @@ -212,7 +212,8 @@ test("safety release cancels a staged reload and note-on before ordered note-off run.engine.accept = true; run.dispatcher.pump(); - assert.deepEqual(run.engine.acceptedCalls, ["off"]); + assert.equal(run.engine.acceptedCalls[0], "off"); + assert.match(run.engine.acceptedCalls[1] ?? "", /^update:/); assert.equal(run.engine.noteOns.length, 0); assert.equal(run.runtime.snapshot().patch.source.filter?.cutoffHz, 200); run.runtime.stop(); @@ -240,3 +241,137 @@ test("rejects time-varying topology, retains the last patch, and evaluates futur assert.equal(run.runtime.snapshot().error, null); run.runtime.stop(); }); + +test("program scalar preview persists across frames and cancel restores source evaluation", () => { + const run = harness(({ voice, frame }) => ({ + source: { + frequencyHz: voice.frequencyHz, + oscillators: [{ waveform: "saw" as const }], + filter: { cutoffHz: 200 + frame, resonance: 0.2 }, + }, + })); + run.runtime.start(); + assert.equal( + run.runtime.previewField("gesture", "a".repeat(64), ["source", "filter", "resonance"], 0.8), + true, + ); + assert.equal(JSON.parse(run.engine.updates.at(-1)!).source.filter.resonance, 0.8); + run.setNow(34); + run.pulse(); + assert.equal(JSON.parse(run.engine.updates.at(-1)!).source.filter.resonance, 0.8); + assert.equal(run.runtime.cancelPreview("gesture"), true); + assert.equal(JSON.parse(run.engine.updates.at(-1)!).source.filter.resonance, 0.2); + run.runtime.stop(); +}); + +test("rapid previews coalesce outside the ordered voice queue", () => { + const run = harness(({ voice }) => ({ + source: { + frequencyHz: voice.frequencyHz, + oscillators: [{ waveform: "saw" as const }], + filter: { cutoffHz: 500, resonance: 0 }, + }, + })); + run.runtime.start(); + run.engine.accept = false; + for (let index = 0; index < 100; index += 1) { + assert.equal( + run.runtime.previewField("g", "a".repeat(64), ["source", "filter", "resonance"], index / 100), + true, + ); + } + assert.equal(run.dispatcher.pendingVoiceOperations, 0); + run.engine.accept = true; + run.dispatcher.pump(); + assert.equal( + JSON.parse(run.engine.acceptedCalls.at(-1)!.slice("update:".length)).source.filter.resonance, + 0.99, + ); + run.runtime.stop(); +}); + +test("static base-frequency preview never overrides or persists a held keyboard note", () => { + const engine = new FakeEngine(); + const dispatcher = new NativeDispatcher({ + engine, + onError: (error) => assert.fail(String(error)), + onOverflow: assert.fail, + }); + const runtime = new PatchRuntime({ + initialModule: normalizePatchModuleExport({ + source: { frequencyHz: 110, oscillators: [{ waveform: "sine" }] }, + }), + initialVoice: { frequencyHz: 261.625565, gate: false }, + dispatcher, + onState: () => undefined, + onProgramError: (message) => assert.fail(message), + }); + const keyboard = new KeyboardState(); + const action = keyboard.keyDown("KeyA"); + assert.equal(runtime.handleKeyboard(action), true); + const played = keyboard.snapshot().voice.frequencyHz; + assert.equal(runtime.previewField("g", "a".repeat(64), ["source", "frequencyHz"], 220), true); + assert.equal(JSON.parse(engine.updates.at(-1)!).source.frequencyHz, played); + assert.equal(runtime.cancelPreview("g"), true); + assert.equal(JSON.parse(engine.updates.at(-1)!).source.frequencyHz, played); + runtime.releaseVoice(); + dispatcher.stop(); +}); + +test("stale preview target is dropped instead of rejecting a valid reload", async () => { + const run = harness(({ voice }) => ({ + source: { + frequencyHz: voice.frequencyHz, + oscillators: [{ waveform: "saw" as const }], + filter: { cutoffHz: 500, resonance: 0.2 }, + }, + })); + assert.equal( + run.runtime.previewField("g", "a".repeat(64), ["source", "filter", "resonance"], 0.8), + true, + ); + const accepted = await run.runtime.stageReload( + normalizePatchModuleExport(({ voice }: PatchProgramContext) => ({ + source: { frequencyHz: voice.frequencyHz, oscillators: [{ waveform: "sine" as const }] }, + })), + ); + assert.equal(accepted, true); + assert.equal(run.runtime.snapshot().patch.source.filter, null); + run.runtime.stop(); +}); + +test("stop cancels a pending note-on so pumping cannot reopen the gate", () => { + const run = harness(({ voice }) => patch(voice.frequencyHz)); + run.engine.accept = false; + const keyboard = new KeyboardState(); + assert.equal(run.runtime.handleKeyboard(keyboard.keyDown("KeyA")), true); + run.runtime.stop(); + run.engine.accept = true; + run.dispatcher.pump(); + assert.equal( + run.engine.acceptedCalls.some((call) => call.startsWith("on:")), + false, + ); + run.dispatcher.stop(); +}); + +test("safety release orders note-off before restoring source parameters", () => { + const run = harness(({ voice }) => ({ + source: { + frequencyHz: voice.frequencyHz, + oscillators: [{ waveform: "saw" as const }], + filter: { cutoffHz: 500, resonance: 0.2 }, + }, + })); + assert.equal( + run.runtime.previewField("g", "a".repeat(64), ["source", "filter", "resonance"], 0.8), + true, + ); + run.engine.acceptedCalls = []; + run.runtime.releaseVoice(); + assert.equal(run.engine.acceptedCalls[0], "off"); + const restored = JSON.parse(run.engine.acceptedCalls[1].slice("update:".length)); + assert.equal(restored.source.filter.resonance, 0.2); + run.runtime.stop(); + run.dispatcher.stop(); +}); diff --git a/packages/cli/test/source-file.test.ts b/packages/cli/test/source-file.test.ts new file mode 100644 index 0000000..df168d6 --- /dev/null +++ b/packages/cli/test/source-file.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, realpath, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + atomicReplaceSource, + readWritableSource, + SourceConflictError, +} from "../src/source-file.js"; + +test("atomically replaces only the expected regular TypeScript revision", async () => { + const dir = await realpath(await mkdtemp(join(tmpdir(), "patchwave-source-"))); + const path = join(dir, "sound.ts"); + await writeFile(path, "export default 1;\n", { mode: 0o640 }); + const before = await readWritableSource(path); + const revision = await atomicReplaceSource( + path, + before.revision, + Buffer.from("export default 2;\n"), + ); + assert.notEqual(revision, before.revision); + assert.equal(await readFile(path, "utf8"), "export default 2;\n"); + await assert.rejects( + () => atomicReplaceSource(path, before.revision, Buffer.from("bad")), + SourceConflictError, + ); + assert.equal(await readFile(path, "utf8"), "export default 2;\n"); +}); + +test("symlink and unsupported extension are read-only", async () => { + const dir = await realpath(await mkdtemp(join(tmpdir(), "patchwave-source-"))); + const target = join(dir, "target.ts"); + const link = join(dir, "link.ts"); + await writeFile(target, "export default 1"); + await symlink(target, link); + await assert.rejects(() => readWritableSource(link), /symbolic/); + const json = join(dir, "sound.json"); + await writeFile(json, "{}"); + await assert.rejects(() => readWritableSource(json), /.ts and .tsx/); +}); + +test("ancestor symlink paths are playback-only", async () => { + const dir = await realpath(await mkdtemp(join(tmpdir(), "patchwave-source-"))); + const realDirectory = join(dir, "real"); + const linkedDirectory = join(dir, "linked"); + await mkdir(realDirectory); + await symlink(realDirectory, linkedDirectory); + const target = join(realDirectory, "sound.ts"); + await writeFile(target, "export default 1"); + await assert.rejects( + () => readWritableSource(join(linkedDirectory, "sound.ts")), + /symbolic-link path components/, + ); + assert.equal(await readFile(target, "utf8"), "export default 1"); +}); diff --git a/packages/cli/test/studio-document.test.ts b/packages/cli/test/studio-document.test.ts new file mode 100644 index 0000000..94726c0 --- /dev/null +++ b/packages/cli/test/studio-document.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { validatePatch } from "@patchwave/schema"; +import { StudioDocumentController } from "../src/studio-document.js"; + +const text = `export default {source: {frequencyHz: 110, gainDb: -12, oscillators: [{waveform: "saw"}]}} satisfies Patch;\n`; +const canonical = validatePatch({ + source: { frequencyHz: 110, gainDb: -12, oscillators: [{ waveform: "saw" }] }, +}); + +async function setup() { + const dir = await realpath(await mkdtemp(join(tmpdir(), "patchwave-document-"))); + const path = join(dir, "sound.ts"); + await writeFile(path, text); + const previews: unknown[] = []; + let cancellations = 0; + const controller = await StudioDocumentController.create({ + path, + getCanonical: () => canonical, + preview: (input) => { + previews.push(input); + return true; + }, + cancelPreview: () => { + cancellations += 1; + return true; + }, + onChange: () => undefined, + }); + return { + controller, + path, + previews, + get cancellations() { + return cancellations; + }, + }; +} + +test("preview is disk-free and a commit performs one revisioned source transaction", async () => { + const { controller, path, previews } = await setup(); + const baseRevision = controller.snapshot().revision; + const preview = await controller.handle({ + type: "preview", + protocol: 1, + requestId: "p", + gestureId: "g", + baseRevision, + path: ["source", "gainDb"], + value: -18, + }); + assert.equal(preview.status, "previewed"); + assert.equal(await readFile(path, "utf8"), text); + assert.equal(previews.length, 1); + const mismatched = await controller.handle({ + type: "commit", + protocol: 1, + requestId: "wrong", + gestureId: "g", + baseRevision, + operation: { type: "setField", path: ["source", "frequencyHz"], value: 220 }, + }); + assert.equal(mismatched.status, "rejected"); + const staleCancel = await controller.handle({ + type: "cancelPreview", + protocol: 1, + requestId: "stale-cancel", + gestureId: "other", + }); + assert.equal(staleCancel.status, "rejected"); + assert.equal(controller.snapshot().phase, "previewing"); + assert.equal(await readFile(path, "utf8"), text); + const commit = await controller.handle({ + type: "commit", + protocol: 1, + requestId: "c", + gestureId: "g", + baseRevision, + operation: { type: "setField", path: ["source", "gainDb"], value: -18 }, + }); + assert.equal(commit.status, "committed"); + assert.match(await readFile(path, "utf8"), /gainDb: -18/); + assert.equal(controller.snapshot().canUndo, true); + await controller.refreshAfterReload(true, controller.snapshot().revision); + await controller.refreshAfterReload(true, controller.snapshot().revision); + assert.equal(controller.snapshot().canUndo, true); +}); + +test("stale revisions and external changes never overwrite source", async () => { + const { controller, path } = await setup(); + const baseRevision = controller.snapshot().revision; + await writeFile(path, text.replace("-12", "-20")); + const result = await controller.handle({ + type: "commit", + protocol: 1, + requestId: "c", + gestureId: null, + baseRevision, + operation: { type: "setField", path: ["source", "gainDb"], value: -18 }, + }); + assert.equal(result.status, "conflict"); + assert.match(await readFile(path, "utf8"), /-20/); +}); + +test("undo is revision guarded and restores the complete transaction", async () => { + const { controller, path } = await setup(); + const before = controller.snapshot().revision; + await controller.handle({ + type: "commit", + protocol: 1, + requestId: "c", + gestureId: null, + baseRevision: before, + operation: { type: "setField", path: ["source", "gainDb"], value: -18 }, + }); + const after = controller.snapshot().revision; + const result = await controller.handle({ + type: "undo", + protocol: 1, + requestId: "u", + baseRevision: after, + }); + assert.equal(result.status, "committed"); + assert.equal(await readFile(path, "utf8"), text); +}); + +test("accepted own reload retires the gesture and permits the next edit", async () => { + const { controller } = await setup(); + const revision = controller.snapshot().revision; + await controller.handle({ + type: "preview", + protocol: 1, + requestId: "p", + gestureId: "g", + baseRevision: revision, + path: ["source", "gainDb"], + value: -18, + }); + await controller.handle({ + type: "commit", + protocol: 1, + requestId: "c", + gestureId: "g", + baseRevision: revision, + operation: { type: "setField", path: ["source", "gainDb"], value: -18 }, + }); + const written = controller.snapshot().revision; + assert.equal(await controller.refreshAfterReload(true, written), true); + const next = await controller.handle({ + type: "commit", + protocol: 1, + requestId: "next", + gestureId: null, + baseRevision: written, + operation: { type: "addFilter" }, + }); + assert.equal(next.status, "committed"); +}); + +test("controller generation rejects queued input after disconnect cleanup", async () => { + const setupResult = await setup(); + const pending = setupResult.controller.handle({ + type: "preview", + protocol: 1, + requestId: "p", + gestureId: "g", + baseRevision: setupResult.controller.snapshot().revision, + path: ["source", "gainDb"], + value: -18, + }); + setupResult.controller.cancelAllPreviews(); + assert.equal((await pending).status, "rejected"); + assert.equal(setupResult.previews.length, 0); + await setupResult.controller.drain(); +}); + +test("reload acknowledgement never labels later source bytes audio-accepted", async () => { + const { controller, path } = await setup(); + const loadedRevision = controller.snapshot().revision; + await writeFile(path, text.replace("-12", "-20")); + assert.equal(await controller.refreshAfterReload(true, loadedRevision), false); + assert.equal(controller.snapshot().phase, "reloading"); + assert.notEqual(controller.snapshot().revision, loadedRevision); +}); + +test("reload read failure retires the active preview and history", async () => { + const setupResult = await setup(); + const loadedRevision = setupResult.controller.snapshot().revision; + await setupResult.controller.handle({ + type: "preview", + protocol: 1, + requestId: "preview-before-delete", + gestureId: "gesture-before-delete", + baseRevision: loadedRevision, + path: ["source", "gainDb"], + value: -18, + }); + await rm(setupResult.path); + assert.equal(await setupResult.controller.refreshAfterReload(true, loadedRevision), false); + assert.equal(setupResult.controller.snapshot().phase, "error"); + assert.equal(setupResult.controller.snapshot().canUndo, false); + assert.equal(setupResult.cancellations, 1); +}); + +test("a no-op source edit is cancelled without history or error phase", async () => { + const { controller, path } = await setup(); + const before = controller.snapshot().revision; + const result = await controller.handle({ + type: "commit", + protocol: 1, + requestId: "noop", + gestureId: null, + baseRevision: before, + operation: { type: "setField", path: ["source", "gainDb"], value: -12 }, + }); + assert.equal(result.status, "cancelled"); + assert.equal(controller.snapshot().phase, "ready"); + assert.equal(controller.snapshot().canUndo, false); + assert.equal(await readFile(path, "utf8"), text); +}); diff --git a/packages/cli/test/studio-history.test.ts b/packages/cli/test/studio-history.test.ts new file mode 100644 index 0000000..8e0f49a --- /dev/null +++ b/packages/cli/test/studio-history.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { StudioHistory } from "../src/studio-history.js"; + +test("history is transaction-level and clears redo after a new commit", () => { + const history = new StudioHistory(); + const entry = { + label: "gain", + before: Buffer.from("a"), + beforeRevision: "a", + after: Buffer.from("b"), + afterRevision: "b", + }; + history.push(entry); + assert.equal(history.canUndo, true); + history.didUndo(); + assert.equal(history.canRedo, true); + history.didRedo(); + assert.equal(history.canUndo, true); + history.didUndo(); + history.push({ ...entry, label: "next" }); + assert.equal(history.canRedo, false); +}); diff --git a/packages/schema/src/editor.ts b/packages/schema/src/editor.ts new file mode 100644 index 0000000..e040345 --- /dev/null +++ b/packages/schema/src/editor.ts @@ -0,0 +1,76 @@ +import { EFFECT_COUNT_MAX, LIMITS, OSCILLATOR_COUNT_MAX } from "./limits.js"; + +export type PatchEditorControl = Readonly<{ + label: string; + kind: "number" | "enum" | "boolean"; + min?: number; + max?: number; + step?: number; + unit?: string; + values?: readonly string[]; + defaultValue?: number | string | boolean; + integer?: boolean; +}>; + +const number = ( + label: string, + limits: readonly [number, number], + step: number, + unit: string | undefined, + defaultValue?: number, + integer = false, +): PatchEditorControl => ({ + label, + kind: "number", + min: limits[0], + max: limits[1], + step, + ...(unit ? { unit } : {}), + ...(defaultValue === undefined ? {} : { defaultValue }), + ...(integer ? { integer: true } : {}), +}); + +export const PATCH_EDITOR = Object.freeze({ + oscillatorCountMax: OSCILLATOR_COUNT_MAX, + effectCountMax: EFFECT_COUNT_MAX, + controls: Object.freeze({ + frequencyHz: number("Frequency", LIMITS.frequencyHz, 1, "Hz"), + gainDb: number("Gain", LIMITS.sourceGainDb, 0.1, "dB", -12), + waveform: { + label: "Waveform", + kind: "enum", + values: ["sine", "triangle", "saw", "pulse", "noise"], + }, + transposeSemitones: number("Transpose", LIMITS.transposeSemitones, 1, "semitones", 0, true), + detuneCents: number("Detune", LIMITS.detuneCents, 1, "cents", 0), + pulseWidth: number("Pulse width", LIMITS.pulseWidth, 0.01, undefined, 0.5), + level: number("Level", LIMITS.normalized, 0.01, undefined, 1), + filterMode: { + label: "Mode", + kind: "enum", + values: ["lowpass", "bandpass", "highpass"], + defaultValue: "lowpass", + }, + cutoffHz: number("Cutoff", LIMITS.cutoffHz, 1, "Hz"), + resonance: number("Resonance", LIMITS.normalized, 0.01, undefined, 0), + lfoShape: { + label: "Shape", + kind: "enum", + values: ["sine", "triangle", "sawUp", "sawDown", "square"], + defaultValue: "sine", + }, + lfoRateHz: number("Rate", LIMITS.lfoFrequencyHz, 0.01, "Hz"), + amountOctaves: number("Amount", LIMITS.amountOctaves, 0.01, "octaves"), + attackSeconds: number("Attack", LIMITS.envelopeSeconds, 0.001, "s", 0.005), + decaySeconds: number("Decay", LIMITS.envelopeSeconds, 0.001, "s", 0), + sustain: number("Sustain", LIMITS.normalized, 0.01, undefined, 1), + releaseSeconds: number("Release", LIMITS.envelopeSeconds, 0.001, "s", 0.1), + driveDb: number("Drive", LIMITS.driveDb, 0.1, "dB"), + outputGainDb: number("Output", LIMITS.outputGainDb, 0.1, "dB", 0), + mix: number("Mix", LIMITS.normalized, 0.01, undefined), + timeSeconds: number("Time", LIMITS.delayTimeSeconds, 0.001, "s"), + feedback: number("Feedback", LIMITS.feedback, 0.01, undefined, 0), + damping: number("Damping", LIMITS.normalized, 0.01, undefined, 0), + pingPong: { label: "Ping-pong", kind: "boolean", defaultValue: false }, + } satisfies Record), +}); diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 2c47154..3f920a7 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,3 +1,4 @@ +export { PATCH_EDITOR, type PatchEditorControl } from "./editor.js"; export { EFFECT_COUNT_MAX, LIMITS, OSCILLATOR_COUNT_MAX, PATCH_JSON_MAX_BYTES } from "./limits.js"; export type { CutoffLfo, diff --git a/packages/studio/client/index.html b/packages/studio/client/index.html new file mode 100644 index 0000000..c3be33c --- /dev/null +++ b/packages/studio/client/index.html @@ -0,0 +1,14 @@ + + + + + + + + Patchwave Studio + + +
+ + + diff --git a/packages/studio/client/src/app.test.tsx b/packages/studio/client/src/app.test.tsx new file mode 100644 index 0000000..aac1b8d --- /dev/null +++ b/packages/studio/client/src/app.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import type { StudioDocumentSnapshot } from "../../src/edit-types.js"; +import { App } from "./main.js"; + +class MockWebSocket extends EventTarget { + static OPEN = 1; + static instances: MockWebSocket[] = []; + readyState = MockWebSocket.OPEN; + sent: unknown[] = []; + constructor(_url: URL) { + super(); + MockWebSocket.instances.push(this); + } + send(value: string) { + this.sent.push(JSON.parse(value)); + } + close() { + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + message(value: unknown) { + this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(value) })); + } +} + +const revision = "a".repeat(64); +const documentSnapshot: StudioDocumentSnapshot = { + revision, + mode: "static", + writable: true, + fileLabel: "sound.ts", + diagnostic: null, + phase: "ready", + bindings: [ + { + path: ["source", "frequencyHz"], + sourceForm: { kind: "literal", value: 110, explicit: true }, + location: { fileLabel: "sound.ts", line: 2, column: 20 }, + control: { label: "Frequency", kind: "number", min: 20, max: 20_000, step: 1, unit: "Hz" }, + }, + ], + editableStructure: { oscillators: true, filter: true, cutoffLfo: false, effects: true }, + canUndo: false, + canRedo: false, +}; +const state = { + patch: { + source: { + frequencyHz: 261.625, + gainDb: -12, + oscillators: [{ waveform: "saw", level: 1 }], + filter: null, + ampEnvelope: { attackSeconds: 0.005, decaySeconds: 0, sustain: 1, releaseSeconds: 0.1 }, + }, + effects: [], + }, + keyboard: { voice: { frequencyHz: 261.625, gate: true }, activeNoteLabel: "C3" }, + summary: "261.63 Hz; 1 oscillator; 0 effects", + status: "Audio running", + document: documentSnapshot, +}; + +beforeEach(() => { + MockWebSocket.instances = []; + vi.stubGlobal("WebSocket", MockWebSocket); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", () => undefined); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function mountStudio(initialState: unknown = state) { + const view = render(); + const socket = MockWebSocket.instances[0]; + socket.dispatchEvent(new Event("open")); + socket.message({ type: "state", state: initialState }); + return { ...view, socket }; +} + +test("editable frequency uses authored source instead of performed keyboard pitch", async () => { + mountStudio(); + const input = await screen.findByRole("spinbutton", { name: /Frequency/ }); + expect((input as HTMLInputElement).value).toBe("110"); + expect(screen.getByText(/261.63 Hz/)).toBeTruthy(); +}); + +test("imperative pending guard prevents back-to-back stale structural commits", async () => { + const { socket } = mountStudio(); + const addFilter = await screen.findByRole("button", { name: "+ Filter" }); + fireEvent.click(addFilter); + fireEvent.click(addFilter); + const commits = socket.sent.filter((message: any) => message.type === "commit"); + expect(commits).toHaveLength(1); + expect((commits[0] as { baseRevision: string }).baseRevision).toBe(revision); +}); + +test("selection reconciles when a filter and its attached LFO disappear", async () => { + const filterState: any = structuredClone(state); + filterState.patch.source.filter = { + mode: "lowpass", + cutoffHz: 1200, + resonance: 0.2, + cutoffLfo: { shape: "sine", rateHz: 1, amountOctaves: 1 }, + }; + filterState.document.editableStructure.cutoffLfo = true; + const { socket } = mountStudio(filterState); + fireEvent.click(await screen.findByRole("button", { name: "Edit cutoff LFO" })); + expect(screen.getByText("lfo")).toBeTruthy(); + socket.message({ type: "state", state }); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Edit Oscillator mix" }).getAttribute("aria-pressed"), + ).toBe("true"), + ); +}); + +test("rack exposes the real serial stages and keeps the LFO attached to Filter", async () => { + const filterState: any = structuredClone(state); + filterState.patch.source.filter = { + mode: "lowpass", + cutoffHz: 1200, + resonance: 0.2, + cutoffLfo: { shape: "sine", rateHz: 1, amountOctaves: 1 }, + }; + mountStudio(filterState); + await screen.findByRole("button", { name: "Edit Filter" }); + const stages = ["Oscillator mix", "Filter", "Envelope", "Safety"].map((name) => + screen.getByRole("button", { name: `Edit ${name}` }), + ); + for (let index = 1; index < stages.length; index += 1) { + expect( + stages[index - 1].compareDocumentPosition(stages[index]) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } + expect(screen.queryByRole("button", { name: "Edit Cutoff LFO" })).toBeNull(); + expect(screen.getByRole("button", { name: "Edit cutoff LFO" })).toBeTruthy(); +}); + +test("pointer cancellation sends one preview cancellation", async () => { + const { socket } = mountStudio(); + const slider = await screen.findByRole("slider", { name: /Frequency/ }); + fireEvent.change(slider, { target: { value: "220" } }); + fireEvent.pointerCancel(slider); + expect(socket.sent.filter((message: any) => message.type === "cancelPreview")).toHaveLength(1); +}); + +test("returning to the authored value cancels instead of committing", async () => { + const { socket } = mountStudio(); + const slider = await screen.findByRole("slider", { name: /Frequency/ }); + fireEvent.change(slider, { target: { value: "220" } }); + fireEvent.change(slider, { target: { value: "110" } }); + fireEvent.pointerUp(slider); + expect(socket.sent.filter((message: any) => message.type === "commit")).toHaveLength(0); + expect(socket.sent.filter((message: any) => message.type === "cancelPreview")).toHaveLength(1); +}); + +test("window blur cancels an active gesture and releases performance keys", async () => { + const { socket } = mountStudio(); + const slider = await screen.findByRole("slider", { name: /Frequency/ }); + fireEvent.change(slider, { target: { value: "220" } }); + fireEvent.blur(window); + expect(socket.sent.filter((message: any) => message.type === "cancelPreview")).toHaveLength(1); + expect(socket.sent.filter((message: any) => message.type === "releaseAll")).toHaveLength(1); +}); diff --git a/packages/studio/client/src/favicon.svg b/packages/studio/client/src/favicon.svg new file mode 100644 index 0000000..1a756bc --- /dev/null +++ b/packages/studio/client/src/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/studio/client/src/keyboard.test.ts b/packages/studio/client/src/keyboard.test.ts new file mode 100644 index 0000000..052068e --- /dev/null +++ b/packages/studio/client/src/keyboard.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import { isEditingTarget, shouldHandlePerformanceKey } from "./keyboard.js"; + +describe("performance keyboard focus safety", () => { + it("ignores form controls and modifier shortcuts", () => { + const mapped = new Set(["KeyA"]); + const input = document.createElement("input"); + expect(isEditingTarget(input)).toBe(true); + expect( + shouldHandlePerformanceKey( + { code: "KeyA", ctrlKey: false, metaKey: false, altKey: false, target: input }, + mapped, + ), + ).toBe(false); + expect( + shouldHandlePerformanceKey( + { code: "KeyA", ctrlKey: true, metaKey: false, altKey: false, target: document.body }, + mapped, + ), + ).toBe(false); + expect( + shouldHandlePerformanceKey( + { code: "KeyA", ctrlKey: false, metaKey: false, altKey: false, target: document.body }, + mapped, + ), + ).toBe(true); + }); +}); diff --git a/packages/studio/client/src/keyboard.ts b/packages/studio/client/src/keyboard.ts new file mode 100644 index 0000000..8154a16 --- /dev/null +++ b/packages/studio/client/src/keyboard.ts @@ -0,0 +1,21 @@ +export function isEditingTarget(target: EventTarget | null): boolean { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLButtonElement || + target instanceof HTMLTextAreaElement + ); +} + +export function shouldHandlePerformanceKey( + event: Pick, + mapped: ReadonlySet, +): boolean { + return ( + mapped.has(event.code) && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !isEditingTarget(event.target) + ); +} diff --git a/packages/studio/client/src/main.tsx b/packages/studio/client/src/main.tsx new file mode 100644 index 0000000..9d79923 --- /dev/null +++ b/packages/studio/client/src/main.tsx @@ -0,0 +1,899 @@ +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import type { + EffectType, + OscillatorWaveform, + PatchEditOperation, + PatchFieldPath, + PatchScalar, + SourceBinding, + StudioDocumentSnapshot, + StudioEditResult, + StudioServerMessage, +} from "../../src/index.js"; +import { shouldHandlePerformanceKey } from "./keyboard.js"; +import "./studio.css"; + +type RuntimeState = any; +type Selection = { + kind: "source" | "oscillator" | "filter" | "lfo" | "envelope" | "effect" | "safety"; + index?: number; +}; + +function requestId(): string { + return crypto.randomUUID(); +} +function pathEqual(a: PatchFieldPath, b: PatchFieldPath): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function App() { + const [connected, setConnected] = useState(false); + const [runtime, setRuntime] = useState(); + const [document, setDocument] = useState(); + const [selection, setSelection] = useState({ kind: "source" }); + const [notice, setNotice] = useState("Connecting…"); + const [pendingRequest, setPendingRequest] = useState(undefined); + const pendingRequestRef = useRef(undefined); + const socket = useRef(undefined); + + const send = useCallback((message: unknown): boolean => { + if (socket.current?.readyState !== WebSocket.OPEN) return false; + socket.current.send(JSON.stringify(message)); + return true; + }, []); + const edit = useCallback( + (operation: PatchEditOperation, gestureId: string | null = null): boolean => { + if (!document || pendingRequestRef.current) return false; + const id = requestId(); + pendingRequestRef.current = id; + setPendingRequest(id); + if ( + send({ + type: "commit", + protocol: 1, + requestId: id, + gestureId, + baseRevision: document.revision, + operation, + }) + ) + return true; + pendingRequestRef.current = undefined; + setPendingRequest(undefined); + return false; + }, + [document, send], + ); + + useEffect(() => { + const url = new URL("./socket", window.location.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(url); + socket.current = ws; + ws.addEventListener("open", () => { + setConnected(true); + setNotice("Audio running"); + }); + ws.addEventListener("close", () => { + pendingRequestRef.current = undefined; + setPendingRequest(undefined); + setConnected(false); + setNotice("Studio disconnected. Restart Patchwave to reconnect."); + }); + ws.addEventListener("message", (event) => { + try { + const message = JSON.parse(String(event.data)) as StudioServerMessage; + if (message.type === "state") { + const state = message.state as RuntimeState; + setRuntime(state); + if (state?.document) setDocument(state.document); + setNotice( + state?.error || state?.document?.diagnostic || state?.status || "Audio running", + ); + } else if (message.type === "document") setDocument(message.document); + else { + const result = message as StudioEditResult; + if (pendingRequestRef.current === result.requestId) { + pendingRequestRef.current = undefined; + setPendingRequest(undefined); + } + if (result.document) setDocument(result.document); + setNotice( + result.message ?? + (result.status === "committed" ? "Saved to code; reloading audio…" : result.status), + ); + } + } catch { + ws.close(); + } + }); + return () => ws.close(); + }, []); + + useEffect(() => { + const mapped = new Set([ + "KeyA", + "KeyW", + "KeyS", + "KeyE", + "KeyD", + "KeyF", + "KeyT", + "KeyG", + "KeyY", + "KeyH", + "KeyU", + "KeyJ", + "KeyK", + "KeyZ", + "KeyX", + ]); + const down = (event: KeyboardEvent) => { + if (!shouldHandlePerformanceKey(event, mapped)) return; + event.preventDefault(); + if (!event.repeat) send({ type: "keyDown", code: event.code }); + }; + const up = (event: KeyboardEvent) => { + if (!mapped.has(event.code)) return; + event.preventDefault(); + send({ type: "keyUp", code: event.code }); + }; + const release = () => { + window.dispatchEvent(new Event("patchwave-cancel-gestures")); + send({ type: "releaseAll" }); + }; + window.addEventListener("keydown", down); + window.addEventListener("keyup", up); + window.addEventListener("blur", release); + const visibility = () => { + if (globalThis.document.hidden) release(); + }; + globalThis.document.addEventListener("visibilitychange", visibility); + window.addEventListener("pagehide", release); + return () => { + window.removeEventListener("keydown", down); + window.removeEventListener("keyup", up); + window.removeEventListener("blur", release); + globalThis.document.removeEventListener("visibilitychange", visibility); + window.removeEventListener("pagehide", release); + }; + }, [send]); + + const patch = runtime?.patch; + const bindings = useMemo( + () => document?.bindings.filter((binding) => matchesSelection(binding.path, selection)) ?? [], + [document, selection], + ); + const phase = document?.phase ?? "ready"; + const busy = + Boolean(pendingRequest) || ["writing", "source-written", "reloading"].includes(phase); + const history = (type: "undo" | "redo") => { + if (!document || pendingRequestRef.current) return; + const id = requestId(); + pendingRequestRef.current = id; + setPendingRequest(id); + if (!send({ type, protocol: 1, requestId: id, baseRevision: document.revision })) { + pendingRequestRef.current = undefined; + setPendingRequest(undefined); + } + }; + + useEffect(() => { + if (!patch) return; + setSelection((current) => { + if (current.kind === "oscillator" && current.index! >= patch.source.oscillators.length) + return { kind: "source" }; + if (current.kind === "effect" && current.index! >= patch.effects.length) + return { kind: "source" }; + if (current.kind === "filter" && !patch.source.filter) return { kind: "source" }; + if (current.kind === "lfo" && !patch.source.filter?.cutoffLfo) + return patch.source.filter ? { kind: "filter" } : { kind: "source" }; + return current; + }); + }, [patch?.source?.oscillators?.length, patch?.source?.filter, patch?.effects?.length]); + + return ( +
+
+
+

PATCHWAVE

+

Studio

+
+
+ + + + {connected ? "Connected" : "Disconnected"} + +
+
+ +
+ + +
+
+
+

SIGNAL PATH

+

{runtime?.summary ?? "Waiting for patch"}

+
+ {phase.replaceAll("-", " ")} +
+
+
+ setSelection({ kind: "source" })} + /> + {(patch?.source?.oscillators ?? []).map((osc: any, index: number) => ( + setSelection({ kind: "oscillator", index })} + drag={ + document?.editableStructure.oscillators && !busy + ? { + group: "oscillator", + index, + onMove: (from, to) => edit({ type: "moveOscillator", from, to }), + } + : undefined + } + actions={ + document?.editableStructure.oscillators ? ( + 1} + index={index} + count={patch.source.oscillators.length} + onMove={(to) => edit({ type: "moveOscillator", from: index, to })} + onRemove={() => edit({ type: "removeOscillator", index })} + /> + ) : null + } + /> + ))} +
+ {patch?.source?.filter && ( + setSelection({ kind: "filter" })} + actions={ + <> + {patch.source.filter.cutoffLfo && ( + + )} + {patch.source.filter.cutoffLfo && document?.editableStructure.cutoffLfo && ( + + )} + {document?.editableStructure.filter && ( + + )} + + } + /> + )} + setSelection({ kind: "envelope" })} + /> + {(patch?.effects ?? []).map((effect: any, index: number) => ( + setSelection({ kind: "effect", index })} + drag={ + document?.editableStructure.effects && !busy + ? { + group: "effect", + index, + onMove: (from, to) => edit({ type: "moveEffect", from, to }), + } + : undefined + } + actions={ + document?.editableStructure.effects ? ( + edit({ type: "moveEffect", from: index, to })} + onRemove={() => edit({ type: "removeEffect", index })} + /> + ) : null + } + /> + ))} + setSelection({ kind: "safety" })} + /> +
+
+ + +
+ +
+
+ KEYBOARD + {runtime?.keyboard?.activeNoteLabel ?? "—"} + + {format(runtime?.keyboard?.voice?.frequencyHz)} Hz · gate{" "} + {runtime?.keyboard?.voice?.gate ? "open" : "closed"} + +
+
+ {["A", "W", "S", "E", "D", "F", "T", "G", "Y", "H", "U", "J", "K"].map((key) => ( + {key} + ))} +
+

+ {notice} +

+
+
+ ); +} + +function Device({ + title, + detail, + selected, + onClick, + actions, + drag, +}: { + title: string; + detail: string; + selected: boolean; + onClick: () => void; + actions?: React.ReactNode; + drag?: { + group: "oscillator" | "effect"; + index: number; + onMove: (from: number, to: number) => void; + }; +}) { + return ( +
{ + if (!drag) return; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData( + "application/x-patchwave-block", + JSON.stringify({ group: drag.group, index: drag.index }), + ); + }} + onDragOver={(event) => { + if (drag) event.preventDefault(); + }} + onDrop={(event) => { + if (!drag) return; + event.preventDefault(); + try { + const source = JSON.parse(event.dataTransfer.getData("application/x-patchwave-block")); + if ( + source.group === drag.group && + Number.isInteger(source.index) && + source.index !== drag.index + ) { + drag.onMove(source.index, drag.index); + } + } catch { + // Ignore unrelated drag payloads. + } + }} + > + + {actions &&
{actions}
} +
+ ); +} +function BlockActions({ + label, + disabled, + canRemove, + index, + count, + onMove, + onRemove, +}: { + label: string; + disabled: boolean; + canRemove: boolean; + index: number; + count: number; + onMove: (to: number) => void; + onRemove: () => void; +}) { + return ( + <> + + + + + ); +} +function Parameter({ + binding, + value, + revision, + disabled, + send, + onCommit, +}: { + binding: SourceBinding; + value: PatchScalar; + revision: string; + disabled: boolean; + send: (message: unknown) => boolean; + onCommit: (operation: PatchEditOperation, gesture?: string | null) => boolean; +}) { + const labelId = useId(); + const controlName = binding.path.map(String).join("."); + const [draft, setDraft] = useState(value); + const draftRef = useRef(value); + const initialRef = useRef(value); + const gesture = useRef(undefined); + const pendingPreview = useRef(undefined); + const previewFrame = useRef(undefined); + const cancelRef = useRef<() => void>(() => undefined); + useEffect(() => { + if (gesture.current) return; + initialRef.current = value; + draftRef.current = value; + setDraft(value); + }, [value]); + const computed = binding.sourceForm.kind === "computed"; + const begin = () => { + if (!gesture.current) { + gesture.current = requestId(); + initialRef.current = value; + } + return gesture.current; + }; + const sendPreview = (id: string, next: PatchScalar) => + send({ + type: "preview", + protocol: 1, + requestId: requestId(), + gestureId: id, + baseRevision: revision, + path: binding.path, + value: next, + }); + const preview = (next: PatchScalar) => { + const id = begin(); + draftRef.current = next; + setDraft(next); + pendingPreview.current = next; + if (previewFrame.current !== undefined) return; + previewFrame.current = requestAnimationFrame(() => { + previewFrame.current = undefined; + const latest = pendingPreview.current; + pendingPreview.current = undefined; + if (latest !== undefined && gesture.current === id) sendPreview(id, latest); + }); + }; + const cancel = () => { + const id = gesture.current; + if (!id) return; + if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current); + previewFrame.current = undefined; + pendingPreview.current = undefined; + send({ type: "cancelPreview", protocol: 1, requestId: requestId(), gestureId: id }); + gesture.current = undefined; + draftRef.current = initialRef.current; + setDraft(initialRef.current); + }; + cancelRef.current = cancel; + useEffect(() => { + const cancelActive = () => cancelRef.current(); + window.addEventListener("patchwave-cancel-gestures", cancelActive); + return () => { + window.removeEventListener("patchwave-cancel-gestures", cancelActive); + cancelActive(); + }; + }, []); + const commit = () => { + const id = gesture.current; + if (!id) return; + if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current); + previewFrame.current = undefined; + const latest = pendingPreview.current; + pendingPreview.current = undefined; + if (latest !== undefined) sendPreview(id, latest); + if (Object.is(draftRef.current, initialRef.current)) { + cancel(); + return; + } + if (onCommit({ type: "setField", path: binding.path, value: draftRef.current }, id)) + gesture.current = undefined; + }; + return ( +
+
+ {binding.control.label} + {binding.sourceForm.kind} +
+ {binding.control.kind === "number" ? ( + <> + event.currentTarget.setPointerCapture(event.pointerId)} + onChange={(event) => preview(Number(event.target.value))} + onPointerUp={commit} + onPointerCancel={cancel} + onLostPointerCapture={commit} + onKeyUp={(event) => { + if ( + [ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", + ].includes(event.key) + ) + commit(); + }} + onKeyDown={(event) => { + if (event.key === "Escape") cancel(); + }} + /> + + preview(Number(event.target.value))} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") commit(); + if (event.key === "Escape") cancel(); + }} + /> + {binding.control.unit} + + + ) : binding.control.kind === "enum" ? ( + + ) : ( + + onCommit({ type: "setField", path: binding.path, value: event.target.checked }) + } + /> + )} + {binding.sourceForm.kind === "computed" ? ( + + {binding.sourceForm.message} + {binding.location + ? ` · ${binding.location.fileLabel}:${binding.location.line}:${binding.location.column}` + : ""} + + ) : binding.control.defaultValue !== undefined && binding.sourceForm.kind === "literal" ? ( + + ) : null} +
+ ); +} +function matchesSelection(path: PatchFieldPath, selection: Selection): boolean { + if (selection.kind === "source") return path[0] === "source" && path.length === 2; + if (selection.kind === "oscillator") + return path[0] === "source" && path[1] === "oscillators" && path[2] === selection.index; + if (selection.kind === "filter") + return path[0] === "source" && path[1] === "filter" && path.length === 3; + if (selection.kind === "lfo") + return path[0] === "source" && path[1] === "filter" && path[2] === "cutoffLfo"; + if (selection.kind === "envelope") return path[0] === "source" && path[1] === "ampEnvelope"; + if (selection.kind === "effect") return path[0] === "effects" && path[1] === selection.index; + return false; +} +function valueAt(patch: any, path: PatchFieldPath): PatchScalar { + let value = patch; + for (const part of path) value = value?.[part as any]; + return value as PatchScalar; +} +function selectionLabel(selection: Selection): string { + return selection.index === undefined + ? selection.kind + : `${selection.kind} ${selection.index + 1}`; +} +function format(value: unknown): string { + const number = Number(value); + return Number.isFinite(number) ? number.toFixed(number < 10 ? 2 : 0) : "—"; +} + +const root = document.getElementById("root"); +if (root) + createRoot(root).render( + + + , + ); diff --git a/packages/studio/client/src/studio.css b/packages/studio/client/src/studio.css new file mode 100644 index 0000000..2f77061 --- /dev/null +++ b/packages/studio/client/src/studio.css @@ -0,0 +1,458 @@ +:root { + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + sans-serif; + color: #e7ece9; + background: #0a0c0b; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: radial-gradient(circle at 70% -10%, #183a2d 0, transparent 38%), #0a0c0b; +} +button, +input, +select { + font: inherit; +} +button { + color: #dce5df; + background: #181d1a; + border: 1px solid #333b36; + border-radius: 7px; + padding: 7px 10px; + cursor: pointer; +} +button:hover:not(:disabled) { + border-color: #67de96; + background: #202a24; +} +button:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.studio-shell { + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr auto; +} +.topbar { + height: 72px; + padding: 13px 20px; + border-bottom: 1px solid #28302b; + display: flex; + align-items: center; + justify-content: space-between; + background: #101311e8; + backdrop-filter: blur(18px); +} +h1, +h2, +h3, +p { + margin: 0; +} +.topbar h1 { + font-size: 24px; + letter-spacing: -0.04em; +} +.eyebrow { + font-size: 10px; + letter-spacing: 0.18em; + color: #70e49c; + font-weight: 800; +} +.topbar-actions { + display: flex; + gap: 8px; + align-items: center; +} +.connection, +.phase { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.09em; + border: 1px solid #313a34; + padding: 7px 10px; + border-radius: 999px; +} +.connection.online { + color: #70e49c; +} +.connection.offline, +.error-status { + color: #ff7878; +} +.workspace { + display: grid; + grid-template-columns: 210px minmax(300px, 1fr) 280px; + min-height: 0; +} +.browser-panel, +.inspector-panel { + background: #101311; + border-color: #28302b; + padding: 18px; + overflow: auto; +} +.browser-panel { + border-right: 1px solid #28302b; +} +.inspector-panel { + border-left: 1px solid #28302b; +} +.browser-panel h2, +.inspector-panel h2 { + font-size: 16px; +} +.browser-panel h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #7f8983; + margin: 22px 0 8px; +} +.panel-help { + color: #7f8983; + font-size: 12px; + line-height: 1.45; + margin-top: 5px; +} +.device-actions { + display: grid; + gap: 6px; +} +.device-actions button { + text-align: left; + text-transform: capitalize; +} +.rack-panel { + padding: 22px; + overflow: hidden; + background-image: + linear-gradient(#ffffff05 1px, transparent 1px), + linear-gradient(90deg, #ffffff05 1px, transparent 1px); + background-size: 24px 24px; +} +.rack-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.rack-heading h2 { + font-size: 14px; + font-weight: 500; + color: #aab3ad; + margin-top: 4px; +} +.phase { + color: #c0c8c3; + background: #111512; +} +.phase-previewing { + color: #f4c86a; +} +.phase-source-written, +.phase-reloading { + color: #70bcec; +} +.phase-audio-accepted { + color: #70e49c; +} +.phase-conflict, +.phase-error { + color: #ff7878; +} +.rack { + display: flex; + align-items: stretch; + gap: 10px; + overflow-x: auto; + padding: 36px 6px 24px; + min-height: 250px; +} +.oscillator-mix { + display: flex; + gap: 8px; + padding: 8px; + border: 1px solid #3b463f; + border-radius: 13px; + background: #101512; + position: relative; +} +.oscillator-mix .device { + flex-basis: 132px; +} +.device { + flex: 0 0 156px; + min-height: 165px; + border: 1px solid #323a35; + border-radius: 11px; + padding: 16px; + background: linear-gradient(145deg, #1b201d, #111411); + box-shadow: 0 12px 28px #0006; + position: relative; + outline: none; +} +.rack > .device:after, +.rack > .oscillator-mix:after { + content: ""; + position: absolute; + width: 11px; + height: 1px; + background: #536057; + right: -11px; + top: 50%; +} +.rack > :last-child:after { + display: none; +} +.device:hover, +.device:has(.device-select:focus-visible) { + border-color: #56655b; +} +.device.selected { + border-color: #6ee69c; + box-shadow: + 0 0 0 1px #6ee69c44, + 0 14px 34px #0008; +} +.device-led { + display: block; + width: 7px; + height: 7px; + border-radius: 50%; + background: #67de96; + box-shadow: 0 0 12px #67de96; + margin-bottom: 45px; +} +.device-select { + display: block; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.device-select:focus-visible { + outline: 2px solid #6ee69c; + outline-offset: 3px; +} +.device-title { + display: block; + font-size: 14px; + font-weight: 700; +} +.device-detail { + display: block; + font-size: 11px; + color: #8b958f; + margin-top: 5px; + line-height: 1.4; +} +.block-actions { + position: absolute; + left: 9px; + right: 9px; + bottom: 9px; + display: flex; + gap: 4px; +} +.block-actions button { + font-size: 9px; + padding: 5px; + flex: 1; +} +.parameter { + display: block; + border-bottom: 1px solid #272e2a; + padding: 15px 0; +} +.parameter-label { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 700; + margin-bottom: 9px; +} +.source-badge { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + border: 1px solid #3b453f; + border-radius: 999px; + padding: 3px 6px; + color: #aab3ad; +} +.source-badge.computed { + color: #f2b96b; +} +.source-badge.default { + color: #7fb6e7; +} +.parameter input[type="range"], +.parameter select { + width: 100%; +} +.parameter input[type="range"] { + accent-color: #67de96; +} +.number-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; + margin-top: 6px; +} +.number-row input { + width: 100%; + background: #151916; + border: 1px solid #343c37; + color: #edf2ef; + border-radius: 6px; + padding: 6px; +} +.number-row span { + font-size: 10px; + color: #7f8983; +} +.parameter small { + display: block; + color: #b4a17c; + line-height: 1.4; + margin-top: 7px; +} +.reset { + font-size: 10px; + margin-top: 7px; + padding: 4px 7px; +} +.diagnostic, +.computed-note { + font-size: 12px; + line-height: 1.5; + color: #b4a17c; + background: #1d1912; + border: 1px solid #493d28; + border-radius: 8px; + padding: 10px; + margin-top: 14px; +} +.performance { + min-height: 96px; + border-top: 1px solid #28302b; + background: #0f1210; + display: grid; + grid-template-columns: 210px 1fr 280px; + gap: 18px; + align-items: center; + padding: 14px 20px; +} +.performance > div:first-child { + display: grid; + gap: 4px; +} +.performance strong { + font-size: 20px; +} +.performance span:not(.eyebrow) { + font-size: 11px; + color: #8b958f; +} +.keys { + display: flex; + justify-content: center; + gap: 5px; + overflow: auto; +} +.keys kbd { + min-width: 31px; + padding: 12px 7px; + text-align: center; + border: 1px solid #424b45; + border-radius: 5px; + background: #f3f6f4; + color: #151815; + font: + 700 11px ui-monospace, + monospace; + box-shadow: 0 3px 0 #667068; +} +.keys kbd:nth-child(2), +.keys kbd:nth-child(4), +.keys kbd:nth-child(7), +.keys kbd:nth-child(9), +.keys kbd:nth-child(11) { + background: #222825; + color: #f2f5f3; + transform: translateY(-6px); + box-shadow: 0 3px 0 #090b0a; +} +.performance > p { + font-size: 11px; + line-height: 1.4; + color: #99a39d; +} +@media (max-width: 900px) { + .workspace { + grid-template-columns: 160px minmax(260px, 1fr); + } + .inspector-panel { + grid-column: 1/-1; + border-left: 0; + border-top: 1px solid #28302b; + max-height: 310px; + } + .performance { + grid-template-columns: 150px 1fr; + } + .performance > p { + grid-column: 1/-1; + } + .browser-panel { + padding: 13px; + } +} +@media (max-width: 620px) { + .topbar { + height: auto; + } + .workspace { + display: block; + } + .browser-panel { + border-right: 0; + border-bottom: 1px solid #28302b; + } + .device-actions { + grid-template-columns: repeat(2, 1fr); + } + .rack-panel { + padding: 16px; + } + .performance { + display: block; + } + .keys { + justify-content: flex-start; + margin: 14px 0; + } + .performance > p { + margin-top: 8px; + } + .topbar-actions .connection { + display: none; + } +} diff --git a/packages/studio/package.json b/packages/studio/package.json index 4e1aa85..53e56ed 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "files": [ - "public", + "dist/client", "src" ], "type": "module", @@ -14,17 +14,31 @@ } }, "scripts": { - "build": "tsc --noEmit", - "test": "tsx --test test/**/*.test.ts" + "build": "vite build && tsc --noEmit", + "dev": "vite", + "test": "vite build && tsx --test test/**/*.test.ts && vitest run" }, "dependencies": { + "@babel/types": "7.28.4", + "@patchwave/schema": "workspace:*", + "react": "19.1.1", + "react-dom": "19.1.1", + "recast": "0.23.11", "ws": "8.18.3" }, "devDependencies": { + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", "@types/node": "24.13.3", + "@types/react": "19.1.16", + "@types/react-dom": "19.1.9", "@types/ws": "8.18.1", + "@vitejs/plugin-react": "5.0.4", + "jsdom": "26.1.0", "tsx": "4.23.1", - "typescript": "7.0.2" + "typescript": "7.0.2", + "vite": "7.1.7", + "vitest": "3.2.4" }, "engines": { "node": ">=24" diff --git a/packages/studio/public/app.js b/packages/studio/public/app.js deleted file mode 100644 index 787d080..0000000 --- a/packages/studio/public/app.js +++ /dev/null @@ -1,210 +0,0 @@ -const connection = document.querySelector("#connection"); -const mode = document.querySelector("#mode"); -const gate = document.querySelector("#gate"); -const octave = document.querySelector("#octave"); -const time = document.querySelector("#time"); -const frame = document.querySelector("#frame"); -const note = document.querySelector("#note"); -const frequency = document.querySelector("#frequency"); -const summary = document.querySelector("#summary"); -const chain = document.querySelector("#chain"); -const keyboard = document.querySelector("#keyboard"); -const statusPanel = document.querySelector("#status-panel"); -const status = document.querySelector("#status"); -const CHAIN_RENDER_INTERVAL_MS = 100; -let chainTimer; -let pendingChainPatch; -let renderedChainKey; - -const keys = [ - ["KeyA", "A", false], - ["KeyW", "W", true], - ["KeyS", "S", false], - ["KeyE", "E", true], - ["KeyD", "D", false], - ["KeyF", "F", false], - ["KeyT", "T", true], - ["KeyG", "G", false], - ["KeyY", "Y", true], - ["KeyH", "H", false], - ["KeyU", "U", true], - ["KeyJ", "J", false], - ["KeyK", "K", false], -]; -const keyElements = new Map(); -for (const [code, label, black] of keys) { - const element = document.createElement("div"); - element.className = `key${black ? " black" : ""}`; - element.dataset.code = code; - const text = document.createElement("span"); - text.textContent = label; - element.append(text); - keyboard.append(element); - keyElements.set(code, element); -} - -const socketUrl = new URL("./socket", window.location.href); -socketUrl.protocol = socketUrl.protocol === "https:" ? "wss:" : "ws:"; -const socket = new WebSocket(socketUrl); - -socket.addEventListener("open", () => { - document.body.classList.add("connected"); - document.body.classList.remove("disconnected"); - connection.textContent = "Connected"; -}); -socket.addEventListener("close", () => { - stopChainUpdates(); - document.body.classList.remove("connected"); - document.body.classList.add("disconnected"); - connection.textContent = "Disconnected"; - statusPanel.classList.add("error"); - status.textContent = "Studio connection closed. Restart Patchwave to reconnect."; -}); -socket.addEventListener("message", (event) => { - try { - const message = JSON.parse(event.data); - if (message?.type === "state") render(message.state); - } catch { - socket.close(); - } -}); - -const mappedCodes = new Set([...keys.map(([code]) => code), "KeyZ", "KeyX"]); -window.addEventListener("keydown", (event) => { - if (!mappedCodes.has(event.code) || event.ctrlKey || event.metaKey || event.altKey) return; - event.preventDefault(); - if (!event.repeat) send({ type: "keyDown", code: event.code }); -}); -window.addEventListener("keyup", (event) => { - if (!mappedCodes.has(event.code)) return; - event.preventDefault(); - send({ type: "keyUp", code: event.code }); -}); -window.addEventListener("blur", releaseAll); -document.addEventListener("visibilitychange", () => { - if (document.hidden) releaseAll(); -}); -window.addEventListener("pagehide", () => { - releaseAll(); - stopChainUpdates(); -}); - -function releaseAll() { - send({ type: "releaseAll" }); -} -function send(message) { - if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); -} - -function render(state) { - if (!state || typeof state !== "object") return; - mode.textContent = state.mode ?? "static"; - gate.textContent = state.keyboard?.voice?.gate ? "open" : "closed"; - octave.textContent = state.keyboard?.octaveLabel ?? "C3"; - time.textContent = `${Number(state.timeSeconds ?? 0).toFixed(2)} s`; - frame.textContent = String(state.frame ?? 0); - note.textContent = state.keyboard?.activeNoteLabel ?? "—"; - frequency.textContent = `${Number(state.keyboard?.voice?.frequencyHz ?? 0).toFixed(2)} Hz`; - summary.textContent = state.summary ?? ""; - const held = new Set(state.keyboard?.heldCodes ?? []); - for (const [code, element] of keyElements) element.classList.toggle("active", held.has(code)); - statusPanel.classList.toggle("error", Boolean(state.error)); - status.textContent = state.error || state.status || "Audio running"; - scheduleChainRender(state.patch); -} - -function scheduleChainRender(patch) { - pendingChainPatch = patch; - if (chainTimer !== undefined) return; - chainTimer = setTimeout(() => { - chainTimer = undefined; - const nextPatch = pendingChainPatch; - pendingChainPatch = undefined; - const nextKey = JSON.stringify(nextPatch ?? null); - if (nextKey === renderedChainKey) return; - renderedChainKey = nextKey; - renderChain(nextPatch); - }, CHAIN_RENDER_INTERVAL_MS); -} - -function stopChainUpdates() { - if (chainTimer !== undefined) clearTimeout(chainTimer); - chainTimer = undefined; - pendingChainPatch = undefined; -} - -function renderChain(patch) { - chain.replaceChildren(); - if (!patch?.source) return; - addDevice("Source", "Oscillator mix", [ - `${format(patch.source.frequencyHz)} Hz source frequency`, - `${patch.source.oscillators.length} oscillator${patch.source.oscillators.length === 1 ? "" : "s"}`, - `${format(patch.source.gainDb)} dB gain`, - ]); - patch.source.oscillators.forEach((oscillator, index) => { - const values = [`${format(oscillator.level)} level`]; - if (oscillator.waveform !== "noise") { - values.unshift( - `${format(oscillator.transposeSemitones)} semitones`, - `${format(oscillator.detuneCents)} cents detune`, - ); - } - if (oscillator.waveform === "pulse") { - values.push(`${format(oscillator.pulseWidth)} pulse width`); - } - addDevice("Oscillator", `${index + 1} · ${oscillator.waveform}`, values); - }); - if (patch.source.filter) { - addDevice("Filter", patch.source.filter.mode, [ - `${format(patch.source.filter.cutoffHz)} Hz cutoff`, - `${format(patch.source.filter.resonance)} resonance`, - patch.source.filter.cutoffLfo - ? `${patch.source.filter.cutoffLfo.shape} · ${format(patch.source.filter.cutoffLfo.rateHz)} Hz · ${format(patch.source.filter.cutoffLfo.amountOctaves)} octaves` - : "No cutoff LFO", - ]); - } - const envelope = patch.source.ampEnvelope; - addDevice("Envelope", "Amplitude", [ - `${format(envelope.attackSeconds)} s attack`, - `${format(envelope.decaySeconds)} s decay`, - `${format(envelope.sustain)} sustain`, - `${format(envelope.releaseSeconds)} s release`, - ]); - for (const effect of patch.effects ?? []) { - if (effect.type === "saturator") { - addDevice("Effect", "Saturator", [ - `${format(effect.driveDb)} dB drive`, - `${format(effect.outputGainDb)} dB output`, - `${format(effect.mix)} mix`, - ]); - } else { - addDevice("Effect", "Stereo delay", [ - `${format(effect.timeSeconds)} s`, - `${format(effect.feedback)} feedback`, - `${format(effect.damping)} damping`, - effect.pingPong ? "Ping-pong on" : "Ping-pong off", - `${format(effect.mix)} mix`, - ]); - } - } - addDevice("Output", "Safety guard", ["Finite", "Bounded", "Stereo"]); -} - -function addDevice(kind, name, values) { - const element = document.createElement("article"); - element.className = "device"; - const kindElement = document.createElement("p"); - kindElement.className = "device-kind"; - kindElement.textContent = kind; - const nameElement = document.createElement("p"); - nameElement.className = "device-name"; - nameElement.textContent = name; - const valuesElement = document.createElement("p"); - valuesElement.className = "device-values"; - valuesElement.textContent = values.join("\n"); - element.append(kindElement, nameElement, valuesElement); - chain.append(element); -} -function format(value) { - return Number(value).toFixed(Number(value) < 10 ? 2 : 0); -} diff --git a/packages/studio/public/index.html b/packages/studio/public/index.html deleted file mode 100644 index 8e71923..0000000 --- a/packages/studio/public/index.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - Patchwave Studio - - - -
-
-
-

PATCHWAVE

-

Studio

-
-
- Connecting -
-
- -
-
-

Keyboard / controller input

-
- 261.63 Hz -
-
-
-
-
Mode
-
static
-
-
-
Gate
-
closed
-
-
-
Octave
-
C3
-
-
-
Time
-
0.00 s
-
-
-
Frame
-
0
-
-
-
- -
-
-

Signal chain

- -
-
-
- -
-
-

Computer keyboard

- Z / X changes octave -
-
-
- -
- -
-

Runtime

-

Waiting for the audio session…

-
-
-

- Keep this page focused to play. Leaving or closing it safely releases the voice. -

-
- - - diff --git a/packages/studio/public/styles.css b/packages/studio/public/styles.css deleted file mode 100644 index b6b05cd..0000000 --- a/packages/studio/public/styles.css +++ /dev/null @@ -1,316 +0,0 @@ -:root { - color-scheme: dark; - font-family: - Inter, - ui-sans-serif, - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; - background: #090b0f; - color: #f5f7fb; - font-synthesis: none; -} - -* { - box-sizing: border-box; -} -body { - margin: 0; - min-width: 320px; - min-height: 100vh; - background: radial-gradient(circle at 50% -20%, #202b39 0, #0d1117 38%, #090b0f 70%); -} -button, -input { - font: inherit; -} -.shell { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 44px 0 64px; -} -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - margin-bottom: 28px; -} -h1, -h2, -p { - margin: 0; -} -h1 { - font-size: clamp(28px, 5vw, 48px); - font-weight: 620; - letter-spacing: -0.045em; -} -h2 { - font-size: 15px; - font-weight: 620; - letter-spacing: -0.01em; -} -.eyebrow, -.label { - color: #7f8a99; - font-size: 10px; - font-weight: 760; - letter-spacing: 0.18em; - text-transform: uppercase; -} -.eyebrow { - margin-bottom: 7px; - color: #83f1c5; -} -.connection { - display: flex; - align-items: center; - gap: 9px; - color: #9aa4b2; - font-size: 13px; -} -#connection-dot, -.status-light { - width: 8px; - height: 8px; - border-radius: 999px; - background: #f0b45c; - box-shadow: 0 0 16px currentColor; -} -.connected #connection-dot { - background: #65e6b2; -} -.disconnected #connection-dot { - background: #ff6e78; -} -.panel { - border: 1px solid #29303a; - border-radius: 18px; - background: linear-gradient(145deg, rgba(27, 32, 41, 0.94), rgba(15, 19, 25, 0.94)); - box-shadow: 0 24px 70px rgba(0, 0, 0, 0.25); -} -.hero { - display: grid; - grid-template-columns: minmax(210px, 1fr) 2fr; - align-items: center; - gap: 32px; - padding: 26px 30px; -} -.note-row { - display: flex; - align-items: baseline; - gap: 14px; - margin-top: 8px; -} -.note-row strong { - font-size: 42px; - font-weight: 560; - letter-spacing: -0.05em; -} -.note-row span { - color: #9aa4b2; - font-variant-numeric: tabular-nums; -} -.metrics { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 8px; - margin: 0; -} -.metrics div { - min-width: 0; - padding: 10px 14px; - border-left: 1px solid #303742; -} -.metrics dt { - color: #737e8d; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} -.metrics dd { - overflow: hidden; - margin: 7px 0 0; - color: #e6eaf0; - font-size: 14px; - font-variant-numeric: tabular-nums; - text-overflow: ellipsis; - white-space: nowrap; -} -.section { - margin-top: 36px; -} -.section-title { - display: flex; - justify-content: space-between; - gap: 20px; - margin: 0 2px 13px; - color: #818b99; - font-size: 12px; -} -.section-title h2 { - color: #dce1e8; -} -.chain { - display: flex; - align-items: stretch; - gap: 0; - overflow-x: auto; - padding: 3px 0 12px; -} -.device { - position: relative; - flex: 0 0 190px; - min-height: 138px; - padding: 18px; - border: 1px solid #303844; - border-radius: 14px; - background: #131820; -} -.device + .device { - margin-left: 30px; -} -.device + .device::before { - content: ""; - position: absolute; - top: 50%; - left: -31px; - width: 30px; - height: 1px; - background: #43505d; -} -.device + .device::after { - content: "›"; - position: absolute; - top: calc(50% - 12px); - left: -18px; - color: #788493; - font-size: 20px; -} -.device-kind { - margin-bottom: 13px; - color: #7ef0c2; - font-size: 11px; - font-weight: 760; - letter-spacing: 0.13em; - text-transform: uppercase; -} -.device-name { - margin-bottom: 9px; - color: #f2f5f9; - font-size: 17px; - font-weight: 580; -} -.device-values { - color: #8994a3; - font-size: 12px; - line-height: 1.65; - font-variant-numeric: tabular-nums; - white-space: pre-line; -} -.keyboard { - position: relative; - display: flex; - height: 184px; - overflow-x: auto; - padding-bottom: 8px; - user-select: none; -} -.key { - position: relative; - flex: 1 0 72px; - min-width: 72px; - border: 1px solid #424a56; - border-radius: 0 0 11px 11px; - background: linear-gradient(#f5f5f3, #cfd2d1); - color: #222830; - box-shadow: inset 0 -8px 16px rgba(0, 0, 0, 0.12); -} -.key + .key { - margin-left: 2px; -} -.key.black { - z-index: 2; - flex: 0 0 48px; - min-width: 48px; - height: 112px; - margin: 0 -25px; - border-color: #090b0e; - border-radius: 0 0 8px 8px; - background: linear-gradient(#262c35, #090b0e); - color: #d5dbe4; - box-shadow: 0 8px 12px rgba(0, 0, 0, 0.5); -} -.key span { - position: absolute; - right: 0; - bottom: 13px; - left: 0; - text-align: center; - font-size: 12px; - font-weight: 760; -} -.key.active { - border-color: #73edbd; - background: linear-gradient(#9af6d1, #59cfa2); - color: #09251b; - box-shadow: 0 0 24px rgba(101, 230, 178, 0.38); -} -.key.black.active { - background: linear-gradient(#78eabc, #2e9771); - color: #071b14; -} -.status { - display: flex; - align-items: center; - gap: 14px; - margin-top: 28px; - padding: 15px 18px; -} -.status-light { - flex: 0 0 auto; - background: #65e6b2; -} -.status.error { - border-color: #61333a; -} -.status.error .status-light { - background: #ff6e78; -} -#status { - margin-top: 3px; - color: #aab3bf; - font-size: 13px; -} -.hint { - margin-top: 12px; - color: #626d7a; - font-size: 12px; - text-align: center; -} - -@media (max-width: 780px) { - .shell { - width: min(100% - 20px, 680px); - padding-top: 24px; - } - .hero { - grid-template-columns: 1fr; - padding: 22px; - } - .metrics { - grid-template-columns: repeat(3, 1fr); - } - .metrics div { - border-left: 0; - border-top: 1px solid #303742; - padding-left: 0; - } - .topbar { - align-items: flex-end; - } -} diff --git a/packages/studio/src/edit-types.ts b/packages/studio/src/edit-types.ts new file mode 100644 index 0000000..17c8bcf --- /dev/null +++ b/packages/studio/src/edit-types.ts @@ -0,0 +1,95 @@ +import type { PatchEditorControl } from "@patchwave/schema"; + +export type PatchFieldPath = + | readonly ["source", "frequencyHz" | "gainDb"] + | readonly [ + "source", + "oscillators", + number, + "transposeSemitones" | "detuneCents" | "pulseWidth" | "level", + ] + | readonly ["source", "filter", "mode" | "cutoffHz" | "resonance"] + | readonly ["source", "filter", "cutoffLfo", "shape" | "rateHz" | "amountOctaves"] + | readonly [ + "source", + "ampEnvelope", + "attackSeconds" | "decaySeconds" | "sustain" | "releaseSeconds", + ] + | readonly [ + "effects", + number, + "driveDb" | "outputGainDb" | "mix" | "timeSeconds" | "feedback" | "damping" | "pingPong", + ]; + +export type PatchScalar = number | string | boolean; +export type OscillatorWaveform = "sine" | "triangle" | "saw" | "pulse" | "noise"; +export type EffectType = "saturator" | "stereoDelay"; + +export type PatchEditOperation = + | Readonly<{ type: "setField"; path: PatchFieldPath; value: PatchScalar }> + | Readonly<{ type: "resetField"; path: PatchFieldPath }> + | Readonly<{ type: "addOscillator"; index: number; waveform: OscillatorWaveform }> + | Readonly<{ type: "removeOscillator"; index: number }> + | Readonly<{ type: "moveOscillator"; from: number; to: number }> + | Readonly<{ type: "replaceOscillator"; index: number; waveform: OscillatorWaveform }> + | Readonly<{ type: "addFilter" }> + | Readonly<{ type: "removeFilter" }> + | Readonly<{ type: "addCutoffLfo" }> + | Readonly<{ type: "removeCutoffLfo" }> + | Readonly<{ type: "addEffect"; index: number; effectType: EffectType }> + | Readonly<{ type: "removeEffect"; index: number }> + | Readonly<{ type: "moveEffect"; from: number; to: number }> + | Readonly<{ type: "replaceEffect"; index: number; effectType: EffectType }>; + +export type ComputedReason = + | "unsupported-export" + | "export-indirection" + | "multiple-returns" + | "spread-container" + | "computed-key" + | "duplicate-key" + | "expression" + | "dynamic-discriminant" + | "dynamic-array"; + +export type SourceLocation = Readonly<{ fileLabel: string; line: number; column: number }>; + +export type SourceForm = + | Readonly<{ kind: "literal"; value: PatchScalar; explicit: true }> + | Readonly<{ kind: "default"; value: PatchScalar; explicit: false }> + | Readonly<{ kind: "computed"; reason: ComputedReason; message: string }>; + +export type SourceBinding = Readonly<{ + path: PatchFieldPath; + sourceForm: SourceForm; + location: SourceLocation | null; + control: PatchEditorControl; +}>; + +export type StudioDocumentPhase = + | "ready" + | "previewing" + | "writing" + | "source-written" + | "reloading" + | "audio-accepted" + | "conflict" + | "error"; + +export type StudioDocumentSnapshot = Readonly<{ + revision: string; + mode: "static" | "program" | "unknown"; + writable: boolean; + fileLabel: string; + diagnostic: string | null; + phase: StudioDocumentPhase; + bindings: readonly SourceBinding[]; + editableStructure: Readonly<{ + oscillators: boolean; + filter: boolean; + cutoffLfo: boolean; + effects: boolean; + }>; + canUndo: boolean; + canRedo: boolean; +}>; diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index 7bbb58c..84fc292 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -7,9 +7,35 @@ export { type KeyboardSnapshot, type VoiceSnapshot, } from "./keyboard-state.js"; +export type { + ComputedReason, + EffectType, + OscillatorWaveform, + PatchEditOperation, + PatchFieldPath, + PatchScalar, + SourceBinding, + SourceForm, + SourceLocation, + StudioDocumentPhase, + StudioDocumentSnapshot, +} from "./edit-types.js"; export { - startStudioServer, + STUDIO_PROTOCOL_VERSION, + type StudioEditInput, + type StudioEditResult, type StudioInput, - type StudioServer, - type StudioServerOptions, -} from "./server.js"; + type StudioKeyboardInput, + type StudioServerMessage, +} from "./protocol.js"; +export { parseStudioInput } from "./protocol-validate.js"; +export { + analyzeSource, + applySourceEdit, + parseSource, + pathKey, + printSource, + type InternalAnalysis, + type SourceAnalysis, +} from "./source/index.js"; +export { startStudioServer, type StudioServer, type StudioServerOptions } from "./server.js"; diff --git a/packages/studio/src/protocol-validate.ts b/packages/studio/src/protocol-validate.ts new file mode 100644 index 0000000..6817cb1 --- /dev/null +++ b/packages/studio/src/protocol-validate.ts @@ -0,0 +1,274 @@ +import { EFFECT_COUNT_MAX, OSCILLATOR_COUNT_MAX } from "@patchwave/schema"; +import { NOTE_CODES } from "./keyboard-state.js"; +import type { PatchEditOperation, PatchFieldPath, PatchScalar } from "./edit-types.js"; +import type { StudioInput } from "./protocol.js"; + +const INPUT_CODES = new Set([...Object.keys(NOTE_CODES), "KeyZ", "KeyX"]); +const WAVEFORMS = new Set(["sine", "triangle", "saw", "pulse", "noise"]); +const EFFECT_TYPES = new Set(["saturator", "stereoDelay"]); +const FIELD_NAMES = new Set([ + "frequencyHz", + "gainDb", + "transposeSemitones", + "detuneCents", + "pulseWidth", + "level", + "mode", + "cutoffHz", + "resonance", + "shape", + "rateHz", + "amountOctaves", + "attackSeconds", + "decaySeconds", + "sustain", + "releaseSeconds", + "driveDb", + "outputGainDb", + "mix", + "timeSeconds", + "feedback", + "damping", + "pingPong", +]); + +export function parseStudioInput(serialized: string): StudioInput | undefined { + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + return undefined; + } + if (!isRecord(value) || typeof value.type !== "string") return undefined; + if (value.type === "releaseAll" && exact(value, ["type"])) return { type: "releaseAll" }; + if ( + (value.type === "keyDown" || value.type === "keyUp") && + exact(value, ["code", "type"]) && + typeof value.code === "string" && + INPUT_CODES.has(value.code) + ) { + return { type: value.type, code: value.code }; + } + if (!validProtocol(value) || !validRequestId(value.requestId)) return undefined; + if (value.type === "preview") { + if ( + !exact(value, [ + "baseRevision", + "gestureId", + "path", + "protocol", + "requestId", + "type", + "value", + ]) || + !validRevision(value.baseRevision) || + !validId(value.gestureId) || + !isPatchFieldPath(value.path) || + !isScalar(value.value) + ) + return undefined; + return { + type: "preview", + protocol: 1, + requestId: value.requestId, + gestureId: value.gestureId, + baseRevision: value.baseRevision, + path: value.path, + value: value.value, + }; + } + if (value.type === "cancelPreview") { + if (!exact(value, ["gestureId", "protocol", "requestId", "type"]) || !validId(value.gestureId)) + return undefined; + return { + type: "cancelPreview", + protocol: 1, + requestId: value.requestId, + gestureId: value.gestureId, + }; + } + if (value.type === "commit") { + if ( + !exact(value, ["baseRevision", "gestureId", "operation", "protocol", "requestId", "type"]) || + !validRevision(value.baseRevision) || + !(value.gestureId === null || validId(value.gestureId)) + ) + return undefined; + const operation = parseOperation(value.operation); + if (!operation) return undefined; + return { + type: "commit", + protocol: 1, + requestId: value.requestId, + gestureId: value.gestureId, + baseRevision: value.baseRevision, + operation, + }; + } + if (value.type === "undo" || value.type === "redo") { + if ( + !exact(value, ["baseRevision", "protocol", "requestId", "type"]) || + !validRevision(value.baseRevision) + ) + return undefined; + return { + type: value.type, + protocol: 1, + requestId: value.requestId, + baseRevision: value.baseRevision, + }; + } + return undefined; +} + +function parseOperation(value: unknown): PatchEditOperation | undefined { + if (!isRecord(value) || typeof value.type !== "string") return undefined; + if (value.type === "setField") { + if ( + !exact(value, ["path", "type", "value"]) || + !isPatchFieldPath(value.path) || + !isScalar(value.value) + ) + return undefined; + return { type: value.type, path: value.path, value: value.value }; + } + if (value.type === "resetField") { + if (!exact(value, ["path", "type"]) || !isPatchFieldPath(value.path)) return undefined; + return { type: value.type, path: value.path }; + } + if (value.type === "addOscillator" || value.type === "replaceOscillator") { + if ( + !exact(value, ["index", "type", "waveform"]) || + !validIndex(value.index, OSCILLATOR_COUNT_MAX) || + typeof value.waveform !== "string" || + !WAVEFORMS.has(value.waveform) + ) + return undefined; + return { + type: value.type, + index: value.index, + waveform: value.waveform as "sine" | "triangle" | "saw" | "pulse" | "noise", + }; + } + if (value.type === "removeOscillator") { + if (!exact(value, ["index", "type"]) || !validIndex(value.index, OSCILLATOR_COUNT_MAX - 1)) + return undefined; + return { type: value.type, index: value.index }; + } + if (value.type === "moveOscillator") { + if ( + !exact(value, ["from", "to", "type"]) || + !validIndex(value.from, OSCILLATOR_COUNT_MAX - 1) || + !validIndex(value.to, OSCILLATOR_COUNT_MAX - 1) + ) + return undefined; + return { type: value.type, from: value.from, to: value.to }; + } + if (["addFilter", "removeFilter", "addCutoffLfo", "removeCutoffLfo"].includes(value.type)) { + if (!exact(value, ["type"])) return undefined; + return { type: value.type } as PatchEditOperation; + } + if (value.type === "addEffect" || value.type === "replaceEffect") { + if ( + !exact(value, ["effectType", "index", "type"]) || + !validIndex(value.index, EFFECT_COUNT_MAX) || + typeof value.effectType !== "string" || + !EFFECT_TYPES.has(value.effectType) + ) + return undefined; + return { + type: value.type, + index: value.index, + effectType: value.effectType as "saturator" | "stereoDelay", + }; + } + if (value.type === "removeEffect") { + if (!exact(value, ["index", "type"]) || !validIndex(value.index, EFFECT_COUNT_MAX - 1)) + return undefined; + return { type: value.type, index: value.index }; + } + if (value.type === "moveEffect") { + if ( + !exact(value, ["from", "to", "type"]) || + !validIndex(value.from, EFFECT_COUNT_MAX - 1) || + !validIndex(value.to, EFFECT_COUNT_MAX - 1) + ) + return undefined; + return { type: value.type, from: value.from, to: value.to }; + } + return undefined; +} + +export function isPatchFieldPath(value: unknown): value is PatchFieldPath { + if ( + !Array.isArray(value) || + value.length < 2 || + value.length > 4 || + value.some((part) => typeof part !== "string" && !Number.isInteger(part)) + ) + return false; + const last = value.at(-1); + if (typeof last !== "string" || !FIELD_NAMES.has(last)) return false; + if (value[0] === "effects") + return ( + value.length === 3 && + validIndex(value[1], EFFECT_COUNT_MAX - 1) && + ["driveDb", "outputGainDb", "mix", "timeSeconds", "feedback", "damping", "pingPong"].includes( + last, + ) + ); + if (value[0] !== "source") return false; + if (value.length === 2) return last === "frequencyHz" || last === "gainDb"; + if (value[1] === "oscillators") + return ( + value.length === 4 && + validIndex(value[2], OSCILLATOR_COUNT_MAX - 1) && + ["transposeSemitones", "detuneCents", "pulseWidth", "level"].includes(last) + ); + if (value[1] === "filter") { + if (value.length === 3) return ["mode", "cutoffHz", "resonance"].includes(last); + return ( + value.length === 4 && + value[2] === "cutoffLfo" && + ["shape", "rateHz", "amountOctaves"].includes(last) + ); + } + return ( + value[1] === "ampEnvelope" && + value.length === 3 && + ["attackSeconds", "decaySeconds", "sustain", "releaseSeconds"].includes(last) + ); +} + +function isScalar(value: unknown): value is PatchScalar { + return ( + (typeof value === "number" && Number.isFinite(value)) || + typeof value === "boolean" || + (typeof value === "string" && value.length <= 128) + ); +} +function validProtocol( + value: Record, +): value is Record & { protocol: 1; requestId: string } { + return value.protocol === 1 && typeof value.requestId === "string"; +} +function validRevision(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/.test(value); +} +function validRequestId(value: unknown): value is string { + return validId(value); +} +function validId(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 64; +} +function validIndex(value: unknown, max: number): value is number { + return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= max; +} +function exact(value: Record, keys: string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/studio/src/protocol.ts b/packages/studio/src/protocol.ts new file mode 100644 index 0000000..62dee43 --- /dev/null +++ b/packages/studio/src/protocol.ts @@ -0,0 +1,55 @@ +import type { + PatchEditOperation, + PatchFieldPath, + PatchScalar, + StudioDocumentSnapshot, +} from "./edit-types.js"; + +export const STUDIO_PROTOCOL_VERSION = 1 as const; + +export type StudioKeyboardInput = + | Readonly<{ type: "keyDown" | "keyUp"; code: string }> + | Readonly<{ type: "releaseAll" }>; + +export type StudioEditInput = + | Readonly<{ + type: "preview"; + protocol: 1; + requestId: string; + gestureId: string; + baseRevision: string; + path: PatchFieldPath; + value: PatchScalar; + }> + | Readonly<{ + type: "cancelPreview"; + protocol: 1; + requestId: string; + gestureId: string; + }> + | Readonly<{ + type: "commit"; + protocol: 1; + requestId: string; + gestureId: string | null; + baseRevision: string; + operation: PatchEditOperation; + }> + | Readonly<{ type: "undo" | "redo"; protocol: 1; requestId: string; baseRevision: string }>; + +export type StudioInput = StudioKeyboardInput | StudioEditInput; + +export type StudioEditResult = Readonly<{ + type: "editResult"; + protocol: 1; + requestId: string; + status: "previewed" | "cancelled" | "committed" | "conflict" | "rejected"; + revision: string; + message?: string; + document?: StudioDocumentSnapshot; +}>; + +export type StudioServerMessage = + | Readonly<{ type: "state"; state: unknown }> + | StudioEditResult + | Readonly<{ type: "document"; protocol: 1; document: StudioDocumentSnapshot }>; diff --git a/packages/studio/src/server.ts b/packages/studio/src/server.ts index e5ab4a8..f0056f3 100644 --- a/packages/studio/src/server.ts +++ b/packages/studio/src/server.ts @@ -2,19 +2,15 @@ import { randomBytes } from "node:crypto"; import { readFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { WebSocket, WebSocketServer } from "ws"; -import { NOTE_CODES } from "./keyboard-state.js"; +import { parseStudioInput } from "./protocol-validate.js"; +import type { StudioInput, StudioServerMessage } from "./protocol.js"; +export type { StudioInput } from "./protocol.js"; const MAX_CLIENT_MESSAGE_BYTES = 4_096; const MAX_BUFFERED_BYTES = 65_536; const STATE_PUBLISH_INTERVAL_MS = 40; -const INPUT_CODES = new Set([...Object.keys(NOTE_CODES), "KeyZ", "KeyX"]); - -export type StudioInput = - | Readonly<{ type: "keyDown" | "keyUp"; code: string }> - | Readonly<{ type: "releaseAll" }>; - export type StudioServerOptions = { - onInput: (input: StudioInput) => void; + onInput: (input: StudioInput) => void | StudioServerMessage | Promise; onControllerClosed: () => void; getState: () => unknown; }; @@ -22,26 +18,15 @@ export type StudioServerOptions = { export type StudioServer = { url: string; publish(state: unknown): void; + send(message: StudioServerMessage): void; disconnect(): void; close(): Promise; }; -const ASSETS = { - "index.html": { - type: "text/html; charset=utf-8", - url: new URL("../public/index.html", import.meta.url), - }, - "app.js": { - type: "text/javascript; charset=utf-8", - url: new URL("../public/app.js", import.meta.url), - }, - "styles.css": { - type: "text/css; charset=utf-8", - url: new URL("../public/styles.css", import.meta.url), - }, -} as const; +type StudioAsset = Readonly<{ type: string; url: URL }>; export async function startStudioServer(options: StudioServerOptions): Promise { + const assets = await loadStudioAssets(); const token = randomBytes(24).toString("base64url"); const basePath = `/session/${token}/`; let expectedHost = ""; @@ -63,7 +48,19 @@ export async function startStudioServer(options: StudioServerOptions): Promise { + releaseController(socket); + try { + socket.terminate(); + } catch { + // Already closed. + } }; const flushState = (): void => { publishTimer = undefined; @@ -73,16 +70,11 @@ export async function startStudioServer(options: StudioServerOptions): Promise MAX_BUFFERED_BYTES) { - releaseController(socket); - socket.terminate(); - return; - } - sendState(socket, state); + if (!sendGuarded(socket, { type: "state", state }, MAX_BUFFERED_BYTES)) failSocket(socket); }; const server = createServer((request, response) => { - void handleHttp(request, response, expectedHost, basePath); + void handleHttp(request, response, expectedHost, basePath, assets); }); const webSockets = new WebSocketServer({ noServer: true, @@ -123,17 +115,34 @@ export async function startStudioServer(options: StudioServerOptions): Promise; + try { + result = options.onInput(input); + } catch { + failSocket(socket); + return; + } + void Promise.resolve(result) + .then((message) => { + if (!message || controller !== socket || controllerReleased) return; + if (!sendGuarded(socket, message, MAX_BUFFERED_BYTES)) failSocket(socket); + }) + .catch(() => failSocket(socket)); }); socket.on("close", () => releaseController(socket)); socket.on("error", () => releaseController(socket)); - sendState(socket, options.getState()); + try { + if (!sendGuarded(socket, { type: "state", state: options.getState() }, MAX_BUFFERED_BYTES)) + failSocket(socket); + } catch { + failSocket(socket); + } }); await new Promise((resolve, reject) => { @@ -161,17 +170,30 @@ export async function startStudioServer(options: StudioServerOptions): Promise { const socket = controller; if (socket) { releaseController(socket); - socket.close(1001, "Server shutting down"); + try { + socket.close(1001, "Server shutting down"); + } catch { + failSocket(socket); + } } else { cancelPendingPublish(); } @@ -182,11 +204,60 @@ export async function startStudioServer(options: StudioServerOptions): Promise> { + const root = new URL("../dist/client/", import.meta.url); + const manifestValue: unknown = JSON.parse( + await readFile(new URL(".vite/manifest.json", root), "utf8"), + ); + if (!isRecord(manifestValue)) throw new Error("Studio Vite manifest is invalid"); + const allowed = new Set(["index.html"]); + for (const entry of Object.values(manifestValue)) { + if (!isRecord(entry) || typeof entry.file !== "string") + throw new Error("Studio Vite manifest entry is invalid"); + allowed.add(validAssetPath(entry.file)); + for (const key of ["css", "assets"] as const) { + const values = entry[key]; + if (values === undefined) continue; + if (!Array.isArray(values) || values.some((value) => typeof value !== "string")) + throw new Error("Studio Vite manifest asset list is invalid"); + for (const value of values as string[]) allowed.add(validAssetPath(value)); + } + } + const assets = new Map(); + for (const relative of allowed) { + const url = new URL(relative, root); + await readFile(url); + assets.set(relative, { type: assetType(relative), url }); + } + return assets; +} + +function validAssetPath(path: string): string { + if ( + path.length === 0 || + path.startsWith("/") || + path.includes("\\") || + path.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ) + throw new Error("Studio Vite manifest contains an unsafe asset path"); + return path; +} + +function assetType(path: string): string { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".js")) return "text/javascript; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".svg")) return "image/svg+xml"; + if (path.endsWith(".json")) return "application/json; charset=utf-8"; + return "application/octet-stream"; +} + async function handleHttp( request: IncomingMessage, response: ServerResponse, expectedHost: string, basePath: string, + assets: ReadonlyMap, ): Promise { setSecurityHeaders(response); if (request.method !== "GET" || request.headers.host !== expectedHost) { @@ -196,12 +267,12 @@ async function handleHttp( const isIndex = request.url === basePath; const relative = isIndex ? "index.html" : request.url?.slice(basePath.length); const expectedUrl = isIndex ? basePath : `${basePath}${relative ?? ""}`; - if (!relative || !(relative in ASSETS) || request.url !== expectedUrl) { + if (!relative || !assets.has(relative) || request.url !== expectedUrl) { response.writeHead(404).end("Not found"); return; } try { - const asset = ASSETS[relative as keyof typeof ASSETS]; + const asset = assets.get(relative)!; const body = await readFile(asset.url); response.writeHead(200, { "Content-Type": asset.type, @@ -227,39 +298,23 @@ function setSecurityHeaders(response: ServerResponse): void { response.setHeader("X-Frame-Options", "DENY"); } -function parseInput(serialized: string): StudioInput | undefined { - let value: unknown; +function sendGuarded(socket: WebSocket, message: StudioServerMessage, maxBytes: number): boolean { try { - value = JSON.parse(serialized); + if (socket.readyState !== WebSocket.OPEN) return false; + const serialized = JSON.stringify(message); + const bytes = Buffer.byteLength(serialized, "utf8"); + if (bytes > maxBytes || socket.bufferedAmount + bytes > maxBytes) return false; + socket.send(serialized); + return true; } catch { - return undefined; - } - if (!isRecord(value) || typeof value.type !== "string") return undefined; - if (value.type === "releaseAll" && exactKeys(value, ["type"])) return { type: "releaseAll" }; - if ( - (value.type === "keyDown" || value.type === "keyUp") && - exactKeys(value, ["code", "type"]) && - typeof value.code === "string" && - INPUT_CODES.has(value.code) - ) { - return { type: value.type, code: value.code }; + return false; } - return undefined; -} - -function exactKeys(value: Record, expected: string[]): boolean { - const actual = Object.keys(value).sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function sendState(socket: WebSocket, state: unknown): void { - socket.send(JSON.stringify({ type: "state", state })); -} - function closeHttpServer(server: ReturnType): Promise { if (!server.listening) return Promise.resolve(); return new Promise((resolve, reject) => { diff --git a/packages/studio/src/source/analyze.ts b/packages/studio/src/source/analyze.ts new file mode 100644 index 0000000..947efdc --- /dev/null +++ b/packages/studio/src/source/analyze.ts @@ -0,0 +1,550 @@ +import { PATCH_EDITOR, type PatchEditorControl } from "@patchwave/schema"; +import type { File } from "@babel/types"; +import type { PatchFieldPath, PatchScalar, SourceBinding, SourceLocation } from "../edit-types.js"; +import { locatePatchRoot, type LocatedPatchRoot, unwrapExpression } from "./unwrap.js"; + +export type PropertyLookup = + | { kind: "property"; property: any; value: any } + | { kind: "missing" } + | { kind: "computed"; node: any; reason: string }; + +export type SourceAnalysis = Readonly<{ + mode: "static" | "program" | "unknown"; + diagnostic: string | null; + bindings: readonly SourceBinding[]; + editableStructure: Readonly<{ + oscillators: boolean; + filter: boolean; + cutoffLfo: boolean; + effects: boolean; + }>; +}>; + +export type InternalAnalysis = SourceAnalysis & { + file: File; + root: LocatedPatchRoot; + sourceObject: any | null; + oscillatorArray: any | null; + filterObject: any | null; + lfoObject: any | null; + envelopeObject: any | null; + effectsArray: any | null; + bindingNodes: Map< + string, + { parent: any | null; property: any | null; value: any | null; editable: boolean } + >; +}; + +export function pathKey(path: PatchFieldPath | readonly (string | number)[]): string { + return JSON.stringify(path); +} + +export function analyzeSource(file: File, canonical: any, fileLabel: string): InternalAnalysis { + const root = locatePatchRoot(file); + const bindings: SourceBinding[] = []; + const bindingNodes = new Map< + string, + { parent: any | null; property: any | null; value: any | null; editable: boolean } + >(); + if (!root.object) { + return { + file, + root, + mode: "unknown", + diagnostic: root.diagnostic, + bindings, + editableStructure: { oscillators: false, filter: false, cutoffLfo: false, effects: false }, + sourceObject: null, + oscillatorArray: null, + filterObject: null, + lfoObject: null, + envelopeObject: null, + effectsArray: null, + bindingNodes, + }; + } + + const sourceLookup = propertyFor(root.object, "source"); + const sourceObject = directObject(sourceLookup); + const oscLookup = sourceObject + ? propertyFor(sourceObject, "oscillators") + : computedLookup(sourceLookup); + const oscillatorArray = directArray(oscLookup); + const filterLookup = sourceObject + ? propertyFor(sourceObject, "filter") + : computedLookup(sourceLookup); + const filterObject = directObject(filterLookup); + const lfoLookup = filterObject + ? propertyFor(filterObject, "cutoffLfo") + : filterLookup.kind === "missing" + ? { kind: "missing" as const } + : computedLookup(filterLookup); + const lfoObject = directObject(lfoLookup); + const envelopeLookup = sourceObject + ? propertyFor(sourceObject, "ampEnvelope") + : computedLookup(sourceLookup); + const envelopeObject = directObject(envelopeLookup); + const effectsLookup = propertyFor(root.object, "effects"); + const effectsArray = directArray(effectsLookup); + + const add = ( + path: PatchFieldPath, + parent: any | null, + key: string, + control: PatchEditorControl, + runtimeValue: PatchScalar, + fallback?: PatchScalar, + inherited?: PropertyLookup, + ) => { + const lookup = + inherited ?? + (parent + ? propertyFor(parent, key) + : ({ kind: "computed", node: null, reason: "container is computed" } as const)); + const sourceForm = scalarSourceForm(lookup, runtimeValue, fallback); + const node = + lookup.kind === "property" ? lookup.value : lookup.kind === "computed" ? lookup.node : parent; + const binding: SourceBinding = { + path, + sourceForm, + location: location(node, fileLabel), + control, + }; + bindings.push(binding); + bindingNodes.set(pathKey(path), { + parent, + property: lookup.kind === "property" ? lookup.property : null, + value: lookup.kind === "property" ? lookup.value : null, + editable: sourceForm.kind !== "computed", + }); + }; + + add( + ["source", "frequencyHz"], + sourceObject, + "frequencyHz", + PATCH_EDITOR.controls.frequencyHz, + canonical.source.frequencyHz, + undefined, + sourceObject ? undefined : computedLookup(sourceLookup), + ); + add( + ["source", "gainDb"], + sourceObject, + "gainDb", + PATCH_EDITOR.controls.gainDb, + canonical.source.gainDb, + -12, + sourceObject ? undefined : computedLookup(sourceLookup), + ); + + const oscillatorElements = oscillatorArray?.elements ?? []; + canonical.source.oscillators.forEach((oscillator: any, index: number) => { + const element = unwrapExpression(oscillatorElements[index]); + const object = + element?.type === "ObjectExpression" && + objectIsDirect(element) && + discriminantValue(element, "waveform") === oscillator.waveform + ? element + : null; + const inherited: PropertyLookup | undefined = object + ? undefined + : { + kind: "computed", + node: element ?? oscillatorArray, + reason: "oscillator is not a direct object literal", + }; + if (oscillator.waveform !== "noise") { + add( + ["source", "oscillators", index, "transposeSemitones"], + object, + "transposeSemitones", + PATCH_EDITOR.controls.transposeSemitones, + oscillator.transposeSemitones, + 0, + inherited, + ); + add( + ["source", "oscillators", index, "detuneCents"], + object, + "detuneCents", + PATCH_EDITOR.controls.detuneCents, + oscillator.detuneCents, + 0, + inherited, + ); + } + if (oscillator.waveform === "pulse") + add( + ["source", "oscillators", index, "pulseWidth"], + object, + "pulseWidth", + PATCH_EDITOR.controls.pulseWidth, + oscillator.pulseWidth, + 0.5, + inherited, + ); + add( + ["source", "oscillators", index, "level"], + object, + "level", + PATCH_EDITOR.controls.level, + oscillator.level, + 1, + inherited, + ); + }); + + if (canonical.source.filter) { + const inherited = filterObject ? undefined : computedLookup(filterLookup); + add( + ["source", "filter", "mode"], + filterObject, + "mode", + PATCH_EDITOR.controls.filterMode, + canonical.source.filter.mode, + "lowpass", + inherited, + ); + add( + ["source", "filter", "cutoffHz"], + filterObject, + "cutoffHz", + PATCH_EDITOR.controls.cutoffHz, + canonical.source.filter.cutoffHz, + undefined, + inherited, + ); + add( + ["source", "filter", "resonance"], + filterObject, + "resonance", + PATCH_EDITOR.controls.resonance, + canonical.source.filter.resonance, + 0, + inherited, + ); + if (canonical.source.filter.cutoffLfo) { + const lfoInherited = lfoObject ? undefined : computedLookup(lfoLookup); + add( + ["source", "filter", "cutoffLfo", "shape"], + lfoObject, + "shape", + PATCH_EDITOR.controls.lfoShape, + canonical.source.filter.cutoffLfo.shape, + "sine", + lfoInherited, + ); + add( + ["source", "filter", "cutoffLfo", "rateHz"], + lfoObject, + "rateHz", + PATCH_EDITOR.controls.lfoRateHz, + canonical.source.filter.cutoffLfo.rateHz, + undefined, + lfoInherited, + ); + add( + ["source", "filter", "cutoffLfo", "amountOctaves"], + lfoObject, + "amountOctaves", + PATCH_EDITOR.controls.amountOctaves, + canonical.source.filter.cutoffLfo.amountOctaves, + undefined, + lfoInherited, + ); + } + } + + const envelopeInherited = envelopeObject + ? undefined + : envelopeLookup.kind === "missing" && sourceObject + ? undefined + : computedLookup(envelopeLookup); + add( + ["source", "ampEnvelope", "attackSeconds"], + envelopeObject, + "attackSeconds", + PATCH_EDITOR.controls.attackSeconds, + canonical.source.ampEnvelope.attackSeconds, + 0.005, + envelopeInherited, + ); + add( + ["source", "ampEnvelope", "decaySeconds"], + envelopeObject, + "decaySeconds", + PATCH_EDITOR.controls.decaySeconds, + canonical.source.ampEnvelope.decaySeconds, + 0, + envelopeInherited, + ); + add( + ["source", "ampEnvelope", "sustain"], + envelopeObject, + "sustain", + PATCH_EDITOR.controls.sustain, + canonical.source.ampEnvelope.sustain, + 1, + envelopeInherited, + ); + add( + ["source", "ampEnvelope", "releaseSeconds"], + envelopeObject, + "releaseSeconds", + PATCH_EDITOR.controls.releaseSeconds, + canonical.source.ampEnvelope.releaseSeconds, + 0.1, + envelopeInherited, + ); + + const effectElements = effectsArray?.elements ?? []; + canonical.effects.forEach((effect: any, index: number) => { + const element = unwrapExpression(effectElements[index]); + const object = + element?.type === "ObjectExpression" && + objectIsDirect(element) && + discriminantValue(element, "type") === effect.type + ? element + : null; + const inherited: PropertyLookup | undefined = object + ? undefined + : { + kind: "computed", + node: element ?? effectsArray, + reason: "effect is not a direct object literal", + }; + if (effect.type === "saturator") { + add( + ["effects", index, "driveDb"], + object, + "driveDb", + PATCH_EDITOR.controls.driveDb, + effect.driveDb, + undefined, + inherited, + ); + add( + ["effects", index, "outputGainDb"], + object, + "outputGainDb", + PATCH_EDITOR.controls.outputGainDb, + effect.outputGainDb, + 0, + inherited, + ); + add( + ["effects", index, "mix"], + object, + "mix", + { ...PATCH_EDITOR.controls.mix, defaultValue: 1 }, + effect.mix, + 1, + inherited, + ); + } else { + add( + ["effects", index, "timeSeconds"], + object, + "timeSeconds", + PATCH_EDITOR.controls.timeSeconds, + effect.timeSeconds, + undefined, + inherited, + ); + add( + ["effects", index, "feedback"], + object, + "feedback", + PATCH_EDITOR.controls.feedback, + effect.feedback, + 0, + inherited, + ); + add( + ["effects", index, "damping"], + object, + "damping", + PATCH_EDITOR.controls.damping, + effect.damping, + 0, + inherited, + ); + add( + ["effects", index, "pingPong"], + object, + "pingPong", + PATCH_EDITOR.controls.pingPong, + effect.pingPong, + false, + inherited, + ); + add( + ["effects", index, "mix"], + object, + "mix", + PATCH_EDITOR.controls.mix, + effect.mix, + undefined, + inherited, + ); + } + }); + + return { + file, + root, + mode: root.mode, + diagnostic: root.diagnostic, + bindings, + editableStructure: { + oscillators: Boolean( + oscillatorArray && + oscillatorElements.every((element: any) => { + const object = unwrapExpression(element); + return ( + object?.type === "ObjectExpression" && + objectIsDirect(object) && + ["sine", "triangle", "saw", "pulse", "noise"].includes( + discriminantValue(object, "waveform") ?? "", + ) + ); + }), + ), + filter: Boolean(sourceObject && (filterLookup.kind === "missing" || filterObject)), + cutoffLfo: Boolean(filterObject && (lfoLookup.kind === "missing" || lfoObject)), + effects: + effectsLookup.kind === "missing" || + Boolean( + effectsArray && + effectElements.every((element: any) => { + const object = unwrapExpression(element); + return ( + object?.type === "ObjectExpression" && + objectIsDirect(object) && + ["saturator", "stereoDelay"].includes(discriminantValue(object, "type") ?? "") + ); + }), + ), + }, + sourceObject, + oscillatorArray, + filterObject, + lfoObject, + envelopeObject, + effectsArray, + bindingNodes, + }; +} + +export function propertyFor(object: any, key: string): PropertyLookup { + if (!object || object.type !== "ObjectExpression") + return { kind: "computed", node: object, reason: "container is not an object literal" }; + if (!objectIsDirect(object)) + return { + kind: "computed", + node: object, + reason: "container uses a spread, computed key, accessor, or duplicate key", + }; + const matches = object.properties.filter( + (property: any) => property.type === "ObjectProperty" && propertyName(property) === key, + ); + if (matches.length === 0) return { kind: "missing" }; + const property = matches[0]; + return { kind: "property", property, value: unwrapExpression(property.value) }; +} + +export function objectIsDirect(object: any): boolean { + if (!object || object.type !== "ObjectExpression") return false; + const names = new Set(); + for (const property of object.properties) { + if (property.type !== "ObjectProperty" || property.computed || property.method) return false; + const name = propertyName(property); + if (name === null || names.has(name)) return false; + names.add(name); + } + return true; +} + +function discriminantValue(object: any, key: string): string | null { + const lookup = propertyFor(object, key); + return lookup.kind === "property" && lookup.value?.type === "StringLiteral" + ? lookup.value.value + : null; +} + +function propertyName(property: any): string | null { + if (property.computed) return null; + if (property.key?.type === "Identifier") return property.key.name; + if (property.key?.type === "StringLiteral") return property.key.value; + return null; +} +function directObject(lookup: PropertyLookup): any | null { + return lookup.kind === "property" && + lookup.value?.type === "ObjectExpression" && + objectIsDirect(lookup.value) + ? lookup.value + : null; +} +function directArray(lookup: PropertyLookup): any | null { + if (lookup.kind !== "property" || lookup.value?.type !== "ArrayExpression") return null; + return lookup.value.elements.every( + (element: any) => element !== null && element.type !== "SpreadElement", + ) + ? lookup.value + : null; +} +function computedLookup(lookup: PropertyLookup): PropertyLookup { + return lookup.kind === "computed" + ? lookup + : { + kind: "computed", + node: lookup.kind === "property" ? lookup.value : null, + reason: "container is not directly editable", + }; +} +function scalarSourceForm( + lookup: PropertyLookup, + runtimeValue: PatchScalar, + fallback?: PatchScalar, +): SourceBinding["sourceForm"] { + if (lookup.kind === "missing") { + return fallback === undefined + ? { + kind: "computed", + reason: "expression", + message: "This required value is not an inline literal.", + } + : { kind: "default", value: fallback, explicit: false }; + } + if (lookup.kind === "computed") + return { kind: "computed", reason: "expression", message: lookup.reason }; + const literal = readLiteral(lookup.value); + return literal.ok + ? { kind: "literal", value: literal.value, explicit: true } + : { + kind: "computed", + reason: "expression", + message: `Computed in code (${lookup.value?.type ?? "unknown expression"}).`, + }; +} +function readLiteral(node: any): { ok: true; value: PatchScalar } | { ok: false } { + const current = unwrapExpression(node); + if (current?.type === "NumericLiteral" && Number.isFinite(current.value)) + return { ok: true, value: current.value }; + if (current?.type === "StringLiteral") return { ok: true, value: current.value }; + if (current?.type === "BooleanLiteral") return { ok: true, value: current.value }; + if ( + current?.type === "UnaryExpression" && + ["-", "+"].includes(current.operator) && + current.argument?.type === "NumericLiteral" + ) { + const value = current.operator === "-" ? -current.argument.value : current.argument.value; + return Number.isFinite(value) ? { ok: true, value } : { ok: false }; + } + return { ok: false }; +} +function location(node: any, fileLabel: string): SourceLocation | null { + const start = node?.loc?.start; + return start ? { fileLabel, line: start.line, column: start.column + 1 } : null; +} diff --git a/packages/studio/src/source/apply-edit.ts b/packages/studio/src/source/apply-edit.ts new file mode 100644 index 0000000..9c22a11 --- /dev/null +++ b/packages/studio/src/source/apply-edit.ts @@ -0,0 +1,321 @@ +import { EFFECT_COUNT_MAX, OSCILLATOR_COUNT_MAX } from "@patchwave/schema"; +import * as recast from "recast"; +import type { PatchEditOperation, PatchFieldPath, PatchScalar } from "../edit-types.js"; +import { analyzeSource, pathKey, propertyFor, type InternalAnalysis } from "./analyze.js"; +import { parseSource, printSource } from "./parse.js"; + +const b = recast.types.builders; +const PROPERTY_ORDER = [ + "source", + "effects", + "frequencyHz", + "gainDb", + "oscillators", + "filter", + "ampEnvelope", + "waveform", + "transposeSemitones", + "detuneCents", + "pulseWidth", + "level", + "mode", + "cutoffHz", + "resonance", + "cutoffLfo", + "shape", + "rateHz", + "amountOctaves", + "attackSeconds", + "decaySeconds", + "sustain", + "releaseSeconds", + "type", + "driveDb", + "outputGainDb", + "timeSeconds", + "feedback", + "damping", + "pingPong", + "mix", +] as const; + +export function applySourceEdit( + source: string, + canonical: any, + fileLabel: string, + operation: PatchEditOperation, +): string { + const file = parseSource(source); + const analysis = analyzeSource(file, canonical, fileLabel); + if (!analysis.root.object) + throw new Error(analysis.diagnostic ?? "Source is read-only in Studio"); + applyOperation(analysis, operation); + const output = printSource(file); + parseSource(output); + if (output === source) throw new Error("Edit did not change the source file"); + return output; +} + +function applyOperation(analysis: InternalAnalysis, operation: PatchEditOperation): void { + if (operation.type.endsWith("Oscillator") && !analysis.editableStructure.oscillators) { + throw new Error("Oscillator structure is computed in code"); + } + if ( + ["addFilter", "removeFilter"].includes(operation.type) && + !analysis.editableStructure.filter + ) { + throw new Error("Filter structure is computed in code"); + } + if ( + ["addCutoffLfo", "removeCutoffLfo"].includes(operation.type) && + !analysis.editableStructure.cutoffLfo + ) { + throw new Error("Cutoff LFO structure is computed in code"); + } + if (operation.type.endsWith("Effect") && !analysis.editableStructure.effects) { + throw new Error("Effect structure is computed in code"); + } + switch (operation.type) { + case "setField": + setField(analysis, operation.path, operation.value); + return; + case "resetField": + resetField(analysis, operation.path); + return; + case "addOscillator": { + const array = requireArray(analysis.oscillatorArray, "Oscillator array is computed"); + if (array.elements.length >= OSCILLATOR_COUNT_MAX) + throw new Error("Patch already has four oscillators"); + if (operation.index > array.elements.length) + throw new Error("Oscillator insertion index is out of range"); + array.elements.splice(operation.index, 0, oscillatorTemplate(operation.waveform)); + return; + } + case "removeOscillator": { + const array = requireArray(analysis.oscillatorArray, "Oscillator array is computed"); + if (array.elements.length <= 1) throw new Error("A patch must keep at least one oscillator"); + requireExisting(array.elements, operation.index, "oscillator"); + array.elements.splice(operation.index, 1); + return; + } + case "moveOscillator": + moveInArray( + requireArray(analysis.oscillatorArray, "Oscillator array is computed"), + operation.from, + operation.to, + "oscillator", + ); + return; + case "replaceOscillator": { + const array = requireArray(analysis.oscillatorArray, "Oscillator array is computed"); + const old = requireExisting(array.elements, operation.index, "oscillator"); + const replacement = oscillatorTemplate(operation.waveform); + retainProperties( + old, + replacement, + operation.waveform === "noise" ? ["level"] : ["transposeSemitones", "detuneCents", "level"], + ); + array.elements[operation.index] = replacement; + return; + } + case "addFilter": { + const source = requireObject(analysis.sourceObject, "Source object is computed"); + if (propertyFor(source, "filter").kind !== "missing") + throw new Error("Filter already exists or is computed"); + addProperty(source, "filter", objectTemplate("{ cutoffHz: 1200 }")); + return; + } + case "removeFilter": + removeProperty( + requireObject(analysis.sourceObject, "Source object is computed"), + "filter", + true, + ); + return; + case "addCutoffLfo": { + const filter = requireObject( + analysis.filterObject, + "Add a direct filter before adding its LFO", + ); + if (propertyFor(filter, "cutoffLfo").kind !== "missing") + throw new Error("Cutoff LFO already exists or is computed"); + addProperty(filter, "cutoffLfo", objectTemplate("{ rateHz: 1, amountOctaves: 1 }")); + return; + } + case "removeCutoffLfo": + removeProperty( + requireObject(analysis.filterObject, "Filter object is computed"), + "cutoffLfo", + true, + ); + return; + case "addEffect": { + let array = analysis.effectsArray; + if (!array) { + if (propertyFor(analysis.root.object, "effects").kind !== "missing") + throw new Error("Effects are computed"); + array = b.arrayExpression([]); + addProperty(analysis.root.object, "effects", array); + } + if (array.elements.length >= EFFECT_COUNT_MAX) + throw new Error("Patch already has seven effects"); + if (operation.index > array.elements.length) + throw new Error("Effect insertion index is out of range"); + array.elements.splice(operation.index, 0, effectTemplate(operation.effectType)); + return; + } + case "removeEffect": { + const array = requireArray(analysis.effectsArray, "Effects are computed or absent"); + requireExisting(array.elements, operation.index, "effect"); + array.elements.splice(operation.index, 1); + if (array.elements.length === 0) removeProperty(analysis.root.object, "effects", true); + return; + } + case "moveEffect": + moveInArray( + requireArray(analysis.effectsArray, "Effects are computed"), + operation.from, + operation.to, + "effect", + ); + return; + case "replaceEffect": { + const array = requireArray(analysis.effectsArray, "Effects are computed"); + requireExisting(array.elements, operation.index, "effect"); + array.elements[operation.index] = effectTemplate(operation.effectType); + return; + } + } +} + +function setField(analysis: InternalAnalysis, path: PatchFieldPath, value: PatchScalar): void { + const binding = analysis.bindings.find((item) => pathKey(item.path) === pathKey(path)); + if (!binding || binding.sourceForm.kind === "computed") + throw new Error("This field is computed in code and cannot be edited"); + validateControlValue(binding.control, value); + let node = analysis.bindingNodes.get(pathKey(path)); + if (!node) throw new Error("Field binding is unavailable"); + let parent = node.parent; + if (!parent && path[0] === "source" && path[1] === "ampEnvelope") { + const source = requireObject(analysis.sourceObject, "Source object is computed"); + const envelope = b.objectExpression([]); + addProperty(source, "ampEnvelope", envelope); + parent = envelope; + } + parent = requireObject(parent, "Containing object is computed"); + const key = String(path.at(-1)); + const literal = scalarNode(value); + if (node.property) node.property.value = literal; + else addProperty(parent, key, literal); +} + +function resetField(analysis: InternalAnalysis, path: PatchFieldPath): void { + const binding = analysis.bindings.find((item) => pathKey(item.path) === pathKey(path)); + if (!binding || binding.sourceForm.kind === "computed") + throw new Error("This field is computed in code and cannot be reset"); + if (binding.control.defaultValue === undefined) + throw new Error("This required field has no default"); + const node = analysis.bindingNodes.get(pathKey(path)); + if (!node?.property || !node.parent) throw new Error("Field already uses its default"); + removeProperty(node.parent, String(path.at(-1)), true); + if (path[0] === "source" && path[1] === "ampEnvelope" && node.parent.properties.length === 0) { + removeProperty( + requireObject(analysis.sourceObject, "Source object is computed"), + "ampEnvelope", + true, + ); + } +} + +function validateControlValue(control: any, value: PatchScalar): void { + if (control.kind === "number") { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < control.min || + value > control.max || + (control.integer && !Number.isInteger(value)) + ) + throw new Error(`${control.label} is outside its supported range`); + return; + } + if (control.kind === "boolean") { + if (typeof value !== "boolean") throw new Error(`${control.label} must be a boolean`); + return; + } + if (typeof value !== "string" || !control.values.includes(value)) + throw new Error(`${control.label} is unsupported`); +} +function scalarNode(value: PatchScalar): any { + if (typeof value === "number") return b.numericLiteral(value); + if (typeof value === "boolean") return b.booleanLiteral(value); + return b.stringLiteral(value); +} +function oscillatorTemplate(waveform: string): any { + return objectTemplate(`{ waveform: ${JSON.stringify(waveform)} }`); +} +function effectTemplate(type: "saturator" | "stereoDelay"): any { + return type === "saturator" + ? objectTemplate('{ type: "saturator", driveDb: 6 }') + : objectTemplate('{ type: "stereoDelay", timeSeconds: 0.25, mix: 0.25 }'); +} +function objectTemplate(source: string): any { + const file = parseSource(`const value = ${source};\n`); + const statement = file.program.body[0] as any; + const value = statement?.declarations?.[0]?.init; + if (value?.type !== "ObjectExpression") throw new Error("Invalid Studio object template"); + return value; +} +function retainProperties(oldNode: any, newNode: any, keys: string[]): void { + if (oldNode?.type !== "ObjectExpression") return; + for (const key of keys) { + const lookup = propertyFor(oldNode, key); + if (lookup.kind === "property") newNode.properties.push(lookup.property); + } +} +function moveInArray(array: any, from: number, to: number, label: string): void { + if (from === to) throw new Error(`${label} is already at that position`); + const item = requireExisting(array.elements, from, label); + if (to < 0 || to >= array.elements.length) + throw new Error(`${label} destination is out of range`); + array.elements.splice(from, 1); + array.elements.splice(to, 0, item); +} +function addProperty(object: any, key: string, value: any): void { + const property = b.objectProperty(b.identifier(key), value); + const rank = PROPERTY_ORDER.indexOf(key as (typeof PROPERTY_ORDER)[number]); + const index = object.properties.findIndex((candidate: any) => { + const candidateKey = + candidate?.key?.type === "Identifier" + ? candidate.key.name + : candidate?.key?.type === "StringLiteral" + ? candidate.key.value + : ""; + const candidateRank = PROPERTY_ORDER.indexOf(candidateKey as (typeof PROPERTY_ORDER)[number]); + return rank >= 0 && candidateRank > rank; + }); + if (index === -1) object.properties.push(property); + else object.properties.splice(index, 0, property); +} +function removeProperty(object: any, key: string, required: boolean): void { + const lookup = propertyFor(object, key); + if (lookup.kind !== "property") { + if (required) throw new Error(`${key} is absent or computed`); + return; + } + const index = object.properties.indexOf(lookup.property); + object.properties.splice(index, 1); +} +function requireObject(value: any, message: string): any { + if (!value || value.type !== "ObjectExpression") throw new Error(message); + return value; +} +function requireArray(value: any, message: string): any { + if (!value || value.type !== "ArrayExpression") throw new Error(message); + return value; +} +function requireExisting(values: any[], index: number, label: string): any { + const value = values[index]; + if (!value) throw new Error(`${label} index is out of range`); + return value; +} diff --git a/packages/studio/src/source/index.ts b/packages/studio/src/source/index.ts new file mode 100644 index 0000000..564dcac --- /dev/null +++ b/packages/studio/src/source/index.ts @@ -0,0 +1,3 @@ +export { analyzeSource, pathKey, type InternalAnalysis, type SourceAnalysis } from "./analyze.js"; +export { applySourceEdit } from "./apply-edit.js"; +export { parseSource, printSource } from "./parse.js"; diff --git a/packages/studio/src/source/parse.ts b/packages/studio/src/source/parse.ts new file mode 100644 index 0000000..a81bf40 --- /dev/null +++ b/packages/studio/src/source/parse.ts @@ -0,0 +1,11 @@ +import type { File } from "@babel/types"; +import * as recast from "recast"; +import * as babelTsParser from "recast/parsers/babel-ts.js"; + +export function parseSource(source: string): File { + return recast.parse(source, { parser: babelTsParser }) as File; +} + +export function printSource(file: File): string { + return recast.print(file, { parser: babelTsParser, trailingComma: true }).code; +} diff --git a/packages/studio/src/source/unwrap.ts b/packages/studio/src/source/unwrap.ts new file mode 100644 index 0000000..2322d47 --- /dev/null +++ b/packages/studio/src/source/unwrap.ts @@ -0,0 +1,93 @@ +import type { File } from "@babel/types"; + +export type LocatedPatchRoot = + | { mode: "static" | "program"; object: any; diagnostic: null } + | { mode: "unknown"; object: null; diagnostic: string }; + +export function unwrapExpression(node: any): any { + let current = node; + while ( + current && + [ + "TSAsExpression", + "TSSatisfiesExpression", + "TypeCastExpression", + "ParenthesizedExpression", + "TSNonNullExpression", + ].includes(current.type) + ) { + current = current.expression; + } + return current; +} + +export function locatePatchRoot(file: File): LocatedPatchRoot { + const exports = file.program.body.filter( + (statement: any) => statement.type === "ExportDefaultDeclaration", + ) as any[]; + if (exports.length !== 1) + return { + mode: "unknown", + object: null, + diagnostic: "Studio requires one direct default export.", + }; + const exported = unwrapExpression(exports[0].declaration); + if (exported?.type === "ObjectExpression") + return { mode: "static", object: exported, diagnostic: null }; + if ( + exported?.type !== "ArrowFunctionExpression" && + exported?.type !== "FunctionExpression" && + exported?.type !== "FunctionDeclaration" + ) { + return { + mode: "unknown", + object: null, + diagnostic: + "The default export is indirect or computed; playback works, but Studio will not rewrite it.", + }; + } + if (exported.body.type !== "BlockStatement") { + const object = unwrapExpression(exported.body); + return object?.type === "ObjectExpression" + ? { mode: "program", object, diagnostic: null } + : { mode: "unknown", object: null, diagnostic: "The PatchProgram return value is computed." }; + } + const returns: any[] = []; + collectReturns(exported.body, returns); + if (returns.length !== 1 || !exported.body.body.includes(returns[0])) { + return { + mode: "unknown", + object: null, + diagnostic: "The PatchProgram must have one direct function-scope return to be editable.", + }; + } + const object = unwrapExpression(returns[0].argument); + return object?.type === "ObjectExpression" + ? { mode: "program", object, diagnostic: null } + : { + mode: "unknown", + object: null, + diagnostic: "The PatchProgram return object is indirect or computed.", + }; +} + +function collectReturns(node: any, output: any[]): void { + if (!node || typeof node !== "object") return; + if (node.type === "ReturnStatement") { + output.push(node); + return; + } + if ( + ["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"].includes(node.type) + ) { + return; + } + for (const [key, value] of Object.entries(node)) { + if (["loc", "tokens", "comments", "extra"].includes(key)) continue; + if (Array.isArray(value)) { + for (const child of value) collectReturns(child, output); + } else { + collectReturns(value, output); + } + } +} diff --git a/packages/studio/test/browser-app.test.ts b/packages/studio/test/browser-app.test.ts index a58f724..42b1f94 100644 --- a/packages/studio/test/browser-app.test.ts +++ b/packages/studio/test/browser-app.test.ts @@ -1,173 +1,23 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -import { runInNewContext } from "node:vm"; import test from "node:test"; -class FakeElement { - textContent = ""; - className = ""; - dataset: Record = {}; - children: FakeElement[] = []; - classList = { - add: (..._names: string[]) => undefined, - remove: (..._names: string[]) => undefined, - toggle: (..._values: unknown[]) => false, - }; - append(...children: FakeElement[]): void { - this.children.push(...children); - } - replaceChildren(...children: FakeElement[]): void { - this.children = children; - } -} - -class FakeWebSocket { - static readonly OPEN = 1; - static instances: FakeWebSocket[] = []; - readyState = FakeWebSocket.OPEN; - sent: unknown[] = []; - listeners = new Map void>(); - - constructor(_url: URL) { - FakeWebSocket.instances.push(this); - } - addEventListener(type: string, listener: (event?: any) => void): void { - this.listeners.set(type, listener); - } - send(serialized: string): void { - this.sent.push(JSON.parse(serialized)); - } - close(): void {} - emit(type: string, event?: any): void { - this.listeners.get(type)?.(event); - } -} - -test("browser shortcuts do not play mapped notes while keyup and pagehide still release", async () => { - FakeWebSocket.instances = []; - const elements = new Map(); - const windowListeners = new Map void>(); - const documentListeners = new Map void>(); - const timers = new Map void>(); - const clearedTimers: number[] = []; - let nextTimer = 1; - const body = new FakeElement(); - const document = { - body, - hidden: false, - querySelector(selector: string) { - let element = elements.get(selector); - if (!element) { - element = new FakeElement(); - elements.set(selector, element); - } - return element; - }, - createElement: () => new FakeElement(), - addEventListener(type: string, listener: (event: any) => void) { - documentListeners.set(type, listener); - }, - }; - const window = { - location: { href: "http://127.0.0.1:1234/session/token/" }, - addEventListener(type: string, listener: (event: any) => void) { - windowListeners.set(type, listener); - }, - }; - const source = await readFile(new URL("../public/app.js", import.meta.url), "utf8"); - runInNewContext(source, { - URL, - WebSocket: FakeWebSocket, - clearTimeout(timer: number) { - timers.delete(timer); - clearedTimers.push(timer); - }, - console, - document, - setTimeout(callback: () => void) { - const timer = nextTimer++; - timers.set(timer, callback); - return timer; - }, - window, - }); - - const socket = FakeWebSocket.instances[0]; - let prevented = 0; - windowListeners.get("keydown")!({ - code: "KeyA", - ctrlKey: true, - metaKey: false, - altKey: false, - repeat: false, - preventDefault: () => { - prevented += 1; - }, - }); - assert.deepEqual(socket.sent, []); - assert.equal(prevented, 0); - - windowListeners.get("keydown")!({ - code: "KeyA", - ctrlKey: false, - metaKey: false, - altKey: false, - repeat: false, - preventDefault: () => { - prevented += 1; - }, - }); - windowListeners.get("keyup")!({ - code: "KeyA", - ctrlKey: true, - metaKey: false, - altKey: false, - preventDefault: () => { - prevented += 1; - }, - }); - assert.deepEqual(socket.sent, [ - { type: "keyDown", code: "KeyA" }, - { type: "keyUp", code: "KeyA" }, - ]); - - const stateMessage = { - data: JSON.stringify({ - type: "state", - state: { - patch: { - source: { - frequencyHz: 440, - gainDb: -12, - oscillators: [{ waveform: "saw", transposeSemitones: 0, detuneCents: 0, level: 1 }], - ampEnvelope: { - attackSeconds: 0.01, - decaySeconds: 0.1, - sustain: 0.8, - releaseSeconds: 0.2, - }, - }, - effects: [], - }, - }, - }), - }; - socket.emit("message", stateMessage); - assert.equal(timers.size, 1); - const [chainTimerId, renderChain] = timers.entries().next().value!; - timers.delete(chainTimerId); - renderChain(); - const sourceValues = elements.get("#chain")!.children[0].children[2].textContent; - assert.match(sourceValues, /440 Hz source frequency/); - - socket.emit("message", stateMessage); - assert.equal(timers.size, 1); - windowListeners.get("pagehide")!({}); - assert.deepEqual(socket.sent.at(-1), { type: "releaseAll" }); - assert.equal(timers.size, 0); - assert.equal(clearedTimers.length, 1); - assert.ok(documentListeners.has("visibilitychange")); +test("React Studio preserves keyboard safety and gesture-end source commits", async () => { + const source = await readFile(new URL("../client/src/main.tsx", import.meta.url), "utf8"); + assert.match(source, /shouldHandlePerformanceKey/); + assert.match(source, /type: "keyUp"/); + assert.match(source, /visibilitychange/); + assert.match(source, /pagehide/); + assert.match(source, /type: "preview"/); + assert.match(source, /onPointerUp=\{commit\}/); + assert.match(source, /application\/x-patchwave-block/); + assert.match(source, /type: "setField"/); + assert.match(source, /type: "cancelPreview"/); +}); - const html = await readFile(new URL("../public/index.html", import.meta.url), "utf8"); - assert.match(html, /Keyboard \/ controller input/); +test("Vite emits a capability-relative production shell without inline application code", async () => { + const html = await readFile(new URL("../dist/client/index.html", import.meta.url), "utf8"); + assert.match(html, /Patchwave Studio/); + assert.match(html, /\.\/assets\/index-/); + assert.doesNotMatch(html, /src\/main\.tsx/); }); diff --git a/packages/studio/test/protocol.test.ts b/packages/studio/test/protocol.test.ts new file mode 100644 index 0000000..d77457e --- /dev/null +++ b/packages/studio/test/protocol.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseStudioInput } from "../src/index.js"; + +const revision = "a".repeat(64); + +test("accepts closed semantic edit messages", () => { + assert.deepEqual( + parseStudioInput( + JSON.stringify({ + type: "preview", + protocol: 1, + requestId: "r", + gestureId: "g", + baseRevision: revision, + path: ["source", "filter", "resonance"], + value: 0.5, + }), + ), + { + type: "preview", + protocol: 1, + requestId: "r", + gestureId: "g", + baseRevision: revision, + path: ["source", "filter", "resonance"], + value: 0.5, + }, + ); + assert.equal( + parseStudioInput( + JSON.stringify({ + type: "commit", + protocol: 1, + requestId: "r", + gestureId: null, + baseRevision: revision, + operation: { type: "addEffect", index: 0, effectType: "saturator" }, + }), + )?.type, + "commit", + ); +}); + +test("rejects paths, source text, versions, extra keys, nonfinite and invalid indices", () => { + const valid = { + type: "commit", + protocol: 1, + requestId: "r", + gestureId: null, + baseRevision: revision, + operation: { type: "setField", path: ["source", "gainDb"], value: -12 }, + }; + assert.equal(parseStudioInput(JSON.stringify({ ...valid, filePath: "/tmp/a.ts" })), undefined); + assert.equal(parseStudioInput(JSON.stringify({ ...valid, protocol: 2 })), undefined); + assert.equal( + parseStudioInput( + JSON.stringify({ ...valid, operation: { ...valid.operation, source: "evil" } }), + ), + undefined, + ); + assert.equal( + parseStudioInput(JSON.stringify({ ...valid, operation: { type: "removeEffect", index: 99 } })), + undefined, + ); + assert.equal(parseStudioInput('{"type":"preview","value":1e999}'), undefined); +}); + +test("rejects cross-device field names in semantic paths", () => { + for (const path of [ + ["source", "oscillators", 0, "gainDb"], + ["source", "oscillators", 0, "frequencyHz"], + ["source", "oscillators", 0, "attackSeconds"], + ["source", "oscillators", 0, "driveDb"], + ["effects", 0, "detuneCents"], + ["effects", 0, "cutoffHz"], + ]) { + const input = { + type: "commit", + protocol: 1, + requestId: "r", + gestureId: null, + baseRevision: revision, + operation: { type: "setField", path, value: 0 }, + }; + assert.equal(parseStudioInput(JSON.stringify(input)), undefined); + } +}); diff --git a/packages/studio/test/server.test.ts b/packages/studio/test/server.test.ts index 01b5f3f..2ee8005 100644 --- a/packages/studio/test/server.test.ts +++ b/packages/studio/test/server.test.ts @@ -39,6 +39,13 @@ test("serves capability-scoped assets with restrictive headers", async () => { assert.equal(response.status, 200); assert.match(response.headers.get("content-security-policy") ?? "", /default-src 'none'/); assert.equal(response.headers.get("referrer-policy"), "no-referrer"); + const html = await response.text(); + const script = html.match(/src="(\.\/assets\/[^"]+\.js)"/)?.[1]; + assert.ok(script); + const scriptResponse = await fetch(new URL(script, server.url)); + assert.equal(scriptResponse.status, 200); + assert.match(scriptResponse.headers.get("content-type") ?? "", /javascript/); + assert.equal((await fetch(new URL(".vite/manifest.json", server.url))).status, 404); assert.equal((await fetch(new URL("missing", server.url))).status, 404); } finally { await server.close(); @@ -108,7 +115,9 @@ test("accepts one strict same-origin controller and releases on malformed input" const inputs: StudioInput[] = []; let releases = 0; const server = await startStudioServer({ - onInput: (input) => inputs.push(input), + onInput: (input) => { + inputs.push(input); + }, onControllerClosed: () => { releases += 1; }, @@ -134,7 +143,9 @@ test("malformed then valid pipelined messages cannot reacquire released control" const inputs: StudioInput[] = []; let releases = 0; const server = await startStudioServer({ - onInput: (input) => inputs.push(input), + onInput: (input) => { + inputs.push(input); + }, onControllerClosed: () => { releases += 1; }, @@ -157,7 +168,9 @@ test("rejects unknown key codes, binary messages, and oversized payloads", async const inputs: StudioInput[] = []; let releases = 0; const server = await startStudioServer({ - onInput: (input) => inputs.push(input), + onInput: (input) => { + inputs.push(input); + }, onControllerClosed: () => { releases += 1; }, @@ -224,3 +237,181 @@ test("rejects a cross-origin WebSocket upgrade", async () => { await server.close(); } }); + +test("sends reliable edit acknowledgements independently of coalesced runtime state", async () => { + const revision = "a".repeat(64); + const server = await startStudioServer({ + onInput: (input) => + input.type === "undo" + ? { + type: "editResult" as const, + protocol: 1 as const, + requestId: input.requestId, + status: "rejected" as const, + revision, + message: "Nothing to undo", + } + : undefined, + onControllerClosed: () => undefined, + getState: () => ({ frame: 0 }), + }); + const socket = await openSocket(server.url); + const messages: any[] = []; + socket.on("message", (data) => messages.push(JSON.parse(data.toString()))); + try { + socket.send( + JSON.stringify({ type: "undo", protocol: 1, requestId: "undo-1", baseRevision: revision }), + ); + for ( + let attempt = 0; + attempt < 30 && !messages.some((message) => message.type === "editResult"); + attempt += 1 + ) + await new Promise((resolve) => setTimeout(resolve, 2)); + assert.deepEqual( + messages.find((message) => message.type === "editResult"), + { + type: "editResult", + protocol: 1, + requestId: "undo-1", + status: "rejected", + revision, + message: "Nothing to undo", + }, + ); + } finally { + socket.close(); + await server.close(); + } +}); + +test("malformed edit followed by a valid commit cannot reach write authority", async () => { + const inputs: StudioInput[] = []; + const revision = "a".repeat(64); + const server = await startStudioServer({ + onInput: (input) => { + inputs.push(input); + }, + onControllerClosed: () => undefined, + getState: () => ({}), + }); + try { + const socket = await openSocket(server.url); + const closePromise = closed(socket); + socket.send( + JSON.stringify({ + type: "commit", + protocol: 1, + requestId: "bad", + gestureId: null, + baseRevision: revision, + operation: { type: "setField", path: ["source", "gainDb"], value: -12 }, + source: "export default evil", + }), + ); + socket.send( + JSON.stringify({ + type: "commit", + protocol: 1, + requestId: "good", + gestureId: null, + baseRevision: revision, + operation: { type: "setField", path: ["source", "gainDb"], value: -12 }, + }), + ); + await closePromise; + assert.deepEqual(inputs, []); + } finally { + await server.close(); + } +}); + +test("synchronous input and cleanup throws fail closed before pipelined input", async () => { + let releases = 0; + const inputs: StudioInput[] = []; + const server = await startStudioServer({ + onInput: (input) => { + inputs.push(input); + if (inputs.length === 1) throw new Error("sync boom"); + }, + onControllerClosed: () => { + releases += 1; + throw new Error("cleanup boom"); + }, + getState: () => ({ ready: true }), + }); + try { + const first = await openSocket(server.url); + const firstClosed = closed(first); + first.send(JSON.stringify({ type: "releaseAll" })); + first.send(JSON.stringify({ type: "keyDown", code: "KeyA" })); + await firstClosed; + assert.equal(releases, 1); + assert.deepEqual(inputs, [{ type: "releaseAll" }]); + const replacement = await openSocket(server.url); + const replacementClosed = closed(replacement); + server.disconnect(); + await replacementClosed; + assert.equal(releases, 2); + } finally { + await server.close(); + } +}); + +test("oversized or unserializable outbound state releases the controller", async () => { + let state: unknown = { ready: true }; + let releases = 0; + const server = await startStudioServer({ + onInput: () => ({ type: "state", state }) as const, + onControllerClosed: () => { + releases += 1; + }, + getState: () => ({ ready: true }), + }); + try { + const oversized = await openSocket(server.url); + const oversizedClosed = closed(oversized); + state = { text: "x".repeat(70_000) }; + oversized.send(JSON.stringify({ type: "releaseAll" })); + await oversizedClosed; + assert.equal(releases, 1); + + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + const second = await openSocket(server.url); + const secondClosed = closed(second); + state = cyclic; + second.send(JSON.stringify({ type: "releaseAll" })); + await secondClosed; + assert.equal(releases, 2); + } finally { + await server.close(); + } +}); + +test("unserializable initial state releases authority and allows a replacement", async () => { + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + let state: unknown = cyclic; + let releases = 0; + const server = await startStudioServer({ + onInput: () => undefined, + onControllerClosed: () => { + releases += 1; + }, + getState: () => state, + }); + try { + const target = new URL("socket", server.url); + const first = new WebSocket(target, { origin: new URL(server.url).origin }); + await closed(first); + assert.equal(releases, 1); + state = { ready: true }; + const replacement = await openSocket(server.url); + server.disconnect(); + await closed(replacement); + assert.equal(releases, 2); + } finally { + await server.close(); + } +}); diff --git a/packages/studio/test/source-analyze.test.ts b/packages/studio/test/source-analyze.test.ts new file mode 100644 index 0000000..77cc7ae --- /dev/null +++ b/packages/studio/test/source-analyze.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validatePatch } from "@patchwave/schema"; +import { analyzeSource, parseSource } from "../src/index.js"; + +const canonical = validatePatch({ + source: { + frequencyHz: 110, + oscillators: [{ waveform: "saw", level: 0.5 }], + filter: { cutoffHz: 1200, resonance: 0.45 }, + }, + effects: [{ type: "saturator", driveDb: 8 }], +}); + +function binding(analysis: ReturnType, suffix: string) { + return analysis.bindings.find((item) => item.path.join(".").endsWith(suffix)); +} + +test("classifies static literals and omitted defaults", () => { + const analysis = analyzeSource( + parseSource(`export default { + source: {frequencyHz: 110, oscillators: [{waveform: "saw", level: 0.5}], filter: {cutoffHz: 1200, resonance: 0.45}}, + effects: [{type: "saturator", driveDb: 8}], + } satisfies Patch;`), + canonical, + "sound.ts", + ); + assert.equal(analysis.mode, "static"); + assert.equal(binding(analysis, "source.frequencyHz")?.sourceForm.kind, "literal"); + assert.equal(binding(analysis, "source.gainDb")?.sourceForm.kind, "default"); + assert.equal(binding(analysis, "source.filter.resonance")?.sourceForm.kind, "literal"); + assert.equal(analysis.editableStructure.effects, true); +}); + +test("edits inline program literals while expressions remain computed", () => { + const analysis = analyzeSource( + parseSource(`export default (({voice, timeSeconds}) => { + const movement = Math.sin(timeSeconds); + return {source: {frequencyHz: voice.frequencyHz, oscillators: [{waveform: "saw", level: 0.5}], filter: {cutoffHz: 200 + movement * 2400, resonance: 0.45}}, effects: [{type: "saturator", driveDb: 8}]}; + }) satisfies PatchProgram;`), + canonical, + "program.ts", + ); + assert.equal(analysis.mode, "program"); + assert.equal(binding(analysis, "source.frequencyHz")?.sourceForm.kind, "computed"); + assert.equal(binding(analysis, "source.filter.cutoffHz")?.sourceForm.kind, "computed"); + assert.equal(binding(analysis, "source.filter.resonance")?.sourceForm.kind, "literal"); +}); + +test("unsupported export and spread containers fail closed", () => { + const indirect = analyzeSource( + parseSource(`const patch = {}; export default patch;`), + canonical, + "sound.ts", + ); + assert.equal(indirect.mode, "unknown"); + assert.equal(indirect.bindings.length, 0); + const spread = analyzeSource( + parseSource( + `export default {source: {...base, frequencyHz: 110, oscillators: [{waveform: "saw"}]}};`, + ), + canonical, + "sound.ts", + ); + assert.equal(binding(spread, "source.frequencyHz")?.sourceForm.kind, "computed"); + assert.equal(spread.editableStructure.oscillators, false); + const conditional = analyzeSource( + parseSource( + `export default (() => { if (flag) return other; return {source: {frequencyHz: 110, oscillators: [{waveform: "saw"}]}}; });`, + ), + canonical, + "sound.ts", + ); + assert.equal(conditional.mode, "unknown"); + const dynamicDiscriminant = analyzeSource( + parseSource( + `export default {source: {frequencyHz: 110, oscillators: [{waveform, level: 0.5}]}};`, + ), + canonical, + "sound.ts", + ); + assert.equal(binding(dynamicDiscriminant, "oscillators.0.level")?.sourceForm.kind, "computed"); + assert.equal(dynamicDiscriminant.editableStructure.oscillators, false); +}); + +test("accepts named and anonymous direct default function PatchPrograms", () => { + for (const declaration of ["export default function program()", "export default function ()"]) { + const analysis = analyzeSource( + parseSource( + `${declaration} { return {source: {frequencyHz: 110, oscillators: [{waveform: \"saw\", level: 0.5}], filter: {cutoffHz: 1200, resonance: 0.45}}, effects: [{type: \"saturator\", driveDb: 8}]}; }`, + ), + canonical, + "program.ts", + ); + assert.equal(analysis.mode, "program"); + assert.equal(binding(analysis, "source.filter.resonance")?.sourceForm.kind, "literal"); + } +}); diff --git a/packages/studio/test/source-edit.test.ts b/packages/studio/test/source-edit.test.ts new file mode 100644 index 0000000..b5d9ce0 --- /dev/null +++ b/packages/studio/test/source-edit.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validatePatch } from "@patchwave/schema"; +import { applySourceEdit, parseSource } from "../src/index.js"; + +const source = `import type {Patch} from "@patchwave/schema"; +// Keep this comment. +export default { + source: { + frequencyHz: 110, + gainDb: -12, + oscillators: [{waveform: "saw", level: 0.5}], + }, + effects: [{type: "saturator", driveDb: 8}], +} satisfies Patch; +`; +const canonical = validatePatch({ + source: { frequencyHz: 110, gainDb: -12, oscillators: [{ waveform: "saw", level: 0.5 }] }, + effects: [{ type: "saturator", driveDb: 8 }], +}); + +test("changes one literal and preserves surrounding source", () => { + const output = applySourceEdit(source, canonical, "sound.ts", { + type: "setField", + path: ["source", "gainDb"], + value: -18, + }); + assert.match(output, /gainDb: -18/); + assert.match(output, /Keep this comment/); + assert.match(output, /frequencyHz: 110/); + parseSource(output); +}); + +test("reset removes optional author property instead of writing canonical absence", () => { + const output = applySourceEdit(source, canonical, "sound.ts", { + type: "resetField", + path: ["source", "gainDb"], + }); + assert.doesNotMatch(output, /gainDb/); + assert.doesNotMatch(output, /null/); +}); + +test("adds, moves, replaces, and removes direct blocks", () => { + const withOsc = applySourceEdit(source, canonical, "sound.ts", { + type: "addOscillator", + index: 1, + waveform: "pulse", + }); + assert.match(withOsc, /waveform: "pulse"/); + const withFilter = applySourceEdit(source, canonical, "sound.ts", { type: "addFilter" }); + assert.match(withFilter, /filter:/); + assert.match(withFilter, /cutoffHz: 1200/); + const withDelay = applySourceEdit(source, canonical, "sound.ts", { + type: "addEffect", + index: 1, + effectType: "stereoDelay", + }); + assert.match(withDelay, /type: "stereoDelay"/); + assert.match(withDelay, /timeSeconds: 0.25/); + assert.match(withDelay, /mix: 0.25 },\n\s*]/); + assert.doesNotMatch(withDelay, /}, \{\n/); +}); + +test("cannot replace a computed expression", () => { + const computedSource = source.replace("gainDb: -12", "gainDb: baseGain - 2"); + assert.throws( + () => + applySourceEdit(computedSource, canonical, "sound.ts", { + type: "setField", + path: ["source", "gainDb"], + value: -20, + }), + /computed/, + ); +}); + +test("cannot structurally replace a dynamic discriminant", () => { + const computedSource = source.replace('waveform: "saw"', "waveform"); + assert.throws( + () => + applySourceEdit(computedSource, canonical, "sound.ts", { + type: "replaceOscillator", + index: 0, + waveform: "pulse", + }), + /computed/, + ); +}); + +test("rejects out-of-range scalar values", () => { + assert.throws( + () => + applySourceEdit(source, canonical, "sound.ts", { + type: "setField", + path: ["source", "gainDb"], + value: 3, + }), + /range/, + ); + assert.throws( + () => + applySourceEdit(source, canonical, "sound.ts", { + type: "setField", + path: ["source", "oscillators", 0, "transposeSemitones"], + value: 1.5, + }), + /range/, + ); +}); diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json index 34e0cb2..36cbb4d 100644 --- a/packages/studio/tsconfig.json +++ b/packages/studio/tsconfig.json @@ -2,7 +2,15 @@ "extends": "../../tsconfig.json", "compilerOptions": { "noEmit": true, - "types": ["node"] + "types": ["node", "vite/client"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx" }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": [ + "src/**/*.ts", + "client/src/**/*.ts", + "client/src/**/*.tsx", + "test/**/*.ts", + "vite.config.ts" + ] } diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts new file mode 100644 index 0000000..d312b42 --- /dev/null +++ b/packages/studio/vite.config.ts @@ -0,0 +1,15 @@ +import { resolve } from "node:path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: resolve(import.meta.dirname, "client"), + base: "./", + plugins: [react()], + build: { + outDir: resolve(import.meta.dirname, "dist/client"), + emptyOutDir: true, + manifest: true, + sourcemap: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2840d0e..4ba5155 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,12 +26,12 @@ importers: '@patchwave/native': specifier: workspace:* version: link:../native - '@patchwave/studio': - specifier: workspace:* - version: link:../studio '@patchwave/schema': specifier: workspace:* version: link:../schema + '@patchwave/studio': + specifier: workspace:* + version: link:../studio chokidar: specifier: 5.0.0 version: 5.0.0 @@ -52,18 +52,11 @@ importers: specifier: 3.7.3 version: 3.7.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3) - packages/studio: - dependencies: - ws: - specifier: 8.18.3 - version: 8.18.3 + packages/schema: devDependencies: '@types/node': specifier: 24.13.3 version: 24.13.3 - '@types/ws': - specifier: 8.18.1 - version: 8.18.1 tsx: specifier: 4.23.1 version: 4.23.1 @@ -71,20 +64,188 @@ importers: specifier: 7.0.2 version: 7.0.2 - packages/schema: + packages/studio: + dependencies: + '@babel/types': + specifier: 7.28.4 + version: 7.28.4 + '@patchwave/schema': + specifier: workspace:* + version: link:../schema + react: + specifier: 19.1.1 + version: 19.1.1 + react-dom: + specifier: 19.1.1 + version: 19.1.1(react@19.1.1) + recast: + specifier: 0.23.11 + version: 0.23.11 + ws: + specifier: 8.18.3 + version: 8.18.3 devDependencies: + '@testing-library/react': + specifier: 16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@testing-library/user-event': + specifier: 14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: 24.13.3 version: 24.13.3 + '@types/react': + specifier: 19.1.16 + version: 19.1.16 + '@types/react-dom': + specifier: 19.1.9 + version: 19.1.9(@types/react@19.1.16) + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 + '@vitejs/plugin-react': + specifier: 5.0.4 + version: 5.0.4(vite@7.1.7(@types/node@24.13.3)(tsx@4.23.1)) + jsdom: + specifier: 26.1.0 + version: 26.1.0 tsx: specifier: 4.23.1 version: 4.23.1 typescript: specifier: 7.0.2 version: 7.0.2 + vite: + specifier: 7.1.7 + version: 7.1.7(@types/node@24.13.3)(tsx@4.23.1) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.13.3)(jsdom@26.1.0)(tsx@4.23.1) packages: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} @@ -94,156 +255,312 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -384,6 +701,22 @@ packages: '@types/node': optional: true + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/cli@3.7.3': resolution: {integrity: sha512-iu5BOoYjYVixp5jwE7JniHvg72XuKWXUfXteu+6Gt/XY4/mslsS+Qbipleg1+3CAUGHkWc+ebaMJj7Pc93BXSQ==} engines: {node: '>= 16'} @@ -911,94 +1244,292 @@ packages: cpu: [x64] os: [win32] - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@rolldown/pluginutils@1.0.0-beta.38': + resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] - '@typescript/typescript-aix-ppc64@7.0.2': - resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [aix] + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] - '@typescript/typescript-darwin-arm64@7.0.2': - resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@typescript/typescript-darwin-x64@7.0.2': - resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@typescript/typescript-freebsd-arm64@7.0.2': - resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - '@typescript/typescript-freebsd-x64@7.0.2': - resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@typescript/typescript-linux-arm64@7.0.2': - resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] os: [linux] + libc: [glibc] - '@typescript/typescript-linux-arm@7.0.2': - resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] + libc: [musl] - '@typescript/typescript-linux-loong64@7.0.2': - resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] + libc: [glibc] - '@typescript/typescript-linux-mips64el@7.0.2': - resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} - engines: {node: '>=16.20.0'} - cpu: [mips64el] + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] os: [linux] + libc: [musl] - '@typescript/typescript-linux-ppc64@7.0.2': - resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@typescript/typescript-linux-riscv64@7.0.2': - resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@typescript/typescript-linux-s390x@7.0.2': - resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] + libc: [glibc] - '@typescript/typescript-linux-x64@7.0.2': - resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} - engines: {node: '>=16.20.0'} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] + libc: [glibc] - '@typescript/typescript-netbsd-arm64@7.0.2': + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.1.9': + resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react@19.1.16': + resolution: {integrity: sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} engines: {node: '>=16.20.0'} cpu: [arm64] @@ -1040,15 +1571,101 @@ packages: cpu: [x64] os: [win32] + '@vitejs/plugin-react@5.0.4': + resolution: {integrity: sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -1069,6 +1686,20 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1078,6 +1709,23 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + electron-to-chromium@1.5.393: + resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} + emnapi@1.11.2: resolution: {integrity: sha512-iMt/XQc69fFn2EvcU6tm14HmXKwyy0lnABugsQlqp6xFuZIUuO+ONVSg2mz+MTVF8WbC+bic65AvRXdoldALKg==} peerDependencies: @@ -1086,14 +1734,42 @@ packages: node-addon-api: optional: true + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -1103,22 +1779,95 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-with-bigint@3.5.10: resolution: {integrity: sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1126,6 +1875,18 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -1143,26 +1904,157 @@ packages: vite-plus: optional: true + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.1.1: + resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} + peerDependencies: + react: ^19.1.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.1.1: + resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} + engines: {node: '>=0.10.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1185,6 +2077,111 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.1.7: + resolution: {integrity: sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -1197,8 +2194,165 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + snapshots: + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -1215,81 +2369,159 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -1412,6 +2644,25 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/cli@3.7.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)': dependencies: '@inquirer/prompts': 8.5.2(@types/node@24.13.3) @@ -1794,15 +3045,157 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.59.0': optional: true + '@rolldown/pluginutils@1.0.0-beta.38': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 + '@types/react-dom@19.1.9(@types/react@19.1.16)': + dependencies: + '@types/react': 19.1.16 + + '@types/react@19.1.16': + dependencies: + csstype: 3.2.3 + '@types/ws@8.18.1': dependencies: '@types/node': 24.13.3 @@ -1867,12 +3260,110 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@vitejs/plugin-react@5.0.4(vite@7.1.7(@types/node@24.13.3)(tsx@4.23.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.38 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.1.7(@types/node@24.13.3)(tsx@4.23.1) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.1.7(@types/node@24.13.3)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.7(@types/node@24.13.3)(tsx@4.23.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + baseline-browser-mapping@2.10.43: {} + before-after-hook@4.0.0: {} + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.393 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + + cac@6.7.14: {} + + caniuse-lite@1.0.30001806: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chardet@2.2.0: {} + check-error@2.1.3: {} + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -1887,14 +3378,71 @@ snapshots: content-type@2.0.0: {} + convert-source-map@2.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + electron-to-chromium@1.5.393: {} + emnapi@1.11.2: {} + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + es-toolkit@1.49.0: {} + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -1924,6 +3472,16 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + + esprima@4.0.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -1934,23 +3492,108 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + js-yaml@4.3.0: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + json-with-bigint@3.5.10: {} + json5@2.2.3: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + ms@2.1.3: {} mute-stream@3.0.0: {} + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + nwsapi@2.2.24: {} + obug@2.1.3: {} oxfmt@0.59.0: @@ -1977,18 +3620,150 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.59.0 '@oxfmt/binding-win32-x64-msvc': 0.59.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@19.1.1(react@19.1.1): + dependencies: + react: 19.1.1 + scheduler: 0.26.0 + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react@19.1.1: {} + readdirp@5.0.0: {} + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.26.0: {} + + semver@6.3.1: {} + semver@7.8.5: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + symbol-tree@3.2.4: {} + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + tinypool@2.1.0: {} - tslib@2.8.1: - optional: true + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: {} tsx@4.23.1: dependencies: @@ -2025,4 +3800,114 @@ snapshots: universal-user-agent@7.0.3: {} + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite-node@3.2.4(@types/node@24.13.3)(tsx@4.23.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.1.7(@types/node@24.13.3)(tsx@4.23.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.1.7(@types/node@24.13.3)(tsx@4.23.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.19 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + tsx: 4.23.1 + + vitest@3.2.4(@types/node@24.13.3)(jsdom@26.1.0)(tsx@4.23.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.1.7(@types/node@24.13.3)(tsx@4.23.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.1.7(@types/node@24.13.3)(tsx@4.23.1) + vite-node: 3.2.4(@types/node@24.13.3)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} From acfcee250261e582f374a294e7fddfe7d782e2b6 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Sun, 19 Jul 2026 13:49:11 +0200 Subject: [PATCH 2/4] internal: fix CI formatting --- .pi/settings.json | 6 ++---- example/classic-bass.ts | 17 ++++++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.pi/settings.json b/.pi/settings.json index ac48a2c..5d1def4 100644 --- a/.pi/settings.json +++ b/.pi/settings.json @@ -1,5 +1,3 @@ { - "packages": [ - "npm:@noice-tech/pi-changelog" - ] -} \ No newline at end of file + "packages": ["npm:@noice-tech/pi-changelog"] +} diff --git a/example/classic-bass.ts b/example/classic-bass.ts index 0ae8abf..ca91551 100644 --- a/example/classic-bass.ts +++ b/example/classic-bass.ts @@ -20,11 +20,14 @@ export default { releaseSeconds: 0.18, }, }, - effects: [{ type: "saturator", driveDb: 24.6 }, { - type: "stereoDelay", - timeSeconds: 0.906, - feedback: 0.32, - damping: 0.16, - mix: 0.25, - }], + effects: [ + { type: "saturator", driveDb: 24.6 }, + { + type: "stereoDelay", + timeSeconds: 0.906, + feedback: 0.32, + damping: 0.16, + mix: 0.25, + }, + ], } satisfies Patch; From 94cacd05e17bff576ac8947a5536b954c1236eff Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Sun, 19 Jul 2026 14:47:46 +0200 Subject: [PATCH 3/4] fix: format Studio source writes with oxfmt --- README.md | 2 +- packages/cli/package.json | 1 + packages/cli/src/source-format.ts | 10 ++++++++++ packages/cli/src/studio-document.ts | 3 +++ packages/cli/test/studio-document.test.ts | 5 ++++- pnpm-lock.yaml | 3 +++ 6 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/source-format.ts diff --git a/README.md b/README.md index aba513b..a8dcfe2 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Patch programs are control-rate automation: JavaScript never runs in the audio c Studio binds only to loopback with a random per-process capability URL. Its React interface combines the playable keyboard with an Ableton-style serial device rack, device browser, and inspector. Supported edits are written back to the one TypeScript entry file passed to the CLI; Studio is never a remote hosting surface and the browser cannot choose a path or send source code. -Literal scalar controls preview the sound while you drag. Releasing the pointer, pressing Enter, or leaving the input commits one validated source edit and creates one process-local undo entry. Adding, removing, replacing, or reordering oscillator/filter/LFO/effect blocks commits immediately. Studio compares source content and file identity immediately before an atomic replacement and verifies the result, so ordinary stale edits fail with a visible conflict. Portable filesystems do not provide an indivisible content-hash-and-replace primitive; a truly simultaneous external rename in the final replacement interval can still race, so avoid saving from an editor during the instant Studio commits. +Literal scalar controls preview the sound while you drag. Releasing the pointer, pressing Enter, or leaving the input commits one validated source edit and creates one process-local undo entry. Adding, removing, replacing, or reordering oscillator/filter/LFO/effect blocks commits immediately. Studio formats edited source with `oxfmt` before validating and writing it. Studio compares source content and file identity immediately before an atomic replacement and verifies the result, so ordinary stale edits fail with a visible conflict. Portable filesystems do not provide an indivisible content-hash-and-replace primitive; a truly simultaneous external rename in the final replacement interval can still race, so avoid saving from an editor during the instant Studio commits. ### Editable TypeScript diff --git a/packages/cli/package.json b/packages/cli/package.json index fb84e84..a1eacfe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,7 @@ "@patchwave/schema": "workspace:*", "@patchwave/studio": "workspace:*", "chokidar": "5.0.0", + "oxfmt": "0.59.0", "tsx": "4.23.1" }, "devDependencies": { diff --git a/packages/cli/src/source-format.ts b/packages/cli/src/source-format.ts new file mode 100644 index 0000000..3ba4b0b --- /dev/null +++ b/packages/cli/src/source-format.ts @@ -0,0 +1,10 @@ +import { format } from "oxfmt"; + +export async function formatSource(fileName: string, source: string): Promise { + const result = await format(fileName, source); + if (result.errors.length > 0) { + const detail = result.errors.map((error) => error.message).join("; "); + throw new Error(`Could not format the edited source: ${detail}`); + } + return result.code; +} diff --git a/packages/cli/src/studio-document.ts b/packages/cli/src/studio-document.ts index 4f787c2..ef683d2 100644 --- a/packages/cli/src/studio-document.ts +++ b/packages/cli/src/studio-document.ts @@ -17,6 +17,7 @@ import { sourceRevision, SourceConflictError, } from "./source-file.js"; +import { formatSource } from "./source-format.js"; import { StudioHistory } from "./studio-history.js"; export type StudioDocumentControllerOptions = { @@ -292,6 +293,8 @@ export class StudioDocumentController { this.#fileLabel, input.operation, ); + output = await formatSource(this.#path, output); + parseSource(output); } catch (error) { if (errorMessage(error) !== "Edit did not change the source file") throw error; this.#cancelPreview(input.gestureId ?? undefined); diff --git a/packages/cli/test/studio-document.test.ts b/packages/cli/test/studio-document.test.ts index 94726c0..bca30ad 100644 --- a/packages/cli/test/studio-document.test.ts +++ b/packages/cli/test/studio-document.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import test from "node:test"; import { validatePatch } from "@patchwave/schema"; import { StudioDocumentController } from "../src/studio-document.js"; +import { formatSource } from "../src/source-format.js"; const text = `export default {source: {frequencyHz: 110, gainDb: -12, oscillators: [{waveform: "saw"}]}} satisfies Patch;\n`; const canonical = validatePatch({ @@ -82,7 +83,9 @@ test("preview is disk-free and a commit performs one revisioned source transacti operation: { type: "setField", path: ["source", "gainDb"], value: -18 }, }); assert.equal(commit.status, "committed"); - assert.match(await readFile(path, "utf8"), /gainDb: -18/); + const written = await readFile(path, "utf8"); + assert.match(written, /gainDb: -18/); + assert.equal(written, await formatSource(path, written)); assert.equal(controller.snapshot().canUndo, true); await controller.refreshAfterReload(true, controller.snapshot().revision); await controller.refreshAfterReload(true, controller.snapshot().revision); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ba5155..a9d763b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: chokidar: specifier: 5.0.0 version: 5.0.0 + oxfmt: + specifier: 0.59.0 + version: 0.59.0 tsx: specifier: 4.23.1 version: 4.23.1 From 70db6b750aee8c9eb5cceaa40c960ed850d83243 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Sun, 19 Jul 2026 15:58:50 +0200 Subject: [PATCH 4/4] internal: stabilize and deduplicate CI validation --- .github/workflows/ci.yml | 2 ++ packages/cli/test/cli.test.ts | 29 ++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efc5f56..bd31307 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: CI on: push: + branches: + - main pull_request: permissions: diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index d2a4331..720002b 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import test from "node:test"; +import test, { type TestContext } from "node:test"; import { validatePatch } from "@patchwave/schema"; import { runCli, @@ -109,11 +109,11 @@ function logger(): CliLogger & { logs: string[]; errors: string[] } { } async function waitUntil(predicate: () => boolean): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - if (predicate()) return; - await new Promise((resolve) => setImmediate(resolve)); + const deadline = performance.now() + 1_000; + while (!predicate()) { + if (performance.now() >= deadline) throw new Error("timed out waiting for fake CLI state"); + await new Promise((resolve) => setTimeout(resolve, 2)); } - throw new Error("timed out waiting for fake CLI startup"); } function startHarness( @@ -159,8 +159,16 @@ function startHarness( }; } -test("browser input drives ordered notes and normal cleanup without a TTY", async () => { +function cleanUpHarness(context: TestContext, harness: ReturnType): void { + context.after(async () => { + harness.signals.emit("SIGTERM"); + await harness.promise; + }); +} + +test("browser input drives ordered notes and normal cleanup without a TTY", async (context) => { const harness = startHarness(); + cleanUpHarness(context, harness); await waitUntil(() => harness.studioOptions !== undefined); harness.studioOptions!.onInput({ type: "keyDown", code: "KeyA" }); harness.studioOptions!.onInput({ type: "keyDown", code: "KeyD" }); @@ -182,8 +190,9 @@ test("browser input drives ordered notes and normal cleanup without a TTY", asyn assert.match(harness.output.logs.join("\n"), /Studio: http:\/\/127\.0\.0\.1/); }); -test("SIGINT releases a held key before stopping audio", async () => { +test("SIGINT releases a held key before stopping audio", async (context) => { const harness = startHarness(); + cleanUpHarness(context, harness); await waitUntil(() => harness.studioOptions !== undefined); harness.studioOptions!.onInput({ type: "keyDown", code: "KeyA" }); harness.signals.emit("SIGINT"); @@ -193,10 +202,11 @@ test("SIGINT releases a held key before stopping audio", async () => { assert.equal(harness.watcher.closed, true); }); -test("controller close cancels a backpressured reload and staged note before safety off", async () => { +test("controller close cancels a backpressured reload and staged note before safety off", async (context) => { const engine = new FakeEngine(); let loads = 0; const harness = startHarness(engine, async () => patchModule(++loads === 1 ? 110 : 220)); + cleanUpHarness(context, harness); await waitUntil(() => harness.studioOptions !== undefined); engine.accept = false; harness.watcher.emit("change"); @@ -231,10 +241,11 @@ test("controller close cancels a backpressured reload and staged note before saf assert.ok(offIndex >= 0 && restoredIndex > offIndex); }); -test("runtime-error polling reports failure and shuts down", async () => { +test("runtime-error polling reports failure and shuts down", async (context) => { const engine = new FakeEngine(); engine.runtimeError = true; const harness = startHarness(engine); + cleanUpHarness(context, harness); const code = await harness.promise; assert.equal(code, 1); assert.equal(engine.stops, 1);