diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fac4bbf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/node_modules
+/exports
diff --git a/BlueprintWorkspace.outline.txt b/BlueprintWorkspace.outline.txt
new file mode 100644
index 0000000..0c88312
--- /dev/null
+++ b/BlueprintWorkspace.outline.txt
@@ -0,0 +1,128 @@
+Outline for scripts\ui\BlueprintWorkspace.js
+
+ 77 : class BlueprintWorkspace
+ 81 : method BlueprintWorkspace.constructor
+ 242 : method BlueprintWorkspace.static #requireTemplate
+ 253 : method BlueprintWorkspace.initialize
+ 329 : method BlueprintWorkspace.redo
+ 336 : method BlueprintWorkspace.exportLua
+ 343 : method BlueprintWorkspace.#bindGraphEvents
+ 536 : method BlueprintWorkspace.#bindUiEvents
+ 779 : method BlueprintWorkspace.removeNode
+ 811 : method BlueprintWorkspace.clearSelection
+ 834 : method BlueprintWorkspace.selectNode
+ 859 : method BlueprintWorkspace.setSelectionState
+ 866 : method BlueprintWorkspace.#frameSelection
+ 892 : method BlueprintWorkspace.#togglePaletteVisibility
+ 901 : method BlueprintWorkspace.#setPaletteVisibility
+ 919 : method BlueprintWorkspace.#applyPaletteVisibility
+ 956 : method BlueprintWorkspace.#selectVariable
+ 977 : method BlueprintWorkspace.#renderInspectorState
+ 1025 : method BlueprintWorkspace.#enhanceInspectorTooltip
+ 1041 : arrow clearRaf
+ 1048 : arrow schedulePosition
+ 1055 : arrow handleScroll
+ 1063 : arrow handleResize
+ 1070 : arrow handleDocumentPointerDown
+ 1078 : arrow close
+ 1097 : arrow position
+ 1168 : arrow open
+ 1181 : arrow handlePointerLeave
+ 1185 : arrow handleFocusOut
+ 1193 : arrow handleKeyDown
+ 1226 : method BlueprintWorkspace.#renderProjectSettingsInspector
+ 1319 : method BlueprintWorkspace.#setInspectorView
+ 1330 : method BlueprintWorkspace.#updateProjectSettingsToggle
+ 1345 : method BlueprintWorkspace.#handleProjectSettingsChanged
+ 1356 : method BlueprintWorkspace.#highlightVariableSelection
+ 1386 : method BlueprintWorkspace.#renderVariableInspector
+ 1452 : method BlueprintWorkspace.#setVariableName
+ 1508 : method BlueprintWorkspace.#setVariableType
+ 1542 : method BlueprintWorkspace.#openVariableTypeDropdown
+ 1631 : method BlueprintWorkspace.#closeVariableTypeDropdown
+ 1657 : method BlueprintWorkspace.#startVariableRename
+ 1689 : arrow finish
+ 1735 : method BlueprintWorkspace.#cancelVariableRename
+ 1766 : method BlueprintWorkspace.#setSelection
+ 1794 : method BlueprintWorkspace.#applySelectionState
+ 1828 : method BlueprintWorkspace.#isWorkspaceBackgroundTarget
+ 1852 : method BlueprintWorkspace.#renderOverview
+ 1863 : method BlueprintWorkspace.#createGlobalVariable
+ 1886 : method BlueprintWorkspace.#registerVariable
+ 1905 : method BlueprintWorkspace.#suggestVariableName
+ 1947 : method BlueprintWorkspace.#normalizeVariableName
+ 1957 : method BlueprintWorkspace.#formatVariableType
+ 1976 : method BlueprintWorkspace.#findVariableByName
+ 2003 : method BlueprintWorkspace.#nodeReferencesVariable
+ 2035 : method BlueprintWorkspace.#resolveVariableBinding
+ 2066 : method BlueprintWorkspace.#refreshVariableNode
+ 2089 : method BlueprintWorkspace.#applyVariableNodeTitle
+ 2131 : method BlueprintWorkspace.#applyVariableNodePins
+ 2137 : arrow updatePin
+ 2222 : method BlueprintWorkspace.#getCustomEventNodes
+ 2235 : method BlueprintWorkspace.#generateUniqueCustomEventName
+ 2274 : method BlueprintWorkspace.#resolveCustomEventLabel
+ 2288 : method BlueprintWorkspace.#customEventOutputPinId
+ 2298 : method BlueprintWorkspace.#callCustomEventInputPinId
+ 2308 : method BlueprintWorkspace.#allocateCustomEventParameterId
+ 2323 : method BlueprintWorkspace.#sanitizeCustomEventParameters
+ 2431 : method BlueprintWorkspace.#applyCustomEventNodePins
+ 2506 : method BlueprintWorkspace.#applyCallCustomEventNodePins
+ 2581 : method BlueprintWorkspace.#ensureConnectionsReflectPinKinds
+ 2620 : method BlueprintWorkspace.#syncCallCustomEventArguments
+ 2651 : method BlueprintWorkspace.#addCustomEventParameter
+ 2679 : method BlueprintWorkspace.#removeCustomEventParameter
+ 2702 : method BlueprintWorkspace.#renameCustomEventParameter
+ 2724 : method BlueprintWorkspace.#setCustomEventParameterType
+ 2746 : method BlueprintWorkspace.#ensureCustomEventStructure
+ 2764 : method BlueprintWorkspace.#refreshCustomEventNode
+ 2802 : method BlueprintWorkspace.#syncCallNodesForEvent
+ 2833 : method BlueprintWorkspace.#refreshCustomEventCallNode
+ 2899 : method BlueprintWorkspace.#refreshInspectorIfActive
+ 2917 : method BlueprintWorkspace.#refreshCallNodesForEvent
+ 2931 : method BlueprintWorkspace.#handleCustomEventRemoval
+ 2950 : method BlueprintWorkspace.#renderEventList
+ 2970 : method BlueprintWorkspace.#renderVariableList
+ 3005 : method BlueprintWorkspace.#renderOverviewEmpty
+ 3021 : method BlueprintWorkspace.#isEventNode
+ 3049 : method BlueprintWorkspace.#handleConnectionPointerDown
+ 3076 : method BlueprintWorkspace.createNodeFromDefinition
+ 3142 : method BlueprintWorkspace.spawnNodeFromContextMenu
+ 3187 : method BlueprintWorkspace.#computeSpawnPosition
+ 3211 : method BlueprintWorkspace.#startDraggingNode
+ 3223 : method BlueprintWorkspace.#tryAutoConnectSelectedNode
+ 3271 : method BlueprintWorkspace.#isAutoConnectStartPinAvailable
+ 3283 : method BlueprintWorkspace.#findAutoConnectTargets
+ 3398 : method BlueprintWorkspace.#beginConnection
+ 3421 : arrow handlePointerMove
+ 3431 : arrow handlePointerUp
+ 3458 : method BlueprintWorkspace.#updatePendingConnectionPath
+ 3497 : method BlueprintWorkspace.#finalizeConnection
+ 3515 : method BlueprintWorkspace.#cancelPendingConnection
+ 3529 : method BlueprintWorkspace.#tryOpenPendingConnectionMenu
+ 3590 : method BlueprintWorkspace.#getCompatibleDefinitionsForPending
+ 3610 : method BlueprintWorkspace.#connectSpawnedNodeToPending
+ 3657 : method BlueprintWorkspace.#createConnectionPath
+ 3668 : method BlueprintWorkspace.scheduleConnectionRefresh
+ 3675 : method BlueprintWorkspace.cancelConnectionRefresh
+ 3682 : method BlueprintWorkspace.flushConnectionRefresh
+ 3689 : method BlueprintWorkspace.#scheduleConnectionRefresh
+ 3703 : method BlueprintWorkspace.#cancelConnectionRefresh
+ 3713 : method BlueprintWorkspace.#flushConnectionRefresh
+ 3724 : method BlueprintWorkspace.#renderConnections
+ 3769 : method BlueprintWorkspace.#computeConnectionGeometry
+ 3806 : method BlueprintWorkspace.#getPinHandle
+ 3825 : method BlueprintWorkspace.#getPinHandleCenter
+ 3853 : method BlueprintWorkspace.#getPinElement
+ 3871 : method BlueprintWorkspace.#highlightPinsForPending
+ 3932 : method BlueprintWorkspace.#shouldHighlightHover
+ 3944 : method BlueprintWorkspace.#isPinCompatibleWithPending
+ 3983 : method BlueprintWorkspace.#clearPinHighlights
+ 4040 : method BlueprintWorkspace.#arePinKindsCompatible
+ 4052 : method BlueprintWorkspace.#renderInspector
+ 4178 : method BlueprintWorkspace.#renderCustomEventInspector
+ 4331 : method BlueprintWorkspace.#renderCallCustomEventInspector
+ 4415 : method BlueprintWorkspace.#refreshLuaOutput
+ 4430 : method BlueprintWorkspace.serializeWorkspace
+ 4439 : method BlueprintWorkspace.applySerializedWorkspace
+ 4559 : method BlueprintWorkspace.#rebuildNodeCounters
\ No newline at end of file
diff --git a/electron-main.js b/electron-main.js
new file mode 100644
index 0000000..c8d3f24
--- /dev/null
+++ b/electron-main.js
@@ -0,0 +1,104 @@
+const { app, BrowserWindow, ipcMain } = require("electron");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+
+const OUTPUT_FILENAME = "compiled.lua";
+
+/**
+ * Resolves the absolute directory where compiled Lua output is stored.
+ *
+ * @returns {string}
+ */
+function getOutputDirectory() {
+ const applicationData = app.getPath("appData");
+ return path.join(applicationData, "pico-8", "carts", "picograph");
+}
+
+/**
+ * Resolves the absolute path to the generated Lua file.
+ *
+ * @returns {string}
+ */
+function getOutputPath() {
+ return path.join(getOutputDirectory(), OUTPUT_FILENAME);
+}
+
+/** @type {import("electron").BrowserWindow | null} */
+let mainWindow = null;
+
+/**
+ * Creates the primary application window and loads the web UI.
+ *
+ * @returns {void}
+ */
+function createMainWindow() {
+ const window = new BrowserWindow({
+ width: 1440,
+ height: 900,
+ minWidth: 960,
+ minHeight: 640,
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ preload: path.join(__dirname, "preload.js"),
+ },
+ show: false,
+ });
+
+ window.once("ready-to-show", () => {
+ window.show();
+ });
+
+ window.loadFile(path.join(__dirname, "index.html"));
+
+ window.on("closed", () => {
+ mainWindow = null;
+ });
+
+ mainWindow = window;
+}
+
+/**
+ * Registers IPC handlers used by renderer processes.
+ *
+ * @returns {void}
+ */
+function registerIpcHandlers() {
+ ipcMain.handle("lua:compile", async (_event, source) => {
+ if (typeof source !== "string") {
+ throw new TypeError("Lua source must be a string");
+ }
+
+ const outputPath = getOutputPath();
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
+ await fs.writeFile(outputPath, source, "utf8");
+
+ return { filePath: outputPath };
+ });
+}
+
+/**
+ * Wires core Electron lifecycle events for the application.
+ *
+ * @returns {void}
+ */
+function registerAppLifecycle() {
+ app.whenReady().then(() => {
+ createMainWindow();
+ registerIpcHandlers();
+
+ app.on("activate", () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createMainWindow();
+ }
+ });
+ });
+
+ app.on("window-all-closed", () => {
+ if (process.platform !== "darwin") {
+ app.quit();
+ }
+ });
+}
+
+registerAppLifecycle();
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..6d8cfee
--- /dev/null
+++ b/index.html
@@ -0,0 +1,147 @@
+
+
+
+
+
+ picoGraph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..20f907c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,871 @@
+{
+ "name": "picograph",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "picograph",
+ "version": "0.1.0",
+ "devDependencies": {
+ "electron": "^30.0.0"
+ }
+ },
+ "node_modules/@electron/get": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
+ "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^2.2.0",
+ "fs-extra": "^8.1.0",
+ "got": "^11.8.5",
+ "progress": "^2.0.3",
+ "semver": "^6.2.0",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "global-agent": "^3.0.0"
+ }
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@szmarczak/http-timer": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
+ "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defer-to-connect": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@types/cacheable-request": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
+ "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-cache-semantics": "*",
+ "@types/keyv": "^3.1.4",
+ "@types/node": "*",
+ "@types/responselike": "^1.0.0"
+ }
+ },
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
+ "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/keyv": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
+ "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.22",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.22.tgz",
+ "integrity": "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/responselike": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
+ "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/boolean": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
+ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
+ "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.6.0"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
+ "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clone-response": "^1.0.2",
+ "get-stream": "^5.1.0",
+ "http-cache-semantics": "^4.0.0",
+ "keyv": "^4.0.0",
+ "lowercase-keys": "^2.0.0",
+ "normalize-url": "^6.0.1",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/clone-response": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
+ "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defer-to-connect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/electron": {
+ "version": "30.5.1",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-30.5.1.tgz",
+ "integrity": "sha512-AhL7+mZ8Lg14iaNfoYTkXQ2qee8mmsQyllKdqxlpv/zrKgfxz6jNVtcRRbQtLxtF8yzcImWdfTQROpYiPumdbw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron/get": "^2.0.0",
+ "@types/node": "^20.9.0",
+ "extract-zip": "^2.0.1"
+ },
+ "bin": {
+ "electron": "cli.js"
+ },
+ "engines": {
+ "node": ">= 12.20.55"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-error": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/global-agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/global-agent/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/got": {
+ "version": "11.8.6",
+ "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
+ "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^4.0.0",
+ "@szmarczak/http-timer": "^4.0.5",
+ "@types/cacheable-request": "^6.0.1",
+ "@types/responselike": "^1.0.0",
+ "cacheable-lookup": "^5.0.3",
+ "cacheable-request": "^7.0.2",
+ "decompress-response": "^6.0.0",
+ "http2-wrapper": "^1.0.0-beta.5.2",
+ "lowercase-keys": "^2.0.0",
+ "p-cancelable": "^2.0.0",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http2-wrapper": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
+ "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/jsonfile": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "dev": true,
+ "license": "MIT",
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/lowercase-keys": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+ "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/matcher": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
+ "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-url": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
+ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/responselike": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
+ "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lowercase-keys": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/roarr": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
+ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "detect-node": "^2.0.4",
+ "globalthis": "^1.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "semver-compare": "^1.0.0",
+ "sprintf-js": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/semver-compare": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
+ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/serialize-error": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/sumchecker": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
+ "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/universalify": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3f51339
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "picograph",
+ "version": "0.1.0",
+ "description": "PicoGraph desktop wrapper",
+ "main": "electron-main.js",
+ "scripts": {
+ "start": "electron ."
+ },
+ "devDependencies": {
+ "electron": "^30.0.0"
+ }
+}
diff --git a/preload.js b/preload.js
new file mode 100644
index 0000000..98e4c78
--- /dev/null
+++ b/preload.js
@@ -0,0 +1,22 @@
+const { contextBridge, ipcRenderer } = require("electron");
+
+/**
+ * Provides renderer processes with limited access to Electron IPC bridges.
+ */
+const api = {
+ /**
+ * Requests that the main process compile the supplied Lua source to disk.
+ *
+ * @param {string} source Lua program text emitted from the workspace.
+ * @returns {Promise<{ filePath: string }>}
+ */
+ compileLua(source) {
+ if (typeof source !== "string") {
+ return Promise.reject(new TypeError("Lua source must be a string"));
+ }
+
+ return ipcRenderer.invoke("lua:compile", source);
+ },
+};
+
+contextBridge.exposeInMainWorld("electronAPI", api);
diff --git a/scripts/core/BlueprintNode.js b/scripts/core/BlueprintNode.js
new file mode 100644
index 0000000..6c750d9
--- /dev/null
+++ b/scripts/core/BlueprintNode.js
@@ -0,0 +1,74 @@
+/**
+ * @typedef {Object} PinDescriptor
+ * @property {string} id Unique pin identifier scoped to the node.
+ * @property {string} name Public display name.
+ * @property {('input'|'output')} direction Pin direction relative to the node.
+ * @property {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Pin kind describing blueprint behavior.
+ * @property {string} [description] Optional tooltip/description.
+ * @property {unknown} [defaultValue] Default value for data pins.
+ */
+
+/**
+ * @typedef {Object} BlueprintNodeInit
+ * @property {string} id Node identifier.
+ * @property {string} type Node definition identifier.
+ * @property {string} title Display title for the node.
+ * @property {{x:number,y:number}} position Node position within the workspace.
+ * @property {Array} inputs Input pin descriptors.
+ * @property {Array} outputs Output pin descriptors.
+ * @property {Record} properties Arbitrary node properties.
+ */
+
+/**
+ * Represents a blueprint node instance within the graph.
+ */
+export class BlueprintNode {
+ /**
+ * @param {BlueprintNodeInit} init Initialization payload.
+ */
+ constructor(init) {
+ this.id = init.id;
+ this.type = init.type;
+ this.title = init.title;
+ this.position = { ...init.position };
+ this.inputs = init.inputs.map((pin) => ({ ...pin }));
+ this.outputs = init.outputs.map((pin) => ({ ...pin }));
+ this.properties = { ...init.properties };
+ }
+
+ /**
+ * Updates the node position immutably.
+ *
+ * @param {{x:number,y:number}} position New position coordinates.
+ */
+ setPosition(position) {
+ this.position = { x: position.x, y: position.y };
+ }
+
+ /**
+ * Provides a shallow clone suitable for serialization.
+ *
+ * @returns {BlueprintNodeInit}
+ */
+ toJSON() {
+ return {
+ id: this.id,
+ type: this.type,
+ title: this.title,
+ position: { ...this.position },
+ inputs: this.inputs.map((pin) => ({ ...pin })),
+ outputs: this.outputs.map((pin) => ({ ...pin })),
+ properties: { ...this.properties },
+ };
+ }
+
+ /**
+ * Retrieves a pin descriptor matching the supplied identifier.
+ *
+ * @param {string} pinId Pin identifier.
+ * @returns {PinDescriptor | undefined}
+ */
+ getPin(pinId) {
+ return [...this.inputs, ...this.outputs].find((pin) => pin.id === pinId);
+ }
+}
diff --git a/scripts/core/Connection.js b/scripts/core/Connection.js
new file mode 100644
index 0000000..71d3053
--- /dev/null
+++ b/scripts/core/Connection.js
@@ -0,0 +1,46 @@
+/**
+ * Generates a lightweight unique identifier.
+ *
+ * @returns {string}
+ */
+const createConnectionId = () =>
+ globalThis.crypto?.randomUUID?.() ??
+ `conn_${Math.random().toString(36).slice(2, 10)}`;
+
+/**
+ * @typedef {Object} PinReference
+ * @property {string} nodeId Linked node identifier.
+ * @property {string} pinId Pin identifier within the node.
+ */
+
+/**
+ * Represents a directional connection between two node pins.
+ */
+export class Connection {
+ /**
+ * @param {PinReference} from Origin pin (must be output).
+ * @param {PinReference} to Target pin (must be input).
+ * @param {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Pin kind carried by the connection.
+ * @param {string} [id] Optional connection identifier.
+ */
+ constructor(from, to, kind, id) {
+ this.id = id ?? createConnectionId();
+ this.from = { ...from };
+ this.to = { ...to };
+ this.kind = kind;
+ }
+
+ /**
+ * Serializes the connection for persistence.
+ *
+ * @returns {{from: PinReference, to: PinReference, kind: Connection['kind']}}
+ */
+ toJSON() {
+ return {
+ id: this.id,
+ from: { ...this.from },
+ to: { ...this.to },
+ kind: this.kind,
+ };
+ }
+}
diff --git a/scripts/core/LuaGenerator.js b/scripts/core/LuaGenerator.js
new file mode 100644
index 0000000..b0c0bd5
--- /dev/null
+++ b/scripts/core/LuaGenerator.js
@@ -0,0 +1,1181 @@
+/**
+ * @typedef {import('./NodeGraph.js').NodeGraph} NodeGraph
+ */
+
+/**
+ * @typedef {import('./BlueprintNode.js').BlueprintNode} BlueprintNode
+ */
+
+/**
+ * @typedef {import('../nodes/NodeRegistry.js').NodeRegistry} NodeRegistry
+ */
+
+/**
+ * @typedef {Object} ProjectSettings
+ * @property {boolean} use60Fps Enables the 60fps update loop via _UPDATE60.
+ */
+
+/**
+ * Converts a node graph into executable PICO-8 Lua code.
+ */
+export class LuaGenerator {
+ /**
+ * @param {NodeRegistry} registry Node registry for metadata lookups.
+ */
+ constructor(registry) {
+ this.registry = registry;
+ this.indentUnit = " ";
+ this.valueCache = new Map();
+ this.valueStack = new Set();
+ /** @type {Map} */
+ this.customEventFunctionNames = new Map();
+ /**
+ * @type {Map>}
+ */
+ this.customEventParameters = new Map();
+ /** @type {Map} */
+ this.customEventParameterLookup = new Map();
+ /** @type {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} */
+ this.variables = [];
+ /** @type {Map} */
+ this.globalVariableNames = new Map();
+ /** @type {ProjectSettings} */
+ this.projectSettings = {
+ use60Fps: false,
+ };
+ }
+
+ /**
+ * Applies project-level configuration.
+ *
+ * @param {ProjectSettings} settings Project configuration flags.
+ */
+ setProjectSettings(settings) {
+ this.projectSettings = {
+ use60Fps: Boolean(settings.use60Fps),
+ };
+ }
+
+ /**
+ * Updates the workspace variable metadata used for Lua generation.
+ *
+ * @param {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} variables Variable definitions.
+ */
+ setVariables(variables) {
+ if (!Array.isArray(variables)) {
+ this.variables = [];
+ return;
+ }
+
+ this.variables = variables.map((entry, index) => ({
+ id: typeof entry?.id === "string" ? entry.id : `variable_${index}`,
+ name: typeof entry?.name === "string" ? entry.name : "",
+ type: this.#normalizeVariableType(entry?.type),
+ defaultValue: Object.prototype.hasOwnProperty.call(entry ?? {}, "defaultValue")
+ ? entry?.defaultValue
+ : null,
+ }));
+ }
+
+ /**
+ * Generates Lua code for the provided graph instance.
+ *
+ * @param {NodeGraph} graph Blueprint graph to compile.
+ * @returns {string}
+ */
+ generate(graph, options = {}) {
+ this.graph = graph;
+ this.connections = graph.getConnections();
+ this.nodes = new Map(graph.getNodes().map((node) => [node.id, node]));
+ this.valueCache.clear();
+ this.valueStack.clear();
+ this.globalVariableNames.clear();
+
+ const variableEntries = Array.isArray(options)
+ ? options
+ : Array.isArray(options?.variables)
+ ? options.variables
+ : this.variables;
+
+ this.setVariables(variableEntries);
+ const globalDeclarations = this.#prepareGlobalVariableDeclarations(
+ this.variables
+ );
+
+ const use60Fps = Boolean(this.projectSettings.use60Fps);
+ const entryTypes = new Set(this.registry.getEntryNodeTypes());
+ const entryEventMap = this.registry.getEntryPointEvents();
+ const entryCandidates = [...this.nodes.values()].filter((node) =>
+ entryTypes.has(node.type)
+ );
+
+ /** @type {Array} */
+ const customEvents = [];
+ /** @type {Array} */
+ const entryNodes = [];
+
+ entryCandidates.forEach((node) => {
+ if (node.type === "custom_event") {
+ customEvents.push(node);
+ } else {
+ entryNodes.push(node);
+ }
+ });
+
+ if (!entryNodes.length && !customEvents.length) {
+ return ["-- Generated with picoGraph", "-- No entry node present."].join(
+ "\n"
+ );
+ }
+
+ this.customEventFunctionNames =
+ this.#buildCustomEventFunctionMap(customEvents);
+ const { parameterMap, lookupMap } =
+ this.#buildCustomEventParameterMap(customEvents);
+ this.customEventParameters = parameterMap;
+ this.customEventParameterLookup = lookupMap;
+
+ /** @type {Map>} */
+ const grouped = new Map();
+ entryNodes.forEach((node) => {
+ let eventName = entryEventMap.get(node.type) ?? "_init";
+ if (eventName === "_update" && use60Fps) {
+ eventName = "_update60";
+ }
+ const collection = grouped.get(eventName) ?? [];
+ collection.push(node);
+ grouped.set(eventName, collection);
+ });
+
+ const output = ["-- Generated with picoGraph"];
+ if (globalDeclarations.length) {
+ output.push("");
+ output.push(...globalDeclarations);
+ }
+ const appendEvent = (eventName, node) => {
+ const lines = this.#emitExecChain(node.id, 1, new Set());
+ output.push("");
+ output.push(`function ${eventName}()`);
+ if (lines.length) {
+ output.push(...lines);
+ }
+ output.push("end");
+ };
+
+ const preferredOrder = ["_init", "_update", "_update60", "_draw"];
+ preferredOrder.forEach((eventName) => {
+ const nodes = grouped.get(eventName);
+ if (!nodes || !nodes.length) {
+ return;
+ }
+
+ appendEvent(eventName, nodes[0]);
+ grouped.delete(eventName);
+ });
+
+ const remainingEvents = [...grouped.entries()].sort((a, b) =>
+ a[0].localeCompare(b[0])
+ );
+
+ remainingEvents.forEach(([eventName, nodes]) => {
+ if (!nodes.length) {
+ return;
+ }
+ appendEvent(eventName, nodes[0]);
+ });
+
+ if (customEvents.length) {
+ const orderedCustomEvents = customEvents
+ .slice()
+ .sort((a, b) =>
+ this.#getCustomEventDisplayName(a).localeCompare(
+ this.#getCustomEventDisplayName(b)
+ )
+ );
+
+ orderedCustomEvents.forEach((node) => {
+ const functionName = this.customEventFunctionNames.get(node.id);
+ if (!functionName) {
+ return;
+ }
+ const signature = this.customEventParameters.get(node.id) ?? [];
+ const parameterList = signature
+ .map((entry) => entry.luaName)
+ .join(", ");
+ const lines = this.#emitExecChain(node.id, 1, new Set());
+ output.push("");
+ if (parameterList.length) {
+ output.push(`function ${functionName}(${parameterList})`);
+ } else {
+ output.push(`function ${functionName}()`);
+ }
+ if (lines.length) {
+ output.push(...lines);
+ }
+ output.push("end");
+ });
+ }
+
+ return output.join("\n");
+ }
+
+ /**
+ * Recursively walks the exec chain starting at a node.
+ *
+ * @param {string} nodeId Starting node identifier.
+ * @param {number} indentLevel Indentation level.
+ * @param {Set} path Guard set preventing infinite recursion.
+ * @returns {Array}
+ */
+ #emitExecChain(nodeId, indentLevel, path) {
+ if (path.has(nodeId)) {
+ return [
+ `${this.#indent(
+ indentLevel
+ )}-- cyclic exec connection involving ${nodeId}`,
+ ];
+ }
+ path.add(nodeId);
+
+ const node = this.nodes.get(nodeId);
+ if (!node) {
+ return [`${this.#indent(indentLevel)}-- missing node ${nodeId}`];
+ }
+
+ const lines = this.#emitFlowNode(node, indentLevel, path);
+ path.delete(nodeId);
+ return lines;
+ }
+
+ /**
+ * Builds the execution context shared with node behaviors.
+ *
+ * @param {BlueprintNode} node Active node reference.
+ * @param {number} indentLevel Current indentation level.
+ * @param {Set} path Cycle detection guard.
+ * @returns {import('../nodes/nodeTypes.js').ExecContext}
+ */
+ #createExecContext(node, indentLevel, path) {
+ return {
+ node,
+ indentLevel,
+ path,
+ indent: (level = indentLevel) => this.#indent(level),
+ resolveValueInput: (pinId, fallback) =>
+ this.#resolveValueInput(node, pinId, fallback),
+ emitNextExec: (pinId, overrides = {}) => {
+ const nextIndentLevel = overrides.indentLevel ?? indentLevel;
+ const nextPath = overrides.path ?? path;
+ return this.#emitNextExec(node.id, pinId, nextIndentLevel, nextPath);
+ },
+ emitBranch: (pinId, overrides = {}) => {
+ const branchIndent = overrides.indentLevel ?? indentLevel;
+ const branchPath = overrides.path ?? path;
+ return this.#emitBranch(node.id, pinId, branchIndent, branchPath);
+ },
+ emitExecChain: (targetNodeId, targetIndentLevel, targetPath) =>
+ this.#emitExecChain(targetNodeId, targetIndentLevel, targetPath),
+ findExecTargets: (pinId) => this.#findExecTargets(node.id, pinId),
+ sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
+ sanitizeOperator: (value) => this.#sanitizeOperator(String(value)),
+ formatLiteral: (kind, value) => this.#formatLiteral(kind, value),
+ };
+ }
+
+ /**
+ * Dispatches code generation for a specific flow node.
+ *
+ * @param {BlueprintNode} node Target node.
+ * @param {number} indentLevel Indentation level.
+ * @param {Set} path Cycle guard collection.
+ * @returns {Array}
+ */
+ #emitFlowNode(node, indentLevel, path) {
+ const behavior = this.registry.getBehavior(node.type);
+ if (behavior?.emitExec) {
+ return behavior.emitExec(
+ this.#createExecContext(node, indentLevel, path)
+ );
+ }
+
+ switch (node.type) {
+ case "event_start":
+ case "event_init":
+ case "event_update":
+ case "event_draw":
+ return this.#emitNextExec(node.id, "exec_out", indentLevel, path);
+ case "print":
+ return this.#emitPrint(node, indentLevel, path);
+ case "set_var":
+ return this.#emitSetVariable(node, indentLevel, path);
+ case "if":
+ return this.#emitIf(node, indentLevel, path);
+ case "for_loop":
+ return this.#emitForLoop(node, indentLevel, path);
+ case "sequence":
+ return this.#emitSequence(node, indentLevel, path);
+ case "call_custom_event":
+ return this.#emitCallCustomEvent(node, indentLevel, path);
+ default:
+ return [
+ `${this.#indent(indentLevel)}-- unsupported flow node: ${node.title}`,
+ ];
+ }
+ }
+
+ /**
+ * Builds the value context shared with node behaviors.
+ *
+ * @param {BlueprintNode} node Active node reference.
+ * @param {string} pinId Value output pin.
+ * @returns {import('../nodes/nodeTypes.js').ValueContext & { pinId: string }}
+ */
+ #createValueContext(node, pinId) {
+ return {
+ node,
+ pinId,
+ resolveValueInput: (inputPinId, fallback) =>
+ this.#resolveValueInput(node, inputPinId, fallback),
+ sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
+ sanitizeOperator: (value) => this.#sanitizeOperator(String(value)),
+ formatLiteral: (kind, value) => this.#formatLiteral(kind, value),
+ };
+ }
+
+ /**
+ * Emits Lua for a print node.
+ *
+ * @param {BlueprintNode} node Print node.
+ * @param {number} indentLevel Indent level.
+ * @param {Set} path Cycle guard.
+ * @returns {Array}
+ */
+ #emitPrint(node, indentLevel, path) {
+ const text = this.#resolveValueInput(node, "msg", '""');
+ const x = this.#resolveValueInput(node, "x", "0");
+ const y = this.#resolveValueInput(node, "y", "0");
+ const color = this.#resolveValueInput(node, "color", "7");
+ const line = `${this.#indent(
+ indentLevel
+ )}print(${text}, ${x}, ${y}, ${color})`;
+ return [
+ line,
+ ...this.#emitNextExec(node.id, "exec_out", indentLevel, path),
+ ];
+ }
+
+ /**
+ * Emits Lua for a variable assignment node.
+ *
+ * @param {BlueprintNode} node Set variable node.
+ * @param {number} indentLevel Indent level.
+ * @param {Set} path Cycle guard.
+ * @returns {Array}
+ */
+ #emitSetVariable(node, indentLevel, path) {
+ const name = this.#resolveWorkspaceVariableName(node);
+ const value = this.#resolveValueInput(node, "value", "nil");
+ const line = `${this.#indent(indentLevel)}${name} = ${value}`;
+ return [
+ line,
+ ...this.#emitNextExec(node.id, "exec_out", indentLevel, path),
+ ];
+ }
+
+ /**
+ * Emits Lua for a branch node.
+ *
+ * @param {BlueprintNode} node Branch node.
+ * @param {number} indentLevel Indent level.
+ * @param {Set} path Cycle guard.
+ * @returns {Array}
+ */
+ #emitIf(node, indentLevel, path) {
+ const condition = this.#resolveValueInput(node, "condition", "false");
+ const lines = [`${this.#indent(indentLevel)}if ${condition} then`];
+
+ const thenLines = this.#emitBranch(
+ node.id,
+ "then",
+ indentLevel + 1,
+ new Set(path)
+ );
+ if (!thenLines.length) {
+ lines.push(`${this.#indent(indentLevel + 1)}-- then branch`);
+ } else {
+ lines.push(...thenLines);
+ }
+
+ const elseTargets = this.#findExecTargets(node.id, "else");
+ if (elseTargets.length) {
+ lines.push(`${this.#indent(indentLevel)}else`);
+ const elseLines = this.#emitBranch(
+ node.id,
+ "else",
+ indentLevel + 1,
+ new Set(path)
+ );
+ if (!elseLines.length) {
+ lines.push(`${this.#indent(indentLevel + 1)}-- else branch`);
+ } else {
+ lines.push(...elseLines);
+ }
+ }
+
+ lines.push(`${this.#indent(indentLevel)}end`);
+ return lines;
+ }
+
+ /**
+ * Emits Lua for a for-loop node.
+ *
+ * @param {BlueprintNode} node Loop node.
+ * @param {number} indentLevel Indent level.
+ * @param {Set} path Cycle guard.
+ * @returns {Array}
+ */
+ #emitForLoop(node, indentLevel, path) {
+ const indexName = this.#sanitizeIdentifier(
+ String(node.properties.index ?? "i")
+ );
+ const startValue = this.#resolveValueInput(node, "start", "0");
+ const endValue = this.#resolveValueInput(node, "end", "0");
+ const stepValue = this.#resolveValueInput(node, "step", "1");
+
+ const lines = [
+ `${this.#indent(
+ indentLevel
+ )}for ${indexName} = ${startValue}, ${endValue}, ${stepValue} do`,
+ ];
+
+ const loopLines = this.#emitBranch(
+ node.id,
+ "loop",
+ indentLevel + 1,
+ new Set(path)
+ );
+ if (!loopLines.length) {
+ lines.push(`${this.#indent(indentLevel + 1)}-- loop body`);
+ } else {
+ lines.push(...loopLines);
+ }
+
+ lines.push(`${this.#indent(indentLevel)}end`);
+ lines.push(...this.#emitBranch(node.id, "completed", indentLevel, path));
+ return lines;
+ }
+
+ /**
+ * Emits Lua for a sequence node, executing each branch in order.
+ *
+ * @param {BlueprintNode} node Sequence node.
+ * @param {number} indentLevel Indentation level.
+ * @param {Set} path Cycle guard collection.
+ * @returns {Array}
+ */
+ #emitSequence(node, indentLevel, path) {
+ const lines = [];
+ node.outputs.forEach((pin) => {
+ const label = (pin.name ?? pin.id).toLowerCase();
+ lines.push(`${this.#indent(indentLevel)}-- sequence ${label}`);
+
+ const targets = this.#findExecTargets(node.id, pin.id);
+ if (!targets.length) {
+ return;
+ }
+
+ targets.forEach((target) => {
+ const branchPath = new Set(path);
+ lines.push(
+ ...this.#emitExecChain(target.nodeId, indentLevel, branchPath)
+ );
+ });
+ });
+ return lines;
+ }
+
+ /**
+ * Emits Lua for a call custom event node.
+ *
+ * @param {BlueprintNode} node Call node reference.
+ * @param {number} indentLevel Indentation level.
+ * @param {Set} path Cycle detection guard.
+ * @returns {Array}
+ */
+ #emitCallCustomEvent(node, indentLevel, path) {
+ const eventId =
+ typeof node.properties.eventId === "string"
+ ? node.properties.eventId
+ : "";
+ const functionName = eventId
+ ? this.customEventFunctionNames.get(eventId)
+ : null;
+ const lines = [];
+ if (functionName) {
+ const signature = this.customEventParameters.get(eventId) ?? [];
+ const argEntries = signature.map((entry) => {
+ const fallback = entry.optional
+ ? "nil"
+ : this.#defaultLiteralForKind(entry.kind);
+ const value = this.#resolveValueInput(
+ node,
+ entry.inputPinId,
+ fallback
+ );
+ return { value, optional: Boolean(entry.optional) };
+ });
+ while (
+ argEntries.length &&
+ argEntries[argEntries.length - 1].optional &&
+ argEntries[argEntries.length - 1].value === "nil"
+ ) {
+ argEntries.pop();
+ }
+ const call = argEntries.length
+ ? `${functionName}(${argEntries
+ .map((entry) => entry.value)
+ .join(", ")})`
+ : `${functionName}()`;
+ lines.push(`${this.#indent(indentLevel)}${call}`);
+ } else {
+ lines.push(`${this.#indent(indentLevel)}-- missing custom event target`);
+ }
+ lines.push(...this.#emitNextExec(node.id, "exec_out", indentLevel, path));
+ return lines;
+ }
+
+ /**
+ * Emits a branch for a given exec output pin.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Output pin identifier.
+ * @param {number} indentLevel Indent level.
+ * @param {Set} path Cycle guard.
+ * @returns {Array}
+ */
+ #emitBranch(nodeId, pinId, indentLevel, path) {
+ const targets = this.#findExecTargets(nodeId, pinId);
+ if (!targets.length) {
+ return [];
+ }
+
+ const lines = [];
+ targets.forEach((target) => {
+ lines.push(...this.#emitExecChain(target.nodeId, indentLevel, path));
+ });
+ return lines;
+ }
+
+ /**
+ * Advances to the next node for sequential exec outputs.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Exec output pin identifier.
+ * @param {number} indentLevel Indent level.
+ * @param {Set} path Cycle guard.
+ * @returns {Array}
+ */
+ #emitNextExec(nodeId, pinId, indentLevel, path) {
+ const targets = this.#findExecTargets(nodeId, pinId);
+ if (!targets.length) {
+ return [];
+ }
+
+ return this.#emitExecChain(targets[0].nodeId, indentLevel, path);
+ }
+
+ /**
+ * Retrieves execution targets for a given output pin.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Output pin identifier.
+ * @returns {Array<{nodeId:string,pinId:string}>}
+ */
+ #findExecTargets(nodeId, pinId) {
+ return this.connections
+ .filter(
+ (connection) =>
+ connection.kind === "exec" &&
+ connection.from.nodeId === nodeId &&
+ connection.from.pinId === pinId
+ )
+ .map((connection) => connection.to);
+ }
+
+ /**
+ * Resolves the expression bound to an input pin.
+ *
+ * @param {BlueprintNode} node Parent node.
+ * @param {string} pinId Input pin identifier.
+ * @param {string} fallback Fallback literal when unconnected.
+ * @returns {string}
+ */
+ #resolveValueInput(node, pinId, fallback) {
+ const connection = this.#getConnectionTo(node.id, pinId);
+ if (connection) {
+ return this.#evaluateValue(connection.from.nodeId, connection.from.pinId);
+ }
+
+ const pin = this.#findPin(node, pinId);
+ const inlineKey = this.#inlinePropertyKey(pinId);
+ if (Object.prototype.hasOwnProperty.call(node.properties, inlineKey)) {
+ const inlineValue = node.properties[inlineKey];
+ if (inlineValue !== undefined && inlineValue !== null) {
+ return this.#formatLiteral(pin?.kind ?? "any", inlineValue);
+ }
+ }
+
+ if (pin && pin.defaultValue !== undefined && pin.defaultValue !== null) {
+ return this.#formatLiteral(pin.kind, pin.defaultValue);
+ }
+
+ return fallback;
+ }
+
+ /**
+ * Retrieves the pin descriptor for a node.
+ *
+ * @param {BlueprintNode} node Parent node.
+ * @param {string} pinId Pin identifier.
+ * @returns {import('../core/BlueprintNode.js').PinDescriptor | undefined}
+ */
+ #findPin(node, pinId) {
+ return [...node.inputs, ...node.outputs].find((pin) => pin.id === pinId);
+ }
+
+ /**
+ * Locates the incoming connection for a specific pin.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @returns {import('./Connection.js').Connection | undefined}
+ */
+ #getConnectionTo(nodeId, pinId) {
+ return this.connections.find(
+ (connection) =>
+ connection.to.nodeId === nodeId && connection.to.pinId === pinId
+ );
+ }
+
+ /**
+ * Evaluates an expression produced by a value pin.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Output pin identifier.
+ * @returns {string}
+ */
+ #evaluateValue(nodeId, pinId) {
+ const cacheKey = `${nodeId}:${pinId}`;
+ if (this.valueCache.has(cacheKey)) {
+ return this.valueCache.get(cacheKey);
+ }
+
+ if (this.valueStack.has(cacheKey)) {
+ return "nil";
+ }
+
+ this.valueStack.add(cacheKey);
+ const node = this.nodes.get(nodeId);
+ if (!node) {
+ this.valueStack.delete(cacheKey);
+ return "nil";
+ }
+
+ let expression;
+ const behavior = this.registry.getBehavior(node.type);
+ if (behavior?.evaluateValue) {
+ expression = behavior.evaluateValue(
+ this.#createValueContext(node, pinId)
+ );
+ }
+
+ if (expression === undefined) {
+ switch (node.type) {
+ case "number_literal":
+ expression = this.#formatLiteral(
+ "number",
+ node.properties.value ?? 0
+ );
+ break;
+ case "string_literal":
+ expression = this.#formatLiteral(
+ "string",
+ node.properties.value ?? ""
+ );
+ break;
+ case "boolean_literal":
+ expression = this.#formatLiteral(
+ "boolean",
+ node.properties.value ?? "true"
+ );
+ break;
+ case "get_var": {
+ const name = this.#resolveWorkspaceVariableName(node);
+ expression = name;
+ break;
+ }
+ case "custom_event": {
+ const parameterName = this.customEventParameterLookup.get(
+ `${node.id}:${pinId}`
+ );
+ expression = parameterName ?? "nil";
+ break;
+ }
+ case "add_number": {
+ const a = this.#resolveValueInput(node, "a", "0");
+ const b = this.#resolveValueInput(node, "b", "0");
+ expression = `(${a}) + (${b})`;
+ break;
+ }
+ case "multiply_number": {
+ const a = this.#resolveValueInput(node, "a", "1");
+ const b = this.#resolveValueInput(node, "b", "1");
+ expression = `(${a}) * (${b})`;
+ break;
+ }
+ case "compare": {
+ const a = this.#resolveValueInput(node, "a", "0");
+ const b = this.#resolveValueInput(node, "b", "0");
+ const operator = this.#sanitizeOperator(
+ String(node.properties.operator ?? "==")
+ );
+ expression = `(${a}) ${operator} (${b})`;
+ break;
+ }
+ default: {
+ expression = "nil";
+ break;
+ }
+ }
+ }
+
+ this.valueStack.delete(cacheKey);
+ this.valueCache.set(cacheKey, expression);
+ return expression;
+ }
+
+ /**
+ * Sanitizes an identifier for Lua output.
+ *
+ * @param {string} name Input name.
+ * @returns {string}
+ */
+ #sanitizeIdentifier(name) {
+ const trimmed = name.trim();
+ let sanitized = trimmed.replace(/[^a-zA-Z0-9_]/g, "_");
+ if (!sanitized.length) {
+ sanitized = "var";
+ }
+
+ if (!/^[a-zA-Z_]/.test(sanitized)) {
+ sanitized = `v_${sanitized}`;
+ }
+
+ return sanitized.toLowerCase();
+ }
+
+ /**
+ * Ensures comparison operators are valid for Lua.
+ *
+ * @param {string} operator Operator candidate.
+ * @returns {string}
+ */
+ #sanitizeOperator(operator) {
+ const allowed = ["==", "!=", ">", "<", ">=", "<="];
+ if (allowed.includes(operator)) {
+ return operator === "!=" ? "~=" : operator;
+ }
+ return "==";
+ }
+
+ /**
+ * Formats a literal based on pin kind.
+ *
+ * @param {string} kind Pin kind.
+ * @param {unknown} value Raw value.
+ * @returns {string}
+ */
+ #formatLiteral(kind, value) {
+ switch (kind) {
+ case "number": {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? String(numeric) : "0";
+ }
+ case "boolean": {
+ if (typeof value === "string") {
+ return value === "false" ? "false" : "true";
+ }
+ return value ? "true" : "false";
+ }
+ case "table": {
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ return trimmed.length ? trimmed : "{}";
+ }
+ return "{}";
+ }
+ case "string":
+ return JSON.stringify(String(value ?? ""));
+ default:
+ return JSON.stringify(String(value ?? ""));
+ }
+ }
+
+ /**
+ * Provides a default literal for the supplied pin kind.
+ *
+ * @param {string} kind Pin kind reference.
+ * @returns {string}
+ */
+ #defaultLiteralForKind(kind) {
+ switch (kind) {
+ case "number":
+ return "0";
+ case "boolean":
+ return "false";
+ case "string":
+ return '""';
+ case "table":
+ return "{}";
+ default:
+ return "nil";
+ }
+ }
+
+ /**
+ * Determines the display name for a custom event node.
+ *
+ * @param {BlueprintNode} node Custom event node reference.
+ * @returns {string}
+ */
+ #getCustomEventDisplayName(node) {
+ const raw =
+ typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "";
+ return raw || "CustomEvent";
+ }
+
+ /**
+ * Builds a map of custom event node identifiers to Lua function names.
+ *
+ * @param {Array} customEvents Custom event nodes to process.
+ * @returns {Map}
+ */
+ #buildCustomEventFunctionMap(customEvents) {
+ const map = new Map();
+ const usedNames = new Set();
+
+ customEvents.forEach((node) => {
+ const displayName = this.#getCustomEventDisplayName(node);
+ let baseIdentifier = this.#sanitizeIdentifier(displayName);
+ if (!baseIdentifier.startsWith("custom_")) {
+ baseIdentifier = `custom_${baseIdentifier}`;
+ }
+
+ let candidate = baseIdentifier;
+ let suffix = 1;
+ while (usedNames.has(candidate)) {
+ candidate = `${baseIdentifier}_${suffix}`;
+ suffix += 1;
+ }
+
+ usedNames.add(candidate);
+ map.set(node.id, candidate);
+ });
+
+ return map;
+ }
+
+ /**
+ * Builds metadata describing custom event parameter layouts.
+ *
+ * @param {Array} customEvents Custom event nodes to process.
+ * @returns {{ parameterMap: Map>, lookupMap: Map }}
+ */
+ #buildCustomEventParameterMap(customEvents) {
+ /** @type {Map>} */
+ const parameterMap = new Map();
+ /** @type {Map} */
+ const lookupMap = new Map();
+
+ customEvents.forEach((node) => {
+ const rawParameters = Array.isArray(node.properties.parameters)
+ ? node.properties.parameters
+ : [];
+ const allowedKinds = new Set(["any", "number", "string", "boolean"]);
+ const usedNames = new Set();
+ /** @type {Array<{ id: string, name: string, luaName: string, outputPinId: string, inputPinId: string, kind: string }>} */
+ const entries = [];
+
+ rawParameters.forEach((parameter, index) => {
+ const rawName =
+ typeof parameter?.name === "string" ? parameter.name.trim() : "";
+ const displayName = rawName || `param${index + 1}`;
+ let luaName = this.#sanitizeIdentifier(displayName);
+ if (!luaName.length) {
+ luaName = `param${index + 1}`;
+ }
+ let candidate = luaName;
+ let suffix = 2;
+ while (usedNames.has(candidate)) {
+ candidate = `${luaName}_${suffix}`;
+ suffix += 1;
+ }
+ usedNames.add(candidate);
+
+ const rawId =
+ typeof parameter?.id === "string" ? parameter.id.trim() : "";
+ const parameterId =
+ rawId || `param_${String(index + 1).padStart(2, "0")}`;
+ const outputPinId = `param_${parameterId}`;
+ const inputPinId = `arg_${parameterId}`;
+ const rawKind =
+ typeof parameter?.type === "string" ? parameter.type : "any";
+ const kind = allowedKinds.has(rawKind) ? rawKind : "any";
+ const optional = Boolean(parameter?.optional);
+
+ entries.push({
+ id: parameterId,
+ name: displayName,
+ luaName: candidate,
+ outputPinId,
+ inputPinId,
+ kind,
+ optional,
+ });
+ lookupMap.set(`${node.id}:${outputPinId}`, candidate);
+ });
+
+ parameterMap.set(node.id, entries);
+ });
+
+ return { parameterMap, lookupMap };
+ }
+
+ /**
+ * Prepares global variable declarations for Lua output.
+ *
+ * @param {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} variables Workspace variables.
+ * @returns {Array}
+ */
+ #prepareGlobalVariableDeclarations(variables) {
+ const declarations = [];
+ if (!Array.isArray(variables) || !variables.length) {
+ return declarations;
+ }
+
+ const usedNames = new Set();
+ variables.forEach((entry, index) => {
+ if (!entry) {
+ return;
+ }
+
+ const rawName =
+ typeof entry.name === "string" && entry.name.trim().length
+ ? entry.name.trim()
+ : typeof entry.id === "string" && entry.id
+ ? entry.id
+ : `var${index + 1}`;
+ let baseName = this.#sanitizeIdentifier(rawName);
+ if (!baseName.length) {
+ baseName = `var${index + 1}`;
+ }
+
+ let candidate = baseName;
+ let suffix = 2;
+ while (usedNames.has(candidate)) {
+ candidate = `${baseName}_${suffix}`;
+ suffix += 1;
+ }
+ usedNames.add(candidate);
+
+ if (typeof entry.id === "string" && entry.id.length) {
+ this.globalVariableNames.set(entry.id, candidate);
+ }
+
+ const literal = this.#formatVariableDefaultLiteral(entry);
+ declarations.push(`${candidate} = ${literal}`);
+ });
+
+ return declarations;
+ }
+
+ /**
+ * Formats a workspace variable default for Lua initialization.
+ *
+ * @param {{ type: string, defaultValue: unknown }} variable Variable descriptor.
+ * @returns {string}
+ */
+ #formatVariableDefaultLiteral(variable) {
+ const type = this.#normalizeVariableType(variable?.type);
+ const value = variable?.defaultValue;
+
+ switch (type) {
+ case "number": {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? String(numeric) : "0";
+ }
+ case "boolean":
+ return value ? "true" : "false";
+ case "string":
+ return JSON.stringify(String(value ?? ""));
+ case "table": {
+ if (Array.isArray(value)) {
+ const fragments = value
+ .map((entry) => this.#formatTableDefaultEntry(entry))
+ .filter((fragment) => fragment.length);
+
+ if (!fragments.length) {
+ return "{}";
+ }
+
+ const rawCandidate =
+ value.length === 1 && typeof value[0]?.value === "string"
+ ? value[0].value.trim()
+ : "";
+ if (
+ value.length === 1 &&
+ !value[0]?.key &&
+ rawCandidate.startsWith("{") &&
+ rawCandidate.endsWith("}")
+ ) {
+ return rawCandidate;
+ }
+
+ const singleLine = fragments.join(", ");
+ if (singleLine.length <= 60 && !singleLine.includes("\n")) {
+ return `{ ${singleLine} }`;
+ }
+ return `{\n ${fragments.join(",\n ")}\n}`;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ return trimmed.length ? trimmed : "{}";
+ }
+ return "{}";
+ }
+ default: {
+ if (value === null || value === undefined) {
+ return "nil";
+ }
+ if (typeof value === "number") {
+ return Number.isFinite(value) ? String(value) : "nil";
+ }
+ if (typeof value === "boolean") {
+ return value ? "true" : "false";
+ }
+ return JSON.stringify(String(value));
+ }
+ }
+ }
+
+ /**
+ * Formats a table default entry into a Lua fragment.
+ *
+ * @param {{ key?: string, value?: string }} entry Table entry descriptor.
+ * @returns {string}
+ */
+ #formatTableDefaultEntry(entry) {
+ if (!entry || typeof entry !== "object") {
+ return "";
+ }
+
+ const key =
+ typeof entry.key === "string" ? entry.key.trim() : "";
+ const value =
+ typeof entry.value === "string" ? entry.value.trim() : "";
+
+ if (!key.length) {
+ return value;
+ }
+
+ const formattedKey = this.#formatTableEntryKey(key);
+ const targetValue = value.length ? value : "nil";
+ return `${formattedKey} = ${targetValue}`;
+ }
+
+ /**
+ * Normalizes a table entry key for Lua output.
+ *
+ * @param {string} key Raw key value.
+ * @returns {string}
+ */
+ #formatTableEntryKey(key) {
+ const trimmed = key.trim();
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
+ return trimmed;
+ }
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
+ return trimmed;
+ }
+ return `[${trimmed}]`;
+ }
+
+ /**
+ * Resolves the Lua identifier for a workspace variable node, honoring generator mappings.
+ *
+ * @param {BlueprintNode} node Blueprint node referencing a variable.
+ * @returns {string}
+ */
+ #resolveWorkspaceVariableName(node) {
+ const rawId =
+ typeof node?.properties?.variableId === "string"
+ ? node.properties.variableId
+ : "";
+ if (rawId) {
+ const mapped = this.globalVariableNames.get(rawId);
+ if (mapped) {
+ return mapped;
+ }
+ }
+
+ const fallback =
+ typeof node?.properties?.name === "string"
+ ? node.properties.name
+ : "var";
+ return this.#sanitizeIdentifier(fallback);
+ }
+
+ /**
+ * Normalizes a variable type descriptor.
+ *
+ * @param {unknown} value Candidate type.
+ * @returns {string}
+ */
+ #normalizeVariableType(value) {
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ switch (normalized) {
+ case "number":
+ case "string":
+ case "boolean":
+ case "table":
+ return normalized;
+ default:
+ return "any";
+ }
+ }
+ return "any";
+ }
+
+ /**
+ * Returns indentation whitespace for the given level.
+ *
+ * @param {number} level Indent level.
+ * @returns {string}
+ */
+ #indent(level) {
+ return this.indentUnit.repeat(level);
+ }
+
+ /**
+ * Generates the property key used for inline pin overrides.
+ *
+ * @param {string} pinId Pin identifier.
+ * @returns {string}
+ */
+ #inlinePropertyKey(pinId) {
+ return `pin:${pinId}`;
+ }
+}
diff --git a/scripts/core/NodeGraph.js b/scripts/core/NodeGraph.js
new file mode 100644
index 0000000..77cdee5
--- /dev/null
+++ b/scripts/core/NodeGraph.js
@@ -0,0 +1,355 @@
+import { BlueprintNode } from "./BlueprintNode.js";
+import { Connection } from "./Connection.js";
+
+/** @typedef {import('./BlueprintNode.js').BlueprintNodeInit} BlueprintNodeInit */
+/** @typedef {import('./BlueprintNode.js').PinDescriptor} PinDescriptor */
+/** @typedef {import('./Connection.js').PinReference} PinReference */
+
+/**
+ * Maintains the canonical blueprint data model: nodes, pins, and their connections.
+ */
+export class NodeGraph extends EventTarget {
+ constructor() {
+ super();
+ /** @type {Map} */
+ this.nodes = new Map();
+ /** @type {Map} */
+ this.connections = new Map();
+ this.nodeCounters = new Map();
+ }
+
+ /**
+ * Emits a custom event notifying listeners about a change.
+ *
+ * @param {string} type Event name.
+ * @param {unknown} detail Event payload.
+ */
+ emit(type, detail) {
+ this.dispatchEvent(new CustomEvent(type, { detail }));
+ }
+
+ /**
+ * Generates a unique node identifier scoped by node type.
+ *
+ * @param {string} type Node definition identifier.
+ * @returns {string}
+ */
+ createNodeId(type) {
+ const current = this.nodeCounters.get(type) ?? 0;
+ const next = current + 1;
+ this.nodeCounters.set(type, next);
+ return `${type}_${String(next).padStart(2, "0")}`;
+ }
+
+ /**
+ * Registers a node instance within the graph.
+ *
+ * @param {BlueprintNode} node Node to add.
+ */
+ addNode(node) {
+ this.nodes.set(node.id, node);
+ this.emit("nodeschanged", { type: "add", node });
+ }
+
+ /**
+ * Removes a node and any associated connections.
+ *
+ * @param {string} nodeId Target node identifier.
+ */
+ removeNode(nodeId) {
+ const node = this.nodes.get(nodeId);
+ if (!node) {
+ return;
+ }
+
+ [...this.connections.values()].forEach((connection) => {
+ if (
+ connection.from.nodeId === nodeId ||
+ connection.to.nodeId === nodeId
+ ) {
+ this.connections.delete(connection.id);
+ }
+ });
+
+ this.nodes.delete(nodeId);
+ this.emit("nodeschanged", { type: "remove", nodeId });
+ this.emit("connectionschanged", { type: "prune", nodeId });
+ }
+
+ /**
+ * Updates the node position within the graph.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {{x:number,y:number}} position New coordinates to store.
+ */
+ setNodePosition(nodeId, position) {
+ const node = this.nodes.get(nodeId);
+ if (!node) {
+ return;
+ }
+
+ node.setPosition(position);
+ this.emit("nodepositionchanged", { nodeId, position: { ...position } });
+ }
+
+ /**
+ * Updates a node property bag value.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} key Property key.
+ * @param {unknown} value Property value.
+ */
+ setNodeProperty(nodeId, key, value) {
+ const node = this.nodes.get(nodeId);
+ if (!node) {
+ return;
+ }
+
+ node.properties[key] = value;
+ this.emit("nodepropertychanged", { nodeId, key, value });
+ }
+
+ /**
+ * Removes an existing connection.
+ *
+ * @param {string} connectionId Identifier for the connection.
+ */
+ removeConnection(connectionId) {
+ if (!this.connections.has(connectionId)) {
+ return;
+ }
+
+ this.connections.delete(connectionId);
+ this.emit("connectionschanged", { type: "remove", connectionId });
+ }
+
+ /**
+ * Removes connections linked to a specific pin.
+ *
+ * @param {PinReference} ref Pin reference to clear.
+ */
+ removeConnectionsForPin(ref) {
+ let mutated = false;
+ const affectedNodes = new Set();
+ [...this.connections.values()].forEach((connection) => {
+ if (
+ (connection.from.nodeId === ref.nodeId &&
+ connection.from.pinId === ref.pinId) ||
+ (connection.to.nodeId === ref.nodeId &&
+ connection.to.pinId === ref.pinId)
+ ) {
+ this.connections.delete(connection.id);
+ mutated = true;
+ affectedNodes.add(connection.from.nodeId);
+ affectedNodes.add(connection.to.nodeId);
+ }
+ });
+
+ if (mutated) {
+ this.emit("connectionschanged", {
+ type: "prune",
+ ref,
+ nodes: [...affectedNodes],
+ });
+ }
+ }
+
+ /**
+ * Attempts to connect two pins if the contract is valid.
+ *
+ * @param {PinReference} from Output pin reference.
+ * @param {PinReference} to Input pin reference.
+ * @returns {boolean} Whether the connection was created.
+ */
+ connect(from, to) {
+ const fromNode = this.nodes.get(from.nodeId);
+ const toNode = this.nodes.get(to.nodeId);
+ if (!fromNode || !toNode) {
+ return false;
+ }
+
+ const fromPin = fromNode.getPin(from.pinId);
+ const toPin = toNode.getPin(to.pinId);
+ if (!fromPin || !toPin) {
+ return false;
+ }
+
+ if (!this.#isConnectionValid(fromNode, fromPin, toNode, toPin)) {
+ return false;
+ }
+
+ if (fromPin.kind === "exec") {
+ this.removeConnectionsForPin(from);
+ }
+
+ // Deduplicate single-input connections except for exec inputs which may stack.
+ if (toPin.kind !== "exec") {
+ this.removeConnectionsForPin(to);
+ }
+
+ const connection = new Connection(
+ from,
+ to,
+ toPin.kind === "any" ? fromPin.kind : toPin.kind
+ );
+ this.connections.set(connection.id, connection);
+ this.emit("connectionschanged", { type: "add", connection });
+ return true;
+ }
+
+ /**
+ * Retrieves an immutable list of nodes.
+ *
+ * @returns {Array}
+ */
+ getNodes() {
+ return [...this.nodes.values()].map((node) => node);
+ }
+
+ /**
+ * Retrieves all active connections.
+ *
+ * @returns {Array}
+ */
+ getConnections() {
+ return [...this.connections.values()].map((connection) => connection);
+ }
+
+ /**
+ * Returns connections targeting a particular node and optional pin.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} [pinId] Optional pin identifier.
+ * @returns {Array}
+ */
+ getConnectionsForNode(nodeId, pinId) {
+ return this.getConnections().filter((connection) => {
+ const matchesNode =
+ connection.from.nodeId === nodeId || connection.to.nodeId === nodeId;
+ const matchesPin = !pinId
+ ? true
+ : connection.from.pinId === pinId || connection.to.pinId === pinId;
+ return matchesNode && matchesPin;
+ });
+ }
+
+ /**
+ * Produces a serializable payload for the graph.
+ *
+ * @returns {{nodes: Array>, connections: Array>}}
+ */
+ toJSON() {
+ return {
+ nodes: this.getNodes().map((node) => node.toJSON()),
+ connections: this.getConnections().map((connection) =>
+ connection.toJSON()
+ ),
+ };
+ }
+
+ /**
+ * Restores a graph from serialized payload.
+ *
+ * @param {{nodes:Array, connections:Array>}} payload Serialized graph data.
+ * @returns {NodeGraph}
+ */
+ static fromJSON(payload) {
+ const graph = new NodeGraph();
+ payload.nodes.forEach((nodeData) => {
+ const node = new BlueprintNode(nodeData);
+ graph.nodes.set(node.id, node);
+ });
+ payload.connections.forEach((connectionData) => {
+ const connection = new Connection(
+ connectionData.from,
+ connectionData.to,
+ connectionData.kind,
+ connectionData.id
+ );
+ graph.connections.set(connection.id, connection);
+ });
+ return graph;
+ }
+
+ /**
+ * Replaces the current graph contents using the provided serialized payload.
+ *
+ * @param {{nodes:Array, connections:Array>}} payload Serialized graph data.
+ */
+ replaceState(payload) {
+ this.nodes.clear();
+ this.connections.clear();
+ this.nodeCounters.clear();
+
+ payload.nodes.forEach((nodeData) => {
+ const node = new BlueprintNode(nodeData);
+ this.nodes.set(node.id, node);
+ this.#trackNodeCounter(node);
+ });
+
+ payload.connections.forEach((connectionData) => {
+ const connection = new Connection(
+ connectionData.from,
+ connectionData.to,
+ connectionData.kind,
+ connectionData.id
+ );
+ this.connections.set(connection.id, connection);
+ });
+
+ this.emit("graphrestored", {
+ nodes: this.getNodes(),
+ connections: this.getConnections(),
+ });
+ }
+
+ /**
+ * Determines whether a connection between the provided pins is permitted.
+ *
+ * @param {BlueprintNode} fromNode Source node.
+ * @param {PinDescriptor} fromPin Source pin descriptor.
+ * @param {BlueprintNode} toNode Target node.
+ * @param {PinDescriptor} toPin Target pin descriptor.
+ * @returns {boolean}
+ */
+ #isConnectionValid(fromNode, fromPin, toNode, toPin) {
+ if (fromNode.id === toNode.id) {
+ return false;
+ }
+
+ if (fromPin.direction !== "output" || toPin.direction !== "input") {
+ return false;
+ }
+
+ if (
+ toPin.kind !== "any" &&
+ fromPin.kind !== "any" &&
+ fromPin.kind !== toPin.kind
+ ) {
+ return false;
+ }
+
+ // Avoid duplicate identical connections.
+ const duplicated = [...this.connections.values()].some(
+ (existing) =>
+ existing.from.nodeId === fromNode.id &&
+ existing.from.pinId === fromPin.id &&
+ existing.to.nodeId === toNode.id &&
+ existing.to.pinId === toPin.id
+ );
+
+ return !duplicated;
+ }
+
+ /**
+ * Updates the node counter registry for the supplied node instance.
+ *
+ * @param {BlueprintNode} node Node reference used to update counters.
+ */
+ #trackNodeCounter(node) {
+ const match = /_(\d+)$/.exec(node.id);
+ const numeric = match ? Number.parseInt(match[1], 10) : 0;
+ const current = this.nodeCounters.get(node.type) ?? 0;
+ this.nodeCounters.set(node.type, Math.max(current, numeric));
+ }
+}
diff --git a/scripts/main.js b/scripts/main.js
new file mode 100644
index 0000000..98b7380
--- /dev/null
+++ b/scripts/main.js
@@ -0,0 +1,469 @@
+import { BlueprintWorkspace } from "./ui/BlueprintWorkspace.js";
+import { NodeRegistry } from "./nodes/NodeRegistry.js";
+import { LuaGenerator } from "./core/LuaGenerator.js";
+
+/**
+ * @typedef {{ compileLua(source: string): Promise<{ filePath: string }> }} ElectronAPI
+ */
+
+/**
+ * @template T
+ * @param {T | null} element DOM element reference.
+ * @param {string} id Identifier used for error reporting.
+ * @returns {T}
+ */
+const requireElement = (element, id) => {
+ if (!element) {
+ throw new Error(`Missing required element: ${id}`);
+ }
+ return element;
+};
+
+const appBodyElement = /** @type {HTMLElement} */ (
+ requireElement(document.getElementById("appBody"), "appBody")
+);
+const workspaceElement = /** @type {HTMLElement} */ (
+ requireElement(document.getElementById("workspaceCanvas"), "workspaceCanvas")
+);
+const nodeLayer = /** @type {HTMLElement} */ (
+ requireElement(document.getElementById("nodeLayer"), "nodeLayer")
+);
+const connectionLayer = /** @type {SVGElement} */ (
+ requireElement(document.getElementById("connectionLayer"), "connectionLayer")
+);
+const eventList = /** @type {HTMLUListElement} */ (
+ requireElement(document.getElementById("eventList"), "eventList")
+);
+const variableList = /** @type {HTMLUListElement} */ (
+ requireElement(document.getElementById("variableList"), "variableList")
+);
+const addVariableButton = /** @type {HTMLButtonElement} */ (
+ requireElement(
+ document.getElementById("addVariableButton"),
+ "addVariableButton"
+ )
+);
+const paletteList = /** @type {HTMLElement} */ (
+ requireElement(document.getElementById("paletteList"), "paletteList")
+);
+const paletteSearch = /** @type {HTMLInputElement} */ (
+ requireElement(document.getElementById("paletteSearch"), "paletteSearch")
+);
+const paletteElement = /** @type {HTMLElement} */ (
+ requireElement(document.getElementById("palettePanel"), "palettePanel")
+);
+const inspectorContent = /** @type {HTMLElement} */ (
+ requireElement(
+ document.getElementById("inspectorContent"),
+ "inspectorContent"
+ )
+);
+const duplicateNodeButton = /** @type {HTMLButtonElement} */ (
+ requireElement(
+ document.getElementById("duplicateNodeButton"),
+ "duplicateNodeButton"
+ )
+);
+const deleteNodeButton = /** @type {HTMLButtonElement} */ (
+ requireElement(
+ document.getElementById("deleteNodeButton"),
+ "deleteNodeButton"
+ )
+);
+const compileButton = /** @type {HTMLButtonElement} */ (
+ requireElement(document.getElementById("compileButton"), "compileButton")
+);
+const exportLuaButton = /** @type {HTMLButtonElement} */ (
+ requireElement(document.getElementById("exportLuaButton"), "exportLuaButton")
+);
+const projectSettingsButton = /** @type {HTMLButtonElement} */ (
+ requireElement(
+ document.getElementById("projectSettingsButton"),
+ "projectSettingsButton"
+ )
+);
+const paletteToggleButton = /** @type {HTMLButtonElement} */ (
+ requireElement(
+ document.getElementById("paletteToggleButton"),
+ "paletteToggleButton"
+ )
+);
+const frameSelectionButton = /** @type {HTMLButtonElement} */ (
+ requireElement(
+ document.getElementById("frameSelectionButton"),
+ "frameSelectionButton"
+ )
+);
+const luaModal = /** @type {HTMLElement} */ (
+ requireElement(document.getElementById("luaModal"), "luaModal")
+);
+const luaModalCode = /** @type {HTMLElement} */ (
+ requireElement(document.getElementById("luaModalCode"), "luaModalCode")
+);
+const luaModalCodeContainer = /** @type {HTMLElement} */ (
+ requireElement(
+ luaModal.querySelector(".lua-modal__code"),
+ "luaModalCodeContainer"
+ )
+);
+const copyLuaButton = /** @type {HTMLButtonElement} */ (
+ requireElement(document.getElementById("copyLuaButton"), "copyLuaButton")
+);
+const closeLuaModalButton = /** @type {HTMLButtonElement} */ (
+ requireElement(
+ document.getElementById("closeLuaModalButton"),
+ "closeLuaModalButton"
+ )
+);
+
+const registry = new NodeRegistry();
+const generator = new LuaGenerator(registry);
+const workspace = new BlueprintWorkspace({
+ workspaceElement,
+ nodeLayer,
+ connectionLayer,
+ paletteList,
+ paletteSearch,
+ paletteElement,
+ inspectorContent,
+ duplicateNodeButton,
+ deleteNodeButton,
+ generator,
+ registry,
+ eventList,
+ variableList,
+ addVariableButton,
+ projectSettingsButton,
+ paletteToggleButton,
+ appBodyElement,
+ frameSelectionButton,
+});
+
+workspace.initialize();
+
+/** @type {ElectronAPI | undefined} */
+const electronAPI =
+ typeof window !== "undefined" && "electronAPI" in window
+ ? /** @type {ElectronAPI} */ (window.electronAPI)
+ : undefined;
+
+document.addEventListener("contextmenu", (event) => {
+ event.preventDefault();
+});
+
+const escapeHtml = (value) =>
+ value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+const luaTokenPattern =
+ /(--.*$)|("(?:\\.|[^"\\])*")|('(?!\[)(?:\\.|[^'\\])*')|(\b\d+(?:\.\d+)?\b)|\b(and|break|do|else|elseif|end|false|for|function|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/gm;
+
+const highlightLua = (source) => {
+ if (!source) {
+ return "";
+ }
+
+ let result = "";
+ let lastIndex = 0;
+
+ source.replace(
+ luaTokenPattern,
+ (
+ match,
+ comment,
+ doubleQuoted,
+ singleQuoted,
+ numberLiteral,
+ keyword,
+ offset
+ ) => {
+ result += escapeHtml(source.slice(lastIndex, offset));
+
+ let tokenType = "";
+ if (comment) {
+ tokenType = "comment";
+ } else if (doubleQuoted || singleQuoted) {
+ tokenType = "string";
+ } else if (numberLiteral) {
+ tokenType = "number";
+ } else {
+ tokenType = "keyword";
+ }
+
+ result += `${escapeHtml(
+ match
+ )}`;
+ lastIndex = offset + match.length;
+ return match;
+ }
+ );
+
+ result += escapeHtml(source.slice(lastIndex));
+ return result;
+};
+
+const focusableSelector =
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+let isLuaModalOpen = false;
+let lastFocusedElement = /** @type {HTMLElement | null} */ (null);
+let copyFeedbackTimeout = /** @type {number | null} */ (null);
+let currentLuaSource = "";
+let compileFeedbackTimeout = /** @type {number | null} */ (null);
+
+const scheduleMicrotask = (callback) => {
+ if (typeof queueMicrotask === "function") {
+ queueMicrotask(callback);
+ return;
+ }
+ Promise.resolve().then(callback);
+};
+
+const getModalFocusableElements = () => {
+ return /** @type {Array} */ (
+ Array.from(luaModal.querySelectorAll(focusableSelector)).filter(
+ (element) =>
+ element instanceof HTMLElement && !element.hasAttribute("disabled")
+ )
+ );
+};
+
+const handleModalKeydown = (event) => {
+ if (!isLuaModalOpen) {
+ return;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ closeLuaModal();
+ return;
+ }
+
+ if (event.key !== "Tab") {
+ return;
+ }
+
+ const focusable = getModalFocusableElements();
+ if (!focusable.length) {
+ event.preventDefault();
+ return;
+ }
+
+ const currentIndex = focusable.indexOf(document.activeElement);
+ let nextIndex = currentIndex;
+
+ if (event.shiftKey) {
+ nextIndex = currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1;
+ } else {
+ nextIndex =
+ currentIndex === -1 || currentIndex === focusable.length - 1
+ ? 0
+ : currentIndex + 1;
+ }
+
+ event.preventDefault();
+ focusable[nextIndex].focus();
+};
+
+const writeClipboard = async (text) => {
+ if (
+ navigator.clipboard &&
+ typeof navigator.clipboard.writeText === "function"
+ ) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+
+ const selection = document.getSelection();
+ const previousRange =
+ selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
+ const fallback = document.createElement("textarea");
+ fallback.value = text;
+ fallback.setAttribute("readonly", "true");
+ fallback.style.position = "fixed";
+ fallback.style.opacity = "0";
+ document.body.appendChild(fallback);
+ fallback.select();
+ document.execCommand("copy");
+ document.body.removeChild(fallback);
+
+ if (previousRange && selection) {
+ selection.removeAllRanges();
+ selection.addRange(previousRange);
+ }
+};
+
+const resetCopyFeedback = () => {
+ copyLuaButton.classList.remove("is-success");
+ copyLuaButton.setAttribute("aria-label", "Copy to clipboard");
+ copyLuaButton.setAttribute("title", "Copy to clipboard");
+};
+
+const openLuaModal = () => {
+ currentLuaSource = workspace.exportLua();
+ luaModalCode.innerHTML = highlightLua(currentLuaSource);
+ luaModalCodeContainer.scrollTop = 0;
+ if (copyFeedbackTimeout !== null) {
+ window.clearTimeout(copyFeedbackTimeout);
+ copyFeedbackTimeout = null;
+ }
+ resetCopyFeedback();
+
+ if (isLuaModalOpen) {
+ return;
+ }
+
+ lastFocusedElement =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ luaModal.removeAttribute("hidden");
+ luaModal.setAttribute("aria-hidden", "false");
+ document.body.classList.add("modal-open");
+ isLuaModalOpen = true;
+ document.addEventListener("keydown", handleModalKeydown);
+
+ scheduleMicrotask(() => {
+ const focusTarget = luaModalCodeContainer;
+ focusTarget.focus();
+ });
+};
+
+const closeLuaModal = () => {
+ if (!isLuaModalOpen) {
+ return;
+ }
+
+ isLuaModalOpen = false;
+ luaModal.setAttribute("aria-hidden", "true");
+ luaModal.setAttribute("hidden", "");
+ document.body.classList.remove("modal-open");
+ document.removeEventListener("keydown", handleModalKeydown);
+
+ if (copyFeedbackTimeout !== null) {
+ window.clearTimeout(copyFeedbackTimeout);
+ copyFeedbackTimeout = null;
+ }
+ resetCopyFeedback();
+
+ if (lastFocusedElement && document.contains(lastFocusedElement)) {
+ lastFocusedElement.focus();
+ }
+};
+
+/**
+ * Restores the compile button to its idle appearance.
+ *
+ * @returns {void}
+ */
+const resetCompileButton = () => {
+ if (compileFeedbackTimeout !== null) {
+ window.clearTimeout(compileFeedbackTimeout);
+ compileFeedbackTimeout = null;
+ }
+
+ compileButton.classList.remove("is-success", "is-error", "is-busy");
+ compileButton.disabled = false;
+ compileButton.textContent = "Compile";
+};
+
+/**
+ * Handles compile button clicks by requesting a Lua export from Electron.
+ *
+ * @returns {Promise}
+ */
+const handleCompileClick = async () => {
+ if (!electronAPI) {
+ return;
+ }
+
+ if (compileFeedbackTimeout !== null) {
+ window.clearTimeout(compileFeedbackTimeout);
+ compileFeedbackTimeout = null;
+ }
+
+ compileButton.classList.remove("is-success", "is-error");
+ compileButton.classList.add("is-busy");
+ compileButton.disabled = true;
+ compileButton.textContent = "Compiling...";
+
+ const source = workspace.exportLua();
+
+ try {
+ const result = await electronAPI.compileLua(source);
+ console.info("Lua compiled to", result.filePath);
+ compileButton.classList.remove("is-busy");
+ compileButton.classList.add("is-success");
+ compileButton.disabled = false;
+ compileButton.textContent = "Compiled!";
+ compileFeedbackTimeout = window.setTimeout(() => {
+ resetCompileButton();
+ }, 1800);
+ } catch (error) {
+ console.error("Failed to compile Lua", error);
+ compileButton.classList.remove("is-busy");
+ compileButton.classList.add("is-error");
+ compileButton.disabled = false;
+ compileButton.textContent = "Failed";
+ compileFeedbackTimeout = window.setTimeout(() => {
+ resetCompileButton();
+ }, 2200);
+ }
+};
+
+if (electronAPI && typeof electronAPI.compileLua === "function") {
+ compileButton.disabled = false;
+ compileButton.addEventListener("click", () => {
+ handleCompileClick().catch((error) => {
+ console.error("Unhandled compile error", error);
+ resetCompileButton();
+ });
+ });
+} else {
+ compileButton.disabled = true;
+ compileButton.title = "Compile is available in the desktop app";
+}
+
+exportLuaButton.addEventListener("click", () => {
+ openLuaModal();
+});
+
+luaModal.addEventListener("click", (event) => {
+ const target =
+ event.target instanceof HTMLElement
+ ? event.target.closest("[data-modal-close]")
+ : null;
+ if (target) {
+ event.preventDefault();
+ closeLuaModal();
+ }
+});
+
+copyLuaButton.addEventListener("click", async () => {
+ if (!currentLuaSource) {
+ return;
+ }
+
+ try {
+ await writeClipboard(currentLuaSource);
+ copyLuaButton.classList.add("is-success");
+ copyLuaButton.setAttribute("aria-label", "Copied!");
+ copyLuaButton.setAttribute("title", "Copied!");
+ if (copyFeedbackTimeout !== null) {
+ window.clearTimeout(copyFeedbackTimeout);
+ }
+ copyFeedbackTimeout = window.setTimeout(() => {
+ resetCopyFeedback();
+ copyFeedbackTimeout = null;
+ }, 1500);
+ } catch (error) {
+ console.error("Copy failed", error);
+ }
+});
+
+closeLuaModalButton.addEventListener("click", () => {
+ closeLuaModal();
+});
diff --git a/scripts/nodes/NodeRegistry.js b/scripts/nodes/NodeRegistry.js
new file mode 100644
index 0000000..2a0f38f
--- /dev/null
+++ b/scripts/nodes/NodeRegistry.js
@@ -0,0 +1,296 @@
+import { BlueprintNode } from "../core/BlueprintNode.js";
+import { nodeModules } from "./library/index.js";
+
+/** @typedef {import('./nodeTypes.js').NodeDefinition} NodeDefinition */
+/** @typedef {import('./nodeTypes.js').NodeBehavior} NodeBehavior */
+/** @typedef {import('./nodeTypes.js').NodeModule} NodeModule */
+/** @typedef {{ value: string, weight: number }} SearchField */
+
+/**
+ * Central registry providing node definitions, behavior hooks, and factory helpers.
+ */
+export class NodeRegistry {
+ constructor() {
+ /** @type {Map} */
+ this.definitions = new Map();
+ /** @type {Map} */
+ this.behaviors = new Map();
+ /** @type {Set} */
+ this.entryNodeTypes = new Set();
+ /** @type {Map} */
+ this.entryPointEvents = new Map();
+
+ nodeModules.forEach((module) => this.registerModule(module));
+ }
+
+ /**
+ * Normalizes search inputs for case-insensitive comparisons.
+ *
+ * @param {string} query Raw query string.
+ * @returns {string}
+ */
+ #normalizeQuery(query) {
+ return (query ?? "").trim().toLowerCase();
+ }
+
+ /**
+ * Determines whether a node definition matches the provided query.
+ *
+ * @param {NodeDefinition} definition Definition to inspect.
+ * @param {string} query Search query.
+ * @returns {boolean}
+ */
+ matchesDefinition(definition, query) {
+ const normalized = this.#normalizeQuery(query);
+ if (!normalized) {
+ return true;
+ }
+
+ const score = this.#scoreDefinition(definition, normalized);
+ return Number.isFinite(score);
+ }
+
+ /**
+ * Enumerates every registered definition.
+ *
+ * @returns {Array}
+ */
+ list() {
+ return [...this.definitions.values()];
+ }
+
+ /**
+ * Retrieves a single node definition.
+ *
+ * @param {string} id Definition identifier.
+ * @returns {NodeDefinition | undefined}
+ */
+ get(id) {
+ return this.definitions.get(id);
+ }
+
+ /**
+ * Performs a fuzzy search across node metadata and returns results sorted by distance.
+ *
+ * @param {string} query Search input.
+ * @returns {Array}
+ */
+ search(query) {
+ const normalized = this.#normalizeQuery(query);
+ if (!normalized) {
+ return this.list().sort((a, b) => a.title.localeCompare(b.title));
+ }
+
+ /** @type {Array<{ definition: NodeDefinition, score: number }>} */
+ const ranked = [];
+ this.definitions.forEach((definition) => {
+ const score = this.#scoreDefinition(definition, normalized);
+ if (Number.isFinite(score)) {
+ ranked.push({ definition, score });
+ }
+ });
+
+ ranked.sort((a, b) => {
+ if (a.score === b.score) {
+ return a.definition.title.localeCompare(b.definition.title);
+ }
+ return a.score - b.score;
+ });
+
+ return ranked.map((entry) => entry.definition);
+ }
+
+ /**
+ * Aggregates searchable fields for a node definition and assigns weighting.
+ *
+ * @param {NodeDefinition} definition Definition to inspect.
+ * @returns {Array} Normalized field collection with weights.
+ */
+ #collectSearchFields(definition) {
+ const fields = /** @type {Array} */ ([
+ { value: definition.title, weight: 0.8 },
+ { value: definition.category, weight: 1 },
+ ]);
+
+ if (definition.description) {
+ fields.push({ value: definition.description, weight: 1 });
+ }
+
+ if (Array.isArray(definition.searchTags)) {
+ definition.searchTags.forEach((tag) => {
+ fields.push({ value: tag, weight: 1 });
+ });
+ }
+
+ return fields
+ .filter((field) => typeof field.value === "string" && field.value.trim().length)
+ .map((field) => ({
+ value: field.value.toLowerCase(),
+ weight: field.weight,
+ }));
+ }
+
+ /**
+ * Computes a fuzzy score representing how closely a definition matches a query.
+ *
+ * @param {NodeDefinition} definition Target definition.
+ * @param {string} normalizedQuery Lowercase trimmed query string.
+ * @returns {number} Matching score where lower values are closer; Infinity means no match.
+ */
+ #scoreDefinition(definition, normalizedQuery) {
+ const fields = this.#collectSearchFields(definition);
+ if (!fields.length) {
+ return Number.POSITIVE_INFINITY;
+ }
+
+ let best = Number.POSITIVE_INFINITY;
+ for (const field of fields) {
+ const score = this.#computeFuzzyScore(normalizedQuery, field.value);
+ if (!Number.isFinite(score)) {
+ continue;
+ }
+
+ const weighted = score * field.weight;
+ if (weighted < best) {
+ best = weighted;
+ }
+ if (best === 0) {
+ break;
+ }
+ }
+ return best;
+ }
+
+ /**
+ * Produces a fuzzy matching distance between the query and a candidate string.
+ *
+ * @param {string} query Normalized query string.
+ * @param {string} candidate Normalized candidate string.
+ * @returns {number} Distance score; Infinity indicates the query is not a subsequence of the candidate.
+ */
+ #computeFuzzyScore(query, candidate) {
+ if (!candidate) {
+ return Number.POSITIVE_INFINITY;
+ }
+
+ const qLength = query.length;
+ const cLength = candidate.length;
+ if (!qLength) {
+ return 0;
+ }
+
+ let qIndex = 0;
+ let score = 0;
+ let lastMatch = -1;
+
+ for (let cIndex = 0; cIndex < cLength; cIndex += 1) {
+ if (candidate[cIndex] !== query[qIndex]) {
+ continue;
+ }
+
+ if (lastMatch === -1) {
+ score += cIndex;
+ } else {
+ score += cIndex - lastMatch - 1;
+ }
+
+ lastMatch = cIndex;
+ qIndex += 1;
+
+ if (qIndex === qLength) {
+ break;
+ }
+ }
+
+ if (qIndex !== qLength) {
+ return Number.POSITIVE_INFINITY;
+ }
+
+ score += cLength - lastMatch - 1;
+ return score;
+ }
+
+ /**
+ * Instantiates a blueprint node from a definition.
+ *
+ * @param {string} definitionId Definition identifier.
+ * @param {{id: string, position: {x:number,y:number}}} options Initialization options.
+ * @returns {BlueprintNode}
+ */
+ createNode(definitionId, options) {
+ const definition = this.get(definitionId);
+ if (!definition) {
+ throw new Error(`Unknown node definition: ${definitionId}`);
+ }
+
+ const properties = {};
+ definition.properties.forEach((schema) => {
+ const value = schema.defaultValue ?? null;
+ properties[schema.key] = value;
+ });
+
+ definition.initializeProperties?.(properties);
+
+ return new BlueprintNode({
+ id: options.id,
+ type: definition.id,
+ title: definition.title,
+ position: { ...options.position },
+ inputs: definition.inputs.map((pin) => ({
+ ...pin,
+ })),
+ outputs: definition.outputs.map((pin) => ({
+ ...pin,
+ })),
+ properties,
+ });
+ }
+
+ /**
+ * Registers a node module exposing editor metadata and Lua behavior.
+ *
+ * @param {NodeModule} module Node module to register.
+ */
+ registerModule(module) {
+ const { definition, behavior } = module;
+ this.definitions.set(definition.id, definition);
+
+ if (behavior) {
+ this.behaviors.set(definition.id, behavior);
+ if (behavior.isEntryPoint) {
+ this.entryNodeTypes.add(definition.id);
+ if (behavior.eventName) {
+ this.entryPointEvents.set(definition.id, behavior.eventName);
+ }
+ }
+ }
+ }
+
+ /**
+ * Retrieves behavior metadata for a node type.
+ *
+ * @param {string} id Node identifier.
+ * @returns {NodeBehavior | undefined}
+ */
+ getBehavior(id) {
+ return this.behaviors.get(id);
+ }
+
+ /**
+ * Enumerates node types flagged as entry points.
+ *
+ * @returns {Array}
+ */
+ getEntryNodeTypes() {
+ return [...this.entryNodeTypes];
+ }
+
+ /**
+ * Exposes lifecycle event bindings for entry nodes.
+ *
+ * @returns {Map}
+ */
+ getEntryPointEvents() {
+ return new Map(this.entryPointEvents);
+ }
+}
diff --git a/scripts/nodes/library/addNumber.js b/scripts/nodes/library/addNumber.js
new file mode 100644
index 0000000..658a5bb
--- /dev/null
+++ b/scripts/nodes/library/addNumber.js
@@ -0,0 +1,41 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Adds two numeric values.
+ */
+export const addNumberNode = createNodeModule(
+ {
+ id: "add_number",
+ title: "Add",
+ category: "Math",
+ description: "Add two numbers.",
+ searchTags: ["add", "math", "sum", "number"],
+ inputs: [
+ {
+ id: "a",
+ name: "A",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "b",
+ name: "B",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "res", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "0");
+ return `(${a}) + (${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/audio/index.js b/scripts/nodes/library/audio/index.js
new file mode 100644
index 0000000..3bef2ec
--- /dev/null
+++ b/scripts/nodes/library/audio/index.js
@@ -0,0 +1,16 @@
+import { sfxNode } from "./sfx.js";
+import { musicNode } from "./music.js";
+
+/**
+ * All audio-related node modules backed by PICO-8 helpers.
+ */
+export const audioNodes = [sfxNode, musicNode];
+
+/**
+ * Checklist tracking coverage of documented audio helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const audioFunctionChecklist = [
+ { function: "SFX", nodeId: "audio_sfx", implemented: true },
+ { function: "MUSIC", nodeId: "audio_music", implemented: true },
+];
diff --git a/scripts/nodes/library/audio/music.js b/scripts/nodes/library/audio/music.js
new file mode 100644
index 0000000..f0f563e
--- /dev/null
+++ b/scripts/nodes/library/audio/music.js
@@ -0,0 +1,64 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Plays or stops music using the PICO-8 music helper.
+ */
+export const musicNode = createNodeModule(
+ {
+ id: "audio_music",
+ title: "Play Music",
+ category: "Audio",
+ description:
+ "Start, fade, or stop music playback using MUSIC().",
+ searchTags: ["music", "audio", "song", "pattern"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "pattern",
+ name: "Pattern",
+ direction: "input",
+ kind: "number",
+ description: "Pattern index or command",
+ defaultValue: 0,
+ },
+ {
+ id: "fade",
+ name: "Fade (ms)",
+ direction: "input",
+ kind: "number",
+ description: "Fade duration in milliseconds",
+ },
+ {
+ id: "mask",
+ name: "Channel Mask",
+ direction: "input",
+ kind: "number",
+ description: "Bitmask reserving channels",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const pattern = resolveValueInput("pattern", "0");
+ const fade = resolveValueInput("fade", OMIT);
+ const mask = resolveValueInput("mask", OMIT);
+
+ const args = [pattern];
+ if (fade !== OMIT || mask !== OMIT) {
+ const fadeArg = fade === OMIT ? "nil" : fade;
+ args.push(fadeArg);
+ if (mask !== OMIT) {
+ args.push(mask);
+ }
+ }
+
+ const line = `${indent(indentLevel)}music(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/audio/sfx.js b/scripts/nodes/library/audio/sfx.js
new file mode 100644
index 0000000..cfced1d
--- /dev/null
+++ b/scripts/nodes/library/audio/sfx.js
@@ -0,0 +1,76 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Plays or controls a sound effect using the PICO-8 sfx helper.
+ */
+export const sfxNode = createNodeModule(
+ {
+ id: "audio_sfx",
+ title: "Play SFX",
+ category: "Audio",
+ description:
+ "Trigger a sound effect, control looping, or stop playback via SFX().",
+ searchTags: ["sfx", "audio", "sound", "effect"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "id",
+ name: "SFX",
+ direction: "input",
+ kind: "number",
+ description: "Sound effect slot or command",
+ defaultValue: 0,
+ },
+ {
+ id: "channel",
+ name: "Channel",
+ direction: "input",
+ kind: "number",
+ description: "Playback channel (-1 auto)",
+ },
+ {
+ id: "offset",
+ name: "Offset",
+ direction: "input",
+ kind: "number",
+ description: "Note offset",
+ },
+ {
+ id: "length",
+ name: "Length",
+ direction: "input",
+ kind: "number",
+ description: "Number of notes to play",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const id = resolveValueInput("id", "0");
+ const channel = resolveValueInput("channel", OMIT);
+ const offset = resolveValueInput("offset", OMIT);
+ const length = resolveValueInput("length", OMIT);
+
+ const args = [id];
+ if (channel !== OMIT || offset !== OMIT || length !== OMIT) {
+ const channelArg = channel === OMIT ? "nil" : channel;
+ args.push(channelArg);
+ if (offset !== OMIT || length !== OMIT) {
+ const offsetArg = offset === OMIT ? "nil" : offset;
+ args.push(offsetArg);
+ if (length !== OMIT) {
+ args.push(length);
+ }
+ }
+ }
+
+ const line = `${indent(indentLevel)}sfx(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/booleanLiteral.js b/scripts/nodes/library/booleanLiteral.js
new file mode 100644
index 0000000..8e5d111
--- /dev/null
+++ b/scripts/nodes/library/booleanLiteral.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Constant boolean literal.
+ */
+export const booleanLiteralNode = createNodeModule(
+ {
+ id: "boolean_literal",
+ title: "Boolean",
+ category: "Values",
+ description: "Constant boolean literal.",
+ searchTags: ["boolean", "literal", "true", "false"],
+ inputs: [],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "boolean" },
+ ],
+ properties: [
+ { key: "value", label: "Value", type: "boolean", defaultValue: true },
+ ],
+ },
+ {
+ evaluateValue: ({ node, formatLiteral }) => {
+ return formatLiteral("boolean", node.properties.value ?? true);
+ },
+ }
+);
diff --git a/scripts/nodes/library/callCustomEvent.js b/scripts/nodes/library/callCustomEvent.js
new file mode 100644
index 0000000..f137727
--- /dev/null
+++ b/scripts/nodes/library/callCustomEvent.js
@@ -0,0 +1,32 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Invokes a custom event by reference.
+ * @type {import('../nodeTypes.js').NodeModule}
+ */
+export const callCustomEventNode = createNodeModule(
+ {
+ id: "call_custom_event",
+ title: "Call Custom Event",
+ category: "Events",
+ description: "Invokes a custom event defined elsewhere in the graph.",
+ searchTags: ["event", "call", "trigger", "custom"],
+ inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ initializeProperties: (properties) => {
+ properties.eventId =
+ typeof properties.eventId === "string" ? properties.eventId : "";
+ if (
+ !properties.arguments ||
+ typeof properties.arguments !== "object" ||
+ Array.isArray(properties.arguments)
+ ) {
+ properties.arguments = {};
+ }
+ },
+ },
+ null
+);
diff --git a/scripts/nodes/library/compare.js b/scripts/nodes/library/compare.js
new file mode 100644
index 0000000..5fe3a53
--- /dev/null
+++ b/scripts/nodes/library/compare.js
@@ -0,0 +1,47 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Compares two values using a selected operator.
+ */
+export const compareNode = createNodeModule(
+ {
+ id: "compare",
+ title: "Compare",
+ category: "Logic",
+ description: "Compare two values with a selected operator.",
+ searchTags: ["compare", "logic", "condition", "branch"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "any" },
+ { id: "b", name: "B", direction: "input", kind: "any" },
+ ],
+ outputs: [
+ { id: "res", name: "Result", direction: "output", kind: "boolean" },
+ ],
+ properties: [
+ {
+ key: "operator",
+ label: "Operator",
+ type: "enum",
+ defaultValue: "==",
+ options: [
+ { label: "Equal", value: "==" },
+ { label: "Not Equal", value: "!=" },
+ { label: "Greater", value: ">" },
+ { label: "Less", value: "<" },
+ { label: "Greater Or Equal", value: ">=" },
+ { label: "Less Or Equal", value: "<=" },
+ ],
+ },
+ ],
+ },
+ {
+ evaluateValue: ({ node, resolveValueInput, sanitizeOperator }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "0");
+ const operator = sanitizeOperator(
+ String(node.properties.operator ?? "==")
+ );
+ return `(${a}) ${operator} (${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/customEvent.js b/scripts/nodes/library/customEvent.js
new file mode 100644
index 0000000..82fdc81
--- /dev/null
+++ b/scripts/nodes/library/customEvent.js
@@ -0,0 +1,44 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Defines a reusable custom event entry point.
+ * @type {import('../nodeTypes.js').NodeModule}
+ */
+export const customEventNode = createNodeModule(
+ {
+ id: "custom_event",
+ title: "Custom Event",
+ category: "Events",
+ description: "Defines a custom event that can be triggered elsewhere.",
+ searchTags: ["event", "custom", "broadcast", "trigger"],
+ inputs: [],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [
+ {
+ key: "name",
+ label: "Event Name",
+ type: "string",
+ defaultValue: "CustomEvent",
+ placeholder: "Enter event name",
+ },
+ ],
+ initializeProperties: (properties) => {
+ if (typeof properties.name !== "string" || !properties.name.trim()) {
+ properties.name = "CustomEvent";
+ }
+ if (!Array.isArray(properties.parameters)) {
+ properties.parameters = [];
+ }
+ const counter = Number.isFinite(properties.parameterCounter)
+ ? Number(properties.parameterCounter)
+ : 0;
+ properties.parameterCounter = counter;
+ },
+ },
+ {
+ isEntryPoint: true,
+ emitExec: ({ emitNextExec }) => emitNextExec("exec_out"),
+ }
+);
diff --git a/scripts/nodes/library/data/cartdata.js b/scripts/nodes/library/data/cartdata.js
new file mode 100644
index 0000000..a8ad116
--- /dev/null
+++ b/scripts/nodes/library/data/cartdata.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Opens persistent cartridge storage using cartdata.
+ */
+export const cartdataNode = createNodeModule(
+ {
+ id: "data_cartdata",
+ title: "Cart Data Init",
+ category: "Data",
+ description: "Setup persistent storage for the cartridge using CARTDATA().",
+ searchTags: ["cartdata", "save", "persistent", "data"],
+ inputs: [
+ {
+ id: "id",
+ name: "Identifier",
+ direction: "input",
+ kind: "string",
+ defaultValue: "\"my_cart\"",
+ },
+ ],
+ outputs: [
+ { id: "loaded", name: "Loaded", direction: "output", kind: "boolean" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const id = resolveValueInput("id", '"pico_cart"');
+ return `cartdata(${id})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/data/dget.js b/scripts/nodes/library/data/dget.js
new file mode 100644
index 0000000..632c433
--- /dev/null
+++ b/scripts/nodes/library/data/dget.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads a persistent value using dget.
+ */
+export const dgetNode = createNodeModule(
+ {
+ id: "data_dget",
+ title: "Data Get",
+ category: "Data",
+ description: "Retrieve a persistent number using DGET().",
+ searchTags: ["dget", "load", "data", "persistent"],
+ inputs: [
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const index = resolveValueInput("index", "0");
+ return `dget(${index})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/data/dset.js b/scripts/nodes/library/data/dset.js
new file mode 100644
index 0000000..4299785
--- /dev/null
+++ b/scripts/nodes/library/data/dset.js
@@ -0,0 +1,43 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Writes a persistent value using dset.
+ */
+export const dsetNode = createNodeModule(
+ {
+ id: "data_dset",
+ title: "Data Set",
+ category: "Data",
+ description: "Store a persistent number using DSET().",
+ searchTags: ["dset", "save", "data", "persistent"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const index = resolveValueInput("index", "0");
+ const value = resolveValueInput("value", "0");
+ const line = `${indent(indentLevel)}dset(${index}, ${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/data/index.js b/scripts/nodes/library/data/index.js
new file mode 100644
index 0000000..b844d78
--- /dev/null
+++ b/scripts/nodes/library/data/index.js
@@ -0,0 +1,18 @@
+import { cartdataNode } from "./cartdata.js";
+import { dsetNode } from "./dset.js";
+import { dgetNode } from "./dget.js";
+
+/**
+ * All data persistence node modules backed by PICO-8 helpers.
+ */
+export const dataNodes = [cartdataNode, dsetNode, dgetNode];
+
+/**
+ * Checklist tracking coverage of documented cart data helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const dataFunctionChecklist = [
+ { function: "CARTDATA", nodeId: "data_cartdata", implemented: true },
+ { function: "DSET", nodeId: "data_dset", implemented: true },
+ { function: "DGET", nodeId: "data_dget", implemented: true },
+];
diff --git a/scripts/nodes/library/devkit/devkitConfig.js b/scripts/nodes/library/devkit/devkitConfig.js
new file mode 100644
index 0000000..7a4e85f
--- /dev/null
+++ b/scripts/nodes/library/devkit/devkitConfig.js
@@ -0,0 +1,35 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Applies host configuration flags via devkit_config().
+ */
+export const devkitConfigNode = createNodeModule(
+ {
+ id: "devkit_config",
+ title: "Devkit Config",
+ category: "Devkit",
+ description: "Send a configuration string to devkit_config().",
+ searchTags: ["devkit", "config", "host"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "config",
+ name: "Config",
+ direction: "input",
+ kind: "string",
+ defaultValue: "",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const config = resolveValueInput("config", '""');
+ const line = `${indent(indentLevel)}devkit_config(${config})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/devkit/devkitInputRead.js b/scripts/nodes/library/devkit/devkitInputRead.js
new file mode 100644
index 0000000..1b9e8d3
--- /dev/null
+++ b/scripts/nodes/library/devkit/devkitInputRead.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads values from devkit-provided inputs via devkit_input().
+ */
+export const devkitInputReadNode = createNodeModule(
+ {
+ id: "devkit_input_read",
+ title: "Devkit Input Read",
+ category: "Devkit",
+ description: "Fetch the value of a devkit input index.",
+ searchTags: ["devkit", "input", "read"],
+ inputs: [
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "any" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const index = resolveValueInput("index", "0");
+ return `devkit_input(${index})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/devkit/devkitInputWrite.js b/scripts/nodes/library/devkit/devkitInputWrite.js
new file mode 100644
index 0000000..35cfac1
--- /dev/null
+++ b/scripts/nodes/library/devkit/devkitInputWrite.js
@@ -0,0 +1,43 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Sends values to devkit inputs via devkit_input().
+ */
+export const devkitInputWriteNode = createNodeModule(
+ {
+ id: "devkit_input_write",
+ title: "Devkit Input Write",
+ category: "Devkit",
+ description: "Push a value to a devkit input index.",
+ searchTags: ["devkit", "input", "write"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const index = resolveValueInput("index", "0");
+ const value = resolveValueInput("value", "0");
+ const line = `${indent(indentLevel)}devkit_input(${index}, ${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/devkit/index.js b/scripts/nodes/library/devkit/index.js
new file mode 100644
index 0000000..6f65b38
--- /dev/null
+++ b/scripts/nodes/library/devkit/index.js
@@ -0,0 +1,22 @@
+import { devkitConfigNode } from "./devkitConfig.js";
+import { devkitInputReadNode } from "./devkitInputRead.js";
+import { devkitInputWriteNode } from "./devkitInputWrite.js";
+
+/**
+ * Node modules covering devkit helpers.
+ */
+export const devkitNodes = [
+ devkitConfigNode,
+ devkitInputReadNode,
+ devkitInputWriteNode,
+];
+
+/**
+ * Checklist tracking coverage of devkit helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const devkitFunctionChecklist = [
+ { function: "DEVKIT_CONFIG", nodeId: "devkit_config", implemented: true },
+ { function: "DEVKIT_INPUT (read)", nodeId: "devkit_input_read", implemented: true },
+ { function: "DEVKIT_INPUT (write)", nodeId: "devkit_input_write", implemented: true },
+];
diff --git a/scripts/nodes/library/eventDraw.js b/scripts/nodes/library/eventDraw.js
new file mode 100644
index 0000000..161b1ba
--- /dev/null
+++ b/scripts/nodes/library/eventDraw.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Lifecycle event triggered once per visible frame.
+ * @type {import('../nodeTypes.js').NodeModule}
+ */
+export const eventDrawNode = createNodeModule(
+ {
+ id: "event_draw",
+ title: "Event Draw",
+ category: "Events",
+ description: "Called once per visible frame.",
+ searchTags: ["draw", "render", "frame", "loop"],
+ unique: true,
+ inputs: [],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ isEntryPoint: true,
+ eventName: "_draw",
+ emitExec: ({ emitNextExec }) => emitNextExec("exec_out"),
+ }
+);
diff --git a/scripts/nodes/library/eventStart.js b/scripts/nodes/library/eventStart.js
new file mode 100644
index 0000000..7cbeebd
--- /dev/null
+++ b/scripts/nodes/library/eventStart.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Lifecycle entry triggered once when the cart boots.
+ * @type {import('../nodeTypes.js').NodeModule}
+ */
+export const eventInitNode = createNodeModule(
+ {
+ id: "event_start",
+ title: "Event Init",
+ category: "Events",
+ description: "Called once on cart startup.",
+ searchTags: ["start", "init", "boot", "lifecycle"],
+ unique: true,
+ inputs: [],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ isEntryPoint: true,
+ eventName: "_init",
+ emitExec: ({ emitNextExec }) => emitNextExec("exec_out"),
+ }
+);
diff --git a/scripts/nodes/library/eventUpdate.js b/scripts/nodes/library/eventUpdate.js
new file mode 100644
index 0000000..7c4b8d0
--- /dev/null
+++ b/scripts/nodes/library/eventUpdate.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * @type {import('../nodeTypes.js').NodeModule}
+ */
+export const eventUpdateNode = createNodeModule(
+ {
+ id: "event_update",
+ title: "Event Update",
+ category: "Events",
+ description: "Called once per update",
+ searchTags: ["update", "loop", "frame", "tick"],
+ unique: true,
+ inputs: [],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ isEntryPoint: true,
+ eventName: "_update",
+ emitExec: ({ emitNextExec }) => emitNextExec("exec_out"),
+ }
+);
diff --git a/scripts/nodes/library/forLoop.js b/scripts/nodes/library/forLoop.js
new file mode 100644
index 0000000..9aacb03
--- /dev/null
+++ b/scripts/nodes/library/forLoop.js
@@ -0,0 +1,92 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Iterates from start to end inclusive using PICO-8 for semantics.
+ */
+export const forLoopNode = createNodeModule(
+ {
+ id: "for_loop",
+ title: "For Loop",
+ category: "Logic",
+ description:
+ "Iterate from start to end inclusive using PICO-8 for semantics.",
+ searchTags: ["loop", "for", "iterate", "counter"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "start",
+ name: "Start",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "end",
+ name: "End",
+ direction: "input",
+ kind: "number",
+ defaultValue: 10,
+ },
+ {
+ id: "step",
+ name: "Step",
+ direction: "input",
+ kind: "number",
+ defaultValue: 1,
+ },
+ ],
+ outputs: [
+ { id: "loop", name: "Loop", direction: "output", kind: "exec" },
+ { id: "completed", name: "Completed", direction: "output", kind: "exec" },
+ ],
+ properties: [
+ {
+ key: "index",
+ label: "Index Variable",
+ type: "string",
+ defaultValue: "i",
+ },
+ ],
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ resolveValueInput,
+ sanitizeIdentifier,
+ emitBranch,
+ path,
+ }) => {
+ const index = sanitizeIdentifier(String(node.properties.index ?? "i"));
+ const startValue = resolveValueInput("start", "0");
+ const endValue = resolveValueInput("end", "0");
+ const stepValue = resolveValueInput("step", "1");
+
+ const lines = [
+ `${indent(
+ indentLevel
+ )}for ${index} = ${startValue}, ${endValue}, ${stepValue} do`,
+ ];
+
+ const loopLines = emitBranch("loop", {
+ indentLevel: indentLevel + 1,
+ path: new Set(path),
+ });
+ if (!loopLines.length) {
+ lines.push(`${indent(indentLevel + 1)}-- loop body`);
+ } else {
+ lines.push(...loopLines);
+ }
+
+ lines.push(`${indent(indentLevel)}end`);
+ lines.push(
+ ...emitBranch("completed", {
+ indentLevel,
+ path,
+ })
+ );
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/getLocalVariable.js b/scripts/nodes/library/getLocalVariable.js
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/nodes/library/getVariable.js b/scripts/nodes/library/getVariable.js
new file mode 100644
index 0000000..2f34518
--- /dev/null
+++ b/scripts/nodes/library/getVariable.js
@@ -0,0 +1,30 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Exposes the current value of a variable.
+ */
+export const getVariableNode = createNodeModule(
+ {
+ id: "get_var",
+ title: "Get Variable",
+ category: "Logic",
+ description: "Expose the current value of a variable.",
+ searchTags: ["get", "read", "variable", "access"],
+ inputs: [],
+ outputs: [{ id: "value", name: "Value", direction: "output", kind: "any" }],
+ properties: [
+ {
+ key: "name",
+ label: "Variable Name",
+ type: "string",
+ defaultValue: "score",
+ },
+ ],
+ },
+ {
+ evaluateValue: ({ node, sanitizeIdentifier }) => {
+ const name = sanitizeIdentifier(String(node.properties.name ?? "var"));
+ return name;
+ },
+ }
+);
diff --git a/scripts/nodes/library/gpio/gpioRead.js b/scripts/nodes/library/gpio/gpioRead.js
new file mode 100644
index 0000000..a69d104
--- /dev/null
+++ b/scripts/nodes/library/gpio/gpioRead.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads a value from a GPIO pin using gpio().
+ */
+export const gpioReadNode = createNodeModule(
+ {
+ id: "gpio_read",
+ title: "GPIO Read",
+ category: "IO",
+ description: "Fetch the current value of a GPIO pin.",
+ searchTags: ["gpio", "hardware", "input"],
+ inputs: [
+ {
+ id: "index",
+ name: "Pin",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const pin = resolveValueInput("index", "0");
+ return `gpio(${pin})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/gpio/gpioWrite.js b/scripts/nodes/library/gpio/gpioWrite.js
new file mode 100644
index 0000000..ed89130
--- /dev/null
+++ b/scripts/nodes/library/gpio/gpioWrite.js
@@ -0,0 +1,43 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Writes a value to a GPIO pin using gpio().
+ */
+export const gpioWriteNode = createNodeModule(
+ {
+ id: "gpio_write",
+ title: "GPIO Write",
+ category: "IO",
+ description: "Set the value for a GPIO pin.",
+ searchTags: ["gpio", "hardware", "output"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "index",
+ name: "Pin",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const pin = resolveValueInput("index", "0");
+ const value = resolveValueInput("value", "0");
+ const line = `${indent(indentLevel)}gpio(${pin}, ${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/gpio/index.js b/scripts/nodes/library/gpio/index.js
new file mode 100644
index 0000000..9b8d7a2
--- /dev/null
+++ b/scripts/nodes/library/gpio/index.js
@@ -0,0 +1,16 @@
+import { gpioReadNode } from "./gpioRead.js";
+import { gpioWriteNode } from "./gpioWrite.js";
+
+/**
+ * Node modules covering GPIO helpers.
+ */
+export const gpioNodes = [gpioReadNode, gpioWriteNode];
+
+/**
+ * Checklist tracking coverage of GPIO read/write helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const gpioFunctionChecklist = [
+ { function: "GPIO (read)", nodeId: "gpio_read", implemented: true },
+ { function: "GPIO (write)", nodeId: "gpio_write", implemented: true },
+];
diff --git a/scripts/nodes/library/graphics/camera.js b/scripts/nodes/library/graphics/camera.js
new file mode 100644
index 0000000..d483ded
--- /dev/null
+++ b/scripts/nodes/library/graphics/camera.js
@@ -0,0 +1,37 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Applies a camera offset to subsequent draw calls.
+ */
+export const cameraNode = createNodeModule(
+ {
+ id: "graphics_camera",
+ title: "Add Camera Offset",
+ category: "Graphics",
+ description: "Shift the draw origin by the specified camera offset.",
+ searchTags: ["camera", "offset", "scroll", "view"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "x", name: "X", direction: "input", kind: "number" },
+ { id: "y", name: "Y", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const x = resolveValueInput("x", OMIT);
+ const y = resolveValueInput("y", OMIT);
+ let call = "camera()";
+ if (x !== OMIT) {
+ call = `camera(${x}, ${y === OMIT ? "0" : y})`;
+ }
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/circ.js b/scripts/nodes/library/graphics/circ.js
new file mode 100644
index 0000000..5742c39
--- /dev/null
+++ b/scripts/nodes/library/graphics/circ.js
@@ -0,0 +1,57 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws an outlined circle.
+ */
+export const circNode = createNodeModule(
+ {
+ id: "graphics_circ",
+ title: "Draw Circle",
+ category: "Graphics",
+ description: "Render an outlined circle at the specified position.",
+ searchTags: ["circle", "outline", "shape", "draw"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "radius",
+ name: "Radius",
+ direction: "input",
+ kind: "number",
+ defaultValue: 4,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const radius = resolveValueInput("radius", "4");
+ const color = resolveValueInput("color", "__pg_omit__");
+ const args = [x, y, radius];
+ if (color !== "__pg_omit__") {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}circ(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/circfill.js b/scripts/nodes/library/graphics/circfill.js
new file mode 100644
index 0000000..6d7e584
--- /dev/null
+++ b/scripts/nodes/library/graphics/circfill.js
@@ -0,0 +1,57 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws a filled circle.
+ */
+export const circfillNode = createNodeModule(
+ {
+ id: "graphics_circfill",
+ title: "Draw Filled Circle",
+ category: "Graphics",
+ description: "Render a filled circle at the specified position.",
+ searchTags: ["circle", "filled", "shape", "draw"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "radius",
+ name: "Radius",
+ direction: "input",
+ kind: "number",
+ defaultValue: 4,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const radius = resolveValueInput("radius", "4");
+ const color = resolveValueInput("color", "__pg_omit__");
+ const args = [x, y, radius];
+ if (color !== "__pg_omit__") {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}circfill(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/clip.js b/scripts/nodes/library/graphics/clip.js
new file mode 100644
index 0000000..a14a30d
--- /dev/null
+++ b/scripts/nodes/library/graphics/clip.js
@@ -0,0 +1,63 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Adjusts the active clipping rectangle for subsequent draw calls.
+ */
+export const clipNode = createNodeModule(
+ {
+ id: "graphics_clip",
+ title: "Set Clipping Rectangle",
+ category: "Graphics",
+ description:
+ "Set or reset the clipping rectangle that constrains all draw operations.",
+ searchTags: ["clip", "clipping", "graphics", "viewport"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "x", name: "X", direction: "input", kind: "number" },
+ { id: "y", name: "Y", direction: "input", kind: "number" },
+ { id: "w", name: "Width", direction: "input", kind: "number" },
+ { id: "h", name: "Height", direction: "input", kind: "number" },
+ {
+ id: "clip_previous",
+ name: "Clip Previous",
+ direction: "input",
+ kind: "boolean",
+ defaultValue: false,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const x = resolveValueInput("x", OMIT);
+ const y = resolveValueInput("y", OMIT);
+ const w = resolveValueInput("w", OMIT);
+ const h = resolveValueInput("h", OMIT);
+ const clipPrevious = resolveValueInput("clip_previous", OMIT);
+
+ let call;
+ if (x === OMIT && y === OMIT && w === OMIT && h === OMIT) {
+ call = "clip()";
+ } else {
+ const args = [
+ x === OMIT ? "0" : x,
+ y === OMIT ? "0" : y,
+ w === OMIT ? "128" : w,
+ h === OMIT ? "128" : h,
+ ];
+ if (clipPrevious !== OMIT) {
+ args.push(clipPrevious);
+ }
+ call = `clip(${args.join(", ")})`;
+ }
+
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/cls.js b/scripts/nodes/library/graphics/cls.js
new file mode 100644
index 0000000..c803e57
--- /dev/null
+++ b/scripts/nodes/library/graphics/cls.js
@@ -0,0 +1,32 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Clears the screen and resets clipping.
+ */
+export const clsNode = createNodeModule(
+ {
+ id: "graphics_cls",
+ title: "Clear Screen",
+ category: "Graphics",
+ description: "Clear the display, optionally filling with a specific color.",
+ searchTags: ["cls", "clear", "screen", "background"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const color = resolveValueInput("color", OMIT);
+ const call = color === OMIT ? "cls()" : `cls(${color})`;
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/color.js b/scripts/nodes/library/graphics/color.js
new file mode 100644
index 0000000..a1d4997
--- /dev/null
+++ b/scripts/nodes/library/graphics/color.js
@@ -0,0 +1,32 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Updates the current draw color.
+ */
+export const colorNode = createNodeModule(
+ {
+ id: "graphics_color",
+ title: "Set Active Color",
+ category: "Graphics",
+ description: "Set the active draw color used by subsequent graphics calls.",
+ searchTags: ["color", "color", "ink", "draw"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const color = resolveValueInput("color", OMIT);
+ const call = color === OMIT ? "color()" : `color(${color})`;
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/cursor.js b/scripts/nodes/library/graphics/cursor.js
new file mode 100644
index 0000000..6001ed6
--- /dev/null
+++ b/scripts/nodes/library/graphics/cursor.js
@@ -0,0 +1,46 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Positions the print cursor and optionally sets the draw color.
+ */
+export const cursorNode = createNodeModule(
+ {
+ id: "graphics_cursor",
+ title: "Set Cursor",
+ category: "Graphics",
+ description:
+ "Move the print cursor and optionally change the active color.",
+ searchTags: ["cursor", "print", "text", "position"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "x", name: "X", direction: "input", kind: "number" },
+ { id: "y", name: "Y", direction: "input", kind: "number" },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const x = resolveValueInput("x", OMIT);
+ const y = resolveValueInput("y", OMIT);
+ const color = resolveValueInput("color", OMIT);
+
+ let call = "cursor()";
+ if (x !== OMIT) {
+ const args = [x, y === OMIT ? "0" : y];
+ if (color !== OMIT) {
+ args.push(color);
+ }
+ call = `cursor(${args.join(", ")})`;
+ }
+
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/fget.js b/scripts/nodes/library/graphics/fget.js
new file mode 100644
index 0000000..a44cff3
--- /dev/null
+++ b/scripts/nodes/library/graphics/fget.js
@@ -0,0 +1,39 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads sprite flag data.
+ */
+export const fgetNode = createNodeModule(
+ {
+ id: "graphics_fget",
+ title: "Get Sprite Flag",
+ category: "Graphics",
+ description: "Retrieve the bitfield or specific flag value for a sprite.",
+ searchTags: ["fget", "flag", "sprite", "meta"],
+ inputs: [
+ {
+ id: "sprite",
+ name: "Sprite",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ { id: "flag", name: "Flag", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const sprite = resolveValueInput("sprite", "0");
+ const flag = resolveValueInput("flag", OMIT);
+ if (flag === OMIT) {
+ return `fget(${sprite})`;
+ }
+ return `fget(${sprite}, ${flag})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/fillp.js b/scripts/nodes/library/graphics/fillp.js
new file mode 100644
index 0000000..7289c8b
--- /dev/null
+++ b/scripts/nodes/library/graphics/fillp.js
@@ -0,0 +1,31 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Configures the active fill pattern.
+ */
+export const fillpNode = createNodeModule(
+ {
+ id: "graphics_fillp",
+ title: "Set Fill Pattern",
+ category: "Graphics",
+ description: "Set the 4x4 fill pattern bitfield used by many draw calls.",
+ searchTags: ["fillp", "pattern", "fill", "hatch"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "pattern", name: "Pattern", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const pattern = resolveValueInput("pattern", "__pg_omit__");
+ const call = pattern === "__pg_omit__" ? "fillp()" : `fillp(${pattern})`;
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/fset.js b/scripts/nodes/library/graphics/fset.js
new file mode 100644
index 0000000..0509ee4
--- /dev/null
+++ b/scripts/nodes/library/graphics/fset.js
@@ -0,0 +1,52 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Updates sprite flag values.
+ */
+export const fsetNode = createNodeModule(
+ {
+ id: "graphics_fset",
+ title: "Set Sprite Flag",
+ category: "Graphics",
+ description: "Set a sprite's flag bitfield or a specific flag value.",
+ searchTags: ["fset", "flag", "sprite", "meta"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "sprite",
+ name: "Sprite",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ { id: "flag", name: "Flag", direction: "input", kind: "number" },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "any",
+ defaultValue: true,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const sprite = resolveValueInput("sprite", "0");
+ const flag = resolveValueInput("flag", OMIT);
+ const value = resolveValueInput("value", "false");
+ const args = [sprite];
+ if (flag === OMIT) {
+ args.push(value);
+ } else {
+ args.push(flag, value);
+ }
+ const line = `${indent(indentLevel)}fset(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/index.js b/scripts/nodes/library/graphics/index.js
new file mode 100644
index 0000000..a3ed689
--- /dev/null
+++ b/scripts/nodes/library/graphics/index.js
@@ -0,0 +1,88 @@
+import { clipNode } from "./clip.js";
+import { psetNode } from "./pset.js";
+import { pgetNode } from "./pget.js";
+import { sgetNode } from "./sget.js";
+import { ssetNode } from "./sset.js";
+import { fgetNode } from "./fget.js";
+import { fsetNode } from "./fset.js";
+import { cursorNode } from "./cursor.js";
+import { colorNode } from "./color.js";
+import { clsNode } from "./cls.js";
+import { cameraNode } from "./camera.js";
+import { circNode } from "./circ.js";
+import { circfillNode } from "./circfill.js";
+import { ovalNode } from "./oval.js";
+import { ovalfillNode } from "./ovalfill.js";
+import { lineNode } from "./line.js";
+import { rectNode } from "./rect.js";
+import { rectfillNode } from "./rectfill.js";
+import { rrectNode } from "./rrect.js";
+import { rrectfillNode } from "./rrectfill.js";
+import { palNode } from "./pal.js";
+import { paltNode } from "./palt.js";
+import { sprNode } from "./spr.js";
+import { ssprNode } from "./sspr.js";
+import { fillpNode } from "./fillp.js";
+
+/**
+ * All graphics related node modules backed by PICO-8 draw helpers.
+ */
+export const graphicsNodes = [
+ clipNode,
+ psetNode,
+ pgetNode,
+ sgetNode,
+ ssetNode,
+ fgetNode,
+ fsetNode,
+ cursorNode,
+ colorNode,
+ clsNode,
+ cameraNode,
+ circNode,
+ circfillNode,
+ ovalNode,
+ ovalfillNode,
+ lineNode,
+ rectNode,
+ rectfillNode,
+ rrectNode,
+ rrectfillNode,
+ palNode,
+ paltNode,
+ sprNode,
+ ssprNode,
+ fillpNode,
+];
+
+/**
+ * Checklist tracking coverage of documented graphics helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const graphicsFunctionChecklist = [
+ { function: "CLIP", nodeId: "graphics_clip", implemented: true },
+ { function: "PSET", nodeId: "graphics_pset", implemented: true },
+ { function: "PGET", nodeId: "graphics_pget", implemented: true },
+ { function: "SGET", nodeId: "graphics_sget", implemented: true },
+ { function: "SSET", nodeId: "graphics_sset", implemented: true },
+ { function: "FGET", nodeId: "graphics_fget", implemented: true },
+ { function: "FSET", nodeId: "graphics_fset", implemented: true },
+ { function: "CURSOR", nodeId: "graphics_cursor", implemented: true },
+ { function: "COLOR", nodeId: "graphics_color", implemented: true },
+ { function: "CLS", nodeId: "graphics_cls", implemented: true },
+ { function: "CAMERA", nodeId: "graphics_camera", implemented: true },
+ { function: "CIRC", nodeId: "graphics_circ", implemented: true },
+ { function: "CIRCFILL", nodeId: "graphics_circfill", implemented: true },
+ { function: "OVAL", nodeId: "graphics_oval", implemented: true },
+ { function: "OVALFILL", nodeId: "graphics_ovalfill", implemented: true },
+ { function: "LINE", nodeId: "graphics_line", implemented: true },
+ { function: "RECT", nodeId: "graphics_rect", implemented: true },
+ { function: "RECTFILL", nodeId: "graphics_rectfill", implemented: true },
+ { function: "RRECT", nodeId: "graphics_rrect", implemented: true },
+ { function: "RRECTFILL", nodeId: "graphics_rrectfill", implemented: true },
+ { function: "PAL", nodeId: "graphics_pal", implemented: true },
+ { function: "PALT", nodeId: "graphics_palt", implemented: true },
+ { function: "SPR", nodeId: "graphics_spr", implemented: true },
+ { function: "SSPR", nodeId: "graphics_sspr", implemented: true },
+ { function: "FILLP", nodeId: "graphics_fillp", implemented: true },
+];
diff --git a/scripts/nodes/library/graphics/line.js b/scripts/nodes/library/graphics/line.js
new file mode 100644
index 0000000..39f0127
--- /dev/null
+++ b/scripts/nodes/library/graphics/line.js
@@ -0,0 +1,60 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws a line segment.
+ */
+export const lineNode = createNodeModule(
+ {
+ id: "graphics_line",
+ title: "Draw Line",
+ category: "Graphics",
+ description:
+ "Draw a line between two points or update the current pen position.",
+ searchTags: ["line", "draw", "segment", "stroke"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x0",
+ name: "X0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y0",
+ name: "Y0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ { id: "x1", name: "X1", direction: "input", kind: "number" },
+ { id: "y1", name: "Y1", direction: "input", kind: "number" },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const x0 = resolveValueInput("x0", "0");
+ const y0 = resolveValueInput("y0", "0");
+ const x1 = resolveValueInput("x1", OMIT);
+ const y1 = resolveValueInput("y1", OMIT);
+ const color = resolveValueInput("color", OMIT);
+
+ const args = [x0, y0];
+ if (x1 !== OMIT && y1 !== OMIT) {
+ args.push(x1, y1);
+ if (color !== OMIT) {
+ args.push(color);
+ }
+ }
+
+ const line = `${indent(indentLevel)}line(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/oval.js b/scripts/nodes/library/graphics/oval.js
new file mode 100644
index 0000000..d48a596
--- /dev/null
+++ b/scripts/nodes/library/graphics/oval.js
@@ -0,0 +1,65 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws an outlined oval.
+ */
+export const ovalNode = createNodeModule(
+ {
+ id: "graphics_oval",
+ title: "Draw Oval",
+ category: "Graphics",
+ description: "Render an ellipse using the provided bounding box.",
+ searchTags: ["oval", "ellipse", "shape", "outline"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x0",
+ name: "X0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y0",
+ name: "Y0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "x1",
+ name: "X1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "y1",
+ name: "Y1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x0 = resolveValueInput("x0", "0");
+ const y0 = resolveValueInput("y0", "0");
+ const x1 = resolveValueInput("x1", "8");
+ const y1 = resolveValueInput("y1", "8");
+ const color = resolveValueInput("color", "__pg_omit__");
+ const args = [x0, y0, x1, y1];
+ if (color !== "__pg_omit__") {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}oval(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/ovalfill.js b/scripts/nodes/library/graphics/ovalfill.js
new file mode 100644
index 0000000..b26bc92
--- /dev/null
+++ b/scripts/nodes/library/graphics/ovalfill.js
@@ -0,0 +1,65 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws a filled oval.
+ */
+export const ovalfillNode = createNodeModule(
+ {
+ id: "graphics_ovalfill",
+ title: "Draw Filled Oval",
+ category: "Graphics",
+ description: "Render a filled ellipse using the provided bounding box.",
+ searchTags: ["oval", "ellipse", "filled", "shape"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x0",
+ name: "X0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y0",
+ name: "Y0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "x1",
+ name: "X1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "y1",
+ name: "Y1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x0 = resolveValueInput("x0", "0");
+ const y0 = resolveValueInput("y0", "0");
+ const x1 = resolveValueInput("x1", "8");
+ const y1 = resolveValueInput("y1", "8");
+ const color = resolveValueInput("color", "__pg_omit__");
+ const args = [x0, y0, x1, y1];
+ if (color !== "__pg_omit__") {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}ovalfill(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/pal.js b/scripts/nodes/library/graphics/pal.js
new file mode 100644
index 0000000..29214f0
--- /dev/null
+++ b/scripts/nodes/library/graphics/pal.js
@@ -0,0 +1,51 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Remaps palette colors.
+ */
+export const palNode = createNodeModule(
+ {
+ id: "graphics_pal",
+ title: "Remap Palette Color",
+ category: "Graphics",
+ description: "Swap one palette color for another or reset the palette.",
+ searchTags: ["pal", "palette", "color", "remap"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "c0", name: "Color 0", direction: "input", kind: "number" },
+ {
+ id: "c1",
+ name: "Color 1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ { id: "p", name: "Palette", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const c0 = resolveValueInput("c0", OMIT);
+ const c1 = resolveValueInput("c1", "0");
+ const p = resolveValueInput("p", OMIT);
+
+ let call = "pal()";
+ if (c0 !== OMIT) {
+ const args = [c0, c1];
+ if (p !== OMIT) {
+ args.push(p);
+ }
+ call = `pal(${args.join(", ")})`;
+ }
+
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/palt.js b/scripts/nodes/library/graphics/palt.js
new file mode 100644
index 0000000..daa9726
--- /dev/null
+++ b/scripts/nodes/library/graphics/palt.js
@@ -0,0 +1,47 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Adjusts sprite transparency settings.
+ */
+export const paltNode = createNodeModule(
+ {
+ id: "graphics_palt",
+ title: "Set Sprite Transparency",
+ category: "Graphics",
+ description: "Set or reset sprite transparency flags.",
+ searchTags: ["palt", "transparency", "palette", "sprite"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ {
+ id: "transparent",
+ name: "Transparent",
+ direction: "input",
+ kind: "boolean",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const color = resolveValueInput("color", OMIT);
+ const transparent = resolveValueInput("transparent", OMIT);
+
+ let call = "palt()";
+ if (color !== OMIT) {
+ if (transparent === OMIT) {
+ call = `palt(${color})`;
+ } else {
+ call = `palt(${color}, ${transparent})`;
+ }
+ }
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/pget.js b/scripts/nodes/library/graphics/pget.js
new file mode 100644
index 0000000..352d838
--- /dev/null
+++ b/scripts/nodes/library/graphics/pget.js
@@ -0,0 +1,41 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads the color of a screen pixel.
+ */
+export const pgetNode = createNodeModule(
+ {
+ id: "graphics_pget",
+ title: "Get Pixel Color",
+ category: "Graphics",
+ description: "Fetch the color index at the given screen coordinate.",
+ searchTags: ["pget", "pixel", "read", "sample"],
+ inputs: [
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Color", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ return `pget(${x}, ${y})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/pset.js b/scripts/nodes/library/graphics/pset.js
new file mode 100644
index 0000000..8a4adab
--- /dev/null
+++ b/scripts/nodes/library/graphics/pset.js
@@ -0,0 +1,50 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws a single pixel using the current or specified color.
+ */
+export const psetNode = createNodeModule(
+ {
+ id: "graphics_pset",
+ title: "Set Pixel Color",
+ category: "Graphics",
+ description: "Set a pixel on screen to a color index.",
+ searchTags: ["pset", "pixel", "draw", "plot"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const color = resolveValueInput("color", OMIT);
+ const args = [x, y];
+ if (color !== OMIT) {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}pset(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/rect.js b/scripts/nodes/library/graphics/rect.js
new file mode 100644
index 0000000..367898e
--- /dev/null
+++ b/scripts/nodes/library/graphics/rect.js
@@ -0,0 +1,65 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws an outlined rectangle.
+ */
+export const rectNode = createNodeModule(
+ {
+ id: "graphics_rect",
+ title: "Draw Rectangle",
+ category: "Graphics",
+ description: "Render a rectangle outline defined by two corners.",
+ searchTags: ["rect", "rectangle", "outline", "shape"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x0",
+ name: "X0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y0",
+ name: "Y0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "x1",
+ name: "X1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "y1",
+ name: "Y1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x0 = resolveValueInput("x0", "0");
+ const y0 = resolveValueInput("y0", "0");
+ const x1 = resolveValueInput("x1", "8");
+ const y1 = resolveValueInput("y1", "8");
+ const color = resolveValueInput("color", "__pg_omit__");
+ const args = [x0, y0, x1, y1];
+ if (color !== "__pg_omit__") {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}rect(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/rectfill.js b/scripts/nodes/library/graphics/rectfill.js
new file mode 100644
index 0000000..308b13c
--- /dev/null
+++ b/scripts/nodes/library/graphics/rectfill.js
@@ -0,0 +1,65 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws a filled rectangle.
+ */
+export const rectfillNode = createNodeModule(
+ {
+ id: "graphics_rectfill",
+ title: "Draw Filled Rectangle",
+ category: "Graphics",
+ description: "Render a filled rectangle defined by two corners.",
+ searchTags: ["rect", "rectangle", "filled", "shape"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x0",
+ name: "X0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y0",
+ name: "Y0",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "x1",
+ name: "X1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "y1",
+ name: "Y1",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x0 = resolveValueInput("x0", "0");
+ const y0 = resolveValueInput("y0", "0");
+ const x1 = resolveValueInput("x1", "8");
+ const y1 = resolveValueInput("y1", "8");
+ const color = resolveValueInput("color", "__pg_omit__");
+ const args = [x0, y0, x1, y1];
+ if (color !== "__pg_omit__") {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}rectfill(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/rrect.js b/scripts/nodes/library/graphics/rrect.js
new file mode 100644
index 0000000..6dc4f51
--- /dev/null
+++ b/scripts/nodes/library/graphics/rrect.js
@@ -0,0 +1,73 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws an outlined rounded rectangle.
+ */
+export const rrectNode = createNodeModule(
+ {
+ id: "graphics_rrect",
+ title: "Draw Rounded Rectangle",
+ category: "Graphics",
+ description: "Render a rounded rectangle outline with the supplied radius.",
+ searchTags: ["rounded", "rectangle", "outline", "shape"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "w",
+ name: "Width",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "h",
+ name: "Height",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "r",
+ name: "Radius",
+ direction: "input",
+ kind: "number",
+ defaultValue: 2,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const w = resolveValueInput("w", "8");
+ const h = resolveValueInput("h", "8");
+ const r = resolveValueInput("r", "2");
+ const color = resolveValueInput("color", "__pg_omit__");
+ const args = [x, y, w, h, r];
+ if (color !== "__pg_omit__") {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}rrect(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/rrectfill.js b/scripts/nodes/library/graphics/rrectfill.js
new file mode 100644
index 0000000..bd2699a
--- /dev/null
+++ b/scripts/nodes/library/graphics/rrectfill.js
@@ -0,0 +1,73 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws a filled rounded rectangle.
+ */
+export const rrectfillNode = createNodeModule(
+ {
+ id: "graphics_rrectfill",
+ title: "Draw Filled Rounded Rectangle",
+ category: "Graphics",
+ description: "Render a filled rounded rectangle with the supplied radius.",
+ searchTags: ["rounded", "rectangle", "filled", "shape"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "w",
+ name: "Width",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "h",
+ name: "Height",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "r",
+ name: "Radius",
+ direction: "input",
+ kind: "number",
+ defaultValue: 2,
+ },
+ { id: "color", name: "Color", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const w = resolveValueInput("w", "8");
+ const h = resolveValueInput("h", "8");
+ const r = resolveValueInput("r", "2");
+ const color = resolveValueInput("color", "__pg_omit__");
+ const args = [x, y, w, h, r];
+ if (color !== "__pg_omit__") {
+ args.push(color);
+ }
+ const line = `${indent(indentLevel)}rrectfill(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/sget.js b/scripts/nodes/library/graphics/sget.js
new file mode 100644
index 0000000..a86af2f
--- /dev/null
+++ b/scripts/nodes/library/graphics/sget.js
@@ -0,0 +1,42 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads a pixel from the sprite sheet.
+ */
+export const sgetNode = createNodeModule(
+ {
+ id: "graphics_sget",
+ title: "Get Sprite Pixel",
+ category: "Graphics",
+ description:
+ "Retrieve the color index from the sprite sheet at the given coordinate.",
+ searchTags: ["sget", "sprite", "sheet", "read"],
+ inputs: [
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Color", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ return `sget(${x}, ${y})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/spr.js b/scripts/nodes/library/graphics/spr.js
new file mode 100644
index 0000000..7bc15e2
--- /dev/null
+++ b/scripts/nodes/library/graphics/spr.js
@@ -0,0 +1,76 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws sprites from the sprite sheet.
+ */
+export const sprNode = createNodeModule(
+ {
+ id: "graphics_spr",
+ title: "Draw Sprite",
+ category: "Graphics",
+ description: "Blit one or more sprites at the given screen position.",
+ searchTags: ["spr", "sprite", "draw", "blit"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "sprite",
+ name: "Sprite",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ { id: "w", name: "Width", direction: "input", kind: "number" },
+ { id: "h", name: "Height", direction: "input", kind: "number" },
+ { id: "flip_x", name: "Flip X", direction: "input", kind: "boolean" },
+ { id: "flip_y", name: "Flip Y", direction: "input", kind: "boolean" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const sprite = resolveValueInput("sprite", "0");
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const w = resolveValueInput("w", OMIT);
+ const h = resolveValueInput("h", OMIT);
+ const flipX = resolveValueInput("flip_x", OMIT);
+ const flipY = resolveValueInput("flip_y", OMIT);
+
+ const args = [sprite, x, y];
+ const includeSize =
+ w !== OMIT || h !== OMIT || flipX !== OMIT || flipY !== OMIT;
+
+ if (includeSize) {
+ args.push(w === OMIT ? "1" : w, h === OMIT ? "1" : h);
+ }
+
+ if (flipX !== OMIT || flipY !== OMIT) {
+ args.push(
+ flipX === OMIT ? "false" : flipX,
+ flipY === OMIT ? "false" : flipY
+ );
+ }
+
+ const line = `${indent(indentLevel)}spr(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/sset.js b/scripts/nodes/library/graphics/sset.js
new file mode 100644
index 0000000..8832afc
--- /dev/null
+++ b/scripts/nodes/library/graphics/sset.js
@@ -0,0 +1,51 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Writes a pixel to the sprite sheet.
+ */
+export const ssetNode = createNodeModule(
+ {
+ id: "graphics_sset",
+ title: "Set Sprite Pixel",
+ category: "Graphics",
+ description: "Assign a color to the sprite sheet at the given coordinate.",
+ searchTags: ["sset", "sprite", "sheet", "write"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "color",
+ name: "Color",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const color = resolveValueInput("color", "0");
+ const line = `${indent(indentLevel)}sset(${x}, ${y}, ${color})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/graphics/sspr.js b/scripts/nodes/library/graphics/sspr.js
new file mode 100644
index 0000000..fedbff8
--- /dev/null
+++ b/scripts/nodes/library/graphics/sspr.js
@@ -0,0 +1,100 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws a rectangular region of the sprite sheet with scaling support.
+ */
+export const ssprNode = createNodeModule(
+ {
+ id: "graphics_sspr",
+ title: "Draw Scaled Sprite",
+ category: "Graphics",
+ description: "Stretch or flip a sprite sheet region to the screen.",
+ searchTags: ["sspr", "sprite", "scale", "blit"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "sx",
+ name: "SX",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "sy",
+ name: "SY",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "sw",
+ name: "SW",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "sh",
+ name: "SH",
+ direction: "input",
+ kind: "number",
+ defaultValue: 8,
+ },
+ {
+ id: "dx",
+ name: "DX",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "dy",
+ name: "DY",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ { id: "dw", name: "DW", direction: "input", kind: "number" },
+ { id: "dh", name: "DH", direction: "input", kind: "number" },
+ { id: "flip_x", name: "Flip X", direction: "input", kind: "boolean" },
+ { id: "flip_y", name: "Flip Y", direction: "input", kind: "boolean" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const sx = resolveValueInput("sx", "0");
+ const sy = resolveValueInput("sy", "0");
+ const sw = resolveValueInput("sw", "8");
+ const sh = resolveValueInput("sh", "8");
+ const dx = resolveValueInput("dx", "0");
+ const dy = resolveValueInput("dy", "0");
+ const dw = resolveValueInput("dw", OMIT);
+ const dh = resolveValueInput("dh", OMIT);
+ const flipX = resolveValueInput("flip_x", OMIT);
+ const flipY = resolveValueInput("flip_y", OMIT);
+
+ const args = [sx, sy, sw, sh, dx, dy];
+ const includeDestSize =
+ dw !== OMIT || dh !== OMIT || flipX !== OMIT || flipY !== OMIT;
+
+ if (includeDestSize) {
+ args.push(dw === OMIT ? sw : dw, dh === OMIT ? sh : dh);
+ }
+
+ if (flipX !== OMIT || flipY !== OMIT) {
+ args.push(
+ flipX === OMIT ? "false" : flipX,
+ flipY === OMIT ? "false" : flipY
+ );
+ }
+
+ const line = `${indent(indentLevel)}sspr(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/ifNode.js b/scripts/nodes/library/ifNode.js
new file mode 100644
index 0000000..a7515c9
--- /dev/null
+++ b/scripts/nodes/library/ifNode.js
@@ -0,0 +1,67 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Branches execution based on a boolean condition.
+ */
+export const ifNode = createNodeModule(
+ {
+ id: "if",
+ title: "If",
+ category: "Logic",
+ description: "Branch execution based on a boolean condition.",
+ searchTags: ["if", "condition", "branch", "logic"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "condition",
+ name: "Condition",
+ direction: "input",
+ kind: "boolean",
+ },
+ ],
+ outputs: [
+ { id: "then", name: "Then", direction: "output", kind: "exec" },
+ { id: "else", name: "Else", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({
+ indent,
+ indentLevel,
+ resolveValueInput,
+ emitBranch,
+ findExecTargets,
+ path,
+ }) => {
+ const condition = resolveValueInput("condition", "false");
+ const lines = [`${indent(indentLevel)}if ${condition} then`];
+
+ const thenLines = emitBranch("then", {
+ indentLevel: indentLevel + 1,
+ path: new Set(path),
+ });
+ if (!thenLines.length) {
+ lines.push(`${indent(indentLevel + 1)}-- then branch`);
+ } else {
+ lines.push(...thenLines);
+ }
+
+ if (findExecTargets("else").length) {
+ lines.push(`${indent(indentLevel)}else`);
+ const elseLines = emitBranch("else", {
+ indentLevel: indentLevel + 1,
+ path: new Set(path),
+ });
+ if (!elseLines.length) {
+ lines.push(`${indent(indentLevel + 1)}-- else branch`);
+ } else {
+ lines.push(...elseLines);
+ }
+ }
+
+ lines.push(`${indent(indentLevel)}end`);
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/index.js b/scripts/nodes/library/index.js
new file mode 100644
index 0000000..ac160cd
--- /dev/null
+++ b/scripts/nodes/library/index.js
@@ -0,0 +1,87 @@
+import { eventInitNode } from "./eventStart.js";
+import { eventUpdateNode } from "./eventUpdate.js";
+import { eventDrawNode } from "./eventDraw.js";
+import { printNode } from "./print.js";
+import { setVariableNode } from "./setVariable.js";
+import { getVariableNode } from "./getVariable.js";
+import { numberLiteralNode } from "./numberLiteral.js";
+import { stringLiteralNode } from "./stringLiteral.js";
+import { booleanLiteralNode } from "./booleanLiteral.js";
+import { addNumberNode } from "./addNumber.js";
+import { multiplyNumberNode } from "./multiplyNumber.js";
+import { compareNode } from "./compare.js";
+import { sequenceNode } from "./sequence.js";
+import { ifNode } from "./ifNode.js";
+import { forLoopNode } from "./forLoop.js";
+import { customEventNode } from "./customEvent.js";
+import { callCustomEventNode } from "./callCustomEvent.js";
+import { graphicsNodes, graphicsFunctionChecklist } from "./graphics/index.js";
+import { systemNodes, systemFunctionChecklist } from "./system/index.js";
+import { tableNodes, tableFunctionChecklist } from "./table/index.js";
+import { inputNodes, inputFunctionChecklist } from "./input/index.js";
+import { audioNodes, audioFunctionChecklist } from "./audio/index.js";
+import { mapNodes, mapFunctionChecklist } from "./map/index.js";
+import { memoryNodes, memoryFunctionChecklist } from "./memory/index.js";
+import { mathNodes, mathFunctionChecklist } from "./math/index.js";
+import { menuNodes, menuFunctionChecklist } from "./menu/index.js";
+import { stringNodes, stringFunctionChecklist } from "./strings/index.js";
+import { dataNodes, dataFunctionChecklist } from "./data/index.js";
+import { gpioNodes, gpioFunctionChecklist } from "./gpio/index.js";
+import { serialNodes, serialFunctionChecklist } from "./serial/index.js";
+import { devkitNodes, devkitFunctionChecklist } from "./devkit/index.js";
+import { luaNodes, luaFunctionChecklist } from "./lua/index.js";
+import { localVariableNodes } from "./localVariables.js";
+
+export const nodeModules = [
+ eventInitNode,
+ eventUpdateNode,
+ eventDrawNode,
+ printNode,
+ setVariableNode,
+ getVariableNode,
+ numberLiteralNode,
+ stringLiteralNode,
+ booleanLiteralNode,
+ addNumberNode,
+ multiplyNumberNode,
+ compareNode,
+ sequenceNode,
+ ifNode,
+ forLoopNode,
+ customEventNode,
+ callCustomEventNode,
+ ...localVariableNodes,
+ ...graphicsNodes,
+ ...systemNodes,
+ ...tableNodes,
+ ...inputNodes,
+ ...audioNodes,
+ ...mapNodes,
+ ...memoryNodes,
+ ...mathNodes,
+ ...menuNodes,
+ ...stringNodes,
+ ...dataNodes,
+ ...gpioNodes,
+ ...serialNodes,
+ ...devkitNodes,
+ ...luaNodes,
+];
+
+export {
+ systemFunctionChecklist,
+ graphicsFunctionChecklist,
+ tableFunctionChecklist,
+ inputFunctionChecklist,
+ audioFunctionChecklist,
+ mapFunctionChecklist,
+ memoryFunctionChecklist,
+ mathFunctionChecklist,
+ menuFunctionChecklist,
+ stringFunctionChecklist,
+ dataFunctionChecklist,
+ gpioFunctionChecklist,
+ serialFunctionChecklist,
+ devkitFunctionChecklist,
+ luaFunctionChecklist,
+};
diff --git a/scripts/nodes/library/input/btn.js b/scripts/nodes/library/input/btn.js
new file mode 100644
index 0000000..fca18b8
--- /dev/null
+++ b/scripts/nodes/library/input/btn.js
@@ -0,0 +1,55 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads the state of a PICO-8 button using BTN.
+ */
+export const btnNode = createNodeModule(
+ {
+ id: "input_btn",
+ title: "Button State",
+ category: "Input",
+ description: "Read the current pressed state for a controller button.",
+ searchTags: ["btn", "input", "button", "press"],
+ inputs: [
+ {
+ id: "button",
+ name: "Button",
+ direction: "input",
+ kind: "number",
+ description: "Button index or glyph code",
+ },
+ {
+ id: "player",
+ name: "Player",
+ direction: "input",
+ kind: "number",
+ description: "Optional player index",
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Pressed", direction: "output", kind: "boolean" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const button = resolveValueInput("button", OMIT);
+ const player = resolveValueInput("player", OMIT);
+
+ if (button === OMIT && player === OMIT) {
+ return "btn()";
+ }
+
+ if (button === OMIT) {
+ return `btn(nil, ${player})`;
+ }
+
+ if (player === OMIT) {
+ return `btn(${button})`;
+ }
+
+ return `btn(${button}, ${player})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/input/btnp.js b/scripts/nodes/library/input/btnp.js
new file mode 100644
index 0000000..e70565b
--- /dev/null
+++ b/scripts/nodes/library/input/btnp.js
@@ -0,0 +1,56 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads button press transitions using BTNP.
+ */
+export const btnpNode = createNodeModule(
+ {
+ id: "input_btnp",
+ title: "Button Press",
+ category: "Input",
+ description:
+ "Detect when a button is initially pressed or repeats using BTNP.",
+ searchTags: ["btnp", "input", "button", "press", "repeat"],
+ inputs: [
+ {
+ id: "button",
+ name: "Button",
+ direction: "input",
+ kind: "number",
+ description: "Button index or glyph code",
+ },
+ {
+ id: "player",
+ name: "Player",
+ direction: "input",
+ kind: "number",
+ description: "Optional player index",
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Pressed", direction: "output", kind: "boolean" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const button = resolveValueInput("button", OMIT);
+ const player = resolveValueInput("player", OMIT);
+
+ if (button === OMIT && player === OMIT) {
+ return "btnp()";
+ }
+
+ if (button === OMIT) {
+ return `btnp(nil, ${player})`;
+ }
+
+ if (player === OMIT) {
+ return `btnp(${button})`;
+ }
+
+ return `btnp(${button}, ${player})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/input/controllerButtons.js b/scripts/nodes/library/input/controllerButtons.js
new file mode 100644
index 0000000..dd2369b
--- /dev/null
+++ b/scripts/nodes/library/input/controllerButtons.js
@@ -0,0 +1,175 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+const DEFAULT_PLAYER_VALUE = "0";
+
+/**
+ * Normalizes the stored player property to a valid controller index.
+ *
+ * @param {unknown} value Raw inspector value.
+ * @returns {number}
+ */
+const resolvePlayerIndex = (value) => {
+ const numeric = Number.parseInt(
+ typeof value === "string" ? value : String(value ?? DEFAULT_PLAYER_VALUE),
+ 10
+ );
+ if (!Number.isFinite(numeric) || numeric < 0) {
+ return 0;
+ }
+ if (numeric > 7) {
+ return 7;
+ }
+ return numeric;
+};
+
+const BUTTON_OPTIONS = [
+ { value: "0", label: "Left" },
+ { value: "1", label: "Right" },
+ { value: "2", label: "Up" },
+ { value: "3", label: "Down" },
+ { value: "4", label: "O" },
+ { value: "5", label: "X" },
+];
+
+const BUTTON_VALUE_SET = new Set(BUTTON_OPTIONS.map((option) => option.value));
+
+const DEFAULT_BUTTON_VALUE = BUTTON_OPTIONS[0]?.value ?? "0";
+
+/**
+ * Normalizes button property values to a valid BTN index represented as a string.
+ *
+ * @param {unknown} value Raw inspector value.
+ * @returns {string}
+ */
+const resolveButtonValue = (value) => {
+ const numeric = Number.parseInt(
+ typeof value === "string" ? value : String(value ?? DEFAULT_BUTTON_VALUE),
+ 10
+ );
+
+ const candidate = Number.isFinite(numeric) ? String(numeric) : DEFAULT_BUTTON_VALUE;
+ if (BUTTON_VALUE_SET.has(candidate)) {
+ return candidate;
+ }
+ return DEFAULT_BUTTON_VALUE;
+};
+
+const PLAYER_OPTIONS = [
+ { value: "0", label: "Player 0 (Cursors / Z X)" },
+ { value: "1", label: "Player 1 (SFED / Tab Q W A)" },
+ { value: "2", label: "Player 2" },
+ { value: "3", label: "Player 3" },
+ { value: "4", label: "Player 4" },
+ { value: "5", label: "Player 5" },
+ { value: "6", label: "Player 6" },
+ { value: "7", label: "Player 7" },
+];
+
+/**
+ * Evaluates whether the configured button is currently pressed.
+ */
+export const controllerButtonsNode = createNodeModule(
+ {
+ id: "input_button_constants",
+ title: "Named Button",
+ category: "Input",
+ description:
+ "Read a specific controller button with a dropdown selector and optional player override.",
+ searchTags: [
+ "button",
+ "controller",
+ "dpad",
+ "input",
+ "player",
+ "named",
+ "press",
+ ],
+ inputs: [
+ {
+ id: "button",
+ name: "Button",
+ direction: "input",
+ kind: "number",
+ description: "Optional button index override",
+ },
+ {
+ id: "player",
+ name: "Player",
+ direction: "input",
+ kind: "number",
+ description: "Optional player index override",
+ },
+ ],
+ outputs: [
+ {
+ id: "pressed",
+ name: "Pressed",
+ direction: "output",
+ kind: "boolean",
+ },
+ ],
+ properties: [
+ {
+ key: "button",
+ label: "Button",
+ type: "enum",
+ defaultValue: DEFAULT_BUTTON_VALUE,
+ options: BUTTON_OPTIONS,
+ },
+ {
+ key: "player",
+ label: "Default Player",
+ type: "enum",
+ defaultValue: DEFAULT_PLAYER_VALUE,
+ options: PLAYER_OPTIONS,
+ },
+ ],
+ initializeProperties: (properties) => {
+ properties.button = resolveButtonValue(properties.button);
+ const normalizedPlayer = resolvePlayerIndex(properties.player);
+ properties.player = String(normalizedPlayer);
+ const buttonInlineKey = "pin:button";
+ const playerInlineKey = "pin:player";
+ const numericButton = Number.parseInt(properties.button, 10);
+ const numericPlayer = normalizedPlayer;
+ properties[buttonInlineKey] = Number.isFinite(numericButton)
+ ? numericButton
+ : 0;
+ properties[playerInlineKey] = Number.isFinite(numericPlayer)
+ ? numericPlayer
+ : 0;
+ },
+ onPropertiesChanged: (properties) => {
+ properties.button = resolveButtonValue(properties.button);
+ const normalizedPlayer = resolvePlayerIndex(properties.player);
+ properties.player = String(normalizedPlayer);
+ const buttonInlineKey = "pin:button";
+ const playerInlineKey = "pin:player";
+ const numericButton = Number.parseInt(properties.button, 10);
+ const numericPlayer = normalizedPlayer;
+ properties[buttonInlineKey] = Number.isFinite(numericButton)
+ ? numericButton
+ : 0;
+ properties[playerInlineKey] = Number.isFinite(numericPlayer)
+ ? numericPlayer
+ : 0;
+ },
+ },
+ {
+ evaluateValue: ({ node, resolveValueInput, formatLiteral }) => {
+ const buttonValue = resolveButtonValue(node.properties.button);
+ const parsedButton = Number.parseInt(buttonValue, 10);
+ const buttonIndex = Number.isFinite(parsedButton) ? parsedButton : 0;
+ const fallbackButton = formatLiteral("number", buttonIndex);
+ const buttonInput = resolveValueInput("button", fallbackButton);
+
+ const fallbackPlayer = formatLiteral(
+ "number",
+ resolvePlayerIndex(node.properties.player)
+ );
+ const playerInput = resolveValueInput("player", fallbackPlayer);
+
+ return `btn(${buttonInput}, ${playerInput})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/input/index.js b/scripts/nodes/library/input/index.js
new file mode 100644
index 0000000..d2fbde4
--- /dev/null
+++ b/scripts/nodes/library/input/index.js
@@ -0,0 +1,17 @@
+import { btnNode } from "./btn.js";
+import { btnpNode } from "./btnp.js";
+import { controllerButtonsNode } from "./controllerButtons.js";
+
+/**
+ * All input-related node modules backed by PICO-8 helpers.
+ */
+export const inputNodes = [controllerButtonsNode, btnNode, btnpNode];
+
+/**
+ * Checklist tracking coverage of documented input helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const inputFunctionChecklist = [
+ { function: "BTN", nodeId: "input_btn", implemented: true },
+ { function: "BTNP", nodeId: "input_btnp", implemented: true },
+];
diff --git a/scripts/nodes/library/localVariable.js b/scripts/nodes/library/localVariable.js
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/nodes/library/localVariables.js b/scripts/nodes/library/localVariables.js
new file mode 100644
index 0000000..a1ff05a
--- /dev/null
+++ b/scripts/nodes/library/localVariables.js
@@ -0,0 +1,169 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+const LOCAL_TYPES = [
+ { value: "number", label: "Number" },
+ { value: "string", label: "String" },
+ { value: "boolean", label: "Boolean" },
+ { value: "table", label: "Table" },
+];
+
+const DEFAULT_LOCAL_TYPE = LOCAL_TYPES[0]?.value ?? "number";
+
+const sanitizeType = (raw) => {
+ const candidate = typeof raw === "string" ? raw.toLowerCase() : DEFAULT_LOCAL_TYPE;
+ return LOCAL_TYPES.some((entry) => entry.value === candidate)
+ ? candidate
+ : DEFAULT_LOCAL_TYPE;
+};
+
+const ensureName = (raw, fallback = "localVar") => {
+ const value = typeof raw === "string" ? raw.trim() : "";
+ return value.length ? value : fallback;
+};
+
+const resolveValuePropertyKey = (type) => {
+ switch (type) {
+ case "string":
+ return "valueString";
+ case "boolean":
+ return "valueBoolean";
+ case "table":
+ return "valueTable";
+ case "number":
+ default:
+ return "valueNumber";
+ }
+};
+
+const defaultValueForType = (type) => {
+ switch (type) {
+ case "string":
+ return "";
+ case "boolean":
+ return false;
+ case "table":
+ return "{}";
+ case "number":
+ default:
+ return 0;
+ }
+};
+
+const initializeLocalProperties = (properties, { includeValue }) => {
+ const type = sanitizeType(properties.variableType);
+ properties.variableType = type;
+ properties.name = ensureName(properties.name);
+
+ if (typeof properties.valueNumber !== "number") {
+ properties.valueNumber = Number.isFinite(Number(properties.valueNumber))
+ ? Number(properties.valueNumber)
+ : 0;
+ }
+ if (typeof properties.valueString !== "string") {
+ properties.valueString = String(properties.valueString ?? "");
+ }
+ if (typeof properties.valueBoolean !== "boolean") {
+ properties.valueBoolean = Boolean(properties.valueBoolean);
+ }
+ if (typeof properties.valueTable !== "string") {
+ properties.valueTable = String(properties.valueTable ?? "{}");
+ }
+
+ if (includeValue) {
+ const key = resolveValuePropertyKey(type);
+ const value = properties[key] ?? defaultValueForType(type);
+ properties[key] = value;
+ }
+};
+
+const normalizeLocalProperties = (properties, { includeValue }) => {
+ const type = sanitizeType(properties.variableType);
+ properties.variableType = type;
+ properties.name = ensureName(properties.name);
+ if (includeValue) {
+ const key = resolveValuePropertyKey(type);
+ const value = properties[key] ?? defaultValueForType(type);
+ properties[key] = value;
+ }
+};
+
+export const setLocalVariableNode = createNodeModule(
+ {
+ id: "set_local_var",
+ title: "Set Local",
+ category: "Logic",
+ description: "Declare or assign a local variable with an inline literal.",
+ searchTags: ["local", "set", "assign", "variable"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "any",
+ description: "Optional override for the inline literal",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ initializeProperties: (properties) => {
+ initializeLocalProperties(properties, { includeValue: true });
+ },
+ onPropertiesChanged: (properties) => {
+ normalizeLocalProperties(properties, { includeValue: true });
+ },
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ resolveValueInput,
+ sanitizeIdentifier,
+ emitNextExec,
+ formatLiteral,
+ }) => {
+ const type = sanitizeType(node.properties.variableType);
+ const name = sanitizeIdentifier(ensureName(node.properties.name));
+ const valueKey = resolveValuePropertyKey(type);
+ const inlineValue = node.properties[valueKey] ?? defaultValueForType(type);
+ const fallbackLiteral =
+ type === "table"
+ ? String(inlineValue ?? "{}") || "{}"
+ : formatLiteral(type, inlineValue);
+ const valueExpression = resolveValueInput("value", fallbackLiteral);
+ const statement = `${indent(indentLevel)}local ${name} = ${valueExpression}`;
+ return [statement, ...emitNextExec("exec_out")];
+ },
+ }
+);
+
+export const getLocalVariableNode = createNodeModule(
+ {
+ id: "get_local_var",
+ title: "Get Local",
+ category: "Logic",
+ description: "Access a local variable declared earlier in the flow.",
+ searchTags: ["local", "get", "variable", "read"],
+ inputs: [],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "any" },
+ ],
+ properties: [],
+ initializeProperties: (properties) => {
+ initializeLocalProperties(properties, { includeValue: false });
+ },
+ onPropertiesChanged: (properties) => {
+ normalizeLocalProperties(properties, { includeValue: false });
+ },
+ },
+ {
+ evaluateValue: ({ node, sanitizeIdentifier }) => {
+ return sanitizeIdentifier(ensureName(node.properties.name));
+ },
+ }
+);
+
+export const localVariableNodes = [setLocalVariableNode, getLocalVariableNode];
diff --git a/scripts/nodes/library/lua/coroutineCreate.js b/scripts/nodes/library/lua/coroutineCreate.js
new file mode 100644
index 0000000..ce24dc1
--- /dev/null
+++ b/scripts/nodes/library/lua/coroutineCreate.js
@@ -0,0 +1,38 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Creates a coroutine from a function using coroutine.create().
+ */
+export const luaCoroutineCreateNode = createNodeModule(
+ {
+ id: "lua_coroutine_create",
+ title: "Coroutine Create",
+ category: "Lua",
+ description: "Wrap a function in a coroutine via coroutine.create().",
+ searchTags: ["coroutine", "create", "lua"],
+ inputs: [
+ {
+ id: "fn",
+ name: "Function",
+ direction: "input",
+ kind: "any",
+ description: "Function to run inside the coroutine",
+ },
+ ],
+ outputs: [
+ {
+ id: "coroutine",
+ name: "Coroutine",
+ direction: "output",
+ kind: "any",
+ },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const fn = resolveValueInput("fn", "function() end");
+ return `coroutine.create(${fn})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/lua/coroutineResume.js b/scripts/nodes/library/lua/coroutineResume.js
new file mode 100644
index 0000000..ab65a6a
--- /dev/null
+++ b/scripts/nodes/library/lua/coroutineResume.js
@@ -0,0 +1,91 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Resumes a coroutine and exposes the success flag and first result.
+ */
+export const luaCoroutineResumeNode = createNodeModule(
+ {
+ id: "lua_coroutine_resume",
+ title: "Coroutine Resume",
+ category: "Lua",
+ description:
+ "Resume a coroutine and capture the success flag alongside the first returned value.",
+ searchTags: ["coroutine", "resume", "lua"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "coroutine",
+ name: "Coroutine",
+ direction: "input",
+ kind: "any",
+ },
+ {
+ id: "arg1",
+ name: "Arg 1",
+ direction: "input",
+ kind: "any",
+ description: "Optional first value passed to coroutine",
+ },
+ {
+ id: "arg2",
+ name: "Arg 2",
+ direction: "input",
+ kind: "any",
+ description: "Optional second value passed to coroutine",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ {
+ id: "success",
+ name: "Success",
+ direction: "output",
+ kind: "boolean",
+ },
+ {
+ id: "result",
+ name: "Result",
+ direction: "output",
+ kind: "any",
+ },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ resolveValueInput,
+ sanitizeIdentifier,
+ emitNextExec,
+ }) => {
+ const OMIT = "__pg_omit__";
+ const target = resolveValueInput("coroutine", "nil");
+ const arg1 = resolveValueInput("arg1", OMIT);
+ const arg2 = resolveValueInput("arg2", OMIT);
+ const args = [target];
+ if (arg1 !== OMIT) {
+ args.push(arg1);
+ }
+ if (arg2 !== OMIT) {
+ args.push(arg2);
+ }
+ const successName = sanitizeIdentifier(`__pg_${node.id}_co_success`);
+ const resultName = sanitizeIdentifier(`__pg_${node.id}_co_result`);
+ const line = `${indent(indentLevel)}local ${successName}, ${resultName} = coroutine.resume(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ evaluateValue: ({ node, pinId, sanitizeIdentifier }) => {
+ const successName = sanitizeIdentifier(`__pg_${node.id}_co_success`);
+ const resultName = sanitizeIdentifier(`__pg_${node.id}_co_result`);
+ if (pinId === "success") {
+ return successName;
+ }
+ if (pinId === "result") {
+ return resultName;
+ }
+ return "nil";
+ },
+ }
+);
diff --git a/scripts/nodes/library/lua/coroutineRunning.js b/scripts/nodes/library/lua/coroutineRunning.js
new file mode 100644
index 0000000..081206f
--- /dev/null
+++ b/scripts/nodes/library/lua/coroutineRunning.js
@@ -0,0 +1,27 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Returns the currently running coroutine via coroutine.running().
+ */
+export const luaCoroutineRunningNode = createNodeModule(
+ {
+ id: "lua_coroutine_running",
+ title: "Coroutine Running",
+ category: "Lua",
+ description: "Access the currently running coroutine reference.",
+ searchTags: ["coroutine", "running", "lua"],
+ inputs: [],
+ outputs: [
+ {
+ id: "coroutine",
+ name: "Coroutine",
+ direction: "output",
+ kind: "any",
+ },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: () => "coroutine.running()",
+ }
+);
diff --git a/scripts/nodes/library/lua/coroutineStatus.js b/scripts/nodes/library/lua/coroutineStatus.js
new file mode 100644
index 0000000..f759231
--- /dev/null
+++ b/scripts/nodes/library/lua/coroutineStatus.js
@@ -0,0 +1,32 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reports a coroutine's status via coroutine.status().
+ */
+export const luaCoroutineStatusNode = createNodeModule(
+ {
+ id: "lua_coroutine_status",
+ title: "Coroutine Status",
+ category: "Lua",
+ description: "Retrieve a coroutine's status string using coroutine.status().",
+ searchTags: ["coroutine", "status", "lua"],
+ inputs: [
+ {
+ id: "coroutine",
+ name: "Coroutine",
+ direction: "input",
+ kind: "any",
+ },
+ ],
+ outputs: [
+ { id: "status", name: "Status", direction: "output", kind: "string" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const target = resolveValueInput("coroutine", "nil");
+ return `coroutine.status(${target})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/lua/coroutineYield.js b/scripts/nodes/library/lua/coroutineYield.js
new file mode 100644
index 0000000..1f4c8ad
--- /dev/null
+++ b/scripts/nodes/library/lua/coroutineYield.js
@@ -0,0 +1,53 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Yields from within a coroutine using coroutine.yield().
+ */
+export const luaCoroutineYieldNode = createNodeModule(
+ {
+ id: "lua_coroutine_yield",
+ title: "Coroutine Yield",
+ category: "Lua",
+ description: "Yield execution from within a coroutine and optionally return values.",
+ searchTags: ["coroutine", "yield", "lua"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "value1",
+ name: "Value 1",
+ direction: "input",
+ kind: "any",
+ description: "Optional first value yielded to the caller",
+ },
+ {
+ id: "value2",
+ name: "Value 2",
+ direction: "input",
+ kind: "any",
+ description: "Optional second value yielded to the caller",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const value1 = resolveValueInput("value1", OMIT);
+ const value2 = resolveValueInput("value2", OMIT);
+ const args = [];
+ if (value1 !== OMIT) {
+ args.push(value1);
+ }
+ if (value2 !== OMIT) {
+ args.push(value2);
+ }
+ const call = args.length
+ ? `${indent(indentLevel)}coroutine.yield(${args.join(", ")})`
+ : `${indent(indentLevel)}coroutine.yield()`;
+ return [call, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/lua/getMetatable.js b/scripts/nodes/library/lua/getMetatable.js
new file mode 100644
index 0000000..984dce2
--- /dev/null
+++ b/scripts/nodes/library/lua/getMetatable.js
@@ -0,0 +1,27 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Retrieves the metatable associated with a table via getmetatable().
+ */
+export const luaGetMetatableNode = createNodeModule(
+ {
+ id: "lua_getmetatable",
+ title: "Get Metatable",
+ category: "Lua",
+ description: "Fetch a table's metatable using getmetatable().",
+ searchTags: ["getmetatable", "metatable", "lua"],
+ inputs: [
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ ],
+ outputs: [
+ { id: "metatable", name: "Metatable", direction: "output", kind: "table" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const tableValue = resolveValueInput("table", "{}");
+ return `getmetatable(${tableValue})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/lua/index.js b/scripts/nodes/library/lua/index.js
new file mode 100644
index 0000000..2ec7ee6
--- /dev/null
+++ b/scripts/nodes/library/lua/index.js
@@ -0,0 +1,63 @@
+import { luaSetMetatableNode } from "./setMetatable.js";
+import { luaGetMetatableNode } from "./getMetatable.js";
+import { luaRawGetNode } from "./rawGet.js";
+import { luaRawSetNode } from "./rawSet.js";
+import { luaRawEqualNode } from "./rawEqual.js";
+import { luaCoroutineCreateNode } from "./coroutineCreate.js";
+import { luaCoroutineResumeNode } from "./coroutineResume.js";
+import { luaCoroutineYieldNode } from "./coroutineYield.js";
+import { luaCoroutineStatusNode } from "./coroutineStatus.js";
+import { luaCoroutineRunningNode } from "./coroutineRunning.js";
+
+/**
+ * Node modules covering Lua metatable and coroutine helpers.
+ */
+export const luaNodes = [
+ luaSetMetatableNode,
+ luaGetMetatableNode,
+ luaRawGetNode,
+ luaRawSetNode,
+ luaRawEqualNode,
+ luaCoroutineCreateNode,
+ luaCoroutineResumeNode,
+ luaCoroutineYieldNode,
+ luaCoroutineStatusNode,
+ luaCoroutineRunningNode,
+];
+
+/**
+ * Checklist tracking coverage of Lua metatable and coroutine helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const luaFunctionChecklist = [
+ { function: "SETMETATABLE", nodeId: "lua_setmetatable", implemented: true },
+ { function: "GETMETATABLE", nodeId: "lua_getmetatable", implemented: true },
+ { function: "RAWGET", nodeId: "lua_rawget", implemented: true },
+ { function: "RAWSET", nodeId: "lua_rawset", implemented: true },
+ { function: "RAWEQUAL", nodeId: "lua_rawequal", implemented: true },
+ {
+ function: "COROUTINE.CREATE",
+ nodeId: "lua_coroutine_create",
+ implemented: true,
+ },
+ {
+ function: "COROUTINE.RESUME",
+ nodeId: "lua_coroutine_resume",
+ implemented: true,
+ },
+ {
+ function: "COROUTINE.YIELD",
+ nodeId: "lua_coroutine_yield",
+ implemented: true,
+ },
+ {
+ function: "COROUTINE.STATUS",
+ nodeId: "lua_coroutine_status",
+ implemented: true,
+ },
+ {
+ function: "COROUTINE.RUNNING",
+ nodeId: "lua_coroutine_running",
+ implemented: true,
+ },
+];
diff --git a/scripts/nodes/library/lua/rawEqual.js b/scripts/nodes/library/lua/rawEqual.js
new file mode 100644
index 0000000..4f0913f
--- /dev/null
+++ b/scripts/nodes/library/lua/rawEqual.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Compares two values using rawequal().
+ */
+export const luaRawEqualNode = createNodeModule(
+ {
+ id: "lua_rawequal",
+ title: "Raw Equal",
+ category: "Lua",
+ description: "Compare values without metamethods using rawequal().",
+ searchTags: ["rawequal", "compare", "lua"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "any" },
+ { id: "b", name: "B", direction: "input", kind: "any" },
+ ],
+ outputs: [
+ { id: "equal", name: "Equal", direction: "output", kind: "boolean" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "nil");
+ const b = resolveValueInput("b", "nil");
+ return `rawequal(${a}, ${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/lua/rawGet.js b/scripts/nodes/library/lua/rawGet.js
new file mode 100644
index 0000000..044a6bb
--- /dev/null
+++ b/scripts/nodes/library/lua/rawGet.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Retrieves a raw table entry using rawget().
+ */
+export const luaRawGetNode = createNodeModule(
+ {
+ id: "lua_rawget",
+ title: "Raw Get",
+ category: "Lua",
+ description: "Access a table entry without metamethods using rawget().",
+ searchTags: ["rawget", "table", "lua"],
+ inputs: [
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ { id: "key", name: "Key", direction: "input", kind: "any" },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "any" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const tableValue = resolveValueInput("table", "{}");
+ const key = resolveValueInput("key", "nil");
+ return `rawget(${tableValue}, ${key})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/lua/rawSet.js b/scripts/nodes/library/lua/rawSet.js
new file mode 100644
index 0000000..8de2f58
--- /dev/null
+++ b/scripts/nodes/library/lua/rawSet.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Writes a raw table entry using rawset().
+ */
+export const luaRawSetNode = createNodeModule(
+ {
+ id: "lua_rawset",
+ title: "Raw Set",
+ category: "Lua",
+ description: "Assign a table entry without metamethods using rawset().",
+ searchTags: ["rawset", "table", "lua"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ { id: "key", name: "Key", direction: "input", kind: "any" },
+ { id: "value", name: "Value", direction: "input", kind: "any" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const tableValue = resolveValueInput("table", "{}");
+ const key = resolveValueInput("key", "nil");
+ const value = resolveValueInput("value", "nil");
+ const line = `${indent(indentLevel)}rawset(${tableValue}, ${key}, ${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/lua/setMetatable.js b/scripts/nodes/library/lua/setMetatable.js
new file mode 100644
index 0000000..cab19b7
--- /dev/null
+++ b/scripts/nodes/library/lua/setMetatable.js
@@ -0,0 +1,35 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Applies a metatable to a table via setmetatable().
+ */
+export const luaSetMetatableNode = createNodeModule(
+ {
+ id: "lua_setmetatable",
+ title: "Set Metatable",
+ category: "Lua",
+ description: "Assign a metatable to a table using setmetatable().",
+ searchTags: ["setmetatable", "metatable", "lua"],
+ inputs: [
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ {
+ id: "metatable",
+ name: "Metatable",
+ direction: "input",
+ kind: "table",
+ defaultValue: "nil",
+ },
+ ],
+ outputs: [
+ { id: "table_out", name: "Table", direction: "output", kind: "table" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const tableValue = resolveValueInput("table", "{}");
+ const metatable = resolveValueInput("metatable", "nil");
+ return `setmetatable(${tableValue}, ${metatable})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/map/index.js b/scripts/nodes/library/map/index.js
new file mode 100644
index 0000000..5e217fc
--- /dev/null
+++ b/scripts/nodes/library/map/index.js
@@ -0,0 +1,20 @@
+import { mapMgetNode } from "./mget.js";
+import { mapMsetNode } from "./mset.js";
+import { mapDrawNode } from "./map.js";
+import { mapTlineNode } from "./tline.js";
+
+/**
+ * All map-related node modules backed by PICO-8 helpers.
+ */
+export const mapNodes = [mapMgetNode, mapMsetNode, mapDrawNode, mapTlineNode];
+
+/**
+ * Checklist tracking coverage of documented map helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const mapFunctionChecklist = [
+ { function: "MGET", nodeId: "map_mget", implemented: true },
+ { function: "MSET", nodeId: "map_mset", implemented: true },
+ { function: "MAP", nodeId: "map_map", implemented: true },
+ { function: "TLINE", nodeId: "map_tline", implemented: true },
+];
diff --git a/scripts/nodes/library/map/map.js b/scripts/nodes/library/map/map.js
new file mode 100644
index 0000000..6eb2bdc
--- /dev/null
+++ b/scripts/nodes/library/map/map.js
@@ -0,0 +1,108 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws map tiles using PICO-8's map helper.
+ */
+export const mapDrawNode = createNodeModule(
+ {
+ id: "map_map",
+ title: "Draw Map",
+ category: "Map",
+ description:
+ "Render a section of the map to the screen with optional layers.",
+ searchTags: ["map", "draw", "tiles", "layer"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "tile_x",
+ name: "Tile X",
+ direction: "input",
+ kind: "number",
+ description: "Starting tile X coordinate",
+ },
+ {
+ id: "tile_y",
+ name: "Tile Y",
+ direction: "input",
+ kind: "number",
+ description: "Starting tile Y coordinate",
+ },
+ {
+ id: "screen_x",
+ name: "Screen X",
+ direction: "input",
+ kind: "number",
+ description: "Screen X position in pixels",
+ },
+ {
+ id: "screen_y",
+ name: "Screen Y",
+ direction: "input",
+ kind: "number",
+ description: "Screen Y position in pixels",
+ },
+ {
+ id: "tile_w",
+ name: "Tile Width",
+ direction: "input",
+ kind: "number",
+ description: "Number of tiles across",
+ },
+ {
+ id: "tile_h",
+ name: "Tile Height",
+ direction: "input",
+ kind: "number",
+ description: "Number of tiles down",
+ },
+ {
+ id: "layers",
+ name: "Layers",
+ direction: "input",
+ kind: "number",
+ description: "Sprite flag bitmask",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const tileX = resolveValueInput("tile_x", OMIT);
+ const tileY = resolveValueInput("tile_y", OMIT);
+ const screenX = resolveValueInput("screen_x", OMIT);
+ const screenY = resolveValueInput("screen_y", OMIT);
+ const tileW = resolveValueInput("tile_w", OMIT);
+ const tileH = resolveValueInput("tile_h", OMIT);
+ const layers = resolveValueInput("layers", OMIT);
+
+ const values = [
+ { value: tileX, placeholder: "0" },
+ { value: tileY, placeholder: "0" },
+ { value: screenX, placeholder: "0" },
+ { value: screenY, placeholder: "0" },
+ { value: tileW, placeholder: "nil" },
+ { value: tileH, placeholder: "nil" },
+ { value: layers, placeholder: "nil" },
+ ];
+
+ let lastIndex = values.length - 1;
+ while (lastIndex >= 0 && values[lastIndex].value === OMIT) {
+ lastIndex -= 1;
+ }
+
+ const args = [];
+ for (let index = 0; index <= lastIndex; index += 1) {
+ const entry = values[index];
+ args.push(entry.value === OMIT ? entry.placeholder : entry.value);
+ }
+
+ const call = args.length ? `map(${args.join(", ")})` : "map()";
+ const line = `${indent(indentLevel)}${call}`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/map/mget.js b/scripts/nodes/library/map/mget.js
new file mode 100644
index 0000000..d1d0bb5
--- /dev/null
+++ b/scripts/nodes/library/map/mget.js
@@ -0,0 +1,41 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads a map tile value using PICO-8's mget helper.
+ */
+export const mapMgetNode = createNodeModule(
+ {
+ id: "map_mget",
+ title: "Get Map Tile",
+ category: "Map",
+ description: "Fetch the numeric map value at a tile coordinate.",
+ searchTags: ["mget", "map", "tile", "read"],
+ inputs: [
+ {
+ id: "x",
+ name: "Tile X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Tile Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ return `mget(${x}, ${y})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/map/mset.js b/scripts/nodes/library/map/mset.js
new file mode 100644
index 0000000..b39c246
--- /dev/null
+++ b/scripts/nodes/library/map/mset.js
@@ -0,0 +1,51 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Writes a map tile value using PICO-8's mset helper.
+ */
+export const mapMsetNode = createNodeModule(
+ {
+ id: "map_mset",
+ title: "Set Map Tile",
+ category: "Map",
+ description: "Store a numeric value at a tile coordinate.",
+ searchTags: ["mset", "map", "tile", "write"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x",
+ name: "Tile X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Tile Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const value = resolveValueInput("value", "0");
+ const line = `${indent(indentLevel)}mset(${x}, ${y}, ${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/map/tline.js b/scripts/nodes/library/map/tline.js
new file mode 100644
index 0000000..a34e208
--- /dev/null
+++ b/scripts/nodes/library/map/tline.js
@@ -0,0 +1,157 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Draws textured lines or configures precision using PICO-8's tline helper.
+ */
+export const mapTlineNode = createNodeModule(
+ {
+ id: "map_tline",
+ title: "Draw Textured Line",
+ category: "Map",
+ description:
+ "Render a textured line from map data or adjust tline precision.",
+ searchTags: ["tline", "map", "texture", "line"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "x0",
+ name: "X0",
+ direction: "input",
+ kind: "number",
+ description: "Line start X",
+ },
+ {
+ id: "y0",
+ name: "Y0",
+ direction: "input",
+ kind: "number",
+ description: "Line start Y",
+ },
+ {
+ id: "x1",
+ name: "X1",
+ direction: "input",
+ kind: "number",
+ description: "Line end X",
+ },
+ {
+ id: "y1",
+ name: "Y1",
+ direction: "input",
+ kind: "number",
+ description: "Line end Y",
+ },
+ {
+ id: "mx",
+ name: "Map X",
+ direction: "input",
+ kind: "number",
+ description: "Map sample X (tiles)",
+ },
+ {
+ id: "my",
+ name: "Map Y",
+ direction: "input",
+ kind: "number",
+ description: "Map sample Y (tiles)",
+ },
+ {
+ id: "mdx",
+ name: "MDX",
+ direction: "input",
+ kind: "number",
+ description: "Map X delta",
+ },
+ {
+ id: "mdy",
+ name: "MDY",
+ direction: "input",
+ kind: "number",
+ description: "Map Y delta",
+ },
+ {
+ id: "layers",
+ name: "Layers",
+ direction: "input",
+ kind: "number",
+ description: "Sprite flag bitmask",
+ },
+ {
+ id: "precision",
+ name: "Precision",
+ direction: "input",
+ kind: "number",
+ description: "Precision override when setting mode",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [
+ {
+ key: "mode",
+ label: "Mode",
+ type: "enum",
+ defaultValue: "draw",
+ options: [
+ { label: "Draw Line", value: "draw" },
+ { label: "Set Precision", value: "precision" },
+ ],
+ },
+ ],
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ resolveValueInput,
+ emitNextExec,
+ }) => {
+ const mode = String(node.properties.mode ?? "draw");
+ if (mode === "precision") {
+ const precision = resolveValueInput("precision", "16");
+ const line = `${indent(indentLevel)}tline(${precision})`;
+ return [line, ...emitNextExec("exec_out")];
+ }
+
+ const OMIT = "__pg_omit__";
+ const x0 = resolveValueInput("x0", OMIT);
+ const y0 = resolveValueInput("y0", OMIT);
+ const x1 = resolveValueInput("x1", OMIT);
+ const y1 = resolveValueInput("y1", OMIT);
+ const mx = resolveValueInput("mx", OMIT);
+ const my = resolveValueInput("my", OMIT);
+ const mdx = resolveValueInput("mdx", OMIT);
+ const mdy = resolveValueInput("mdy", OMIT);
+ const layers = resolveValueInput("layers", OMIT);
+
+ const values = [
+ { value: x0, placeholder: "0" },
+ { value: y0, placeholder: "0" },
+ { value: x1, placeholder: "0" },
+ { value: y1, placeholder: "0" },
+ { value: mx, placeholder: "0" },
+ { value: my, placeholder: "0" },
+ { value: mdx, placeholder: "0.125" },
+ { value: mdy, placeholder: "0" },
+ { value: layers, placeholder: "nil" },
+ ];
+
+ let lastIndex = values.length - 1;
+ while (lastIndex >= 0 && values[lastIndex].value === OMIT) {
+ lastIndex -= 1;
+ }
+
+ const args = [];
+ for (let index = 0; index <= lastIndex; index += 1) {
+ const entry = values[index];
+ args.push(entry.value === OMIT ? entry.placeholder : entry.value);
+ }
+
+ const call = args.length ? `tline(${args.join(", ")})` : "tline()";
+ const line = `${indent(indentLevel)}${call}`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/abs.js b/scripts/nodes/library/math/abs.js
new file mode 100644
index 0000000..876af07
--- /dev/null
+++ b/scripts/nodes/library/math/abs.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Computes the absolute value using abs.
+ */
+export const mathAbsNode = createNodeModule(
+ {
+ id: "math_abs",
+ title: "Abs",
+ category: "Math",
+ description: "Return the absolute value of a number.",
+ searchTags: ["abs", "absolute", "math"],
+ inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ return `abs(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/atan2.js b/scripts/nodes/library/math/atan2.js
new file mode 100644
index 0000000..2035ccf
--- /dev/null
+++ b/scripts/nodes/library/math/atan2.js
@@ -0,0 +1,30 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Computes an angle from delta components using atan2.
+ */
+export const mathAtan2Node = createNodeModule(
+ {
+ id: "math_atan2",
+ title: "Atan2",
+ category: "Math",
+ description:
+ "Return the screenspace angle for a delta X and Y pair.",
+ searchTags: ["atan2", "angle", "math", "trig"],
+ inputs: [
+ { id: "dx", name: "DX", direction: "input", kind: "number" },
+ { id: "dy", name: "DY", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const dx = resolveValueInput("dx", "0");
+ const dy = resolveValueInput("dy", "0");
+ return `atan2(${dx}, ${dy})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/band.js b/scripts/nodes/library/math/band.js
new file mode 100644
index 0000000..c106f27
--- /dev/null
+++ b/scripts/nodes/library/math/band.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs bitwise and using band.
+ */
+export const mathBandNode = createNodeModule(
+ {
+ id: "math_band",
+ title: "Bitwise AND",
+ category: "Math",
+ description: "Return bits set in both operands using BAND().",
+ searchTags: ["band", "bitwise", "and", "math"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "number" },
+ { id: "b", name: "B", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "0");
+ return `band(${a}, ${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/bnot.js b/scripts/nodes/library/math/bnot.js
new file mode 100644
index 0000000..47c8d40
--- /dev/null
+++ b/scripts/nodes/library/math/bnot.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs bitwise not using bnot.
+ */
+export const mathBnotNode = createNodeModule(
+ {
+ id: "math_bnot",
+ title: "Bitwise NOT",
+ category: "Math",
+ description: "Invert every bit in a value using BNOT().",
+ searchTags: ["bnot", "bitwise", "not", "math"],
+ inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ return `bnot(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/bor.js b/scripts/nodes/library/math/bor.js
new file mode 100644
index 0000000..ab94e3e
--- /dev/null
+++ b/scripts/nodes/library/math/bor.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs bitwise or using bor.
+ */
+export const mathBorNode = createNodeModule(
+ {
+ id: "math_bor",
+ title: "Bitwise OR",
+ category: "Math",
+ description: "Return bits set in either operand using BOR().",
+ searchTags: ["bor", "bitwise", "or", "math"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "number" },
+ { id: "b", name: "B", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "0");
+ return `bor(${a}, ${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/bxor.js b/scripts/nodes/library/math/bxor.js
new file mode 100644
index 0000000..183f428
--- /dev/null
+++ b/scripts/nodes/library/math/bxor.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs bitwise exclusive-or using bxor.
+ */
+export const mathBxorNode = createNodeModule(
+ {
+ id: "math_bxor",
+ title: "Bitwise XOR",
+ category: "Math",
+ description: "Return bits set in exactly one operand using BXOR().",
+ searchTags: ["bxor", "bitwise", "xor", "math"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "number" },
+ { id: "b", name: "B", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "0");
+ return `bxor(${a}, ${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/ceil.js b/scripts/nodes/library/math/ceil.js
new file mode 100644
index 0000000..41f1c19
--- /dev/null
+++ b/scripts/nodes/library/math/ceil.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Ceils a numeric value using ceil.
+ */
+export const mathCeilNode = createNodeModule(
+ {
+ id: "math_ceil",
+ title: "Ceil",
+ category: "Math",
+ description: "Round a value up to the nearest integer.",
+ searchTags: ["ceil", "ceiling", "math", "round"],
+ inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ return `ceil(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/cos.js b/scripts/nodes/library/math/cos.js
new file mode 100644
index 0000000..5f85034
--- /dev/null
+++ b/scripts/nodes/library/math/cos.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Computes the cosine of a value using cos.
+ */
+export const mathCosNode = createNodeModule(
+ {
+ id: "math_cos",
+ title: "Cos",
+ category: "Math",
+ description:
+ "Return the cosine of an angle where 1.0 represents a full turn.",
+ searchTags: ["cos", "cosine", "math", "trig"],
+ inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ return `cos(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/div.js b/scripts/nodes/library/math/div.js
new file mode 100644
index 0000000..5e03348
--- /dev/null
+++ b/scripts/nodes/library/math/div.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs integer division using the floor operation.
+ */
+export const mathDivNode = createNodeModule(
+ {
+ id: "math_div",
+ title: "Integer Divide",
+ category: "Math",
+ description: "Return the floored quotient of two numbers (A \\ B).",
+ searchTags: ["div", "integer", "divide", "math"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "number" },
+ { id: "b", name: "B", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "1");
+ return `(${a}) \\ (${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/flr.js b/scripts/nodes/library/math/flr.js
new file mode 100644
index 0000000..72a1c51
--- /dev/null
+++ b/scripts/nodes/library/math/flr.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Floors a numeric value using flr.
+ */
+export const mathFlrNode = createNodeModule(
+ {
+ id: "math_flr",
+ title: "Floor",
+ category: "Math",
+ description: "Round a value down to the nearest integer.",
+ searchTags: ["flr", "floor", "math", "round"],
+ inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ return `flr(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/index.js b/scripts/nodes/library/math/index.js
new file mode 100644
index 0000000..c00af62
--- /dev/null
+++ b/scripts/nodes/library/math/index.js
@@ -0,0 +1,79 @@
+import { mathMaxNode } from "./max.js";
+import { mathMinNode } from "./min.js";
+import { mathMidNode } from "./mid.js";
+import { mathFlrNode } from "./flr.js";
+import { mathCeilNode } from "./ceil.js";
+import { mathCosNode } from "./cos.js";
+import { mathSinNode } from "./sin.js";
+import { mathAtan2Node } from "./atan2.js";
+import { mathSqrtNode } from "./sqrt.js";
+import { mathAbsNode } from "./abs.js";
+import { mathRndNode } from "./rnd.js";
+import { mathSrandNode } from "./srand.js";
+import { mathBandNode } from "./band.js";
+import { mathBorNode } from "./bor.js";
+import { mathBxorNode } from "./bxor.js";
+import { mathBnotNode } from "./bnot.js";
+import { mathShlNode } from "./shl.js";
+import { mathShrNode } from "./shr.js";
+import { mathLshrNode } from "./lshr.js";
+import { mathRotlNode } from "./rotl.js";
+import { mathRotrNode } from "./rotr.js";
+import { mathDivNode } from "./div.js";
+
+/**
+ * All math-related node modules backed by PICO-8 helpers.
+ */
+export const mathNodes = [
+ mathMaxNode,
+ mathMinNode,
+ mathMidNode,
+ mathFlrNode,
+ mathCeilNode,
+ mathCosNode,
+ mathSinNode,
+ mathAtan2Node,
+ mathSqrtNode,
+ mathAbsNode,
+ mathRndNode,
+ mathSrandNode,
+ mathBandNode,
+ mathBorNode,
+ mathBxorNode,
+ mathBnotNode,
+ mathShlNode,
+ mathShrNode,
+ mathLshrNode,
+ mathRotlNode,
+ mathRotrNode,
+ mathDivNode,
+];
+
+/**
+ * Checklist tracking coverage of documented math helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const mathFunctionChecklist = [
+ { function: "MAX", nodeId: "math_max", implemented: true },
+ { function: "MIN", nodeId: "math_min", implemented: true },
+ { function: "MID", nodeId: "math_mid", implemented: true },
+ { function: "FLR", nodeId: "math_flr", implemented: true },
+ { function: "CEIL", nodeId: "math_ceil", implemented: true },
+ { function: "COS", nodeId: "math_cos", implemented: true },
+ { function: "SIN", nodeId: "math_sin", implemented: true },
+ { function: "ATAN2", nodeId: "math_atan2", implemented: true },
+ { function: "SQRT", nodeId: "math_sqrt", implemented: true },
+ { function: "ABS", nodeId: "math_abs", implemented: true },
+ { function: "RND", nodeId: "math_rnd", implemented: true },
+ { function: "SRAND", nodeId: "math_srand", implemented: true },
+ { function: "BAND", nodeId: "math_band", implemented: true },
+ { function: "BOR", nodeId: "math_bor", implemented: true },
+ { function: "BXOR", nodeId: "math_bxor", implemented: true },
+ { function: "BNOT", nodeId: "math_bnot", implemented: true },
+ { function: "SHL", nodeId: "math_shl", implemented: true },
+ { function: "SHR", nodeId: "math_shr", implemented: true },
+ { function: "LSHR", nodeId: "math_lshr", implemented: true },
+ { function: "ROTL", nodeId: "math_rotl", implemented: true },
+ { function: "ROTR", nodeId: "math_rotr", implemented: true },
+ { function: "\\", nodeId: "math_div", implemented: true },
+];
diff --git a/scripts/nodes/library/math/lshr.js b/scripts/nodes/library/math/lshr.js
new file mode 100644
index 0000000..598145f
--- /dev/null
+++ b/scripts/nodes/library/math/lshr.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs a logical right bit shift using lshr.
+ */
+export const mathLshrNode = createNodeModule(
+ {
+ id: "math_lshr",
+ title: "Logical Shift Right",
+ category: "Math",
+ description: "Shift bits right with zeros entering from the left.",
+ searchTags: ["lshr", "shift", "bitwise", "math"],
+ inputs: [
+ { id: "value", name: "Value", direction: "input", kind: "number" },
+ { id: "amount", name: "Amount", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ const amount = resolveValueInput("amount", "0");
+ return `lshr(${value}, ${amount})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/max.js b/scripts/nodes/library/math/max.js
new file mode 100644
index 0000000..0c389fb
--- /dev/null
+++ b/scripts/nodes/library/math/max.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Returns the maximum of two values using max.
+ */
+export const mathMaxNode = createNodeModule(
+ {
+ id: "math_max",
+ title: "Max",
+ category: "Math",
+ description: "Return the greater of two numeric values.",
+ searchTags: ["max", "maximum", "math", "compare"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "number" },
+ { id: "b", name: "B", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "value", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "0");
+ return `max(${a}, ${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/mid.js b/scripts/nodes/library/math/mid.js
new file mode 100644
index 0000000..30de5f2
--- /dev/null
+++ b/scripts/nodes/library/math/mid.js
@@ -0,0 +1,31 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Returns the median of three values using mid.
+ */
+export const mathMidNode = createNodeModule(
+ {
+ id: "math_mid",
+ title: "Mid",
+ category: "Math",
+ description: "Return the middle value from three numeric inputs.",
+ searchTags: ["mid", "median", "math", "compare"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "number" },
+ { id: "b", name: "B", direction: "input", kind: "number" },
+ { id: "c", name: "C", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "value", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "0");
+ const c = resolveValueInput("c", "0");
+ return `mid(${a}, ${b}, ${c})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/min.js b/scripts/nodes/library/math/min.js
new file mode 100644
index 0000000..da8612c
--- /dev/null
+++ b/scripts/nodes/library/math/min.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Returns the minimum of two values using min.
+ */
+export const mathMinNode = createNodeModule(
+ {
+ id: "math_min",
+ title: "Min",
+ category: "Math",
+ description: "Return the lesser of two numeric values.",
+ searchTags: ["min", "minimum", "math", "compare"],
+ inputs: [
+ { id: "a", name: "A", direction: "input", kind: "number" },
+ { id: "b", name: "B", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "value", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "0");
+ const b = resolveValueInput("b", "0");
+ return `min(${a}, ${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/rnd.js b/scripts/nodes/library/math/rnd.js
new file mode 100644
index 0000000..ea81e2c
--- /dev/null
+++ b/scripts/nodes/library/math/rnd.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Generates random numbers using rnd.
+ */
+export const mathRndNode = createNodeModule(
+ {
+ id: "math_rnd",
+ title: "Random",
+ category: "Math",
+ description: "Return a random number in the range [0, x).",
+ searchTags: ["rnd", "random", "math"],
+ inputs: [{ id: "value", name: "Range", direction: "input", kind: "number" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "1");
+ return `rnd(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/rotl.js b/scripts/nodes/library/math/rotl.js
new file mode 100644
index 0000000..f093368
--- /dev/null
+++ b/scripts/nodes/library/math/rotl.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs a left bit rotation using rotl.
+ */
+export const mathRotlNode = createNodeModule(
+ {
+ id: "math_rotl",
+ title: "Rotate Left",
+ category: "Math",
+ description: "Rotate bits left by a given amount.",
+ searchTags: ["rotl", "rotate", "bitwise", "math"],
+ inputs: [
+ { id: "value", name: "Value", direction: "input", kind: "number" },
+ { id: "amount", name: "Amount", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ const amount = resolveValueInput("amount", "0");
+ return `rotl(${value}, ${amount})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/rotr.js b/scripts/nodes/library/math/rotr.js
new file mode 100644
index 0000000..404827f
--- /dev/null
+++ b/scripts/nodes/library/math/rotr.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs a right bit rotation using rotr.
+ */
+export const mathRotrNode = createNodeModule(
+ {
+ id: "math_rotr",
+ title: "Rotate Right",
+ category: "Math",
+ description: "Rotate bits right by a given amount.",
+ searchTags: ["rotr", "rotate", "bitwise", "math"],
+ inputs: [
+ { id: "value", name: "Value", direction: "input", kind: "number" },
+ { id: "amount", name: "Amount", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ const amount = resolveValueInput("amount", "0");
+ return `rotr(${value}, ${amount})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/shl.js b/scripts/nodes/library/math/shl.js
new file mode 100644
index 0000000..eb80968
--- /dev/null
+++ b/scripts/nodes/library/math/shl.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs a left bit shift using shl.
+ */
+export const mathShlNode = createNodeModule(
+ {
+ id: "math_shl",
+ title: "Shift Left",
+ category: "Math",
+ description: "Shift bits left with zeros entering from the right.",
+ searchTags: ["shl", "shift", "bitwise", "math"],
+ inputs: [
+ { id: "value", name: "Value", direction: "input", kind: "number" },
+ { id: "amount", name: "Amount", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ const amount = resolveValueInput("amount", "0");
+ return `shl(${value}, ${amount})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/shr.js b/scripts/nodes/library/math/shr.js
new file mode 100644
index 0000000..eea74e1
--- /dev/null
+++ b/scripts/nodes/library/math/shr.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs an arithmetic right bit shift using shr.
+ */
+export const mathShrNode = createNodeModule(
+ {
+ id: "math_shr",
+ title: "Shift Right",
+ category: "Math",
+ description: "Shift bits right while extending the sign bit.",
+ searchTags: ["shr", "shift", "bitwise", "math"],
+ inputs: [
+ { id: "value", name: "Value", direction: "input", kind: "number" },
+ { id: "amount", name: "Amount", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ const amount = resolveValueInput("amount", "0");
+ return `shr(${value}, ${amount})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/sin.js b/scripts/nodes/library/math/sin.js
new file mode 100644
index 0000000..a35ddf8
--- /dev/null
+++ b/scripts/nodes/library/math/sin.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Computes the sine of a value using sin.
+ */
+export const mathSinNode = createNodeModule(
+ {
+ id: "math_sin",
+ title: "Sin",
+ category: "Math",
+ description:
+ "Return the sine of an angle where 1.0 represents a full turn.",
+ searchTags: ["sin", "sine", "math", "trig"],
+ inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ return `sin(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/sqrt.js b/scripts/nodes/library/math/sqrt.js
new file mode 100644
index 0000000..2df603d
--- /dev/null
+++ b/scripts/nodes/library/math/sqrt.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Computes a square root using sqrt.
+ */
+export const mathSqrtNode = createNodeModule(
+ {
+ id: "math_sqrt",
+ title: "Sqrt",
+ category: "Math",
+ description: "Return the square root of a numeric value.",
+ searchTags: ["sqrt", "square root", "math"],
+ inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ return `sqrt(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/math/srand.js b/scripts/nodes/library/math/srand.js
new file mode 100644
index 0000000..fdaf190
--- /dev/null
+++ b/scripts/nodes/library/math/srand.js
@@ -0,0 +1,29 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Seeds the random number generator using srand.
+ */
+export const mathSrandNode = createNodeModule(
+ {
+ id: "math_srand",
+ title: "Seed Random",
+ category: "Math",
+ description: "Set the random number generator seed.",
+ searchTags: ["srand", "random", "seed", "math"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "value", name: "Seed", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const value = resolveValueInput("value", "0");
+ const line = `${indent(indentLevel)}srand(${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/cstore.js b/scripts/nodes/library/memory/cstore.js
new file mode 100644
index 0000000..4798b6a
--- /dev/null
+++ b/scripts/nodes/library/memory/cstore.js
@@ -0,0 +1,67 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Copies data from base RAM to cartridge ROM using cstore.
+ */
+export const memoryCstoreNode = createNodeModule(
+ {
+ id: "memory_cstore",
+ title: "Store To Cart",
+ category: "Memory",
+ description:
+ "Copy base RAM into cart ROM, optionally targeting another cartridge.",
+ searchTags: ["cstore", "memory", "cart", "save"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "dest",
+ name: "Dest",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "source",
+ name: "Source",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "length",
+ name: "Length",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "filename",
+ name: "Filename",
+ direction: "input",
+ kind: "string",
+ description: "Optional cartridge filename",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const dest = resolveValueInput("dest", "0");
+ const source = resolveValueInput("source", "0");
+ const length = resolveValueInput("length", "0");
+ const filename = resolveValueInput("filename", OMIT);
+
+ const args = [dest, source, length];
+ if (filename !== OMIT) {
+ args.push(filename);
+ }
+
+ const line = `${indent(indentLevel)}cstore(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/index.js b/scripts/nodes/library/memory/index.js
new file mode 100644
index 0000000..0cab7e0
--- /dev/null
+++ b/scripts/nodes/library/memory/index.js
@@ -0,0 +1,43 @@
+import { memoryPeekNode } from "./peek.js";
+import { memoryPeek2Node } from "./peek2.js";
+import { memoryPeek4Node } from "./peek4.js";
+import { memoryPokeNode } from "./poke.js";
+import { memoryPoke2Node } from "./poke2.js";
+import { memoryPoke4Node } from "./poke4.js";
+import { memoryMemcpyNode } from "./memcpy.js";
+import { memoryMemsetNode } from "./memset.js";
+import { memoryReloadNode } from "./reload.js";
+import { memoryCstoreNode } from "./cstore.js";
+
+/**
+ * All memory-related node modules backed by PICO-8 helpers.
+ */
+export const memoryNodes = [
+ memoryPeekNode,
+ memoryPeek2Node,
+ memoryPeek4Node,
+ memoryPokeNode,
+ memoryPoke2Node,
+ memoryPoke4Node,
+ memoryMemcpyNode,
+ memoryMemsetNode,
+ memoryReloadNode,
+ memoryCstoreNode,
+];
+
+/**
+ * Checklist tracking coverage of documented memory helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const memoryFunctionChecklist = [
+ { function: "PEEK", nodeId: "memory_peek", implemented: true },
+ { function: "PEEK2", nodeId: "memory_peek2", implemented: true },
+ { function: "PEEK4", nodeId: "memory_peek4", implemented: true },
+ { function: "POKE", nodeId: "memory_poke", implemented: true },
+ { function: "POKE2", nodeId: "memory_poke2", implemented: true },
+ { function: "POKE4", nodeId: "memory_poke4", implemented: true },
+ { function: "MEMCPY", nodeId: "memory_memcpy", implemented: true },
+ { function: "MEMSET", nodeId: "memory_memset", implemented: true },
+ { function: "RELOAD", nodeId: "memory_reload", implemented: true },
+ { function: "CSTORE", nodeId: "memory_cstore", implemented: true },
+];
diff --git a/scripts/nodes/library/memory/memcpy.js b/scripts/nodes/library/memory/memcpy.js
new file mode 100644
index 0000000..fc635c0
--- /dev/null
+++ b/scripts/nodes/library/memory/memcpy.js
@@ -0,0 +1,51 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Copies memory regions using memcpy.
+ */
+export const memoryMemcpyNode = createNodeModule(
+ {
+ id: "memory_memcpy",
+ title: "Copy Memory",
+ category: "Memory",
+ description: "Copy a block of memory within base RAM.",
+ searchTags: ["memcpy", "memory", "copy", "block"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "dest",
+ name: "Dest",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "source",
+ name: "Source",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "length",
+ name: "Length",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const dest = resolveValueInput("dest", "0");
+ const source = resolveValueInput("source", "0");
+ const length = resolveValueInput("length", "0");
+ const line = `${indent(indentLevel)}memcpy(${dest}, ${source}, ${length})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/memset.js b/scripts/nodes/library/memory/memset.js
new file mode 100644
index 0000000..8e8f35b
--- /dev/null
+++ b/scripts/nodes/library/memory/memset.js
@@ -0,0 +1,51 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Fills memory with a value using memset.
+ */
+export const memoryMemsetNode = createNodeModule(
+ {
+ id: "memory_memset",
+ title: "Fill Memory",
+ category: "Memory",
+ description: "Fill a region of base RAM with a repeated value.",
+ searchTags: ["memset", "memory", "fill"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "dest",
+ name: "Dest",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "length",
+ name: "Length",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const dest = resolveValueInput("dest", "0");
+ const value = resolveValueInput("value", "0");
+ const length = resolveValueInput("length", "0");
+ const line = `${indent(indentLevel)}memset(${dest}, ${value}, ${length})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/peek.js b/scripts/nodes/library/memory/peek.js
new file mode 100644
index 0000000..6f1af59
--- /dev/null
+++ b/scripts/nodes/library/memory/peek.js
@@ -0,0 +1,45 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads bytes from base RAM using PICO-8's peek helper.
+ */
+export const memoryPeekNode = createNodeModule(
+ {
+ id: "memory_peek",
+ title: "Peek Memory",
+ category: "Memory",
+ description: "Read a byte or sequence of bytes from base RAM.",
+ searchTags: ["peek", "memory", "read"],
+ inputs: [
+ {
+ id: "address",
+ name: "Address",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "count",
+ name: "Count",
+ direction: "input",
+ kind: "number",
+ description: "Optional number of bytes to read",
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const address = resolveValueInput("address", "0");
+ const count = resolveValueInput("count", OMIT);
+ if (count === OMIT) {
+ return `peek(${address})`;
+ }
+ return `peek(${address}, ${count})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/peek2.js b/scripts/nodes/library/memory/peek2.js
new file mode 100644
index 0000000..13cb73e
--- /dev/null
+++ b/scripts/nodes/library/memory/peek2.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads a 16-bit little-endian value using peek2.
+ */
+export const memoryPeek2Node = createNodeModule(
+ {
+ id: "memory_peek2",
+ title: "Peek16 Memory",
+ category: "Memory",
+ description: "Read a 16-bit little-endian value from base RAM.",
+ searchTags: ["peek2", "memory", "read", "16-bit"],
+ inputs: [
+ {
+ id: "address",
+ name: "Address",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const address = resolveValueInput("address", "0");
+ return `peek2(${address})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/peek4.js b/scripts/nodes/library/memory/peek4.js
new file mode 100644
index 0000000..9502eda
--- /dev/null
+++ b/scripts/nodes/library/memory/peek4.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads a 32-bit little-endian value using peek4.
+ */
+export const memoryPeek4Node = createNodeModule(
+ {
+ id: "memory_peek4",
+ title: "Peek32 Memory",
+ category: "Memory",
+ description: "Read a 32-bit little-endian value from base RAM.",
+ searchTags: ["peek4", "memory", "read", "32-bit"],
+ inputs: [
+ {
+ id: "address",
+ name: "Address",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const address = resolveValueInput("address", "0");
+ return `peek4(${address})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/poke.js b/scripts/nodes/library/memory/poke.js
new file mode 100644
index 0000000..1387abb
--- /dev/null
+++ b/scripts/nodes/library/memory/poke.js
@@ -0,0 +1,67 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Writes one or more bytes to base RAM using poke.
+ */
+export const memoryPokeNode = createNodeModule(
+ {
+ id: "memory_poke",
+ title: "Poke Memory",
+ category: "Memory",
+ description: "Write sequential bytes to base RAM.",
+ searchTags: ["poke", "memory", "write"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "address",
+ name: "Address",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [
+ {
+ key: "extraValues",
+ label: "Additional Values",
+ type: "string",
+ placeholder: "e.g. 0x10, 0x11",
+ },
+ ],
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ resolveValueInput,
+ emitNextExec,
+ }) => {
+ const address = resolveValueInput("address", "0");
+ const value = resolveValueInput("value", "0");
+ const args = [address, value];
+
+ const extras = String(node.properties.extraValues ?? "").trim();
+ if (extras.length) {
+ extras
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+ .forEach((entry) => args.push(entry));
+ }
+
+ const line = `${indent(indentLevel)}poke(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/poke2.js b/scripts/nodes/library/memory/poke2.js
new file mode 100644
index 0000000..46b87e5
--- /dev/null
+++ b/scripts/nodes/library/memory/poke2.js
@@ -0,0 +1,43 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Writes a 16-bit little-endian value using poke2.
+ */
+export const memoryPoke2Node = createNodeModule(
+ {
+ id: "memory_poke2",
+ title: "Poke16 Memory",
+ category: "Memory",
+ description: "Write a 16-bit little-endian value to base RAM.",
+ searchTags: ["poke2", "memory", "write", "16-bit"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "address",
+ name: "Address",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const address = resolveValueInput("address", "0");
+ const value = resolveValueInput("value", "0");
+ const line = `${indent(indentLevel)}poke2(${address}, ${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/poke4.js b/scripts/nodes/library/memory/poke4.js
new file mode 100644
index 0000000..b82d6b1
--- /dev/null
+++ b/scripts/nodes/library/memory/poke4.js
@@ -0,0 +1,43 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Writes a 32-bit little-endian value using poke4.
+ */
+export const memoryPoke4Node = createNodeModule(
+ {
+ id: "memory_poke4",
+ title: "Poke32 Memory",
+ category: "Memory",
+ description: "Write a 32-bit little-endian value to base RAM.",
+ searchTags: ["poke4", "memory", "write", "32-bit"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "address",
+ name: "Address",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const address = resolveValueInput("address", "0");
+ const value = resolveValueInput("value", "0");
+ const line = `${indent(indentLevel)}poke4(${address}, ${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/memory/reload.js b/scripts/nodes/library/memory/reload.js
new file mode 100644
index 0000000..e8e4d5c
--- /dev/null
+++ b/scripts/nodes/library/memory/reload.js
@@ -0,0 +1,67 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Copies data from cartridge ROM into base RAM using reload.
+ */
+export const memoryReloadNode = createNodeModule(
+ {
+ id: "memory_reload",
+ title: "Reload Memory",
+ category: "Memory",
+ description:
+ "Copy data from cart ROM into base RAM, optionally from another cart.",
+ searchTags: ["reload", "memory", "copy", "rom"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "dest",
+ name: "Dest",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "source",
+ name: "Source",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "length",
+ name: "Length",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "filename",
+ name: "Filename",
+ direction: "input",
+ kind: "string",
+ description: "Optional cartridge filename",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const dest = resolveValueInput("dest", "0");
+ const source = resolveValueInput("source", "0");
+ const length = resolveValueInput("length", "0");
+ const filename = resolveValueInput("filename", OMIT);
+
+ const args = [dest, source, length];
+ if (filename !== OMIT) {
+ args.push(filename);
+ }
+
+ const line = `${indent(indentLevel)}reload(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/menu/index.js b/scripts/nodes/library/menu/index.js
new file mode 100644
index 0000000..1bd814a
--- /dev/null
+++ b/scripts/nodes/library/menu/index.js
@@ -0,0 +1,14 @@
+import { menuItemNode } from "./menuItem.js";
+
+/**
+ * All menu-related node modules backed by PICO-8 helpers.
+ */
+export const menuNodes = [menuItemNode];
+
+/**
+ * Checklist tracking coverage of documented menu helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const menuFunctionChecklist = [
+ { function: "MENUITEM", nodeId: "menu_item", implemented: true },
+];
diff --git a/scripts/nodes/library/menu/menuItem.js b/scripts/nodes/library/menu/menuItem.js
new file mode 100644
index 0000000..5a7d7bb
--- /dev/null
+++ b/scripts/nodes/library/menu/menuItem.js
@@ -0,0 +1,63 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Adds or updates a pause menu item using PICO-8's menuitem helper.
+ */
+export const menuItemNode = createNodeModule(
+ {
+ id: "menu_item",
+ title: "Menu Item",
+ category: "Menu",
+ description:
+ "Add, update, or remove a custom pause menu entry using MENUITEM().",
+ searchTags: ["menu", "menuitem", "pause", "ui"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ description: "Menu slot and optional mask",
+ defaultValue: 1,
+ },
+ {
+ id: "label",
+ name: "Label",
+ direction: "input",
+ kind: "string",
+ description: "Menu label or nil to remove",
+ },
+ {
+ id: "callback",
+ name: "Callback",
+ direction: "input",
+ kind: "any",
+ description: "Function reference to invoke",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const index = resolveValueInput("index", "1");
+ const label = resolveValueInput("label", OMIT);
+ const callback = resolveValueInput("callback", OMIT);
+
+ const args = [index];
+ if (label !== OMIT || callback !== OMIT) {
+ args.push(label === OMIT ? "nil" : label);
+ if (callback !== OMIT) {
+ args.push(callback);
+ }
+ }
+
+ const line = `${indent(indentLevel)}menuitem(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/multiplyNumber.js b/scripts/nodes/library/multiplyNumber.js
new file mode 100644
index 0000000..f427f32
--- /dev/null
+++ b/scripts/nodes/library/multiplyNumber.js
@@ -0,0 +1,41 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Multiplies two numeric values.
+ */
+export const multiplyNumberNode = createNodeModule(
+ {
+ id: "multiply_number",
+ title: "Multiply",
+ category: "Math",
+ description: "Multiply two numbers.",
+ searchTags: ["multiply", "product", "math", "number"],
+ inputs: [
+ {
+ id: "a",
+ name: "A",
+ direction: "input",
+ kind: "number",
+ defaultValue: 1,
+ },
+ {
+ id: "b",
+ name: "B",
+ direction: "input",
+ kind: "number",
+ defaultValue: 1,
+ },
+ ],
+ outputs: [
+ { id: "res", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const a = resolveValueInput("a", "1");
+ const b = resolveValueInput("b", "1");
+ return `(${a}) * (${b})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/numberLiteral.js b/scripts/nodes/library/numberLiteral.js
new file mode 100644
index 0000000..a8a7b9b
--- /dev/null
+++ b/scripts/nodes/library/numberLiteral.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Constant numeric literal.
+ */
+export const numberLiteralNode = createNodeModule(
+ {
+ id: "number_literal",
+ title: "Number",
+ category: "Values",
+ description: "Constant numeric literal.",
+ searchTags: ["number", "literal", "constant", "value"],
+ inputs: [],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "number" },
+ ],
+ properties: [
+ { key: "value", label: "Number", type: "number", defaultValue: 0 },
+ ],
+ },
+ {
+ evaluateValue: ({ node, formatLiteral }) => {
+ return formatLiteral("number", node.properties.value ?? 0);
+ },
+ }
+);
diff --git a/scripts/nodes/library/print.js b/scripts/nodes/library/print.js
new file mode 100644
index 0000000..8b5e53a
--- /dev/null
+++ b/scripts/nodes/library/print.js
@@ -0,0 +1,59 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Emits a PICO-8 print call.
+ */
+export const printNode = createNodeModule(
+ {
+ id: "print",
+ title: "Print",
+ category: "PICO-8",
+ description: "Render text on screen using the PICO-8 print command.",
+ searchTags: ["print", "text", "output", "debug"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "msg",
+ name: "Text",
+ direction: "input",
+ kind: "string",
+ defaultValue: "hello",
+ },
+ {
+ id: "x",
+ name: "X",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "y",
+ name: "Y",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "color",
+ name: "Color",
+ direction: "input",
+ kind: "number",
+ defaultValue: 7,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const text = resolveValueInput("msg", '""');
+ const x = resolveValueInput("x", "0");
+ const y = resolveValueInput("y", "0");
+ const color = resolveValueInput("color", "7");
+ const line = `${indent(indentLevel)}print(${text}, ${x}, ${y}, ${color})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/sequence.js b/scripts/nodes/library/sequence.js
new file mode 100644
index 0000000..879a733
--- /dev/null
+++ b/scripts/nodes/library/sequence.js
@@ -0,0 +1,74 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Executes multiple branches in sequence.
+ */
+export const sequenceNode = createNodeModule(
+ {
+ id: "sequence",
+ title: "Sequence",
+ category: "Logic",
+ description: "Execute connected branches sequentially.",
+ searchTags: ["sequence", "flow", "order", "multi"],
+ inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }],
+ outputs: [
+ { id: "a", name: "A", direction: "output", kind: "exec" },
+ { id: "b", name: "B", direction: "output", kind: "exec" },
+ { id: "c", name: "C", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ initializeProperties: (properties) => {
+ if (!Array.isArray(properties.branches)) {
+ properties.branches = [
+ { id: "a" },
+ { id: "b" },
+ { id: "c" },
+ ];
+ } else {
+ properties.branches = properties.branches
+ .map((entry) =>
+ entry && typeof entry.id === "string"
+ ? { id: entry.id.trim() || "" }
+ : null
+ )
+ .filter((entry) => entry && entry.id);
+ if (!properties.branches.length) {
+ properties.branches = [
+ { id: "a" },
+ { id: "b" },
+ { id: "c" },
+ ];
+ }
+ }
+
+ if (!Number.isFinite(properties.branchCounter)) {
+ properties.branchCounter = properties.branches.length;
+ }
+ },
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ findExecTargets,
+ emitExecChain,
+ path,
+ }) => {
+ const lines = [];
+ node.outputs.forEach((pin) => {
+ const label = (pin.name ?? pin.id).toLowerCase();
+ lines.push(`${indent(indentLevel)}-- sequence ${label}`);
+ const targets = findExecTargets(pin.id);
+ if (!targets.length) {
+ return;
+ }
+ targets.forEach((target) => {
+ const branchPath = new Set(path);
+ lines.push(...emitExecChain(target.nodeId, indentLevel, branchPath));
+ });
+ });
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/serial/index.js b/scripts/nodes/library/serial/index.js
new file mode 100644
index 0000000..df565b0
--- /dev/null
+++ b/scripts/nodes/library/serial/index.js
@@ -0,0 +1,16 @@
+import { serialSendNode } from "./serialSend.js";
+import { serialReceiveNode } from "./serialReceive.js";
+
+/**
+ * Node modules covering serial helpers.
+ */
+export const serialNodes = [serialSendNode, serialReceiveNode];
+
+/**
+ * Checklist tracking coverage of serial helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const serialFunctionChecklist = [
+ { function: "SERIAL (send)", nodeId: "serial_send", implemented: true },
+ { function: "SERIAL (receive)", nodeId: "serial_receive", implemented: true },
+];
diff --git a/scripts/nodes/library/serial/serialReceive.js b/scripts/nodes/library/serial/serialReceive.js
new file mode 100644
index 0000000..bd1d88f
--- /dev/null
+++ b/scripts/nodes/library/serial/serialReceive.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reads pending data from a serial channel using serial().
+ */
+export const serialReceiveNode = createNodeModule(
+ {
+ id: "serial_receive",
+ title: "Serial Receive",
+ category: "IO",
+ description: "Poll a serial channel for the next message.",
+ searchTags: ["serial", "io", "receive"],
+ inputs: [
+ {
+ id: "channel",
+ name: "Channel",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [
+ { id: "message", name: "Message", direction: "output", kind: "any" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const channel = resolveValueInput("channel", "0");
+ return `serial(${channel})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/serial/serialSend.js b/scripts/nodes/library/serial/serialSend.js
new file mode 100644
index 0000000..127ab6c
--- /dev/null
+++ b/scripts/nodes/library/serial/serialSend.js
@@ -0,0 +1,43 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Sends data over a serial channel using serial().
+ */
+export const serialSendNode = createNodeModule(
+ {
+ id: "serial_send",
+ title: "Serial Send",
+ category: "IO",
+ description: "Transmit a string to a serial channel.",
+ searchTags: ["serial", "io", "send"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "channel",
+ name: "Channel",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ {
+ id: "message",
+ name: "Message",
+ direction: "input",
+ kind: "string",
+ defaultValue: "",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const channel = resolveValueInput("channel", "0");
+ const message = resolveValueInput("message", '""');
+ const line = `${indent(indentLevel)}serial(${channel}, ${message})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/setLocalVariable.js b/scripts/nodes/library/setLocalVariable.js
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/nodes/library/setVariable.js b/scripts/nodes/library/setVariable.js
new file mode 100644
index 0000000..53cc292
--- /dev/null
+++ b/scripts/nodes/library/setVariable.js
@@ -0,0 +1,44 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Assigns a value to a variable within the current scope.
+ */
+export const setVariableNode = createNodeModule(
+ {
+ id: "set_var",
+ title: "Set Variable",
+ category: "Logic",
+ description: "Assign a value to a variable in the current scope.",
+ searchTags: ["set", "assign", "variable", "write"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "value", name: "Value", direction: "input", kind: "any" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [
+ {
+ key: "name",
+ label: "Variable Name",
+ type: "string",
+ defaultValue: "score",
+ },
+ ],
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ resolveValueInput,
+ sanitizeIdentifier,
+ emitNextExec,
+ }) => {
+ const name = sanitizeIdentifier(String(node.properties.name ?? "var"));
+ const value = resolveValueInput("value", "nil");
+ const line = `${indent(indentLevel)}${name} = ${value}`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/stringLiteral.js b/scripts/nodes/library/stringLiteral.js
new file mode 100644
index 0000000..555df75
--- /dev/null
+++ b/scripts/nodes/library/stringLiteral.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../nodeTypes.js";
+
+/**
+ * Constant string literal.
+ */
+export const stringLiteralNode = createNodeModule(
+ {
+ id: "string_literal",
+ title: "String",
+ category: "Values",
+ description: "Constant string literal.",
+ searchTags: ["string", "literal", "text", "value"],
+ inputs: [],
+ outputs: [
+ { id: "value", name: "Value", direction: "output", kind: "string" },
+ ],
+ properties: [
+ { key: "value", label: "Text", type: "string", defaultValue: "hello" },
+ ],
+ },
+ {
+ evaluateValue: ({ node, formatLiteral }) => {
+ return formatLiteral("string", node.properties.value ?? "");
+ },
+ }
+);
diff --git a/scripts/nodes/library/strings/chr.js b/scripts/nodes/library/strings/chr.js
new file mode 100644
index 0000000..510a5fc
--- /dev/null
+++ b/scripts/nodes/library/strings/chr.js
@@ -0,0 +1,43 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Converts ordinal values to characters using chr.
+ */
+export const stringChrNode = createNodeModule(
+ {
+ id: "string_chr",
+ title: "Character From Ordinals",
+ category: "Strings",
+ description: "Build a string from ordinal values using CHR().",
+ searchTags: ["chr", "string", "character"],
+ inputs: [
+ { id: "value", name: "Value", direction: "input", kind: "any" },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "string" },
+ ],
+ properties: [
+ {
+ key: "extraValues",
+ label: "Additional Values",
+ type: "string",
+ placeholder: "e.g. 65, 66, 67",
+ },
+ ],
+ },
+ {
+ evaluateValue: ({ node, resolveValueInput }) => {
+ const value = resolveValueInput("value", "0");
+ const args = [value];
+ const extras = String(node.properties.extraValues ?? "").trim();
+ if (extras.length) {
+ extras
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+ .forEach((entry) => args.push(entry));
+ }
+ return `chr(${args.join(", ")})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/strings/index.js b/scripts/nodes/library/strings/index.js
new file mode 100644
index 0000000..d28ac2a
--- /dev/null
+++ b/scripts/nodes/library/strings/index.js
@@ -0,0 +1,34 @@
+import { stringTostrNode } from "./tostr.js";
+import { stringTonumNode } from "./tonum.js";
+import { stringChrNode } from "./chr.js";
+import { stringOrdNode } from "./ord.js";
+import { stringSubNode } from "./sub.js";
+import { stringSplitNode } from "./split.js";
+import { stringTypeNode } from "./typeOf.js";
+
+/**
+ * All string-related node modules backed by PICO-8 helpers.
+ */
+export const stringNodes = [
+ stringTostrNode,
+ stringTonumNode,
+ stringChrNode,
+ stringOrdNode,
+ stringSubNode,
+ stringSplitNode,
+ stringTypeNode,
+];
+
+/**
+ * Checklist tracking coverage of documented string helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const stringFunctionChecklist = [
+ { function: "TOSTR", nodeId: "string_tostr", implemented: true },
+ { function: "TONUM", nodeId: "string_tonum", implemented: true },
+ { function: "CHR", nodeId: "string_chr", implemented: true },
+ { function: "ORD", nodeId: "string_ord", implemented: true },
+ { function: "SUB", nodeId: "string_sub", implemented: true },
+ { function: "SPLIT", nodeId: "string_split", implemented: true },
+ { function: "TYPE", nodeId: "string_type", implemented: true },
+];
diff --git a/scripts/nodes/library/strings/ord.js b/scripts/nodes/library/strings/ord.js
new file mode 100644
index 0000000..8398ed1
--- /dev/null
+++ b/scripts/nodes/library/strings/ord.js
@@ -0,0 +1,55 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Converts characters to ordinal values using ord.
+ */
+export const stringOrdNode = createNodeModule(
+ {
+ id: "string_ord",
+ title: "Ordinals From String",
+ category: "Strings",
+ description:
+ "Return ordinal codes for characters in a string using ORD().",
+ searchTags: ["ord", "string", "character"],
+ inputs: [
+ { id: "string", name: "String", direction: "input", kind: "string" },
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ description: "Optional starting index",
+ },
+ {
+ id: "count",
+ name: "Count",
+ direction: "input",
+ kind: "number",
+ description: "Optional result count",
+ },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const str = resolveValueInput("string", "\"\"");
+ const index = resolveValueInput("index", OMIT);
+ const count = resolveValueInput("count", OMIT);
+
+ if (index === OMIT && count === OMIT) {
+ return `ord(${str})`;
+ }
+
+ if (count === OMIT) {
+ return `ord(${str}, ${index === OMIT ? "nil" : index})`;
+ }
+
+ const indexArg = index === OMIT ? "nil" : index;
+ return `ord(${str}, ${indexArg}, ${count})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/strings/split.js b/scripts/nodes/library/strings/split.js
new file mode 100644
index 0000000..985e827
--- /dev/null
+++ b/scripts/nodes/library/strings/split.js
@@ -0,0 +1,54 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Splits strings into tables using split.
+ */
+export const stringSplitNode = createNodeModule(
+ {
+ id: "string_split",
+ title: "Split String",
+ category: "Strings",
+ description: "Split a string into tokens using SPLIT().",
+ searchTags: ["split", "string", "table", "parse"],
+ inputs: [
+ { id: "string", name: "String", direction: "input", kind: "string" },
+ {
+ id: "separator",
+ name: "Separator",
+ direction: "input",
+ kind: "any",
+ description: "Optional separator (string or number)",
+ },
+ {
+ id: "convert",
+ name: "Convert Numbers",
+ direction: "input",
+ kind: "boolean",
+ description: "Override numeric conversion (defaults true)",
+ },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "table" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const str = resolveValueInput("string", "\"\"");
+ const sep = resolveValueInput("separator", OMIT);
+ const convert = resolveValueInput("convert", OMIT);
+
+ if (sep === OMIT && convert === OMIT) {
+ return `split(${str})`;
+ }
+
+ if (convert === OMIT) {
+ return `split(${str}, ${sep === OMIT ? "nil" : sep})`;
+ }
+
+ const sepArg = sep === OMIT ? "nil" : sep;
+ return `split(${str}, ${sepArg}, ${convert})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/strings/sub.js b/scripts/nodes/library/strings/sub.js
new file mode 100644
index 0000000..cd14d14
--- /dev/null
+++ b/scripts/nodes/library/strings/sub.js
@@ -0,0 +1,48 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Extracts substrings using sub.
+ */
+export const stringSubNode = createNodeModule(
+ {
+ id: "string_sub",
+ title: "Substring",
+ category: "Strings",
+ description: "Extract a substring using SUB().",
+ searchTags: ["sub", "substring", "string"],
+ inputs: [
+ { id: "string", name: "String", direction: "input", kind: "string" },
+ {
+ id: "start",
+ name: "Start",
+ direction: "input",
+ kind: "number",
+ },
+ {
+ id: "stop",
+ name: "Stop",
+ direction: "input",
+ kind: "any",
+ description: "Optional end position or truthy for single character",
+ },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "string" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const str = resolveValueInput("string", "\"\"");
+ const start = resolveValueInput("start", "1");
+ const stop = resolveValueInput("stop", OMIT);
+
+ if (stop === OMIT) {
+ return `sub(${str}, ${start})`;
+ }
+
+ return `sub(${str}, ${start}, ${stop})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/strings/tonum.js b/scripts/nodes/library/strings/tonum.js
new file mode 100644
index 0000000..02538af
--- /dev/null
+++ b/scripts/nodes/library/strings/tonum.js
@@ -0,0 +1,39 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Converts values to numbers using tonum.
+ */
+export const stringTonumNode = createNodeModule(
+ {
+ id: "string_tonum",
+ title: "To Number",
+ category: "Strings",
+ description: "Convert a value to a number with optional format flags.",
+ searchTags: ["tonum", "string", "number", "convert"],
+ inputs: [
+ { id: "value", name: "Value", direction: "input", kind: "any" },
+ {
+ id: "flags",
+ name: "Flags",
+ direction: "input",
+ kind: "number",
+ description: "Optional format flags bitfield",
+ },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const value = resolveValueInput("value", "nil");
+ const flags = resolveValueInput("flags", OMIT);
+ if (flags === OMIT) {
+ return `tonum(${value})`;
+ }
+ return `tonum(${value}, ${flags})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/strings/tostr.js b/scripts/nodes/library/strings/tostr.js
new file mode 100644
index 0000000..2488f9e
--- /dev/null
+++ b/scripts/nodes/library/strings/tostr.js
@@ -0,0 +1,39 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Converts values to strings using tostr.
+ */
+export const stringTostrNode = createNodeModule(
+ {
+ id: "string_tostr",
+ title: "To String",
+ category: "Strings",
+ description: "Convert a value to a string with optional format flags.",
+ searchTags: ["tostr", "string", "convert"],
+ inputs: [
+ { id: "value", name: "Value", direction: "input", kind: "any" },
+ {
+ id: "flags",
+ name: "Flags",
+ direction: "input",
+ kind: "number",
+ description: "Optional format flags bitfield",
+ },
+ ],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "string" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const value = resolveValueInput("value", "nil");
+ const flags = resolveValueInput("flags", OMIT);
+ if (flags === OMIT) {
+ return `tostr(${value})`;
+ }
+ return `tostr(${value}, ${flags})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/strings/typeOf.js b/scripts/nodes/library/strings/typeOf.js
new file mode 100644
index 0000000..725641e
--- /dev/null
+++ b/scripts/nodes/library/strings/typeOf.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Returns the type of a value using type.
+ */
+export const stringTypeNode = createNodeModule(
+ {
+ id: "string_type",
+ title: "Type Of",
+ category: "Strings",
+ description: "Return the type name of a value using TYPE().",
+ searchTags: ["type", "string", "introspection"],
+ inputs: [{ id: "value", name: "Value", direction: "input", kind: "any" }],
+ outputs: [
+ { id: "result", name: "Result", direction: "output", kind: "string" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const value = resolveValueInput("value", "nil");
+ return `type(${value})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/assert.js b/scripts/nodes/library/system/assert.js
new file mode 100644
index 0000000..23a53f5
--- /dev/null
+++ b/scripts/nodes/library/system/assert.js
@@ -0,0 +1,41 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Stops execution when a condition is false, printing an optional message.
+ */
+export const assertNode = createNodeModule(
+ {
+ id: "system_assert",
+ title: "Assert",
+ category: "System",
+ description:
+ "Assert that a condition is true, stopping the cart when it fails.",
+ searchTags: ["assert", "debug", "check", "system"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "condition",
+ name: "Condition",
+ direction: "input",
+ kind: "boolean",
+ defaultValue: true,
+ },
+ { id: "message", name: "Message", direction: "input", kind: "string" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const condition = resolveValueInput("condition", "true");
+ const message = resolveValueInput("message", OMIT);
+ const callArgs = message === OMIT ? condition : `${condition}, ${message}`;
+ const lines = [`${indent(indentLevel)}assert(${callArgs})`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/extcmd.js b/scripts/nodes/library/system/extcmd.js
new file mode 100644
index 0000000..8f6cd3c
--- /dev/null
+++ b/scripts/nodes/library/system/extcmd.js
@@ -0,0 +1,93 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+const EXT_CMD_DEFAULT = "reset";
+
+const EXT_CMD_OPTIONS = [
+ { value: "pause", label: "Pause Menu" },
+ { value: EXT_CMD_DEFAULT, label: "Reset Cart" },
+ { value: "go_back", label: "Return To Previous Cart" },
+ { value: "label", label: "Capture Label" },
+ { value: "screen", label: "Save Screenshot" },
+ { value: "rec", label: "Mark Video Start" },
+ { value: "rec_frames", label: "Mark Video Start (Frames)" },
+ { value: "video", label: "Save Video" },
+ { value: "audio_rec", label: "Start Audio Recording" },
+ { value: "audio_end", label: "Finish Audio Recording" },
+ { value: "shutdown", label: "Shutdown Cartridge" },
+ { value: "folder", label: "Open Cartridge Folder" },
+ { value: "set_filename", label: "Set Capture Filename" },
+ { value: "set_title", label: "Set Window Title" },
+];
+
+/**
+ * Issues special host commands such as screenshots or recordings.
+ */
+export const extcmdNode = createNodeModule(
+ {
+ id: "system_extcmd",
+ title: "System Command",
+ category: "System",
+ description: "Execute a special system command using extcmd().",
+ searchTags: [
+ "extcmd",
+ "system",
+ "command",
+ "host",
+ "screenshot",
+ "video",
+ "audio",
+ "pause",
+ "reset",
+ ],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "command", name: "Command", direction: "input", kind: "string" },
+ { id: "param1", name: "Param 1", direction: "input", kind: "number" },
+ { id: "param2", name: "Param 2", direction: "input", kind: "number" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [
+ {
+ key: "command",
+ label: "Command",
+ type: "enum",
+ defaultValue: EXT_CMD_DEFAULT,
+ options: EXT_CMD_OPTIONS,
+ },
+ ],
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ resolveValueInput,
+ emitNextExec,
+ formatLiteral,
+ }) => {
+ const OMIT = "__pg_omit__";
+ const fallbackCommand = formatLiteral(
+ "string",
+ node.properties.command ?? EXT_CMD_DEFAULT
+ );
+ const command = resolveValueInput("command", fallbackCommand);
+ const param1 = resolveValueInput("param1", OMIT);
+ const param2 = resolveValueInput("param2", OMIT);
+
+ const args = [command];
+ if (param1 !== OMIT || param2 !== OMIT) {
+ const param1Arg = param1 === OMIT ? "nil" : param1;
+ args.push(param1Arg);
+ if (param2 !== OMIT) {
+ args.push(param2);
+ }
+ }
+
+ const lines = [`${indent(indentLevel)}extcmd(${args.join(", ")})`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/flip.js b/scripts/nodes/library/system/flip.js
new file mode 100644
index 0000000..d341b3f
--- /dev/null
+++ b/scripts/nodes/library/system/flip.js
@@ -0,0 +1,27 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Performs a manual buffer flip when using a custom main loop.
+ */
+export const flipNode = createNodeModule(
+ {
+ id: "system_flip",
+ title: "Flip Buffer",
+ category: "System",
+ description:
+ "Flip the back buffer to the screen and wait for the next frame.",
+ searchTags: ["flip", "sync", "frame", "system"],
+ inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, emitNextExec }) => {
+ const lines = [`${indent(indentLevel)}flip()`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/folder.js b/scripts/nodes/library/system/folder.js
new file mode 100644
index 0000000..68e6bfc
--- /dev/null
+++ b/scripts/nodes/library/system/folder.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Opens the cartridge folder on the host operating system.
+ */
+export const folderNode = createNodeModule(
+ {
+ id: "system_folder",
+ title: "Open Folder",
+ category: "System",
+ description: "Open the cartridge folder on the host operating system.",
+ searchTags: ["folder", "system", "directory", "open"],
+ inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, emitNextExec }) => {
+ const lines = [`${indent(indentLevel)}folder()`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/index.js b/scripts/nodes/library/system/index.js
new file mode 100644
index 0000000..89f2f4e
--- /dev/null
+++ b/scripts/nodes/library/system/index.js
@@ -0,0 +1,61 @@
+import { loadNode } from "./load.js";
+import { saveNode } from "./save.js";
+import { folderNode } from "./folder.js";
+import { lsNode } from "./ls.js";
+import { runNode } from "./run.js";
+import { stopNode } from "./stop.js";
+import { resumeNode } from "./resume.js";
+import { assertNode } from "./assert.js";
+import { rebootNode } from "./reboot.js";
+import { resetNode } from "./reset.js";
+import { infoNode } from "./info.js";
+import { flipNode } from "./flip.js";
+import { printhNode } from "./printh.js";
+import { timeNode } from "./time.js";
+import { statNode } from "./stat.js";
+import { extcmdNode } from "./extcmd.js";
+
+/**
+ * All system related node modules backed by PICO-8 helper functions.
+ */
+export const systemNodes = [
+ loadNode,
+ saveNode,
+ folderNode,
+ lsNode,
+ runNode,
+ stopNode,
+ resumeNode,
+ assertNode,
+ rebootNode,
+ resetNode,
+ infoNode,
+ flipNode,
+ printhNode,
+ timeNode,
+ statNode,
+ extcmdNode,
+];
+
+/**
+ * Checklist tracking coverage of documented system helpers.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const systemFunctionChecklist = [
+ { function: "LOAD", nodeId: "system_load", implemented: true },
+ { function: "SAVE", nodeId: "system_save", implemented: true },
+ { function: "FOLDER", nodeId: "system_folder", implemented: true },
+ { function: "LS", nodeId: "system_ls", implemented: true },
+ { function: "RUN", nodeId: "system_run", implemented: true },
+ { function: "STOP", nodeId: "system_stop", implemented: true },
+ { function: "RESUME", nodeId: "system_resume", implemented: true },
+ { function: "ASSERT", nodeId: "system_assert", implemented: true },
+ { function: "REBOOT", nodeId: "system_reboot", implemented: true },
+ { function: "RESET", nodeId: "system_reset", implemented: true },
+ { function: "INFO", nodeId: "system_info", implemented: true },
+ { function: "FLIP", nodeId: "system_flip", implemented: true },
+ { function: "PRINTH", nodeId: "system_printh", implemented: true },
+ { function: "TIME", nodeId: "system_time", implemented: true },
+ { function: "STAT", nodeId: "system_stat", implemented: true },
+ { function: "EXTCMD", nodeId: "system_extcmd", implemented: true },
+];
diff --git a/scripts/nodes/library/system/info.js b/scripts/nodes/library/system/info.js
new file mode 100644
index 0000000..e1fbdf2
--- /dev/null
+++ b/scripts/nodes/library/system/info.js
@@ -0,0 +1,27 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Prints cartridge diagnostics to the console.
+ */
+export const infoNode = createNodeModule(
+ {
+ id: "system_info",
+ title: "Cartridge Info",
+ category: "System",
+ description:
+ "Print information about the cartridge including size and token counts.",
+ searchTags: ["info", "memory", "stats", "system"],
+ inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, emitNextExec }) => {
+ const lines = [`${indent(indentLevel)}info()`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/load.js b/scripts/nodes/library/system/load.js
new file mode 100644
index 0000000..f376b4d
--- /dev/null
+++ b/scripts/nodes/library/system/load.js
@@ -0,0 +1,57 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Loads another cartridge and optionally supplies breadcrumb and parameter metadata.
+ */
+export const loadNode = createNodeModule(
+ {
+ id: "system_load",
+ title: "Load Cartridge",
+ category: "System",
+ description:
+ "Load a local or BBS cartridge, optionally providing breadcrumb and parameter string.",
+ searchTags: ["load", "cartridge", "system", "cart"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "filename",
+ name: "Filename",
+ direction: "input",
+ kind: "string",
+ defaultValue: "cart.p8",
+ },
+ {
+ id: "breadcrumb",
+ name: "Breadcrumb",
+ direction: "input",
+ kind: "string",
+ },
+ { id: "param", name: "Param String", direction: "input", kind: "string" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const filename = resolveValueInput("filename", '""');
+ const breadcrumb = resolveValueInput("breadcrumb", OMIT);
+ const param = resolveValueInput("param", OMIT);
+
+ const args = [filename];
+ if (breadcrumb !== OMIT || param !== OMIT) {
+ const breadcrumbArg = breadcrumb === OMIT ? "nil" : breadcrumb;
+ args.push(breadcrumbArg);
+ if (param !== OMIT) {
+ args.push(param);
+ }
+ }
+
+ const lines = [`${indent(indentLevel)}load(${args.join(", ")})`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/ls.js b/scripts/nodes/library/system/ls.js
new file mode 100644
index 0000000..ff9760d
--- /dev/null
+++ b/scripts/nodes/library/system/ls.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Lists cartridges within the provided directory relative to the virtual drive.
+ */
+export const lsNode = createNodeModule(
+ {
+ id: "system_ls",
+ title: "List Directory",
+ category: "System",
+ description:
+ "List .p8 and .p8.png files in a directory relative to the current path.",
+ searchTags: ["ls", "list", "files", "system"],
+ inputs: [
+ {
+ id: "directory",
+ name: "Directory",
+ direction: "input",
+ kind: "string",
+ },
+ ],
+ outputs: [
+ { id: "entries", name: "Entries", direction: "output", kind: "table" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const directory = resolveValueInput("directory", "nil");
+ return directory === "nil" ? "ls()" : `ls(${directory})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/printh.js b/scripts/nodes/library/system/printh.js
new file mode 100644
index 0000000..e58c359
--- /dev/null
+++ b/scripts/nodes/library/system/printh.js
@@ -0,0 +1,70 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Prints to the host console or files for debugging utilities.
+ */
+export const printhNode = createNodeModule(
+ {
+ id: "system_printh",
+ title: "Print Host",
+ category: "System",
+ description:
+ "Print a string to the host console or optionally to a file or clipboard.",
+ searchTags: ["printh", "print", "debug", "file", "system"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "text",
+ name: "Text",
+ direction: "input",
+ kind: "string",
+ defaultValue: "debug",
+ },
+ { id: "filename", name: "Filename", direction: "input", kind: "string" },
+ {
+ id: "overwrite",
+ name: "Overwrite",
+ direction: "input",
+ kind: "boolean",
+ defaultValue: false,
+ },
+ {
+ id: "save_to_desktop",
+ name: "Save to Desktop",
+ direction: "input",
+ kind: "boolean",
+ defaultValue: false,
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const text = resolveValueInput("text", '""');
+ const filename = resolveValueInput("filename", "nil");
+ const overwrite = resolveValueInput("overwrite", "false");
+ const saveToDesktop = resolveValueInput("save_to_desktop", "false");
+
+ const args = [text];
+ const hasFilename = filename !== "nil";
+ if (hasFilename) {
+ args.push(filename);
+ const includeOverwrite =
+ overwrite !== "false" || saveToDesktop !== "false";
+ if (includeOverwrite) {
+ args.push(overwrite);
+ if (saveToDesktop !== "false") {
+ args.push(saveToDesktop);
+ }
+ }
+ }
+
+ const lines = [`${indent(indentLevel)}printh(${args.join(", ")})`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/reboot.js b/scripts/nodes/library/system/reboot.js
new file mode 100644
index 0000000..f5a9877
--- /dev/null
+++ b/scripts/nodes/library/system/reboot.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Reboots PICO-8, returning to a fresh command prompt.
+ */
+export const rebootNode = createNodeModule(
+ {
+ id: "system_reboot",
+ title: "Reboot",
+ category: "System",
+ description: "Reboot the virtual machine to begin a new project.",
+ searchTags: ["reboot", "restart", "system", "cart"],
+ inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, emitNextExec }) => {
+ const lines = [`${indent(indentLevel)}reboot()`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/reset.js b/scripts/nodes/library/system/reset.js
new file mode 100644
index 0000000..da24d8c
--- /dev/null
+++ b/scripts/nodes/library/system/reset.js
@@ -0,0 +1,27 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Restores draw state values such as palette and camera to their defaults.
+ */
+export const resetNode = createNodeModule(
+ {
+ id: "system_reset",
+ title: "Reset Draw State",
+ category: "System",
+ description:
+ "Reset memory range 0x5f00..0x5f7f, restoring draw state defaults.",
+ searchTags: ["reset", "clear", "system", "state"],
+ inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, emitNextExec }) => {
+ const lines = [`${indent(indentLevel)}reset()`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/resume.js b/scripts/nodes/library/system/resume.js
new file mode 100644
index 0000000..4213ad9
--- /dev/null
+++ b/scripts/nodes/library/system/resume.js
@@ -0,0 +1,26 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Resumes execution after a stop.
+ */
+export const resumeNode = createNodeModule(
+ {
+ id: "system_resume",
+ title: "Resume",
+ category: "System",
+ description: "Resume the program after it has been stopped.",
+ searchTags: ["resume", "continue", "system", "run"],
+ inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, emitNextExec }) => {
+ const lines = [`${indent(indentLevel)}resume()`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/run.js b/scripts/nodes/library/system/run.js
new file mode 100644
index 0000000..ca7a5c9
--- /dev/null
+++ b/scripts/nodes/library/system/run.js
@@ -0,0 +1,33 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Restarts the current program from the beginning.
+ */
+export const runNode = createNodeModule(
+ {
+ id: "system_run",
+ title: "Run",
+ category: "System",
+ description:
+ "Restart the program optionally providing a parameter string accessible via stat(6).",
+ searchTags: ["run", "launch", "cartridge", "system"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "param", name: "Param String", direction: "input", kind: "string" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const param = resolveValueInput("param", OMIT);
+ const call = param === OMIT ? "run()" : `run(${param})`;
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/save.js b/scripts/nodes/library/system/save.js
new file mode 100644
index 0000000..dcc4e19
--- /dev/null
+++ b/scripts/nodes/library/system/save.js
@@ -0,0 +1,36 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Persists the active cartridge to disk.
+ */
+export const saveNode = createNodeModule(
+ {
+ id: "system_save",
+ title: "Save Cartridge",
+ category: "System",
+ description: "Save the current cartridge to the specified filename.",
+ searchTags: ["save", "cartridge", "system", "cart"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ {
+ id: "filename",
+ name: "Filename",
+ direction: "input",
+ kind: "string",
+ defaultValue: "cart.p8",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const filename = resolveValueInput("filename", '""');
+ const lines = [`${indent(indentLevel)}save(${filename})`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/stat.js b/scripts/nodes/library/system/stat.js
new file mode 100644
index 0000000..48deda2
--- /dev/null
+++ b/scripts/nodes/library/system/stat.js
@@ -0,0 +1,31 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Queries system status values exposed through stat().
+ */
+export const statNode = createNodeModule(
+ {
+ id: "system_stat",
+ title: "System Status",
+ category: "System",
+ description: "Fetch a system status value using the stat(index) helper.",
+ searchTags: ["stat", "status", "system", "metrics"],
+ inputs: [
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ defaultValue: 0,
+ },
+ ],
+ outputs: [{ id: "value", name: "Value", direction: "output", kind: "any" }],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const index = resolveValueInput("index", "0");
+ return `stat(${index})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/stop.js b/scripts/nodes/library/system/stop.js
new file mode 100644
index 0000000..ff7b6ed
--- /dev/null
+++ b/scripts/nodes/library/system/stop.js
@@ -0,0 +1,32 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Stops the cart and optionally prints a message to the console.
+ */
+export const stopNode = createNodeModule(
+ {
+ id: "system_stop",
+ title: "Stop",
+ category: "System",
+ description: "Stop the current cart and optionally print a message.",
+ searchTags: ["stop", "halt", "exit", "system"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "message", name: "Message", direction: "input", kind: "string" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const message = resolveValueInput("message", OMIT);
+ const call = message === OMIT ? "stop()" : `stop(${message})`;
+ const lines = [`${indent(indentLevel)}${call}`];
+ lines.push(...emitNextExec("exec_out"));
+ return lines;
+ },
+ }
+);
diff --git a/scripts/nodes/library/system/time.js b/scripts/nodes/library/system/time.js
new file mode 100644
index 0000000..8924da0
--- /dev/null
+++ b/scripts/nodes/library/system/time.js
@@ -0,0 +1,23 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Returns the number of seconds elapsed since the cart started running.
+ */
+export const timeNode = createNodeModule(
+ {
+ id: "system_time",
+ title: "Time",
+ category: "System",
+ description:
+ "Retrieve the elapsed time in seconds since the cart launched.",
+ searchTags: ["time", "seconds", "system", "elapsed"],
+ inputs: [],
+ outputs: [
+ { id: "value", name: "Seconds", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: () => "time()",
+ }
+);
diff --git a/scripts/nodes/library/table/add.js b/scripts/nodes/library/table/add.js
new file mode 100644
index 0000000..69d4f30
--- /dev/null
+++ b/scripts/nodes/library/table/add.js
@@ -0,0 +1,46 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Adds a value to a table using PICO-8's add helper.
+ */
+export const tableAddNode = createNodeModule(
+ {
+ id: "table_add",
+ title: "Add To Table",
+ category: "Tables",
+ description: "Append a value to a table, optionally inserting at an index.",
+ searchTags: ["add", "table", "insert", "append", "push"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ { id: "value", name: "Value", direction: "input", kind: "any" },
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ description: "Optional insertion index",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const tableValue = resolveValueInput("table", "{}");
+ const value = resolveValueInput("value", "nil");
+ const index = resolveValueInput("index", OMIT);
+
+ const args = [tableValue, value];
+ if (index !== OMIT) {
+ args.push(index);
+ }
+
+ const line = `${indent(indentLevel)}add(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/table/all.js b/scripts/nodes/library/table/all.js
new file mode 100644
index 0000000..3a607d7
--- /dev/null
+++ b/scripts/nodes/library/table/all.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Produces a table iterator using PICO-8's all helper.
+ */
+export const tableAllNode = createNodeModule(
+ {
+ id: "table_all",
+ title: "All Iterator",
+ category: "Tables",
+ description: "Create an iterator covering all indexed table values.",
+ searchTags: ["all", "table", "iterator", "loop"],
+ inputs: [{ id: "table", name: "Table", direction: "input", kind: "table" }],
+ outputs: [
+ { id: "iterator", name: "Iterator", direction: "output", kind: "any" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const tableValue = resolveValueInput("table", "{}");
+ return `all(${tableValue})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/table/count.js b/scripts/nodes/library/table/count.js
new file mode 100644
index 0000000..0b4530a
--- /dev/null
+++ b/scripts/nodes/library/table/count.js
@@ -0,0 +1,40 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Counts entries in a table or matching values using PICO-8's count helper.
+ */
+export const tableCountNode = createNodeModule(
+ {
+ id: "table_count",
+ title: "Count Table",
+ category: "Tables",
+ description: "Return the number of entries in a table, optionally matching a value.",
+ searchTags: ["count", "table", "length", "size"],
+ inputs: [
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ {
+ id: "value",
+ name: "Value",
+ direction: "input",
+ kind: "any",
+ description: "Optional value to match",
+ },
+ ],
+ outputs: [
+ { id: "result", name: "Count", direction: "output", kind: "number" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const OMIT = "__pg_omit__";
+ const tableValue = resolveValueInput("table", "{}");
+ const value = resolveValueInput("value", OMIT);
+ const args = [tableValue];
+ if (value !== OMIT) {
+ args.push(value);
+ }
+ return `count(${args.join(", ")})`;
+ },
+ }
+);
diff --git a/scripts/nodes/library/table/del.js b/scripts/nodes/library/table/del.js
new file mode 100644
index 0000000..8594e69
--- /dev/null
+++ b/scripts/nodes/library/table/del.js
@@ -0,0 +1,31 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Removes the first matching value from a table using PICO-8's del helper.
+ */
+export const tableDelNode = createNodeModule(
+ {
+ id: "table_del",
+ title: "Delete From Table",
+ category: "Tables",
+ description: "Remove the first matching value from a table.",
+ searchTags: ["del", "table", "remove", "delete"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ { id: "value", name: "Value", direction: "input", kind: "any" },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const tableValue = resolveValueInput("table", "{}");
+ const value = resolveValueInput("value", "nil");
+ const line = `${indent(indentLevel)}del(${tableValue}, ${value})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/table/deli.js b/scripts/nodes/library/table/deli.js
new file mode 100644
index 0000000..714c261
--- /dev/null
+++ b/scripts/nodes/library/table/deli.js
@@ -0,0 +1,42 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Removes a table entry at a given index using PICO-8's deli helper.
+ */
+export const tableDeliNode = createNodeModule(
+ {
+ id: "table_deli",
+ title: "Delete Table Index",
+ category: "Tables",
+ description: "Remove and return the value at an index, or the last entry when omitted.",
+ searchTags: ["deli", "table", "remove", "pop"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ {
+ id: "index",
+ name: "Index",
+ direction: "input",
+ kind: "number",
+ description: "Optional index to remove",
+ },
+ ],
+ outputs: [
+ { id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
+ ],
+ properties: [],
+ },
+ {
+ emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => {
+ const OMIT = "__pg_omit__";
+ const tableValue = resolveValueInput("table", "{}");
+ const index = resolveValueInput("index", OMIT);
+ const args = [tableValue];
+ if (index !== OMIT) {
+ args.push(index);
+ }
+ const line = `${indent(indentLevel)}deli(${args.join(", ")})`;
+ return [line, ...emitNextExec("exec_out")];
+ },
+ }
+);
diff --git a/scripts/nodes/library/table/foreach.js b/scripts/nodes/library/table/foreach.js
new file mode 100644
index 0000000..2a173c6
--- /dev/null
+++ b/scripts/nodes/library/table/foreach.js
@@ -0,0 +1,116 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Iterates over a table with a callback using PICO-8's foreach helper and exposes loop variables.
+ */
+export const tableForeachNode = createNodeModule(
+ {
+ id: "table_foreach",
+ title: "Foreach Table",
+ category: "Tables",
+ description:
+ "Execute a callback for each value produced by foreach().",
+ searchTags: ["foreach", "table", "iterate", "loop", "index", "counter"],
+ inputs: [
+ { id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
+ { id: "table", name: "Table", direction: "input", kind: "table" },
+ ],
+ outputs: [
+ { id: "loop", name: "Loop", direction: "output", kind: "exec" },
+ { id: "item", name: "Item", direction: "output", kind: "any" },
+ { id: "index", name: "Index", direction: "output", kind: "number" },
+ {
+ id: "completed",
+ name: "Completed",
+ direction: "output",
+ kind: "exec",
+ },
+ ],
+ properties: [
+ {
+ key: "itemName",
+ label: "Item Variable",
+ type: "string",
+ defaultValue: "item",
+ },
+ {
+ key: "indexName",
+ label: "Index Variable",
+ type: "string",
+ defaultValue: "i",
+ },
+ ],
+ },
+ {
+ emitExec: ({
+ node,
+ indent,
+ indentLevel,
+ resolveValueInput,
+ sanitizeIdentifier,
+ emitBranch,
+ path,
+ }) => {
+ const tableValue = resolveValueInput("table", "{}");
+ let itemName = sanitizeIdentifier(String(node.properties.itemName ?? "item"));
+ if (!itemName) {
+ itemName = "item";
+ }
+ let indexName = sanitizeIdentifier(String(node.properties.indexName ?? "i"));
+ if (!indexName) {
+ indexName = "i";
+ }
+ let counterName = sanitizeIdentifier(`__pg_${node.id}_index`);
+ if (!counterName) {
+ counterName = "__pg_index";
+ }
+ if (counterName === indexName) {
+ counterName = `${counterName}_counter`;
+ }
+ const lines = [
+ `${indent(indentLevel)}local ${counterName} = 0`,
+ `${indent(indentLevel)}foreach(${tableValue}, function(${itemName})`,
+ `${indent(indentLevel + 1)}${counterName} += 1`,
+ `${indent(indentLevel + 1)}local ${indexName} = ${counterName}`,
+ ];
+
+ const branchLines = emitBranch("loop", {
+ indentLevel: indentLevel + 1,
+ path: new Set(path),
+ });
+ if (!branchLines.length) {
+ lines.push(`${indent(indentLevel + 1)}-- foreach body`);
+ } else {
+ lines.push(...branchLines);
+ }
+
+ lines.push(`${indent(indentLevel)}end)`);
+ lines.push(
+ ...emitBranch("completed", {
+ indentLevel,
+ path,
+ })
+ );
+
+ return lines;
+ },
+ evaluateValue: ({ node, pinId, sanitizeIdentifier }) => {
+ switch (pinId) {
+ case "item": {
+ const name = sanitizeIdentifier(
+ String(node.properties.itemName ?? "item")
+ );
+ return name || "item";
+ }
+ case "index": {
+ const name = sanitizeIdentifier(
+ String(node.properties.indexName ?? "i")
+ );
+ return name || "i";
+ }
+ default:
+ return "nil";
+ }
+ },
+ }
+);
diff --git a/scripts/nodes/library/table/index.js b/scripts/nodes/library/table/index.js
new file mode 100644
index 0000000..9d3d027
--- /dev/null
+++ b/scripts/nodes/library/table/index.js
@@ -0,0 +1,34 @@
+import { tableAddNode } from "./add.js";
+import { tableDelNode } from "./del.js";
+import { tableDeliNode } from "./deli.js";
+import { tableCountNode } from "./count.js";
+import { tableAllNode } from "./all.js";
+import { tableForeachNode } from "./foreach.js";
+import { tablePairsNode } from "./pairs.js";
+
+/**
+ * All table-related node modules backed by PICO-8 helpers.
+ */
+export const tableNodes = [
+ tableAddNode,
+ tableDelNode,
+ tableDeliNode,
+ tableCountNode,
+ tableAllNode,
+ tableForeachNode,
+ tablePairsNode,
+];
+
+/**
+ * Checklist tracking table helper coverage from the PICO-8 manual.
+ * @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
+ */
+export const tableFunctionChecklist = [
+ { function: "ADD", nodeId: "table_add", implemented: true },
+ { function: "DEL", nodeId: "table_del", implemented: true },
+ { function: "DELI", nodeId: "table_deli", implemented: true },
+ { function: "COUNT", nodeId: "table_count", implemented: true },
+ { function: "ALL", nodeId: "table_all", implemented: true },
+ { function: "FOREACH", nodeId: "table_foreach", implemented: true },
+ { function: "PAIRS", nodeId: "table_pairs", implemented: true },
+];
diff --git a/scripts/nodes/library/table/pairs.js b/scripts/nodes/library/table/pairs.js
new file mode 100644
index 0000000..c9a746b
--- /dev/null
+++ b/scripts/nodes/library/table/pairs.js
@@ -0,0 +1,25 @@
+import { createNodeModule } from "../../nodeTypes.js";
+
+/**
+ * Produces a key-value iterator using PICO-8's pairs helper.
+ */
+export const tablePairsNode = createNodeModule(
+ {
+ id: "table_pairs",
+ title: "Pairs Iterator",
+ category: "Tables",
+ description: "Create an iterator yielding key-value pairs for a table.",
+ searchTags: ["pairs", "table", "iterator", "loop"],
+ inputs: [{ id: "table", name: "Table", direction: "input", kind: "table" }],
+ outputs: [
+ { id: "iterator", name: "Iterator", direction: "output", kind: "any" },
+ ],
+ properties: [],
+ },
+ {
+ evaluateValue: ({ resolveValueInput }) => {
+ const tableValue = resolveValueInput("table", "{}");
+ return `pairs(${tableValue})`;
+ },
+ }
+);
diff --git a/scripts/nodes/nodeTypes.js b/scripts/nodes/nodeTypes.js
new file mode 100644
index 0000000..d3e7bad
--- /dev/null
+++ b/scripts/nodes/nodeTypes.js
@@ -0,0 +1,91 @@
+/**
+ * @typedef {Object} PropertySchema
+ * @property {string} key Property identifier.
+ * @property {string} label Human-friendly label.
+ * @property {('string'|'number'|'boolean'|'enum'|'multiline')} type Control type used in the inspector.
+ * @property {unknown} [defaultValue] Default property value.
+ * @property {Array<{label:string,value:string}>} [options] Option collection for enum values.
+ * @property {string} [placeholder] Optional placeholder text.
+ */
+
+/**
+ * @typedef {Object} PinConfig
+ * @property {string} id Stable identifier unique within the node definition.
+ * @property {string} name Display name.
+ * @property {('input'|'output')} direction Pin direction relative to the node.
+ * @property {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Blueprint connection kind.
+ * @property {string} [description] Optional tooltip text.
+ * @property {unknown} [defaultValue] Default literal for data pins.
+ */
+
+/**
+ * @typedef {Object} NodeDefinition
+ * @property {string} id Node identifier used for instantiation.
+ * @property {string} title Display title.
+ * @property {string} category Group name for palette organization.
+ * @property {string} [description] Optional description used in the palette.
+ * @property {Array} inputs Input pins.
+ * @property {Array} outputs Output pins.
+ * @property {Array} properties Property schema displayed in inspector.
+ * @property {Array} [searchTags] Optional keywords aiding palette searches.
+ * @property {(properties: Record) => void} [onPropertiesChanged] Lifecycle hook invoked after inspector edits.
+ * @property {(properties: Record) => void} [initializeProperties] Optional initializer for property defaults.
+ * @property {boolean} [unique=false] Whether the node is limited to a single instance within the workspace.
+ */
+
+/**
+ * @typedef {import('../core/BlueprintNode.js').BlueprintNode} BlueprintNode
+ */
+
+/**
+ * @typedef {Object} ExecContext
+ * @property {BlueprintNode} node Active node instance.
+ * @property {number} indentLevel Current indentation level.
+ * @property {Set} path Cycle detection guard set.
+ * @property {(pinId: string) => Array} emitNextExec Emits the next exec branch for the supplied output pin.
+ * @property {(pinId: string, overrides?: { indentLevel?: number, path?: Set }) => Array} emitBranch Emits all branches reachable from the supplied exec output pin.
+ * @property {(nodeId: string, indentLevel: number, path: Set) => Array} emitExecChain Walks the exec chain starting from the provided node identifier.
+ * @property {(pinId: string, fallback: string) => string} resolveValueInput Resolves a value input for the active node.
+ * @property {(pinId: string) => Array<{nodeId: string, pinId: string}>} findExecTargets Finds exec targets for the supplied pin.
+ * @property {(level?: number) => string} indent Produces indentation whitespace for the supplied level (defaults to the current level).
+ * @property {(name: string) => string} sanitizeIdentifier Normalizes identifiers for Lua emission.
+ * @property {(operator: string) => string} sanitizeOperator Normalizes comparison operators for Lua.
+ * @property {(kind: string, value: unknown) => string} formatLiteral Formats a literal value for Lua output.
+ */
+
+/**
+ * @typedef {Object} ValueContext
+ * @property {BlueprintNode} node Active node instance.
+ * @property {(pinId: string, fallback: string) => string} resolveValueInput Resolves a value input for the active node.
+ * @property {(name: string) => string} sanitizeIdentifier Normalizes identifiers for Lua emission.
+ * @property {(operator: string) => string} sanitizeOperator Normalizes comparison operators for Lua.
+ * @property {(kind: string, value: unknown) => string} formatLiteral Formats a literal value for Lua output.
+ */
+
+/**
+ * @typedef {Object} NodeBehavior
+ * @property {boolean} [isEntryPoint] Indicates whether the node should be treated as the primary entry point.
+ * @property {('_init'|'_update'|'_draw'|'_update60')} [eventName] Optional PICO-8 lifecycle callback to generate.
+ * @property {(context: ExecContext) => Array} [emitExec] Generates Lua statements for exec-oriented nodes.
+ * @property {(context: ValueContext) => string} [evaluateValue] Generates a Lua expression for value-oriented nodes.
+ */
+
+/**
+ * @typedef {Object} NodeModule
+ * @property {NodeDefinition} definition Node metadata exposed to the editor.
+ * @property {NodeBehavior} [behavior] Optional Lua emission hooks for the node.
+ */
+
+/**
+ * Creates a node module ensuring a behavior object is always present.
+ *
+ * @param {NodeDefinition} definition Node metadata.
+ * @param {NodeBehavior} [behavior] Optional behavior hooks.
+ * @returns {NodeModule}
+ */
+export const createNodeModule = (definition, behavior = {}) => ({
+ definition,
+ behavior,
+});
+
+export {};
diff --git a/scripts/tools/generateOutline.js b/scripts/tools/generateOutline.js
new file mode 100644
index 0000000..e531bb7
--- /dev/null
+++ b/scripts/tools/generateOutline.js
@@ -0,0 +1,234 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+
+/**
+ * @typedef {{
+ * kind: 'class'|'method'|'function'|'arrow',
+ * name: string,
+ * line: number,
+ * className?: string,
+ * accessor?: 'get'|'set',
+ * isStatic?: boolean
+ * }} OutlineEntry
+ */
+
+/**
+ * Entry point for the CLI.
+ */
+function main() {
+ const [, , inputArg, outputArg] = process.argv;
+ if (!inputArg || inputArg === "--help" || inputArg === "-h") {
+ printUsage(inputArg ? 0 : 1);
+ return;
+ }
+
+ const inputPath = path.resolve(process.cwd(), inputArg);
+ if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) {
+ console.error(`Input file not found: ${inputArg}`);
+ process.exit(1);
+ }
+
+ const suggestedName = `${path.basename(
+ inputPath,
+ path.extname(inputPath)
+ )}.outline.txt`;
+ const outputPath = path.resolve(process.cwd(), outputArg || suggestedName);
+
+ const source = fs.readFileSync(inputPath, "utf8");
+ const outline = collectOutline(source);
+ const formatted = formatOutline(outline, inputPath);
+ fs.writeFileSync(outputPath, formatted.join("\n"), "utf8");
+ console.log(`Outline written to ${path.relative(process.cwd(), outputPath)}`);
+}
+
+/**
+ * Prints usage details for the CLI.
+ *
+ * @param {number} exitCode Process exit code.
+ */
+function printUsage(exitCode) {
+ console.log(
+ "Usage: node scripts/tools/generateOutline.js [output.txt]"
+ );
+ console.log("Creates a simple outline with line numbers and function names.");
+ process.exit(exitCode);
+}
+
+/**
+ * Builds an outline of the provided source text.
+ *
+ * @param {string} source Raw JavaScript source.
+ * @returns {Array} Ordered outline entries.
+ */
+function collectOutline(source) {
+ const lines = source.split(/\r?\n/);
+ /** @type {Array} */
+ const entries = [];
+ /** @type {Array<{ name: string, bodyDepth: number }>} */
+ const classStack = [];
+ let braceDepth = 0;
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const rawLine = lines[index];
+ const trimmed = rawLine.trim();
+ const lineNumber = index + 1;
+
+ if (!trimmed) {
+ braceDepth = updateBraceDepth(braceDepth, rawLine);
+ if (
+ classStack.length &&
+ braceDepth < classStack[classStack.length - 1].bodyDepth
+ ) {
+ classStack.pop();
+ }
+ continue;
+ }
+
+ const classMatch = trimmed.match(/^(?:export\s+)?class\s+([A-Za-z0-9_$]+)/);
+ if (classMatch) {
+ const className = classMatch[1];
+ entries.push({ kind: "class", name: className, line: lineNumber });
+ classStack.push({ name: className, bodyDepth: braceDepth + 1 });
+ }
+
+ const inClass =
+ classStack.length > 0 &&
+ braceDepth >= classStack[classStack.length - 1].bodyDepth;
+
+ if (inClass) {
+ const methodMatch = trimmed.match(
+ /^(?:(static)\s+)?(?:async\s+)?(?:(get|set)\s+)?([A-Za-z0-9_$#]+)\s*\([^;]*\)\s*\{/
+ );
+ if (methodMatch && !isControlKeyword(methodMatch[3])) {
+ entries.push({
+ kind: "method",
+ name: methodMatch[3],
+ line: lineNumber,
+ className: classStack[classStack.length - 1].name,
+ accessor: methodMatch[2] || undefined,
+ isStatic: Boolean(methodMatch[1]),
+ });
+ }
+ }
+
+ const functionMatch = trimmed.match(
+ /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/
+ );
+ if (functionMatch) {
+ entries.push({
+ kind: "function",
+ name: functionMatch[1],
+ line: lineNumber,
+ });
+ }
+
+ const arrowMatch = trimmed.match(
+ /^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s+)?\([^=]*\)\s*=>\s*\{/
+ );
+ if (arrowMatch) {
+ entries.push({ kind: "arrow", name: arrowMatch[1], line: lineNumber });
+ }
+
+ braceDepth = updateBraceDepth(braceDepth, rawLine);
+ while (
+ classStack.length &&
+ braceDepth < classStack[classStack.length - 1].bodyDepth
+ ) {
+ classStack.pop();
+ }
+ }
+
+ return entries;
+}
+
+/**
+ * Determines whether the supplied identifier is a control-flow keyword.
+ *
+ * @param {string} name Identifier candidate.
+ * @returns {boolean} True when the name represents a keyword.
+ */
+function isControlKeyword(name) {
+ return (
+ name === "if" ||
+ name === "for" ||
+ name === "while" ||
+ name === "switch" ||
+ name === "catch" ||
+ name === "with"
+ );
+}
+
+/**
+ * Updates the running brace depth for the outline traversal.
+ *
+ * @param {number} current Existing brace depth.
+ * @param {string} line Source line.
+ * @returns {number} New brace depth.
+ */
+function updateBraceDepth(current, line) {
+ const open = countChar(line, "{");
+ const close = countChar(line, "}");
+ let next = current + open - close;
+ if (next < 0) {
+ next = 0;
+ }
+ return next;
+}
+
+/**
+ * Counts the occurrences of a character within the provided string.
+ *
+ * @param {string} line Text to inspect.
+ * @param {string} char Target character.
+ * @returns {number} Occurrence count.
+ */
+function countChar(line, char) {
+ let count = 0;
+ for (let i = 0; i < line.length; i += 1) {
+ if (line[i] === char) {
+ count += 1;
+ }
+ }
+ return count;
+}
+
+/**
+ * Converts outline data into printable strings.
+ *
+ * @param {Array} outline Ordered outline entries.
+ * @param {string} inputPath Original file path.
+ * @returns {Array} Lines ready for file output.
+ */
+function formatOutline(outline, inputPath) {
+ const header = [`Outline for ${path.relative(process.cwd(), inputPath)}`, ""];
+ const body = outline.map((entry) => {
+ const label = buildLabel(entry);
+ return `${entry.line.toString().padStart(5, " ")} : ${label}`;
+ });
+ return [...header, ...body];
+}
+
+/**
+ * Builds a human-friendly label for an outline entry.
+ *
+ * @param {OutlineEntry} entry Outline entry.
+ * @returns {string} Formatted label.
+ */
+function buildLabel(entry) {
+ if (entry.kind === "class") {
+ return `class ${entry.name}`;
+ }
+ if (entry.kind === "method" && entry.className) {
+ const prefix = entry.isStatic ? "static " : "";
+ const accessor = entry.accessor ? `${entry.accessor} ` : "";
+ return `method ${entry.className}.${prefix}${accessor}${entry.name}`;
+ }
+ if (entry.kind === "function") {
+ return `function ${entry.name}`;
+ }
+ return `arrow ${entry.name}`;
+}
+
+main();
diff --git a/scripts/ui/BlueprintWorkspace.js b/scripts/ui/BlueprintWorkspace.js
new file mode 100644
index 0000000..ea7bb12
--- /dev/null
+++ b/scripts/ui/BlueprintWorkspace.js
@@ -0,0 +1,6562 @@
+import { NodeGraph } from "../core/NodeGraph.js";
+import { WorkspaceGeometry } from "./workspace/WorkspaceGeometry.js";
+import { WorkspaceDragManager } from "./workspace/WorkspaceDragManager.js";
+import { WorkspaceHistoryManager } from "./workspace/WorkspaceHistoryManager.js";
+import { WorkspaceClipboardManager } from "./workspace/WorkspaceClipboardManager.js";
+import { WorkspacePersistenceManager } from "./workspace/WorkspacePersistenceManager.js";
+import { WorkspaceMarqueeManager } from "./workspace/WorkspaceMarqueeManager.js";
+import { WorkspaceContextMenuManager } from "./workspace/WorkspaceContextMenuManager.js";
+import { WorkspacePaletteManager } from "./workspace/WorkspacePaletteManager.js";
+import { WorkspaceDropManager } from "./workspace/WorkspaceDropManager.js";
+import { WorkspaceInlineEditorManager } from "./workspace/WorkspaceInlineEditorManager.js";
+import { WorkspaceNodeRenderer } from "./workspace/WorkspaceNodeRenderer.js";
+import { WorkspaceNavigationManager } from "./workspace/WorkspaceNavigationManager.js";
+import { renderEventList } from "./workspace/renderEventList.js";
+import { renderVariableList } from "./workspace/renderVariableList.js";
+import {
+ WORKSPACE_SHORTCUTS,
+} from "./WorkspaceSpawnShortcuts.js";
+
+/**
+ * @typedef {'any'|'number'|'string'|'boolean'|'table'} VariableType
+ */
+
+/** @type {Array<{ value: VariableType, label: string }>} */
+const VARIABLE_TYPE_DEFINITIONS = [
+ { value: "any", label: "Any" },
+ { value: "number", label: "Number" },
+ { value: "string", label: "String" },
+ { value: "boolean", label: "Boolean" },
+ { value: "table", label: "Table" },
+];
+
+const SUPPORTED_VARIABLE_TYPES = new Set(
+ VARIABLE_TYPE_DEFINITIONS.map((entry) => entry.value)
+);
+
+/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcut} WorkspaceShortcut */
+/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcutModifier} WorkspaceShortcutModifier */
+
+/**
+ * @typedef {{ kind: 'variable', variableId: string } | { kind: 'custom_event_parameter', nodeId: string, parameterId: string } | { kind: 'local_variable', nodeId: string }} TypeDropdownContext
+ */
+
+/**
+ * @typedef {{ id: string, key: string, value: string }} TableDefaultEntry
+ */
+
+/**
+ * @typedef {string | number | boolean | null | Array} VariableDefaultValue
+ */
+
+/**
+ * @typedef {{ id: string, name: string, type: VariableType, defaultValue: VariableDefaultValue }} WorkspaceVariable
+ */
+
+/**
+ * @typedef {{ id: string, name: string, type: VariableType, optional: boolean }} CustomEventParameter
+ */
+
+/**
+ * @typedef {'default'|'project'} InspectorView
+ */
+
+/**
+ * @typedef {{ key: string, definitionId: string, button: number, modifiers: Set }} NormalizedSpawnShortcut
+ */
+
+/**
+ * @typedef {{ key: string, command: string, modifiers: Set }} NormalizedCommandShortcut
+ */
+
+/**
+ * @typedef {Object} WorkspaceOptions
+ * @property {HTMLElement} workspaceElement Root workspace container.
+ * @property {HTMLElement} nodeLayer DOM node layer container.
+ * @property {SVGElement} connectionLayer SVG connection layer.
+ * @property {HTMLElement} paletteList Palette list element.
+ * @property {HTMLInputElement} paletteSearch Palette search input element.
+ * @property {HTMLElement} paletteElement Palette container element.
+ * @property {HTMLElement} inspectorContent Inspector root element.
+ * @property {HTMLButtonElement} duplicateNodeButton Duplicate action button.
+ * @property {HTMLButtonElement} deleteNodeButton Delete action button.
+ * @property {LuaGenerator} generator Lua generator responsible for code output.
+ * @property {NodeRegistry} registry Registry containing available node definitions.
+ * @property {HTMLUListElement} eventList Event summary list element.
+ * @property {HTMLUListElement} variableList Variable summary list element.
+ * @property {HTMLButtonElement} addVariableButton Global variable creation button.
+ * @property {HTMLButtonElement} projectSettingsButton Project settings toggle.
+ * @property {HTMLButtonElement} paletteToggleButton Palette toggle control.
+ * @property {HTMLElement} appBodyElement Layout container hosting the workspace panes.
+ * @property {HTMLButtonElement} frameSelectionButton Frame selection control.
+ */
+
+/**
+ * @typedef {{ type: 'variable', action: 'get'|'set', variableId: string }} VariablePaletteShortcut
+ */
+
+/**
+ * @typedef {{ type: 'custom_event_call', eventNodeId: string }} CustomEventCallPaletteShortcut
+ */
+
+/**
+ * @typedef {import('../nodes/nodeTypes.js').NodeDefinition & {
+ * shortcut?: VariablePaletteShortcut | CustomEventCallPaletteShortcut
+ * }} PaletteNodeDefinition
+ */
+
+/** @typedef {import('./workspace/WorkspaceHistoryManager.js').SerializedWorkspace} SerializedWorkspace */
+/** @typedef {import('./workspace/WorkspaceHistoryManager.js').WorkspaceSnapshot} WorkspaceSnapshot */
+
+const AUTO_CONNECT_DISTANCE = 300;
+const DEFAULT_SEQUENCE_BRANCHES = 3;
+
+/**
+ * Manages blueprint workspace interactions and DOM rendering.
+ */
+export class BlueprintWorkspace {
+ /**
+ * @param {WorkspaceOptions} options Workspace setup options.
+ */
+ constructor(options) {
+ this.workspaceElement = options.workspaceElement;
+ if (
+ this.workspaceElement instanceof HTMLElement &&
+ !this.workspaceElement.hasAttribute("tabindex")
+ ) {
+ this.workspaceElement.setAttribute("tabindex", "-1");
+ }
+ this.nodeLayer = options.nodeLayer;
+ this.connectionLayer = options.connectionLayer;
+ this.paletteList = options.paletteList;
+ this.paletteSearch = options.paletteSearch;
+ /** @type {HTMLElement} */
+ this.paletteElement = options.paletteElement;
+ this.inspectorContent = options.inspectorContent;
+ this.duplicateNodeButton = options.duplicateNodeButton;
+ this.deleteNodeButton = options.deleteNodeButton;
+ this.generator = options.generator;
+ this.registry = options.registry;
+ /** @type {HTMLUListElement} */
+ this.eventListElement = options.eventList;
+ /** @type {HTMLUListElement} */
+ this.variableListElement = options.variableList;
+ this.addVariableButton = options.addVariableButton;
+ /** @type {HTMLButtonElement} */
+ this.projectSettingsButton = options.projectSettingsButton;
+ /** @type {HTMLButtonElement} */
+ this.frameSelectionButton = options.frameSelectionButton;
+ /** @type {HTMLButtonElement} */
+ this.paletteToggleButton = options.paletteToggleButton;
+ /** @type {HTMLElement} */
+ this.appBodyElement = options.appBodyElement;
+ /** @type {Set} */
+ this.activeShortcutKeys = new Set();
+ /** @type {Map<'background', Array>} */
+ this.spawnShortcutLookup = this.#normalizeSpawnShortcuts(
+ WORKSPACE_SHORTCUTS
+ );
+ /** @type {Array} */
+ this.commandShortcuts = this.#normalizeCommandShortcuts(
+ WORKSPACE_SHORTCUTS
+ );
+
+ /** @type {number} */
+ this.gridSize = WorkspaceGeometry.resolveGridSize(this);
+ /** @type {{ element: HTMLElement, width: number, height: number, definitionId: string | null } | null} */
+ this.paletteDragGhost = null;
+ /** @type {{ element: HTMLElement, width: number, height: number, nodeId: string | null } | null} */
+ this.nodeDragGhost = null;
+ /** @type {string | null} */
+ this.activePaletteDefinitionId = null;
+ /** @type {Map} */
+ this.nodeDragRotation = new Map();
+ /** @type {number | null} */
+ this.nodeDragRotationDecayFrame = null;
+ /** @type {number | null} */
+ this.connectionRefreshFrame = null;
+
+ this.graph = new NodeGraph();
+ /** @type {Map} */
+ this.variables = new Map();
+ /** @type {Array} */
+ this.variableOrder = [];
+ this.variableCounter = 0;
+ /** @type {Map} */
+ this.nodeElements = new Map();
+ /** @type {Map} */
+ this.connectionElements = new Map();
+ /** @type {{ container: HTMLElement | null, trigger: HTMLButtonElement | null, context: TypeDropdownContext | null, onSelect: ((type: VariableType) => void) | null }} */
+ this.typeDropdown = {
+ container: null,
+ trigger: null,
+ context: null,
+ onSelect: null,
+ };
+ /** @type {Set} */
+ this.highlightedPins = new Set();
+ this.pendingConnection = null;
+ /**
+ * @type {{ startRef: { nodeId: string, pinId: string }, direction: 'input'|'output', kind: string } | null}
+ */
+ this.pendingConnectionSpawn = null;
+ /** @type {{ variableId: string } | null} */
+ this.pendingVariableSpawn = null;
+ /** @type {Set} */
+ this.selectedNodeIds = new Set();
+ /** @type {string | undefined} */
+ this.selectedNodeId = undefined;
+ /** @type {string | null} */
+ this.selectedVariableId = null;
+ /** @type {{ variableId: string, input: HTMLInputElement, label: HTMLElement, container: HTMLElement, wasDraggable: boolean } | null} */
+ this.activeVariableRename = null;
+ /** @type {{ nodeId: string, property: string } | null} */
+ this.pendingInspectorFocus = null;
+ /** @type {{ variableId: string, entryId: string, target: 'key' | 'value' } | null} */
+ this.pendingVariableInspectorFocus = null;
+ /** @type {{ use60Fps: boolean }} */
+ this.projectSettings = { use60Fps: false };
+ /** @type {boolean} */
+ this.isPaletteVisible = true;
+ /** @type {InspectorView} */
+ this.inspectorView = "default";
+ /** @type {boolean} */
+ this.isDraggingNodes = false;
+ /** @type {boolean} */
+ this.isRestoring = false;
+ /** @type {number} */
+ this.workspaceDragDepth = 0;
+ /** @type {number} */
+ this.spawnIndex = 0;
+ /** @type {string} */
+ this.luaOutput = "";
+ /** @type {WorkspaceHistoryManager | null} */
+ this.historyManager = null;
+ /** @type {WorkspaceClipboardManager | null} */
+ this.clipboardManager = null;
+ /** @type {WorkspacePersistenceManager | null} */
+ this.persistenceManager = null;
+ /** @type {WorkspacePaletteManager | null} */
+ this.paletteManager = null;
+ /** @type {WorkspaceContextMenuManager | null} */
+ this.contextMenuManager = null;
+ /** @type {WorkspaceDropManager | null} */
+ this.dropManager = null;
+ /** @type {WorkspaceMarqueeManager | null} */
+ this.marqueeManager = null;
+ /** @type {WorkspaceInlineEditorManager | null} */
+ this.inlineEditorManager = null;
+ /** @type {WorkspaceNodeRenderer | null} */
+ this.nodeRenderer = null;
+ /** @type {WorkspaceNavigationManager | null} */
+ this.navigationManager = null;
+ /** @type {WorkspaceDragManager | null} */
+ this.dragManager = null;
+ /** @type {HTMLTemplateElement} */
+ this.nodeTemplate = BlueprintWorkspace.#requireTemplate(
+ "nodeTemplate",
+ HTMLTemplateElement
+ );
+ /** @type {HTMLTemplateElement} */
+ this.pinTemplate = BlueprintWorkspace.#requireTemplate(
+ "pinTemplate",
+ HTMLTemplateElement
+ );
+ /** @type {boolean} */
+ this.isInitialized = false;
+
+ Object.defineProperty(this, "zoomLevel", {
+ configurable: true,
+ enumerable: false,
+ get: () => this.navigationManager?.getZoomLevel() ?? 1,
+ set: (value) => {
+ if (this.navigationManager) {
+ this.navigationManager.setZoomLevel(value, { silent: true });
+ this.navigationManager.updateZoomDisplay();
+ }
+ },
+ });
+
+ Object.defineProperty(this, "workspaceBackgroundOffset", {
+ configurable: true,
+ enumerable: false,
+ get: () =>
+ this.navigationManager?.getBackgroundOffset?.() ?? { x: 0, y: 0 },
+ set: (value) => {
+ if (this.navigationManager) {
+ this.navigationManager.setBackgroundOffset(value ?? { x: 0, y: 0 });
+ }
+ },
+ });
+ }
+
+ /**
+ * Ensures a DOM template with the requested identifier exists.
+ *
+ * @template T
+ * @param {string} id Element identifier.
+ * @param {new (...args: Array) => T} ctor Expected constructor.
+ * @returns {T}
+ */
+ static #requireTemplate(id, ctor) {
+ const element = document.getElementById(id);
+ if (!(element instanceof ctor)) {
+ throw new Error(`Missing required template: ${id}`);
+ }
+ return element;
+ }
+
+ /**
+ * Initializes manager instances, event bindings, and persisted state.
+ */
+ initialize() {
+ if (this.isInitialized) {
+ return;
+ }
+
+ this.dragManager = new WorkspaceDragManager(this);
+ this.historyManager = new WorkspaceHistoryManager(this);
+ this.persistenceManager = new WorkspacePersistenceManager(this);
+ this.clipboardManager = new WorkspaceClipboardManager(
+ this,
+ this.historyManager
+ );
+ this.inlineEditorManager = new WorkspaceInlineEditorManager({
+ getGraph: () => this.graph,
+ registry: this.registry,
+ formatVariableType: (type) => this.#formatVariableType(type),
+ openLocalVariableTypeDropdown: (
+ nodeId,
+ trigger,
+ anchor,
+ currentType,
+ onSelect
+ ) => {
+ const typeOptions = this.#getVariableTypeOptions().filter(
+ (entry) => entry.value !== "any"
+ );
+ this.#openTypeDropdown({
+ context: { kind: "local_variable", nodeId },
+ trigger,
+ anchor,
+ currentType,
+ onSelect,
+ placement: "top",
+ options: typeOptions,
+ });
+ },
+ closeTypeDropdown: () => this.#closeTypeDropdown(),
+ isTypeDropdownOpen: (context) => this.#isTypeDropdownOpen(context),
+ });
+ this.contextMenuManager = new WorkspaceContextMenuManager(this);
+ this.paletteManager = new WorkspacePaletteManager(this);
+ this.dropManager = new WorkspaceDropManager(this);
+ this.marqueeManager = new WorkspaceMarqueeManager(this);
+ this.navigationManager = new WorkspaceNavigationManager(this, {
+ renderConnections: () => this.#renderConnections(),
+ schedulePersist: () => this.persistenceManager.schedulePersist(),
+ });
+ this.nodeRenderer = new WorkspaceNodeRenderer(this, {
+ inlineEditorManager: this.inlineEditorManager,
+ applySelectionState: () => this.#applySelectionState(),
+ selectNode: (nodeId) => this.selectNode(nodeId),
+ startNodeDrag: (event, nodeId) =>
+ this.dragManager?.startNodeDrag(event, nodeId),
+ refreshVariableNode: (nodeId) => this.#refreshVariableNode(nodeId),
+ refreshLocalVariableNode: (nodeId) =>
+ this.#refreshLocalVariableNode(nodeId),
+ refreshCustomEventNode: (nodeId) => this.#refreshCustomEventNode(nodeId),
+ refreshCustomEventCallNode: (nodeId) =>
+ this.#refreshCustomEventCallNode(nodeId),
+ renderConnections: () => this.#renderConnections(),
+ shouldHighlightPin: (nodeId, pinId, direction) =>
+ this.#shouldHighlightHover(nodeId, pinId, direction),
+ beginConnection: (event, nodeId, pinId, direction) =>
+ this.#beginConnection(event, nodeId, pinId, direction),
+ finalizeConnection: (nodeId, pinId, direction) =>
+ this.#finalizeConnection(nodeId, pinId, direction),
+ getPinElement: (nodeId, pinId, direction) =>
+ this.#getPinElement(nodeId, pinId, direction),
+ });
+
+ this.contextMenuManager.initialize();
+ this.paletteManager.initialize();
+ this.dropManager.initialize();
+ this.marqueeManager.ensureElement();
+
+ this.navigationManager.updateZoomDisplay();
+ this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
+
+ if (typeof this.generator.setProjectSettings === "function") {
+ this.generator.setProjectSettings({ ...this.projectSettings });
+ }
+
+ this.#bindGraphEvents();
+ this.#bindUiEvents();
+ this.#applyPaletteVisibility(this.isPaletteVisible);
+
+ const restored = this.persistenceManager.restoreFromStorage();
+ if (!restored) {
+ this.#renderOverview();
+ this.#renderInspectorState();
+ this.#refreshLuaOutput();
+ this.navigationManager.updateZoomDisplay();
+ }
+
+ this.historyManager.initialize();
+ this.isInitialized = true;
+ }
+
+ /**
+ * Reverts the most recent checkpoint.
+ */
+ undo() {
+ this.historyManager.undo();
+ }
+
+ /**
+ * Re-applies the most recently undone checkpoint.
+ */
+ redo() {
+ this.historyManager.redo();
+ }
+
+ /**
+ * Produces Lua output using the current generator and graph state.
+ */
+ exportLua() {
+ return this.#refreshLuaOutput();
+ }
+
+ /**
+ * Subscribes to graph lifecycle changes.
+ */
+ #bindGraphEvents() {
+ this.graph.addEventListener("nodeschanged", (event) => {
+ const customEvent = /** @type {CustomEvent} */ (event);
+ const detail = customEvent.detail;
+ if (detail?.type === "add" && detail.node) {
+ if (detail.node.type === "custom_event") {
+ const currentName =
+ typeof detail.node.properties.name === "string"
+ ? detail.node.properties.name
+ : "";
+ const uniqueName = this.#generateUniqueCustomEventName(
+ currentName || "CustomEvent",
+ detail.node.id
+ );
+ if (uniqueName !== currentName) {
+ this.graph.setNodeProperty(detail.node.id, "name", uniqueName);
+ } else {
+ this.#refreshCustomEventNode(detail.node.id);
+ }
+ if (
+ !this.pendingInspectorFocus ||
+ this.pendingInspectorFocus.nodeId !== detail.node.id
+ ) {
+ this.pendingInspectorFocus = {
+ nodeId: detail.node.id,
+ property: "name",
+ };
+ }
+ if (
+ this.selectedNodeId &&
+ this.graph.nodes.get(this.selectedNodeId)?.type ===
+ "call_custom_event"
+ ) {
+ this.#refreshInspectorIfActive(this.selectedNodeId, "eventId");
+ }
+ } else if (detail.node.type === "call_custom_event") {
+ this.#refreshCustomEventCallNode(detail.node.id);
+ if (
+ !this.pendingInspectorFocus ||
+ this.pendingInspectorFocus.nodeId !== detail.node.id
+ ) {
+ this.pendingInspectorFocus = {
+ nodeId: detail.node.id,
+ property: "eventId",
+ };
+ }
+ } else if (detail.node.type === "sequence") {
+ this.#refreshSequenceNode(detail.node.id);
+ }
+ } else if (detail?.type === "remove" && detail.nodeId) {
+ this.#handleCustomEventRemoval(detail.nodeId);
+ }
+
+ this.#renderConnections();
+ this.#refreshLuaOutput();
+ this.#renderOverview();
+ this.nodeRenderer.refreshAllPinConnections();
+ this.historyManager.registerMutation("nodeschanged");
+ this.persistenceManager.schedulePersist();
+ });
+
+ this.graph.addEventListener("nodepositionchanged", () => {
+ if (this.isDraggingNodes) {
+ this.#scheduleConnectionRefresh();
+ } else {
+ this.#flushConnectionRefresh();
+ }
+ this.#refreshLuaOutput();
+ this.historyManager.registerMutation("nodepositionchanged");
+ this.persistenceManager.schedulePersist();
+ });
+
+ this.graph.addEventListener("nodepropertychanged", (event) => {
+ const customEvent = /** @type {CustomEvent} */ (event);
+ const detail = customEvent.detail ?? {};
+ const nodeId = detail.nodeId;
+ const key = detail.key;
+ const value = detail.value;
+
+ let connectionsMutated = false;
+ const nodesToRefreshConnections = new Set();
+
+ if (nodeId) {
+ this.inlineEditorManager.syncNode(nodeId);
+ const node = this.graph.nodes.get(nodeId);
+
+ if (node?.type === "custom_event") {
+ if (key === "name") {
+ const input = typeof value === "string" ? value : "";
+ const trimmed = input.trim();
+ if (trimmed !== input) {
+ this.graph.setNodeProperty(nodeId, "name", trimmed);
+ return;
+ }
+
+ const ensured = trimmed || "CustomEvent";
+ const uniqueName = this.#generateUniqueCustomEventName(
+ ensured,
+ nodeId
+ );
+ if (uniqueName !== ensured) {
+ this.graph.setNodeProperty(nodeId, "name", uniqueName);
+ return;
+ }
+
+ const mutated = this.#refreshCustomEventNode(nodeId);
+ connectionsMutated = mutated || connectionsMutated;
+ this.#refreshInspectorIfActive(nodeId, "name");
+ } else if (key === "parameters") {
+ const mutated = this.#refreshCustomEventNode(nodeId);
+ connectionsMutated = mutated || connectionsMutated;
+ this.#refreshInspectorIfActive(nodeId);
+ } else if (key === "parameterCounter") {
+ // Counter adjustments are handled indirectly by parameter updates.
+ } else {
+ const mutated = this.#refreshCustomEventNode(nodeId);
+ connectionsMutated = mutated || connectionsMutated;
+ }
+ } else if (node?.type === "call_custom_event") {
+ if (key === "eventId") {
+ const eventId = typeof value === "string" ? value : "";
+ const eventNode = eventId ? this.graph.nodes.get(eventId) : null;
+
+ if (eventId && (!eventNode || eventNode.type !== "custom_event")) {
+ if (eventId !== "") {
+ this.graph.setNodeProperty(nodeId, "eventId", "");
+ }
+ return;
+ }
+
+ const mutated = this.#refreshCustomEventCallNode(nodeId);
+ connectionsMutated = mutated || connectionsMutated;
+ this.#refreshInspectorIfActive(nodeId, "eventId");
+ } else if (key === "arguments") {
+ const mutated = this.#refreshCustomEventCallNode(nodeId);
+ connectionsMutated = mutated || connectionsMutated;
+ this.#refreshInspectorIfActive(nodeId, "arguments");
+ } else {
+ const mutated = this.#refreshCustomEventCallNode(nodeId);
+ connectionsMutated = mutated || connectionsMutated;
+ }
+ } else if (node?.type === "sequence") {
+ const mutated = this.#refreshSequenceNode(nodeId);
+ if (mutated) {
+ connectionsMutated = true;
+ nodesToRefreshConnections.add(nodeId);
+ }
+ if (key === "branches") {
+ this.#refreshInspectorIfActive(nodeId);
+ }
+ } else {
+ const variableMutated = this.#refreshVariableNode(nodeId);
+ if (variableMutated) {
+ connectionsMutated = true;
+ nodesToRefreshConnections.add(nodeId);
+ }
+ const localMutated = this.#refreshLocalVariableNode(nodeId);
+ if (localMutated) {
+ connectionsMutated = true;
+ nodesToRefreshConnections.add(nodeId);
+ }
+ }
+ }
+
+ this.#refreshLuaOutput();
+ this.#renderOverview();
+ if (connectionsMutated) {
+ this.#renderConnections();
+ nodesToRefreshConnections.forEach((id) =>
+ this.nodeRenderer.updatePinConnectionsForNode(id)
+ );
+ }
+ this.historyManager.registerMutation("nodepropertychanged");
+ this.persistenceManager.schedulePersist();
+ });
+
+ this.graph.addEventListener("connectionschanged", (event) => {
+ const customEvent = /** @type {CustomEvent} */ (event);
+ const detail = customEvent.detail;
+ /** @type {Set} */
+ const nodesToRefresh = new Set();
+
+ const addNode = (nodeId) => {
+ if (typeof nodeId === "string" && nodeId.length) {
+ nodesToRefresh.add(nodeId);
+ }
+ };
+
+ if (detail?.connection) {
+ addNode(detail.connection.from.nodeId);
+ addNode(detail.connection.to.nodeId);
+ }
+
+ if (detail?.ref) {
+ addNode(detail.ref.nodeId);
+ }
+
+ if (Array.isArray(detail?.nodes)) {
+ detail.nodes.forEach(addNode);
+ }
+
+ if (detail?.nodeId) {
+ addNode(detail.nodeId);
+ }
+
+ if (nodesToRefresh.size) {
+ nodesToRefresh.forEach((nodeId) => {
+ if (this.graph.nodes.has(nodeId)) {
+ this.inlineEditorManager.syncNode(nodeId);
+ }
+ });
+ } else {
+ this.inlineEditorManager.syncAll();
+ this.graph.nodes.forEach((node) => nodesToRefresh.add(node.id));
+ }
+
+ this.#renderConnections();
+ nodesToRefresh.forEach((nodeId) =>
+ this.nodeRenderer.updatePinConnectionsForNode(nodeId)
+ );
+ this.#refreshLuaOutput();
+ this.historyManager.registerMutation("connectionschanged");
+ this.persistenceManager.schedulePersist();
+ });
+ }
+
+ /**
+ * Registers DOM event listeners for interactivity.
+ */
+ #bindUiEvents() {
+ if (this.paletteToggleButton) {
+ this.paletteToggleButton.addEventListener("click", () => {
+ this.#togglePaletteVisibility();
+ });
+ }
+
+ if (this.frameSelectionButton) {
+ this.frameSelectionButton.addEventListener("click", () => {
+ this.#frameSelection();
+ });
+ }
+
+ if (this.projectSettingsButton) {
+ this.projectSettingsButton.addEventListener("click", () => {
+ const wasProjectView = this.inspectorView === "project";
+ if (wasProjectView) {
+ this.#setInspectorView("default");
+ this.clearSelection();
+ this.#renderInspectorState();
+ return;
+ }
+
+ this.clearSelection();
+ this.#setInspectorView("project");
+ this.#renderInspectorState();
+ });
+ }
+
+ if (this.addVariableButton) {
+ this.addVariableButton.addEventListener("click", () => {
+ this.#createGlobalVariable();
+ });
+ }
+
+ this.workspaceElement.addEventListener(
+ "wheel",
+ (event) => {
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ const rawTarget = /** @type {EventTarget | null} */ (event.target);
+ if (
+ rawTarget instanceof Node &&
+ this.contextMenuManager?.isTargetInside(rawTarget)
+ ) {
+ return;
+ }
+
+ if (!Number.isFinite(event.deltaY) || event.deltaY === 0) {
+ return;
+ }
+
+ const rect = this.workspaceElement.getBoundingClientRect();
+ const pointer = {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+
+ if (pointer.x < 0 || pointer.y < 0 || pointer.x > rect.width || pointer.y > rect.height) {
+ return;
+ }
+
+ event.preventDefault();
+ const direction = event.deltaY < 0 ? 1 : -1;
+ this.navigationManager.zoomAt(pointer, direction, {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ },
+ { passive: false }
+ );
+
+ this.workspaceElement.addEventListener("pointerdown", (event) => {
+ const rawTarget = /** @type {EventTarget | null} */ (event.target);
+ if (rawTarget instanceof Element) {
+ const interactive = rawTarget.closest(
+ "input, textarea, select, button, [contenteditable='true']"
+ );
+ if (!interactive) {
+ this.#focusWorkspaceSurface({ force: true });
+ }
+ }
+
+ if (this.contextMenuManager.isVisible()) {
+ if (
+ !(rawTarget instanceof Node) ||
+ !this.contextMenuManager.isTargetInside(rawTarget)
+ ) {
+ this.contextMenuManager.hide();
+ }
+ }
+
+ if (!(rawTarget instanceof Element)) {
+ return;
+ }
+
+ if (!this.#isWorkspaceBackgroundTarget(rawTarget)) {
+ return;
+ }
+
+ if (event.button === 0) {
+ const shortcut = this.#matchBackgroundSpawnShortcut(event);
+ if (shortcut) {
+ event.preventDefault();
+ event.stopPropagation();
+ const spawned = this.#spawnShortcutNodeAtPointer(shortcut, event);
+ if (spawned) {
+ this.#focusWorkspaceSurface({ force: true });
+ }
+ return;
+ }
+ this.clearSelection();
+ this.marqueeManager?.beginSelection(event);
+ } else if (event.button === 2) {
+ this.navigationManager?.beginPan(event);
+ }
+ });
+
+ this.workspaceElement.addEventListener("contextmenu", (event) => {
+ if (event.defaultPrevented) {
+ return;
+ }
+ if (this.navigationManager.isPanRecent(200)) {
+ event.preventDefault();
+ return;
+ }
+ event.preventDefault();
+ this.contextMenuManager.show(event.clientX, event.clientY);
+ });
+
+ this.duplicateNodeButton.addEventListener("click", () => {
+ if (this.selectedNodeIds.size !== 1 || !this.selectedNodeId) {
+ return;
+ }
+ const node = this.graph.nodes.get(this.selectedNodeId);
+ if (!node) {
+ return;
+ }
+
+ const duplicated = this.registry.createNode(node.type, {
+ id: this.graph.createNodeId(node.type),
+ position: { x: node.position.x + 40, y: node.position.y + 40 },
+ });
+ duplicated.properties = { ...node.properties };
+ this.graph.addNode(duplicated);
+ this.nodeRenderer.renderNode(duplicated);
+ this.selectNode(duplicated.id);
+ });
+
+ this.deleteNodeButton.addEventListener("click", () => {
+ if (!this.selectedNodeIds.size) {
+ return;
+ }
+ const targets = [...this.selectedNodeIds];
+ targets.forEach((id) => {
+ this.removeNode(id);
+ });
+ });
+
+ document.addEventListener("keydown", (event) => {
+ const key = event.key;
+ const normalizedKey = typeof key === "string" ? key.toLowerCase() : "";
+ const isModifier = event.ctrlKey || event.metaKey;
+
+ if (normalizedKey && normalizedKey.length === 1) {
+ this.activeShortcutKeys.add(normalizedKey);
+ }
+
+ const target = /** @type {EventTarget | null} */ (event.target);
+ if (target instanceof HTMLElement) {
+ const tagName = target.tagName;
+ if (
+ target.isContentEditable ||
+ tagName === "INPUT" ||
+ tagName === "TEXTAREA" ||
+ tagName === "SELECT"
+ ) {
+ return;
+ }
+ }
+
+ if (isModifier) {
+ const handled = this.#dispatchCommandShortcut(event, normalizedKey);
+ if (handled) {
+ return;
+ }
+ }
+
+ if (key !== "Delete" && normalizedKey !== "q") {
+ return;
+ }
+
+ if (key === "Delete") {
+ const handled = this.#dispatchCommandShortcut(event, "delete");
+ if (!handled) {
+ if (!this.selectedNodeIds.size) {
+ return;
+ }
+ event.preventDefault();
+ const ids = [...this.selectedNodeIds];
+ ids.forEach((id) => this.removeNode(id));
+ }
+ return;
+ }
+
+ if (normalizedKey === "q") {
+ const connected = this.#tryAutoConnectSelectedNode();
+ if (connected) {
+ event.preventDefault();
+ }
+ }
+ });
+
+ document.addEventListener("keyup", (event) => {
+ const key = event.key;
+ const normalizedKey = typeof key === "string" ? key.toLowerCase() : "";
+ if (normalizedKey && normalizedKey.length === 1) {
+ this.activeShortcutKeys.delete(normalizedKey);
+ }
+ });
+
+ window.addEventListener("blur", () => {
+ this.activeShortcutKeys.clear();
+ });
+
+ document.addEventListener("pointerdown", (event) => {
+ const state = this.typeDropdown;
+ if (!state.container || !state.trigger) {
+ return;
+ }
+
+ const target = /** @type {EventTarget | null} */ (event.target);
+ if (!(target instanceof Node)) {
+ this.#closeTypeDropdown();
+ return;
+ }
+
+ if (state.container.contains(target) || state.trigger.contains(target)) {
+ return;
+ }
+
+ this.#closeTypeDropdown();
+ });
+
+ const updateClipboardPointer = (event) => {
+ if (!this.clipboardManager) {
+ return;
+ }
+ const point = WorkspaceGeometry.eventToWorkspacePoint(this, event);
+ this.clipboardManager.updatePointerPosition(point);
+ };
+
+ this.workspaceElement.addEventListener("pointermove", updateClipboardPointer);
+ this.workspaceElement.addEventListener("pointerdown", updateClipboardPointer);
+ this.workspaceElement.addEventListener("pointerenter", updateClipboardPointer);
+ this.workspaceElement.addEventListener("pointerleave", () => {
+ this.clipboardManager?.clearPointerPosition();
+ });
+
+ this.connectionLayer.addEventListener("pointerdown", (event) => {
+ this.#handleConnectionPointerDown(event);
+ });
+ }
+
+ /**
+ * Creates and renders a node of the requested definition.
+ *
+ * @param {string} definitionId Node definition identifier.
+ * @param {{ x: number, y: number }} [position] Optional workspace position.
+ * @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
+ */
+ addNode(definitionId, position) {
+ const definition = this.registry.get(definitionId);
+ if (!definition) {
+ return null;
+ }
+
+ if (definition.unique) {
+ const existing = [...this.graph.nodes.values()].find(
+ (candidate) => candidate.type === definitionId
+ );
+ if (existing) {
+ this.setSelectionState([existing.id], existing.id);
+ const navigator = this.navigationManager;
+ if (navigator) {
+ window.requestAnimationFrame(() => {
+ navigator.frameNodes([existing.id]);
+ });
+ }
+ return existing;
+ }
+ }
+
+ const hasPosition =
+ position &&
+ Number.isFinite(position.x) &&
+ Number.isFinite(position.y);
+ const spawnIndex = this.spawnIndex;
+ const spawnPosition = hasPosition
+ ? WorkspaceGeometry.snapPositionToGrid(this, position)
+ : this.#computeSpawnPosition(definitionId, spawnIndex);
+
+ let node;
+ try {
+ node = this.registry.createNode(definitionId, {
+ id: this.graph.createNodeId(definitionId),
+ position: spawnPosition,
+ });
+ } catch {
+ return null;
+ }
+
+ this.spawnIndex = spawnIndex + 1;
+
+ this.graph.addNode(node);
+ this.renderNode(node);
+ if (node.type === "sequence") {
+ this.#refreshSequenceNode(node.id);
+ }
+ this.setSelectionState([node.id], node.id);
+ return node;
+ }
+
+ /**
+ * Renders a node within the workspace node layer.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode | null | undefined} node Node to render.
+ */
+ renderNode(node) {
+ if (!node) {
+ return;
+ }
+ this.nodeRenderer.renderNode(node);
+ }
+
+ /**
+ * Removes a specific node from the workspace.
+ *
+ * @param {string} nodeId Target node identifier.
+ */
+ removeNode(nodeId) {
+ if (this.nodeDragGhost?.nodeId === nodeId) {
+ this.dragManager.removeNodeGhost();
+ }
+ const element = this.nodeElements.get(nodeId);
+ if (element) {
+ element.remove();
+ this.nodeElements.delete(nodeId);
+ }
+ this.inlineEditorManager.removeNode(nodeId);
+ if (
+ this.pendingConnection &&
+ ((this.pendingConnection.source &&
+ this.pendingConnection.source.nodeId === nodeId) ||
+ (this.pendingConnection.target &&
+ this.pendingConnection.target.nodeId === nodeId))
+ ) {
+ this.#cancelPendingConnection();
+ }
+ this.graph.removeNode(nodeId);
+ if (this.selectedNodeIds.delete(nodeId)) {
+ if (this.selectedNodeId === nodeId) {
+ const iterator = this.selectedNodeIds.values().next();
+ this.selectedNodeId = iterator.done ? undefined : iterator.value;
+ }
+ this.#applySelectionState();
+ }
+ }
+
+ /**
+ * Clears the current node selection.
+ */
+ clearSelection() {
+ this.#cancelVariableRename({ commit: true });
+ const hadVariable = Boolean(this.selectedVariableId);
+ if (hadVariable) {
+ this.selectedVariableId = null;
+ }
+
+ if (!this.selectedNodeIds.size) {
+ if (hadVariable) {
+ this.#highlightVariableSelection();
+ this.#renderInspectorState();
+ }
+ return;
+ }
+
+ this.#setSelection([], undefined);
+ }
+
+ /**
+ * Selects a node, updates UI affordances, and renders inspector controls.
+ *
+ * @param {string} nodeId Node identifier.
+ */
+ selectNode(nodeId) {
+ if (!nodeId) {
+ this.clearSelection();
+ return;
+ }
+
+ if (this.selectedNodeIds.size === 1 && this.selectedNodeId === nodeId) {
+ return;
+ }
+
+ const element = this.nodeElements.get(nodeId);
+ if (!element) {
+ this.clearSelection();
+ return;
+ }
+
+ this.#setSelection([nodeId], nodeId);
+ }
+
+ /**
+ * Applies the supplied selection state and optional primary node designation.
+ *
+ * @param {Array} nodeIds Node identifiers to select.
+ * @param {string | undefined} primaryId Preferred primary selection identifier.
+ */
+ setSelectionState(nodeIds, primaryId) {
+ this.#setSelection(Array.isArray(nodeIds) ? nodeIds : [], primaryId);
+ }
+
+ /**
+ * Frames the active selection or all nodes when nothing is selected.
+ */
+ #frameSelection() {
+ if (!this.navigationManager) {
+ return;
+ }
+
+ const selected = [...this.selectedNodeIds];
+ if (selected.length) {
+ this.navigationManager.frameNodes(selected);
+ return;
+ }
+
+ const allIds = [];
+ this.graph.nodes.forEach((node) => {
+ allIds.push(node.id);
+ });
+
+ if (!allIds.length) {
+ return;
+ }
+
+ this.navigationManager.frameNodes(allIds);
+ }
+
+ /**
+ * Toggles the palette visibility state.
+ */
+ #togglePaletteVisibility() {
+ this.#setPaletteVisibility(!this.isPaletteVisible);
+ }
+
+ /**
+ * Updates internal palette visibility tracking and applies side effects.
+ *
+ * @param {boolean} visible Target palette visibility flag.
+ */
+ #setPaletteVisibility(visible) {
+ const next = Boolean(visible);
+ if (this.isPaletteVisible === next) {
+ this.#applyPaletteVisibility(next);
+ return;
+ }
+
+ const shouldFocus = !this.isPaletteVisible && next;
+ this.isPaletteVisible = next;
+ this.#applyPaletteVisibility(next, { focusSearch: shouldFocus });
+ this.persistenceManager.schedulePersist();
+ }
+
+ /**
+ * Applies palette visibility-related DOM updates.
+ *
+ * @param {boolean} visible Whether the palette should be visible.
+ * @param {{ focusSearch?: boolean }} [options] Behaviour overrides.
+ */
+ #applyPaletteVisibility(visible, options = {}) {
+ const palette = this.paletteElement;
+ const button = this.paletteToggleButton;
+ const appBody = this.appBodyElement;
+ const { focusSearch = false } = options;
+
+ if (appBody) {
+ appBody.classList.toggle("app-body--palette-hidden", !visible);
+ }
+
+ if (palette) {
+ palette.setAttribute("aria-hidden", visible ? "false" : "true");
+ }
+
+ if (button) {
+ button.classList.toggle("is-active", visible);
+ button.setAttribute("aria-pressed", visible ? "true" : "false");
+ const label = visible ? "Hide palette" : "Show palette";
+ button.setAttribute("aria-label", label);
+ button.title = label;
+ }
+
+ if (focusSearch && this.paletteSearch) {
+ window.requestAnimationFrame(() => {
+ if (this.isPaletteVisible && this.paletteSearch) {
+ this.paletteSearch.focus();
+ this.paletteSearch.select();
+ }
+ });
+ }
+ }
+
+ /**
+ * Focuses the workspace surface when appropriate.
+ *
+ * @param {{ force?: boolean }} [options] Behaviour overrides.
+ */
+ #focusWorkspaceSurface(options = {}) {
+ const element = this.workspaceElement;
+ if (!(element instanceof HTMLElement) || typeof element.focus !== "function") {
+ return;
+ }
+
+ const { force = false } = options;
+ const activeElement = /** @type {Element | null} */ (document.activeElement);
+ if (!force && activeElement && activeElement !== document.body) {
+ return;
+ }
+
+ try {
+ element.focus({ preventScroll: true });
+ } catch (_error) {
+ element.focus();
+ }
+ }
+
+ /**
+ * Selects a global variable and opens the inspector for editing.
+ *
+ * @param {string} variableId Variable identifier.
+ */
+ #selectVariable(variableId) {
+ this.#cancelVariableRename({ commit: true });
+ this.#setInspectorView("default");
+ if (!this.variables.has(variableId)) {
+ return;
+ }
+
+ if (this.selectedVariableId === variableId && !this.selectedNodeIds.size) {
+ this.#renderInspectorState();
+ return;
+ }
+
+ this.selectedVariableId = variableId;
+ this.#setSelection([], undefined);
+ this.#highlightVariableSelection();
+ this.#renderInspectorState();
+ }
+
+ /**
+ * Synchronizes inspector content and overview highlighting for variable selection.
+ */
+ #renderInspectorState() {
+ if (this.inspectorView === "project") {
+ this.selectedVariableId = null;
+ this.inspectorContent.innerHTML = "";
+ this.#highlightVariableSelection();
+ this.#renderProjectSettingsInspector();
+ return;
+ }
+
+ if (this.selectedNodeIds.size) {
+ if (this.selectedVariableId) {
+ this.selectedVariableId = null;
+ this.#highlightVariableSelection();
+ }
+ return;
+ }
+
+ if (!this.selectedVariableId) {
+ this.inspectorContent.innerHTML = "";
+ const empty = document.createElement("p");
+ empty.className = "inspector-empty";
+ empty.textContent = "Select a node or variable to edit.";
+ this.inspectorContent.appendChild(empty);
+ this.#highlightVariableSelection();
+ return;
+ }
+
+ const variable = this.variables.get(this.selectedVariableId);
+ if (!variable) {
+ this.selectedVariableId = null;
+ this.#highlightVariableSelection();
+ this.inspectorContent.innerHTML = "";
+ const empty = document.createElement("p");
+ empty.className = "inspector-empty";
+ empty.textContent = "Select a node or variable to edit.";
+ this.inspectorContent.appendChild(empty);
+ return;
+ }
+
+ this.#highlightVariableSelection();
+ this.#renderVariableInspector(variable);
+ }
+
+ /**
+ * Adds viewport-aware positioning and lifecycle management to an inspector tooltip element.
+ *
+ * @param {HTMLElement} tooltipElement Tooltip root element containing trigger and content.
+ */
+ #enhanceInspectorTooltip(tooltipElement) {
+ const trigger = tooltipElement.querySelector(".inspector-tooltip__trigger");
+ const content = tooltipElement.querySelector(".inspector-tooltip__content");
+
+ if (
+ !(trigger instanceof HTMLElement) ||
+ !(content instanceof HTMLElement)
+ ) {
+ return;
+ }
+
+ const margin = 12;
+ const offset = 12;
+ const arrowPadding = 14;
+ let rafId = null;
+
+ const clearRaf = () => {
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ };
+
+ const schedulePosition = () => {
+ if (rafId !== null) {
+ return;
+ }
+ rafId = window.requestAnimationFrame(position);
+ };
+
+ const handleScroll = () => {
+ if (!tooltipElement.classList.contains("is-active")) {
+ close();
+ return;
+ }
+ schedulePosition();
+ };
+
+ const handleResize = () => {
+ if (!tooltipElement.classList.contains("is-active")) {
+ return;
+ }
+ schedulePosition();
+ };
+
+ const handleDocumentPointerDown = (event) => {
+ const target = event.target;
+ if (target instanceof Node && tooltipElement.contains(target)) {
+ return;
+ }
+ close();
+ };
+
+ const close = () => {
+ if (!tooltipElement.classList.contains("is-active")) {
+ clearRaf();
+ trigger.setAttribute("aria-expanded", "false");
+ return;
+ }
+ tooltipElement.classList.remove("is-active");
+ tooltipElement.dataset.placement = "bottom";
+ trigger.setAttribute("aria-expanded", "false");
+ clearRaf();
+ window.removeEventListener("resize", handleResize);
+ window.removeEventListener("scroll", handleScroll, true);
+ document.removeEventListener(
+ "pointerdown",
+ handleDocumentPointerDown,
+ true
+ );
+ };
+
+ const position = () => {
+ rafId = null;
+ if (
+ !tooltipElement.isConnected ||
+ !trigger.isConnected ||
+ !content.isConnected
+ ) {
+ close();
+ return;
+ }
+
+ const previousTransition = content.style.transition;
+ const previousTransform = content.style.transform;
+ content.style.transition = "none";
+ content.style.transform = "translate3d(0, 0, 0)";
+ const contentRect = content.getBoundingClientRect();
+ content.style.transition = previousTransition;
+ content.style.transform = previousTransform;
+
+ const triggerRect = trigger.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ const availableTop = triggerRect.top - margin;
+ const availableBottom = viewportHeight - triggerRect.bottom - margin;
+ let placement = "bottom";
+
+ if (
+ availableBottom < contentRect.height + offset &&
+ availableTop > availableBottom
+ ) {
+ placement = "top";
+ }
+
+ const minLeft = margin;
+ let maxLeft = viewportWidth - margin - contentRect.width;
+ if (maxLeft < minLeft) {
+ maxLeft = minLeft;
+ }
+
+ let left =
+ triggerRect.left + triggerRect.width / 2 - contentRect.width / 2;
+ left = Math.min(Math.max(left, minLeft), maxLeft);
+
+ const minTop = margin;
+ let maxTop = viewportHeight - margin - contentRect.height;
+ if (maxTop < minTop) {
+ maxTop = minTop;
+ }
+
+ let top =
+ placement === "top"
+ ? triggerRect.top - offset - contentRect.height
+ : triggerRect.bottom + offset;
+
+ top = Math.min(Math.max(top, minTop), maxTop);
+
+ const triggerCenter = triggerRect.left + triggerRect.width / 2;
+ let arrowOffset = triggerCenter - left;
+ const arrowMin = arrowPadding;
+ const arrowMax = Math.max(arrowPadding, contentRect.width - arrowPadding);
+ arrowOffset = Math.min(Math.max(arrowOffset, arrowMin), arrowMax);
+
+ content.style.setProperty("--tooltip-left", `${Math.round(left)}px`);
+ content.style.setProperty("--tooltip-top", `${Math.round(top)}px`);
+ content.style.setProperty(
+ "--tooltip-arrow-offset",
+ `${Math.round(arrowOffset)}px`
+ );
+ tooltipElement.dataset.placement = placement;
+ };
+
+ const open = () => {
+ if (tooltipElement.classList.contains("is-active")) {
+ schedulePosition();
+ return;
+ }
+ tooltipElement.classList.add("is-active");
+ trigger.setAttribute("aria-expanded", "true");
+ schedulePosition();
+ window.addEventListener("resize", handleResize);
+ window.addEventListener("scroll", handleScroll, true);
+ document.addEventListener("pointerdown", handleDocumentPointerDown, true);
+ };
+
+ const handlePointerLeave = () => {
+ close();
+ };
+
+ const handleFocusOut = (event) => {
+ const focusEvent = /** @type {FocusEvent} */ (event);
+ const next = focusEvent.relatedTarget;
+ if (!(next instanceof Node) || !tooltipElement.contains(next)) {
+ close();
+ }
+ };
+
+ const handleKeyDown = (event) => {
+ if (!(event instanceof KeyboardEvent)) {
+ return;
+ }
+ if (event.key === "Escape") {
+ close();
+ if (typeof trigger.focus === "function") {
+ trigger.focus();
+ }
+ return;
+ }
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ if (tooltipElement.classList.contains("is-active")) {
+ close();
+ } else {
+ open();
+ }
+ }
+ };
+
+ trigger.setAttribute("aria-haspopup", "true");
+ trigger.setAttribute("aria-expanded", "false");
+ trigger.addEventListener("focus", open);
+ tooltipElement.addEventListener("pointerenter", open);
+ tooltipElement.addEventListener("pointerleave", handlePointerLeave);
+ tooltipElement.addEventListener("focusout", handleFocusOut);
+ tooltipElement.addEventListener("keydown", handleKeyDown);
+ }
+
+ /**
+ * Renders the project settings inspector view.
+ */
+ #renderProjectSettingsInspector() {
+ const heading = document.createElement("h3");
+ heading.className = "inspector-section-title";
+ heading.textContent = "Project Settings";
+ this.inspectorContent.appendChild(heading);
+
+ const fpsField = document.createElement("div");
+ fpsField.className = "inspector-field inspector-field--checkbox";
+
+ const fpsLabel = document.createElement("label");
+ const fieldId = "projectSetting_fps60";
+ fpsLabel.setAttribute("for", fieldId);
+
+ const tooltip = document.createElement("span");
+ tooltip.className = "inspector-tooltip";
+
+ const labelText = document.createElement("span");
+ labelText.className = "inspector-tooltip__trigger";
+ labelText.textContent = "Run at 60 FPS (_UPDATE60)";
+ labelText.setAttribute("tabindex", "0");
+ labelText.setAttribute("role", "button");
+ tooltip.appendChild(labelText);
+
+ const tooltipContent = document.createElement("div");
+ tooltipContent.className = "inspector-tooltip__content";
+ tooltipContent.setAttribute("role", "tooltip");
+ const tooltipId = `${fieldId}_tooltip`;
+ tooltipContent.id = tooltipId;
+ labelText.setAttribute("aria-describedby", tooltipId);
+
+ const tooltipIntro = document.createElement("p");
+ tooltipIntro.append("When ");
+ const tooltipUpdate60 = document.createElement("code");
+ tooltipUpdate60.textContent = "_UPDATE60()";
+ tooltipIntro.append(tooltipUpdate60, " is defined instead of ");
+ const tooltipUpdate = document.createElement("code");
+ tooltipUpdate.textContent = "_UPDATE()";
+ tooltipIntro.append(tooltipUpdate, ", PICO-8 will run in 60fps mode:");
+ tooltipContent.appendChild(tooltipIntro);
+
+ const tooltipList = document.createElement("ul");
+ tooltipList.className = "inspector-tooltip__list";
+ const listItemA = document.createElement("li");
+ listItemA.append("both ");
+ const listCodeA = document.createElement("code");
+ listCodeA.textContent = "_UPDATE60()";
+ listItemA.append(listCodeA, " and ");
+ const listCodeB = document.createElement("code");
+ listCodeB.textContent = "_DRAW()";
+ listItemA.append(listCodeB, " are called at 60fps");
+ tooltipList.appendChild(listItemA);
+ const listItemB = document.createElement("li");
+ listItemB.textContent =
+ "half the PICO-8 CPU is available per frame before dropping down to 30fps";
+ tooltipList.appendChild(listItemB);
+ tooltipContent.appendChild(tooltipList);
+
+ const tooltipNote = document.createElement("p");
+ tooltipNote.className = "inspector-tooltip__note";
+ tooltipNote.append(
+ "Note that not all host machines are capable of running at 60fps. Older machines, and / or web versions might also request PICO-8 to run at 30 fps (or 15 fps), even when the PICO-8 CPU is not over capacity. In this case, multiple "
+ );
+ const noteCode = document.createElement("code");
+ noteCode.textContent = "_UPDATE60";
+ tooltipNote.append(noteCode, " calls are made for every ");
+ const noteDrawCode = document.createElement("code");
+ noteDrawCode.textContent = "_DRAW";
+ tooltipNote.append(noteDrawCode, " call in the same way.");
+ tooltipContent.appendChild(tooltipNote);
+
+ tooltip.appendChild(tooltipContent);
+ this.#enhanceInspectorTooltip(tooltip);
+ fpsLabel.appendChild(tooltip);
+ fpsField.appendChild(fpsLabel);
+
+ const fpsToggle = document.createElement("input");
+ fpsToggle.type = "checkbox";
+ fpsToggle.id = fieldId;
+ fpsToggle.checked = Boolean(this.projectSettings.use60Fps);
+ fpsToggle.addEventListener("change", () => {
+ this.projectSettings.use60Fps = fpsToggle.checked;
+ this.#handleProjectSettingsChanged();
+ });
+ fpsField.appendChild(fpsToggle);
+
+ this.inspectorContent.appendChild(fpsField);
+ }
+
+ /**
+ * Updates the active inspector view flag.
+ *
+ * @param {InspectorView} view Target inspector view.
+ */
+ #setInspectorView(view) {
+ if (this.inspectorView === view) {
+ return;
+ }
+ this.inspectorView = view;
+ this.#updateProjectSettingsToggle();
+ }
+
+ /**
+ * Synchronizes the project settings toggle button appearance/state.
+ */
+ #updateProjectSettingsToggle() {
+ if (!this.projectSettingsButton) {
+ return;
+ }
+ const isActive = this.inspectorView === "project";
+ this.projectSettingsButton.classList.toggle("is-active", isActive);
+ this.projectSettingsButton.setAttribute(
+ "aria-pressed",
+ isActive ? "true" : "false"
+ );
+ }
+
+ /**
+ * Handles updates to project-level configuration values.
+ */
+ #handleProjectSettingsChanged() {
+ if (typeof this.generator.setProjectSettings === "function") {
+ this.generator.setProjectSettings({ ...this.projectSettings });
+ }
+ this.#refreshLuaOutput();
+ this.persistenceManager.schedulePersist();
+ }
+
+ /**
+ * Updates the stored default value for a variable and refreshes the inspector.
+ *
+ * @param {string} variableId Variable identifier.
+ * @param {unknown} nextValue Candidate default value.
+ */
+ #setVariableDefault(variableId, nextValue) {
+ const variable = this.variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+
+ const normalized = this.#normalizeVariableDefault(
+ variable.type,
+ nextValue
+ );
+ if (this.#areVariableDefaultsEqual(variable.defaultValue, normalized)) {
+ return;
+ }
+
+ variable.defaultValue = normalized;
+ this.selectedVariableId = variableId;
+ this.#renderInspectorState();
+ this.#refreshLuaOutput();
+ this.persistenceManager.schedulePersist();
+ }
+
+ /**
+ * Provides the initial default value for the supplied variable type.
+ *
+ * @param {VariableType} type Variable type descriptor.
+ * @returns {VariableDefaultValue}
+ */
+ #defaultVariableValue(type) {
+ switch (type) {
+ case "number":
+ return 0;
+ case "string":
+ return "";
+ case "table":
+ return [];
+ case "boolean":
+ return false;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Normalizes an arbitrary value so it is valid for the requested variable type.
+ *
+ * @param {VariableType} type Variable type descriptor.
+ * @param {unknown} value Candidate default value.
+ * @returns {VariableDefaultValue}
+ */
+ #normalizeVariableDefault(type, value) {
+ const numericPattern =
+ /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/;
+
+ switch (type) {
+ case "number": {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "boolean") {
+ return value ? 1 : 0;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return 0;
+ }
+ if (numericPattern.test(trimmed)) {
+ return Number.parseFloat(trimmed);
+ }
+ }
+ return 0;
+ }
+ case "boolean": {
+ if (typeof value === "boolean") {
+ return value;
+ }
+ if (typeof value === "number") {
+ return value !== 0;
+ }
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ if (!normalized) {
+ return false;
+ }
+ if (["true", "1", "yes", "y"].includes(normalized)) {
+ return true;
+ }
+ if (["false", "0", "no", "n"].includes(normalized)) {
+ return false;
+ }
+ }
+ return Boolean(value);
+ }
+ case "string": {
+ if (value == null) {
+ return "";
+ }
+ if (Array.isArray(value)) {
+ return value
+ .map((entry) =>
+ typeof entry?.value === "string" ? entry.value : ""
+ )
+ .filter((entry) => entry.length)
+ .join(", ");
+ }
+ return String(value);
+ }
+ case "table": {
+ const entries = [];
+ if (Array.isArray(value)) {
+ value.forEach((entry) => {
+ const normalized = this.#normalizeTableDefaultEntry(entry);
+ if (normalized) {
+ entries.push(normalized);
+ }
+ });
+ } else if (
+ value &&
+ typeof value === "object" &&
+ Array.isArray(/** @type {{ entries?: Array }} */ (value).entries)
+ ) {
+ const payload = /** @type {{ entries: Array }} */ (value);
+ payload.entries.forEach((entry) => {
+ const normalized = this.#normalizeTableDefaultEntry(entry);
+ if (normalized) {
+ entries.push(normalized);
+ }
+ });
+ } else if (value != null && value !== "") {
+ const normalized = this.#normalizeTableDefaultEntry({ value });
+ if (normalized) {
+ entries.push(normalized);
+ }
+ }
+ return entries;
+ }
+ default: {
+ if (value == null) {
+ return null;
+ }
+ if (Array.isArray(value)) {
+ return value.length ? value[0]?.value ?? null : null;
+ }
+ if (typeof value === "boolean" || typeof value === "number") {
+ return value;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+ const lowered = trimmed.toLowerCase();
+ if (lowered === "true") {
+ return true;
+ }
+ if (lowered === "false") {
+ return false;
+ }
+ if (numericPattern.test(trimmed)) {
+ return Number.parseFloat(trimmed);
+ }
+ return trimmed;
+ }
+ return String(value);
+ }
+ }
+ }
+
+ /**
+ * Normalizes a raw table default entry descriptor.
+ *
+ * @param {unknown} entry Candidate entry value.
+ * @returns {TableDefaultEntry | null}
+ */
+ #normalizeTableDefaultEntry(entry) {
+ if (entry == null) {
+ return null;
+ }
+
+ if (typeof entry === "string") {
+ return {
+ id: this.#allocateTableDefaultEntryId(),
+ key: "",
+ value: entry.trim(),
+ };
+ }
+
+ if (typeof entry === "number" || typeof entry === "boolean") {
+ return {
+ id: this.#allocateTableDefaultEntryId(),
+ key: "",
+ value: String(entry),
+ };
+ }
+
+ if (typeof entry === "object" && !Array.isArray(entry)) {
+ const record = /** @type {{ id?: unknown, key?: unknown, value?: unknown }} */ (
+ entry
+ );
+ const id =
+ typeof record.id === "string" && record.id.trim().length
+ ? record.id.trim()
+ : this.#allocateTableDefaultEntryId();
+ const key =
+ typeof record.key === "string" ? record.key.trim() : "";
+ let rawValue = record.value;
+ if (rawValue == null && typeof record.entry === "string") {
+ rawValue = record.entry;
+ }
+ const value =
+ rawValue == null ? "" : String(rawValue).trim();
+ return { id, key, value };
+ }
+
+ return null;
+ }
+
+ /**
+ * Allocates a unique identifier for a table default entry.
+ *
+ * @returns {string}
+ */
+ #allocateTableDefaultEntryId() {
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function"
+ ) {
+ return `tbl_${crypto.randomUUID()}`;
+ }
+ return `tbl_${Math.random().toString(36).slice(2, 10)}`;
+ }
+
+ /**
+ * Produces a string representation of a variable default for inspector inputs.
+ *
+ * @param {WorkspaceVariable} variable Variable descriptor.
+ * @returns {string}
+ */
+ #formatVariableDefaultForDisplay(variable) {
+ if (variable.type === "number") {
+ return typeof variable.defaultValue === "number"
+ ? String(variable.defaultValue)
+ : "0";
+ }
+
+ if (variable.defaultValue == null) {
+ return "";
+ }
+
+ if (Array.isArray(variable.defaultValue)) {
+ return variable.defaultValue
+ .map((entry) => (typeof entry?.value === "string" ? entry.value : ""))
+ .filter((text) => text.length)
+ .join(", ");
+ }
+
+ return String(variable.defaultValue);
+ }
+
+ /**
+ * Determines whether two variable defaults are equivalent.
+ *
+ * @param {VariableDefaultValue} a Existing value.
+ * @param {VariableDefaultValue} b Candidate value.
+ * @returns {boolean}
+ */
+ #areVariableDefaultsEqual(a, b) {
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let index = 0; index < a.length; index += 1) {
+ const left = a[index];
+ const right = b[index];
+ if (!right) {
+ return false;
+ }
+ if (left.id !== right.id) {
+ return false;
+ }
+ if (left.key !== right.key) {
+ return false;
+ }
+ if (left.value !== right.value) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return Object.is(a, b);
+ }
+
+ /**
+ * Collects variable metadata for Lua generation.
+ *
+ * @returns {Array<{ id: string, name: string, type: VariableType, defaultValue: VariableDefaultValue }>}
+ */
+ #getGeneratorVariableMetadata() {
+ return this.variableOrder
+ .map((variableId) => this.variables.get(variableId))
+ .filter((entry) => Boolean(entry))
+ .map((entry) => ({
+ id: entry.id,
+ name: entry.name,
+ type: entry.type,
+ defaultValue: entry.defaultValue,
+ }));
+ }
+
+ /**
+ * Highlights the active variable in the overview sidebar.
+ */
+ #highlightVariableSelection() {
+ if (!this.variableListElement) {
+ return;
+ }
+
+ const rows = this.variableListElement.querySelectorAll(".overview-item");
+ rows.forEach((element) => {
+ if (!(element instanceof HTMLElement)) {
+ return;
+ }
+ const variableId = element.dataset.variableId ?? "";
+ const isSelected = Boolean(
+ this.selectedVariableId && variableId === this.selectedVariableId
+ );
+ element.classList.toggle("is-selected", isSelected);
+ if (isSelected) {
+ element.setAttribute("aria-current", "true");
+ element.setAttribute("aria-pressed", "true");
+ } else {
+ element.removeAttribute("aria-current");
+ element.setAttribute("aria-pressed", "false");
+ }
+ });
+ }
+
+ /**
+ * Renders inspector controls for the supplied variable entry.
+ *
+ * @param {WorkspaceVariable} variable Variable to display in the inspector.
+ */
+ #renderVariableInspector(variable) {
+ this.inspectorContent.innerHTML = "";
+
+ const nameField = document.createElement("div");
+ nameField.className = "inspector-field";
+ const nameLabel = document.createElement("label");
+ const nameId = `${variable.id}_name`;
+ nameLabel.setAttribute("for", nameId);
+ nameLabel.textContent = "Variable Name";
+ nameField.appendChild(nameLabel);
+
+ const nameInput = document.createElement("input");
+ nameInput.type = "text";
+ nameInput.id = nameId;
+ nameInput.value = variable.name;
+ nameInput.placeholder = "Variable";
+ nameInput.addEventListener("change", () => {
+ this.#setVariableName(variable.id, nameInput.value);
+ });
+ nameField.appendChild(nameInput);
+ this.inspectorContent.appendChild(nameField);
+
+ const typeField = document.createElement("div");
+ typeField.className = "inspector-field";
+ const typeLabel = document.createElement("label");
+ const typeId = `${variable.id}_type`;
+ typeLabel.setAttribute("for", typeId);
+ typeLabel.textContent = "Variable Type";
+ typeField.appendChild(typeLabel);
+
+ const typeSelect = document.createElement("select");
+ typeSelect.id = typeId;
+ this.#getVariableTypeOptions().forEach((option) => {
+ const opt = document.createElement("option");
+ opt.value = option.value;
+ opt.textContent = option.label;
+ typeSelect.appendChild(opt);
+ });
+ typeSelect.value = variable.type;
+ typeSelect.addEventListener("change", () => {
+ const next = this.#normalizeVariableType(typeSelect.value);
+ this.#setVariableType(variable.id, next);
+ typeSelect.value = next;
+ });
+ typeField.appendChild(typeSelect);
+ this.inspectorContent.appendChild(typeField);
+
+ const defaultField = document.createElement("div");
+ defaultField.className = "inspector-field";
+ const defaultLabel = document.createElement("label");
+ const defaultId = `${variable.id}_default`;
+ defaultLabel.setAttribute("for", defaultId);
+ defaultLabel.textContent = "Default Value";
+ defaultField.appendChild(defaultLabel);
+
+ if (variable.type === "boolean") {
+ const defaultToggle = document.createElement("input");
+ defaultToggle.type = "checkbox";
+ defaultToggle.id = defaultId;
+ defaultToggle.checked = Boolean(variable.defaultValue);
+ defaultToggle.addEventListener("change", () => {
+ this.#setVariableDefault(variable.id, defaultToggle.checked);
+ });
+ defaultField.appendChild(defaultToggle);
+ } else if (variable.type === "number") {
+ const defaultInput = document.createElement("input");
+ defaultInput.type = "number";
+ defaultInput.id = defaultId;
+ defaultInput.value = this.#formatVariableDefaultForDisplay(variable);
+ defaultInput.addEventListener("change", () => {
+ this.#setVariableDefault(variable.id, defaultInput.value);
+ });
+ defaultField.appendChild(defaultInput);
+ } else if (variable.type === "table") {
+ const editor = this.#buildTableDefaultEditor(variable);
+ defaultField.appendChild(editor);
+ } else {
+ const defaultInput = document.createElement("input");
+ defaultInput.type = "text";
+ defaultInput.id = defaultId;
+ defaultInput.value = this.#formatVariableDefaultForDisplay(variable);
+ defaultInput.placeholder = "No default";
+ defaultInput.addEventListener("change", () => {
+ this.#setVariableDefault(variable.id, defaultInput.value);
+ });
+ defaultField.appendChild(defaultInput);
+ }
+
+ this.inspectorContent.appendChild(defaultField);
+ }
+
+ /**
+ * Builds the editable UI for table variable defaults.
+ *
+ * @param {WorkspaceVariable} variable Variable descriptor.
+ * @returns {HTMLElement}
+ */
+ #buildTableDefaultEditor(variable) {
+ const editor = document.createElement("div");
+ editor.className = "table-default-editor";
+
+ const toolbar = document.createElement("div");
+ toolbar.className = "table-default-toolbar";
+
+ const addButton = document.createElement("button");
+ addButton.type = "button";
+ addButton.className = "table-default-add";
+ addButton.textContent = "+";
+ addButton.setAttribute("aria-label", "Add entry");
+ addButton.title = "Add entry";
+ addButton.addEventListener("click", () => {
+ this.#addTableDefaultEntry(variable.id);
+ });
+ toolbar.appendChild(addButton);
+
+ editor.appendChild(toolbar);
+
+ const list = document.createElement("div");
+ list.className = "table-default-list";
+
+ const entries = Array.isArray(variable.defaultValue)
+ ? variable.defaultValue
+ : [];
+
+ if (!entries.length) {
+ const empty = document.createElement("p");
+ empty.className = "table-default-empty";
+ empty.textContent = "No entries yet";
+ list.appendChild(empty);
+ }
+
+ entries.forEach((entry) => {
+ const row = document.createElement("div");
+ row.className = "table-default-row";
+ row.dataset.entryId = entry.id;
+
+ const keyInput = document.createElement("input");
+ keyInput.type = "text";
+ keyInput.placeholder = "Key (optional)";
+ keyInput.value = entry.key;
+ keyInput.dataset.field = "key";
+ keyInput.addEventListener("change", () => {
+ this.#updateTableDefaultEntry(variable.id, entry.id, {
+ key: keyInput.value,
+ });
+ });
+ row.appendChild(keyInput);
+
+ const valueInput = document.createElement("input");
+ valueInput.type = "text";
+ valueInput.placeholder = "Value";
+ valueInput.value = entry.value;
+ valueInput.dataset.field = "value";
+ valueInput.addEventListener("change", () => {
+ this.#updateTableDefaultEntry(variable.id, entry.id, {
+ value: valueInput.value,
+ });
+ });
+ row.appendChild(valueInput);
+
+ const removeButton = document.createElement("button");
+ removeButton.type = "button";
+ removeButton.className =
+ "overview-item-action overview-item-action--remove";
+ removeButton.title = `Delete ${entry.key || entry.value || "entry"}`;
+ removeButton.setAttribute(
+ "aria-label",
+ `Delete ${entry.key || entry.value || "entry"}`
+ );
+ removeButton.addEventListener("click", () => {
+ this.#removeTableDefaultEntry(variable.id, entry.id);
+ });
+ removeButton.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+ row.appendChild(removeButton);
+
+ list.appendChild(row);
+ });
+
+ editor.appendChild(list);
+
+ const focusRequest = this.pendingVariableInspectorFocus;
+ this.pendingVariableInspectorFocus = null;
+ if (focusRequest && focusRequest.variableId === variable.id) {
+ requestAnimationFrame(() => {
+ const selector = `.table-default-row[data-entry-id="${focusRequest.entryId}"] input[data-field="${focusRequest.target}"]`;
+ const target = editor.querySelector(selector);
+ if (target instanceof HTMLInputElement) {
+ target.focus();
+ if (typeof target.select === "function") {
+ target.select();
+ }
+ }
+ });
+ }
+
+ return editor;
+ }
+
+ /**
+ * Appends a new table default entry for the supplied variable.
+ *
+ * @param {string} variableId Target variable identifier.
+ */
+ #addTableDefaultEntry(variableId) {
+ const variable = this.variables.get(variableId);
+ if (!variable || variable.type !== "table") {
+ return;
+ }
+
+ const existing = Array.isArray(variable.defaultValue)
+ ? variable.defaultValue.slice()
+ : [];
+ const entry = {
+ id: this.#allocateTableDefaultEntryId(),
+ key: "",
+ value: "",
+ };
+ existing.push(entry);
+
+ this.pendingVariableInspectorFocus = {
+ variableId,
+ entryId: entry.id,
+ target: "value",
+ };
+
+ this.#setVariableDefault(variableId, existing);
+ }
+
+ /**
+ * Updates a table default entry for the supplied variable.
+ *
+ * @param {string} variableId Target variable identifier.
+ * @param {string} entryId Entry identifier to update.
+ * @param {{ key?: string, value?: string }} updates Field overrides.
+ */
+ #updateTableDefaultEntry(variableId, entryId, updates) {
+ const variable = this.variables.get(variableId);
+ if (!variable || variable.type !== "table") {
+ return;
+ }
+
+ const current = Array.isArray(variable.defaultValue)
+ ? variable.defaultValue
+ : [];
+ const next = current.map((entry) => {
+ if (entry.id !== entryId) {
+ return entry;
+ }
+ const nextKey =
+ updates.key !== undefined ? String(updates.key) : entry.key;
+ const nextValue =
+ updates.value !== undefined ? String(updates.value) : entry.value;
+ return { ...entry, key: nextKey, value: nextValue };
+ });
+
+ this.#setVariableDefault(variableId, next);
+ }
+
+ /**
+ * Removes a table default entry from the supplied variable.
+ *
+ * @param {string} variableId Target variable identifier.
+ * @param {string} entryId Entry identifier slated for removal.
+ */
+ #removeTableDefaultEntry(variableId, entryId) {
+ const variable = this.variables.get(variableId);
+ if (!variable || variable.type !== "table") {
+ return;
+ }
+
+ const current = Array.isArray(variable.defaultValue)
+ ? variable.defaultValue
+ : [];
+ const next = current.filter((entry) => entry.id !== entryId);
+ if (next.length === current.length) {
+ return;
+ }
+
+ this.pendingVariableInspectorFocus = null;
+ this.#setVariableDefault(variableId, next);
+ }
+
+ /**
+ * Updates the stored name for a variable and refreshes the overview.
+ *
+ * @param {string} variableId Variable identifier.
+ * @param {string} nextName Requested variable name.
+ */
+ #setVariableName(variableId, nextName) {
+ const variable = this.variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+
+ const previousName = variable.name;
+ const trimmed = nextName.trim();
+ if (variable.name === trimmed) {
+ return;
+ }
+
+ variable.name = trimmed;
+ this.selectedVariableId = variableId;
+
+ const previousNormalized = previousName.trim();
+ this.graph.nodes.forEach((node) => {
+ if (node.type !== "set_var" && node.type !== "get_var") {
+ return;
+ }
+
+ const nodeVariableId =
+ typeof node.properties.variableId === "string"
+ ? node.properties.variableId
+ : "";
+ if (nodeVariableId === variableId) {
+ this.graph.setNodeProperty(node.id, "name", trimmed);
+ return;
+ }
+
+ const nodeName =
+ typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "";
+ if (
+ !nodeVariableId &&
+ previousNormalized &&
+ nodeName === previousNormalized
+ ) {
+ this.graph.setNodeProperty(node.id, "name", trimmed);
+ this.graph.setNodeProperty(node.id, "variableId", variableId);
+ }
+ });
+
+ this.#renderOverview();
+ this.#highlightVariableSelection();
+ this.#renderInspectorState();
+ this.#refreshLuaOutput();
+ this.persistenceManager.schedulePersist();
+ }
+
+ /**
+ * Updates the stored type for a variable and refreshes dependent UI.
+ *
+ * @param {string} variableId Variable identifier.
+ * @param {VariableType} nextType Next variable type.
+ */
+ #setVariableType(variableId, nextType) {
+ const variable = this.variables.get(variableId);
+ const normalizedType = this.#normalizeVariableType(nextType);
+ if (!variable || variable.type === normalizedType) {
+ return;
+ }
+
+ variable.type = normalizedType;
+ variable.defaultValue = this.#normalizeVariableDefault(
+ normalizedType,
+ variable.defaultValue
+ );
+ this.selectedVariableId = variableId;
+ this.#renderOverview();
+ this.#highlightVariableSelection();
+ this.#renderInspectorState();
+
+ const normalizedName = this.#normalizeVariableName(variable.name);
+ let connectionsMutated = false;
+ this.graph.nodes.forEach((node) => {
+ if (!this.#nodeReferencesVariable(node, variableId, normalizedName)) {
+ return;
+ }
+ const mutated = this.#refreshVariableNode(node.id);
+ connectionsMutated = connectionsMutated || mutated;
+ });
+ if (connectionsMutated) {
+ this.#renderConnections();
+ }
+ this.#refreshLuaOutput();
+ this.persistenceManager.schedulePersist();
+ }
+
+ /**
+ * Removes a global variable from the workspace and clears dependent references.
+ *
+ * @param {string} variableId Variable identifier slated for removal.
+ */
+ #deleteVariable(variableId) {
+ const variable = this.variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+
+ this.#closeTypeDropdown();
+ this.#cancelVariableRename({ commit: false });
+
+ this.variables.delete(variableId);
+ const orderIndex = this.variableOrder.indexOf(variableId);
+ if (orderIndex !== -1) {
+ this.variableOrder.splice(orderIndex, 1);
+ }
+
+ const fallbackName = variable.name || variable.id;
+ const normalizedName = this.#normalizeVariableName(variable.name);
+ this.graph.nodes.forEach((node) => {
+ if (node.type !== "set_var" && node.type !== "get_var") {
+ return;
+ }
+ const nodeVariableId =
+ typeof node.properties.variableId === "string"
+ ? node.properties.variableId
+ : "";
+ if (nodeVariableId === variableId) {
+ this.graph.setNodeProperty(node.id, "variableId", "");
+ this.graph.setNodeProperty(node.id, "name", fallbackName);
+ return;
+ }
+
+ if (!nodeVariableId && normalizedName) {
+ const nodeName =
+ typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "";
+ if (nodeName && nodeName === normalizedName) {
+ this.graph.setNodeProperty(node.id, "name", nodeName);
+ }
+ }
+ });
+
+ if (this.selectedVariableId === variableId) {
+ this.selectedVariableId = null;
+ }
+
+ this.#renderOverview();
+ this.#renderInspectorState();
+ this.#refreshLuaOutput();
+ this.persistenceManager.schedulePersist();
+ }
+
+ /**
+ * Reorders variables within the overview panel based on drag gestures.
+ *
+ * @param {string} variableId Variable identifier being repositioned.
+ * @param {number} targetIndex Desired zero-based insertion index.
+ */
+ #reorderVariable(variableId, targetIndex) {
+ const currentIndex = this.variableOrder.indexOf(variableId);
+ if (currentIndex === -1) {
+ return;
+ }
+
+ if (this.variableOrder.length <= 1) {
+ return;
+ }
+
+ const normalizedIndex = Number.isFinite(targetIndex)
+ ? Math.max(0, Math.floor(targetIndex))
+ : 0;
+ if (normalizedIndex === currentIndex) {
+ return;
+ }
+
+ const [entry] = this.variableOrder.splice(currentIndex, 1);
+ const insertionIndex = Math.max(
+ 0,
+ Math.min(normalizedIndex, this.variableOrder.length)
+ );
+ this.variableOrder.splice(insertionIndex, 0, entry);
+ this.#renderVariableList();
+ this.#highlightVariableSelection();
+ this.persistenceManager.schedulePersist();
+ }
+
+ /**
+ * Presents a dropdown menu for updating a variable type.
+ *
+ * @param {string} variableId Target variable identifier.
+ * @param {HTMLButtonElement} trigger Trigger button opening the menu.
+ * @param {HTMLElement} anchor Element used to anchor the dropdown position.
+ */
+ #openVariableTypeDropdown(variableId, trigger, anchor) {
+ const variable = this.variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+ this.#openTypeDropdown({
+ context: { kind: "variable", variableId },
+ trigger,
+ anchor,
+ currentType: variable.type,
+ onSelect: (nextType) => {
+ if (nextType !== variable.type) {
+ this.#setVariableType(variable.id, nextType);
+ }
+ },
+ });
+ }
+
+ /**
+ * Presents the shared type dropdown with the supplied configuration.
+ *
+ * @param {{
+ * context: TypeDropdownContext,
+ * trigger: HTMLButtonElement,
+ * anchor: HTMLElement,
+ * currentType: VariableType,
+ * onSelect: (type: VariableType) => void,
+ * options?: Array<{ value: VariableType, label: string }>,
+ * placement?: 'bottom' | 'top',
+ * }} options Dropdown configuration.
+ */
+ #openTypeDropdown({
+ context,
+ trigger,
+ anchor,
+ currentType,
+ onSelect,
+ options,
+ placement = "bottom",
+ }) {
+ this.#closeTypeDropdown();
+
+ const dropdown = document.createElement("div");
+ dropdown.className = "variable-type-dropdown";
+ dropdown.setAttribute("role", "menu");
+ dropdown.tabIndex = -1;
+
+ /** @type {Array} */
+ const buttons = [];
+
+ const entries =
+ Array.isArray(options) && options.length
+ ? options
+ : this.#getVariableTypeOptions();
+
+ entries.forEach((entry) => {
+ const option = document.createElement("button");
+ option.type = "button";
+ option.className = "variable-type-option";
+ option.dataset.variableType = entry.value;
+ option.textContent = entry.label;
+ option.setAttribute("role", "menuitemradio");
+ option.setAttribute(
+ "aria-checked",
+ entry.value === currentType ? "true" : "false"
+ );
+ option.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+ option.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (entry.value !== currentType) {
+ onSelect(entry.value);
+ }
+ this.#closeTypeDropdown();
+ });
+ option.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ const index = buttons.indexOf(option);
+ if (index === -1) {
+ return;
+ }
+ const delta = event.key === "ArrowDown" ? 1 : -1;
+ const nextIndex = (index + delta + buttons.length) % buttons.length;
+ const next = buttons[nextIndex];
+ next.focus();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ this.#closeTypeDropdown({ restoreFocus: true });
+ }
+ });
+ buttons.push(option);
+ dropdown.appendChild(option);
+ });
+
+ dropdown.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+
+ if (placement === "top") {
+ dropdown.classList.add("is-above");
+ }
+
+ anchor.appendChild(dropdown);
+ trigger.setAttribute("aria-expanded", "true");
+ this.typeDropdown = { container: dropdown, trigger, context, onSelect };
+
+ requestAnimationFrame(() => {
+ const target = dropdown.querySelector('button[aria-checked="true"]');
+ if (target instanceof HTMLButtonElement) {
+ target.focus();
+ } else if (buttons[0]) {
+ buttons[0].focus();
+ }
+ });
+ }
+
+ /**
+ * Presents the type dropdown for a custom event parameter row.
+ *
+ * @param {string} nodeId Custom event node identifier.
+ * @param {string} parameterId Parameter identifier.
+ * @param {HTMLButtonElement} trigger Trigger button opening the menu.
+ * @param {HTMLElement} anchor Element used to anchor the dropdown position.
+ * @param {(type: VariableType) => void} [afterSelect] Callback fired after a selection is applied.
+ */
+ #openCustomEventParameterTypeDropdown(
+ nodeId,
+ parameterId,
+ trigger,
+ anchor,
+ afterSelect
+ ) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "custom_event") {
+ return;
+ }
+ const { parameters } = this.#sanitizeCustomEventParameters(node);
+ const parameter = parameters.find((entry) => entry.id === parameterId);
+ if (!parameter) {
+ return;
+ }
+ this.#openTypeDropdown({
+ context: { kind: "custom_event_parameter", nodeId, parameterId },
+ trigger,
+ anchor,
+ currentType: parameter.type,
+ onSelect: (nextType) => {
+ this.#setCustomEventParameterType(nodeId, parameterId, nextType);
+ afterSelect?.(nextType);
+ },
+ });
+ }
+
+ /**
+ * Closes the active type dropdown if present.
+ *
+ * @param {{ restoreFocus?: boolean }} [options] Behaviour overrides.
+ */
+ #closeTypeDropdown(options = {}) {
+ const state = this.typeDropdown;
+ const container = state.container;
+ const trigger = state.trigger;
+ if (trigger) {
+ trigger.setAttribute("aria-expanded", "false");
+ }
+ if (container && container.isConnected) {
+ container.remove();
+ }
+ if (options.restoreFocus && trigger) {
+ trigger.focus();
+ }
+ this.typeDropdown = {
+ container: null,
+ trigger: null,
+ context: null,
+ onSelect: null,
+ };
+ }
+
+ /**
+ * Begins inline rename mode for a variable list entry.
+ *
+ * @param {string} variableId Target variable identifier.
+ * @param {HTMLElement} labelElement Label element that was clicked.
+ */
+ #startVariableRename(variableId, labelElement) {
+ this.#closeTypeDropdown();
+ const variable = this.variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+
+ const row = labelElement.closest(".overview-item--variable");
+ if (!(row instanceof HTMLElement)) {
+ return;
+ }
+
+ const parent = labelElement.parentElement;
+ if (!(parent instanceof HTMLElement)) {
+ return;
+ }
+
+ this.#cancelVariableRename({ commit: true });
+
+ const wasDraggable = Boolean(row.draggable);
+ row.draggable = false;
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.className = "overview-item-rename";
+ input.value = variable.name;
+ input.setAttribute(
+ "aria-label",
+ `Rename variable ${variable.name || variable.id}`
+ );
+ input.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+ input.addEventListener("click", (event) => {
+ event.stopPropagation();
+ });
+
+ const finish = (commit) => {
+ if (
+ !this.activeVariableRename ||
+ this.activeVariableRename.input !== input
+ ) {
+ return;
+ }
+ const value = input.value;
+ this.#cancelVariableRename({ commit, value });
+ };
+
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ finish(true);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ finish(false);
+ }
+ });
+ input.addEventListener("blur", () => {
+ finish(true);
+ });
+
+ labelElement.hidden = true;
+ parent.insertBefore(input, labelElement);
+
+ this.activeVariableRename = {
+ variableId,
+ input,
+ label: labelElement,
+ container: row,
+ wasDraggable,
+ };
+
+ requestAnimationFrame(() => {
+ input.focus();
+ input.select();
+ });
+ }
+
+ /**
+ * Cancels the active inline variable rename, optionally committing the value.
+ *
+ * @param {{ commit?: boolean, value?: string }} [options] Behaviour overrides.
+ */
+ #cancelVariableRename(options = {}) {
+ if (!this.activeVariableRename) {
+ return;
+ }
+
+ const { input, label, variableId, container, wasDraggable } =
+ this.activeVariableRename;
+ const value =
+ typeof options.value === "string" ? options.value : input.value;
+
+ if (input.isConnected) {
+ input.remove();
+ }
+ label.hidden = false;
+ if (container.isConnected) {
+ container.draggable = wasDraggable;
+ }
+
+ this.activeVariableRename = null;
+
+ if (options.commit) {
+ this.#setVariableName(variableId, value);
+ }
+ }
+
+ /**
+ * Applies the provided selection set and designates a primary node.
+ *
+ * @param {Array} nodeIds Collection of node identifiers to select.
+ * @param {string | undefined} primaryId Preferred primary selection identifier.
+ */
+ #setSelection(nodeIds, primaryId) {
+ this.#cancelVariableRename({ commit: true });
+ this.#setInspectorView("default");
+ this.contextMenuManager?.hide();
+ if (nodeIds.length) {
+ this.selectedVariableId = null;
+ }
+
+ this.selectedNodeIds.clear();
+ nodeIds.forEach((id) => {
+ if (this.graph.nodes.has(id)) {
+ this.selectedNodeIds.add(id);
+ }
+ });
+
+ if (primaryId && this.selectedNodeIds.has(primaryId)) {
+ this.selectedNodeId = primaryId;
+ } else {
+ const iterator = this.selectedNodeIds.values().next();
+ this.selectedNodeId = iterator.done ? undefined : iterator.value;
+ }
+
+ this.#applySelectionState();
+ }
+
+ /**
+ * Synchronizes DOM affordances and inspector content with the active selection.
+ */
+ #applySelectionState() {
+ this.nodeElements.forEach((element, id) => {
+ const isSelected = this.selectedNodeIds.has(id);
+ const isPrimary = isSelected && this.selectedNodeId === id;
+ element.classList.toggle("is-selected", isSelected);
+ element.classList.toggle("is-selection-primary", isPrimary);
+ });
+
+ this.#raiseSelectedNodes();
+
+ const selectionCount = this.selectedNodeIds.size;
+ this.duplicateNodeButton.disabled = selectionCount !== 1;
+ this.deleteNodeButton.disabled = selectionCount === 0;
+
+ if (
+ selectionCount === 1 &&
+ this.selectedNodeId &&
+ this.inspectorView === "default"
+ ) {
+ this.#renderInspector(this.selectedNodeId);
+ } else {
+ this.inspectorContent.innerHTML = "";
+ if (selectionCount > 1) {
+ const message = document.createElement("p");
+ message.className = "inspector-empty";
+ message.textContent = "Multiple nodes selected.";
+ this.inspectorContent.appendChild(message);
+ }
+ }
+
+ this.#renderInspectorState();
+ }
+
+ /**
+ * Raises selected nodes above unselected nodes to ensure they render on top.
+ *
+ * @returns {void}
+ */
+ #raiseSelectedNodes() {
+ if (!(this.nodeLayer instanceof HTMLElement)) {
+ return;
+ }
+
+ if (!this.selectedNodeIds.size) {
+ return;
+ }
+
+ const primaryId = this.selectedNodeId;
+
+ this.selectedNodeIds.forEach((id) => {
+ if (id === primaryId) {
+ return;
+ }
+ const element = this.nodeElements.get(id);
+ if (
+ element &&
+ element.parentElement === this.nodeLayer &&
+ element.isConnected
+ ) {
+ this.nodeLayer.appendChild(element);
+ }
+ });
+
+ if (primaryId && this.selectedNodeIds.has(primaryId)) {
+ const primaryElement = this.nodeElements.get(primaryId);
+ if (
+ primaryElement &&
+ primaryElement.parentElement === this.nodeLayer &&
+ primaryElement.isConnected
+ ) {
+ this.nodeLayer.appendChild(primaryElement);
+ }
+ }
+ }
+
+ /**
+ * Determines whether the supplied target represents empty workspace space.
+ *
+ * @param {Element} target Candidate event target.
+ * @returns {boolean}
+ */
+ #isWorkspaceBackgroundTarget(target) {
+ if (
+ target === this.workspaceElement ||
+ target === this.nodeLayer ||
+ target === this.connectionLayer ||
+ this.marqueeManager?.isMarqueeElement(target)
+ ) {
+ return true;
+ }
+
+ if (target.closest(".blueprint-node")) {
+ return false;
+ }
+
+ if (this.contextMenuManager?.isTargetInside(target)) {
+ return false;
+ }
+
+ return this.workspaceElement.contains(target);
+ }
+
+ /**
+ * Attempts to match a configured background spawn shortcut for the current pointer event.
+ *
+ * @param {PointerEvent} event Pointer event payload.
+ * @returns {NormalizedSpawnShortcut | null}
+ */
+ #matchBackgroundSpawnShortcut(event) {
+ const shortcuts = this.spawnShortcutLookup.get("background");
+ if (!shortcuts || !shortcuts.length) {
+ return null;
+ }
+
+ for (const shortcut of shortcuts) {
+ if (event.button !== shortcut.button) {
+ continue;
+ }
+
+ if (!this.activeShortcutKeys.has(shortcut.key)) {
+ continue;
+ }
+
+ if (!this.#validateShortcutModifiers(shortcut.modifiers, event)) {
+ continue;
+ }
+
+ return shortcut;
+ }
+
+ return null;
+ }
+
+ /**
+ * Spawns a node tied to a shortcut at the pointer location.
+ *
+ * @param {NormalizedSpawnShortcut} shortcut Normalized shortcut configuration.
+ * @param {PointerEvent} event Pointer event payload.
+ * @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
+ */
+ #spawnShortcutNodeAtPointer(shortcut, event) {
+ const workspaceElement = this.workspaceElement;
+ if (!(workspaceElement instanceof HTMLElement)) {
+ return null;
+ }
+
+ const navigator = this.navigationManager;
+ if (!navigator) {
+ return null;
+ }
+
+ const rect = workspaceElement.getBoundingClientRect();
+ const screenPoint = {
+ x: Math.max(0, Math.min(rect.width, event.clientX - rect.left)),
+ y: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
+ };
+ const worldPoint = navigator.screenPointToWorld(screenPoint);
+ return this.addNode(shortcut.definitionId, worldPoint);
+ }
+
+ /**
+ * Normalizes configured spawn shortcuts into an indexed map keyed by context.
+ *
+ * @param {Array} shortcuts Raw workspace spawn shortcut configuration entries.
+ * @returns {Map<'background', Array>}
+ */
+ #normalizeSpawnShortcuts(shortcuts) {
+ /** @type {Map<'background', Array>} */
+ const lookup = new Map();
+ const allowedModifiers = new Set([
+ "shift",
+ "ctrl",
+ "alt",
+ "meta",
+ ]);
+
+ shortcuts.forEach((entry) => {
+ if (!entry || typeof entry !== "object") {
+ return;
+ }
+
+ const rawKey =
+ typeof entry.key === "string" ? entry.key.trim().toLowerCase() : "";
+ if (!rawKey) {
+ return;
+ }
+
+ const context = entry.context || "background";
+ if (context !== "background") {
+ return;
+ }
+
+ const definitionId =
+ typeof entry.definitionId === "string"
+ ? entry.definitionId.trim()
+ : "";
+ if (!definitionId) {
+ return;
+ }
+
+ const button =
+ typeof entry.button === "number" && Number.isFinite(entry.button)
+ ? entry.button
+ : 0;
+
+ /** @type {Set} */
+ const modifiers = new Set();
+ if (Array.isArray(entry.modifiers)) {
+ entry.modifiers.forEach((modifier) => {
+ if (typeof modifier !== "string") {
+ return;
+ }
+ const normalizedModifier = modifier.trim().toLowerCase();
+ if (allowedModifiers.has(normalizedModifier)) {
+ modifiers.add(
+ /** @type {WorkspaceShortcutModifier} */ (normalizedModifier)
+ );
+ }
+ });
+ }
+
+ const shortcut = {
+ key: rawKey,
+ definitionId,
+ button,
+ modifiers,
+ };
+ const bucket = lookup.get("background");
+ if (bucket) {
+ bucket.push(shortcut);
+ } else {
+ lookup.set("background", [shortcut]);
+ }
+ });
+
+ return lookup;
+ }
+
+ /**
+ * Checks whether the supplied pointer event satisfies the expected modifier state.
+ *
+ * @param {Set} required Required modifier keys.
+ * @param {PointerEvent} event Pointer event payload.
+ * @returns {boolean}
+ */
+ #validateShortcutModifiers(required, event) {
+ return this.#validateKeyboardModifiers(required, event);
+ }
+
+ /**
+ * Attempts to execute a command shortcut bound to the provided key event.
+ *
+ * @param {KeyboardEvent} event Keyboard event payload.
+ * @param {string} normalizedKey Lowercase key identifier from the event.
+ * @returns {boolean}
+ */
+ #dispatchCommandShortcut(event, normalizedKey) {
+ if (!this.commandShortcuts.length) {
+ return false;
+ }
+
+ const handlers = {
+ copy: () => this.clipboardManager.copySelection(),
+ cut: () => this.clipboardManager.cutSelection(),
+ paste: () => {
+ this.clipboardManager.paste();
+ return true;
+ },
+ undo: () => {
+ this.undo();
+ return true;
+ },
+ redo: () => {
+ this.redo();
+ return true;
+ },
+ delete: () => {
+ if (!this.selectedNodeIds.size) {
+ return false;
+ }
+ const ids = [...this.selectedNodeIds];
+ ids.forEach((id) => this.removeNode(id));
+ return true;
+ },
+ };
+
+ for (const shortcut of this.commandShortcuts) {
+ if (shortcut.key !== normalizedKey) {
+ continue;
+ }
+
+ if (!this.#validateKeyboardModifiers(shortcut.modifiers, event)) {
+ continue;
+ }
+
+ const handler = handlers[shortcut.command];
+ if (!handler) {
+ continue;
+ }
+
+ const handled = handler();
+ if (handled) {
+ event.preventDefault();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Normalizes command shortcuts from configuration.
+ *
+ * @param {Array} shortcuts Raw shortcut configuration entries.
+ * @returns {Array}
+ */
+ #normalizeCommandShortcuts(shortcuts) {
+ /** @type {Array} */
+ const result = [];
+ const allowedCommands = new Set([
+ "copy",
+ "cut",
+ "paste",
+ "undo",
+ "redo",
+ "delete",
+ ]);
+
+ shortcuts.forEach((entry) => {
+ if (!entry || typeof entry !== "object") {
+ return;
+ }
+
+ if (entry.action !== "command") {
+ return;
+ }
+
+ const rawKey =
+ typeof entry.key === "string" ? entry.key.trim().toLowerCase() : "";
+ if (!rawKey) {
+ return;
+ }
+
+ const context = entry.context || "workspace";
+ if (context !== "workspace") {
+ return;
+ }
+
+ const command =
+ typeof entry.command === "string"
+ ? entry.command.trim().toLowerCase()
+ : "";
+ if (!command || !allowedCommands.has(command)) {
+ return;
+ }
+
+ /** @type {Set} */
+ const modifiers = new Set();
+ if (Array.isArray(entry.modifiers)) {
+ entry.modifiers.forEach((modifier) => {
+ if (typeof modifier !== "string") {
+ return;
+ }
+ const normalizedModifier = modifier.trim().toLowerCase();
+ switch (normalizedModifier) {
+ case "shift":
+ case "ctrl":
+ case "alt":
+ case "meta":
+ modifiers.add(
+ /** @type {WorkspaceShortcutModifier} */ (normalizedModifier)
+ );
+ break;
+ default:
+ break;
+ }
+ });
+ }
+
+ result.push({
+ key: rawKey,
+ command,
+ modifiers,
+ });
+ });
+
+ return result;
+ }
+
+ /**
+ * Validates modifier keys for keyboard-driven shortcuts.
+ *
+ * @param {Set} required Required modifier keys.
+ * @param {KeyboardEvent | PointerEvent} event Native event payload.
+ * @returns {boolean}
+ */
+ #validateKeyboardModifiers(required, event) {
+ const modifierState = {
+ shift: Boolean(event.shiftKey),
+ ctrl: Boolean(event.ctrlKey),
+ alt: Boolean(event.altKey),
+ meta: Boolean(event.metaKey),
+ };
+
+ /** @type {Array} */
+ const modifierKeys = ["shift", "ctrl", "alt", "meta"];
+
+ for (const modifier of modifierKeys) {
+ const expected = required.has(modifier);
+ const actual = modifierState[modifier];
+ if (expected !== actual) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Renders the overview sidebar lists for events and variables.
+ */
+ #renderOverview() {
+ this.#renderEventList();
+ this.#renderVariableList();
+ if (this.paletteManager) {
+ this.paletteManager.render(this.paletteSearch.value);
+ }
+ }
+
+ /**
+ * Creates a new global variable node and focuses it.
+ */
+ #createGlobalVariable() {
+ const variable = this.#registerVariable();
+ this.selectedVariableId = variable.id;
+ this.#renderOverview();
+ this.#highlightVariableSelection();
+ this.#renderInspectorState();
+ this.#refreshLuaOutput();
+
+ if (this.variableListElement) {
+ const target = this.variableListElement.querySelector(
+ `button.overview-item[data-variable-id="${variable.id}"]`
+ );
+ if (target instanceof HTMLButtonElement) {
+ target.focus();
+ }
+ }
+ }
+
+ /**
+ * Registers a new global variable entry.
+ *
+ * @param {{ name?: string, type?: VariableType }} [options] Optional variable overrides.
+ * @returns {WorkspaceVariable}
+ */
+ #registerVariable(options = {}) {
+ const providedName =
+ typeof options.name === "string" ? options.name.trim() : "";
+ const name = providedName || this.#suggestVariableName();
+ const type = this.#normalizeVariableType(options.type ?? "any");
+ this.variableCounter += 1;
+ const id = `var_${this.variableCounter}`;
+ const defaultValue = this.#defaultVariableValue(type);
+ const entry = { id, name, type, defaultValue };
+ this.variables.set(id, entry);
+ this.variableOrder.push(id);
+ this.persistenceManager.schedulePersist();
+ return entry;
+ }
+
+ /**
+ * Suggests a unique variable name.
+ *
+ * @returns {string}
+ */
+ #suggestVariableName() {
+ const base = "var";
+ const existing = new Set();
+ this.variableOrder.forEach((variableId) => {
+ const variable = this.variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+ const raw = variable.name.trim();
+ if (raw) {
+ existing.add(raw.toLowerCase());
+ }
+ });
+ this.graph.nodes.forEach((node) => {
+ if (node.type === "set_var" || node.type === "get_var") {
+ const raw =
+ typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "";
+ if (raw) {
+ existing.add(raw.toLowerCase());
+ }
+ }
+ });
+
+ if (!existing.has(base)) {
+ return base;
+ }
+
+ let index = 1;
+ while (existing.has(`${base}${index}`)) {
+ index += 1;
+ }
+ return `${base}${index}`;
+ }
+
+ /**
+ * Normalizes a variable name for lookups.
+ *
+ * @param {string} name Raw variable name.
+ * @returns {string}
+ */
+ #normalizeVariableName(name) {
+ return name.trim().toLowerCase();
+ }
+
+ /**
+ * Converts a variable type to a human-readable label.
+ *
+ * @param {VariableType} type Variable type identifier.
+ * @returns {string}
+ */
+ #formatVariableType(type) {
+ const entry = VARIABLE_TYPE_DEFINITIONS.find(
+ (definition) => definition.value === type
+ );
+ return entry ? entry.label : "Any";
+ }
+
+ /**
+ * Normalizes a raw variable type value to a supported type identifier.
+ *
+ * @param {unknown} value Candidate type value.
+ * @returns {VariableType}
+ */
+ #normalizeVariableType(value) {
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ if (
+ SUPPORTED_VARIABLE_TYPES.has(
+ /** @type {VariableType} */ (normalized)
+ )
+ ) {
+ return /** @type {VariableType} */ (normalized);
+ }
+ }
+ return "any";
+ }
+
+ /**
+ * Returns the available variable type dropdown entries.
+ *
+ * @returns {Array<{ value: VariableType, label: string }>}
+ */
+ #getVariableTypeOptions() {
+ return VARIABLE_TYPE_DEFINITIONS.map((entry) => ({ ...entry }));
+ }
+
+ /**
+ * Determines whether the shared type dropdown is open for the supplied context.
+ *
+ * @param {TypeDropdownContext} context Target dropdown context.
+ * @returns {boolean}
+ */
+ #isTypeDropdownOpen(context) {
+ const state = this.typeDropdown;
+ if (!state.context) {
+ return false;
+ }
+ if (state.context.kind !== context.kind) {
+ return false;
+ }
+ if (context.kind === "variable") {
+ return (
+ state.context.kind === "variable" &&
+ state.context.variableId === context.variableId
+ );
+ }
+ if (context.kind === "local_variable") {
+ return (
+ state.context.kind === "local_variable" &&
+ state.context.nodeId === context.nodeId
+ );
+ }
+ return (
+ state.context.kind === "custom_event_parameter" &&
+ state.context.nodeId === context.nodeId &&
+ state.context.parameterId === context.parameterId
+ );
+ }
+
+ /**
+ * Locates a workspace variable by name using case-insensitive matching.
+ *
+ * @param {string} name Candidate variable name.
+ * @returns {WorkspaceVariable | null}
+ */
+ #findVariableByName(name) {
+ const normalized = this.#normalizeVariableName(name);
+ if (!normalized) {
+ return null;
+ }
+
+ for (const variableId of this.variableOrder) {
+ const variable = this.variables.get(variableId);
+ if (!variable) {
+ continue;
+ }
+ if (this.#normalizeVariableName(variable.name) === normalized) {
+ return variable;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Determines whether the supplied node references the provided workspace variable.
+ *
+ * @param {BlueprintNode} node Node to inspect.
+ * @param {string} variableId Variable identifier.
+ * @param {string} normalizedName Normalized variable name used for fallback matching.
+ * @returns {boolean}
+ */
+ #nodeReferencesVariable(node, variableId, normalizedName) {
+ if (node.type !== "set_var" && node.type !== "get_var") {
+ return false;
+ }
+
+ const nodeVariableId =
+ typeof node.properties.variableId === "string"
+ ? node.properties.variableId
+ : "";
+ if (nodeVariableId && nodeVariableId === variableId) {
+ return true;
+ }
+
+ if (!normalizedName) {
+ return false;
+ }
+
+ const nodeName =
+ typeof node.properties.name === "string" ? node.properties.name : "";
+ if (!nodeName) {
+ return false;
+ }
+
+ return this.#normalizeVariableName(nodeName) === normalizedName;
+ }
+
+ /**
+ * Derives binding metadata for a variable node, including display name and type.
+ *
+ * @param {BlueprintNode} node Variable node reference.
+ * @returns {{ displayName: string, type: VariableType, variableId: string | null }}
+ */
+ #resolveVariableBinding(node) {
+ const rawName =
+ typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "";
+ const storedVariableId =
+ typeof node.properties.variableId === "string" &&
+ node.properties.variableId
+ ? node.properties.variableId
+ : "";
+
+ let variable = storedVariableId
+ ? this.variables.get(storedVariableId) ?? null
+ : null;
+ if (!variable && rawName) {
+ variable = this.#findVariableByName(rawName);
+ }
+
+ const displayName = variable?.name?.trim() || rawName || "Variable";
+ const type = variable?.type ?? "any";
+ const resolvedVariableId = variable?.id ?? (storedVariableId || null);
+
+ return { displayName, type, variableId: resolvedVariableId };
+ }
+
+ /**
+ * Updates a variable node's DOM representation to match the active binding.
+ *
+ * @param {string} nodeId Node identifier to refresh.
+ * @returns {boolean} Whether any connection kinds were mutated.
+ */
+ #refreshVariableNode(nodeId) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || (node.type !== "set_var" && node.type !== "get_var")) {
+ return false;
+ }
+
+ const article = this.nodeElements.get(nodeId);
+ if (!article) {
+ return false;
+ }
+
+ const binding = this.#resolveVariableBinding(node);
+ this.#applyVariableNodeTitle(article, node, binding);
+ return this.#applyVariableNodePins(node, binding.type);
+ }
+
+ /**
+ * Updates a local variable node's DOM representation to match its properties.
+ *
+ * @param {string} nodeId Node identifier to refresh.
+ * @returns {boolean} Whether any connection kinds were mutated.
+ */
+ #refreshLocalVariableNode(nodeId) {
+ const node = this.graph.nodes.get(nodeId);
+ if (
+ !node ||
+ (node.type !== "set_local_var" && node.type !== "get_local_var")
+ ) {
+ return false;
+ }
+
+ const article = this.nodeElements.get(nodeId);
+ if (!article) {
+ return false;
+ }
+
+ const rawName =
+ typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "";
+ const displayName = rawName || "localVar";
+ const normalizedType = this.#normalizeVariableType(
+ node.properties.variableType
+ );
+ const type = normalizedType === "any" ? "number" : normalizedType;
+
+ this.#applyVariableNodeTitle(article, node, { displayName, type });
+ return this.#applyVariableNodePins(node, type);
+ }
+
+ /**
+ * Applies variable-specific title styling to the supplied node header.
+ *
+ * @param {HTMLElement} article Node root element.
+ * @param {BlueprintNode} node Node instance.
+ * @param {{ displayName: string, type: VariableType }} binding Binding metadata.
+ */
+ #applyVariableNodeTitle(article, node, binding) {
+ const header = article.querySelector(".node-header");
+ const title = article.querySelector(".node-title");
+ if (!(header instanceof HTMLElement) || !(title instanceof HTMLElement)) {
+ return;
+ }
+
+ const isSetter =
+ node.type === "set_var" || node.type === "set_local_var";
+ const role = isSetter ? "set" : "get";
+ const isLocal =
+ node.type === "set_local_var" || node.type === "get_local_var";
+ article.classList.add("blueprint-node--variable");
+ article.dataset.variableType = binding.type;
+ article.dataset.variableRole = role;
+ article.dataset.variableScope = isLocal ? "local" : "global";
+
+ header.classList.add("node-header--variable");
+ header.setAttribute("data-variable-role", role);
+ header.setAttribute("data-variable-type", binding.type);
+ header.setAttribute("data-variable-scope", isLocal ? "local" : "global");
+ const prefixBase = role === "set" ? "Set" : "Get";
+ const prefix = isLocal ? `${prefixBase} Local` : prefixBase;
+ const titleText = `${prefix} ${binding.displayName}`;
+ header.title = titleText;
+
+ title.classList.add("node-title--variable");
+ title.textContent = "";
+
+ const label = document.createElement("span");
+ label.className = "node-title-label";
+ label.textContent = titleText;
+ title.appendChild(label);
+
+ node.title = titleText;
+ }
+
+ /**
+ * Synchronizes a variable node's data pin kinds with the supplied type.
+ *
+ * @param {BlueprintNode} node Node instance.
+ * @param {VariableType} type Target variable type.
+ * @returns {boolean} Whether any connection kinds were mutated.
+ */
+ #applyVariableNodePins(node, type) {
+ const targetKind = type ?? "any";
+
+ /** @type {Array<{ pinId: string, direction: 'input'|'output', kind: VariableType }>} */
+ const changedPins = [];
+
+ const updatePin = (pin, direction) => {
+ if (!pin) {
+ return;
+ }
+
+ if (pin.kind !== targetKind) {
+ pin.kind = targetKind;
+ changedPins.push({ pinId: pin.id, direction, kind: targetKind });
+ }
+
+ const element = this.#getPinElement(node.id, pin.id, direction);
+ if (element) {
+ element.dataset.type = targetKind;
+ }
+ };
+
+ if (node.type === "set_var" || node.type === "set_local_var") {
+ const valuePin = node.inputs.find((pin) => pin.id === "value");
+ updatePin(valuePin, "input");
+ } else if (node.type === "get_var" || node.type === "get_local_var") {
+ const valuePin = node.outputs.find((pin) => pin.id === "value");
+ updatePin(valuePin, "output");
+ }
+
+ if (!changedPins.length) {
+ return false;
+ }
+
+ const connections = this.graph.getConnections();
+ let connectionsMutated = false;
+ const toRemove = new Set();
+
+ changedPins.forEach((update) => {
+ connections.forEach((connection) => {
+ const isSource =
+ connection.from.nodeId === node.id &&
+ connection.from.pinId === update.pinId;
+ const isTarget =
+ connection.to.nodeId === node.id &&
+ connection.to.pinId === update.pinId;
+ if (!isSource && !isTarget) {
+ return;
+ }
+
+ const otherNodeId = isSource
+ ? connection.to.nodeId
+ : connection.from.nodeId;
+ const otherPinId = isSource
+ ? connection.to.pinId
+ : connection.from.pinId;
+ const otherNode = this.graph.nodes.get(otherNodeId);
+ const otherPin = otherNode?.getPin(otherPinId);
+ if (!otherNode || !otherPin) {
+ return;
+ }
+
+ const outputKind = isSource ? update.kind : otherPin.kind;
+ const inputKind = isSource ? otherPin.kind : update.kind;
+ if (!this.#arePinKindsCompatible(outputKind, inputKind)) {
+ toRemove.add(connection.id);
+ return;
+ }
+
+ if (connection.kind !== update.kind) {
+ connection.kind = update.kind;
+ connectionsMutated = true;
+ }
+ });
+ });
+
+ if (toRemove.size) {
+ toRemove.forEach((connectionId) =>
+ this.graph.removeConnection(connectionId)
+ );
+ connectionsMutated = true;
+ }
+
+ return connectionsMutated;
+ }
+
+ /**
+ * Synchronizes sequence node outputs with configured branches.
+ *
+ * @param {string} nodeId Target node identifier.
+ * @returns {boolean} Whether any connections were mutated.
+ */
+ #refreshSequenceNode(nodeId) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "sequence") {
+ return false;
+ }
+
+ const { branches } = this.#ensureSequenceBranches(node);
+ const result = this.#applySequenceOutputs(node, branches);
+ if (this.nodeRenderer) {
+ this.nodeRenderer.rebuildNodePins(node, { inputs: false, outputs: true });
+ this.nodeRenderer.updatePinConnectionsForNode(node.id);
+ }
+ if (this.navigationManager) {
+ this.navigationManager.updateZoomDisplay();
+ } else {
+ this.scheduleConnectionRefresh();
+ }
+ return result.connectionsMutated;
+ }
+
+ /**
+ * Ensures sequence branch metadata is present and unique.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference.
+ * @returns {{ branches: Array<{ id: string }>, mutated: boolean }}
+ */
+ #ensureSequenceBranches(node) {
+ if (!Array.isArray(node.properties.branches)) {
+ node.properties.branches = [];
+ }
+
+ if (!Number.isFinite(node.properties.branchCounter)) {
+ node.properties.branchCounter = 0;
+ }
+
+ const existing = Array.isArray(node.properties.branches)
+ ? /** @type {Array<{ id?: string }>} */ (node.properties.branches)
+ : [];
+ const sanitized = [];
+ const usedIds = new Set();
+ let mutated = !Array.isArray(node.properties.branches);
+
+ existing.forEach((entry) => {
+ let id = typeof entry?.id === "string" ? entry.id.trim() : "";
+ if (!id || usedIds.has(id)) {
+ id = this.#allocateSequenceBranchId(node);
+ mutated = true;
+ }
+ usedIds.add(id);
+ sanitized.push({ id });
+ });
+
+ if (!sanitized.length) {
+ node.outputs
+ .filter((pin) => pin.direction === "output")
+ .forEach((pin) => {
+ const pinId = typeof pin.id === "string" ? pin.id : "";
+ if (!pinId || usedIds.has(pinId)) {
+ const generated = this.#allocateSequenceBranchId(node);
+ sanitized.push({ id: generated });
+ usedIds.add(generated);
+ mutated = true;
+ } else {
+ sanitized.push({ id: pinId });
+ usedIds.add(pinId);
+ }
+ });
+
+ if (!sanitized.length) {
+ const count = Math.max(DEFAULT_SEQUENCE_BRANCHES, 1);
+ for (let index = 0; index < count; index += 1) {
+ const generated = this.#allocateSequenceBranchId(node);
+ sanitized.push({ id: generated });
+ usedIds.add(generated);
+ }
+ mutated = true;
+ }
+ }
+
+ let maxCounter = Number.isFinite(node.properties.branchCounter)
+ ? Number(node.properties.branchCounter)
+ : 0;
+ sanitized.forEach((entry) => {
+ const match = /_(\d+)$/.exec(entry.id);
+ if (match) {
+ const numeric = Number.parseInt(match[1], 10);
+ if (Number.isFinite(numeric)) {
+ maxCounter = Math.max(maxCounter, numeric);
+ }
+ }
+ });
+ node.properties.branchCounter = maxCounter;
+
+ node.properties.branches = sanitized.map((entry) => ({ id: entry.id }));
+ return {
+ branches: node.properties.branches.map((entry) => ({ id: entry.id })),
+ mutated,
+ };
+ }
+
+ /**
+ * Allocates a unique branch identifier for a sequence node.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference.
+ * @returns {string}
+ */
+ #allocateSequenceBranchId(node) {
+ if (!Number.isFinite(node.properties.branchCounter)) {
+ node.properties.branchCounter = 0;
+ }
+ node.properties.branchCounter += 1;
+ return `branch_${String(node.properties.branchCounter).padStart(2, "0")}`;
+ }
+
+ /**
+ * Derives a human-friendly label for a sequence branch index.
+ *
+ * @param {number} index Branch index starting at zero.
+ * @returns {string}
+ */
+ #formatSequenceBranchLabel(index) {
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ let value = Math.max(0, Math.floor(index));
+ let label = "";
+ do {
+ label = alphabet[value % 26] + label;
+ value = Math.floor(value / 26) - 1;
+ } while (value >= 0);
+ return label;
+ }
+
+ /**
+ * Applies sequence branch descriptors to the node outputs.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference.
+ * @param {Array<{ id: string }>} branches Normalized branch descriptors.
+ * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }}
+ */
+ #applySequenceOutputs(node, branches) {
+ const existing = new Map(node.outputs.map((pin) => [pin.id, pin]));
+ let pinsChanged = false;
+ let connectionsMutated = false;
+ const desired = [];
+
+ branches.forEach((branch, index) => {
+ const pinId = branch.id;
+ const label = this.#formatSequenceBranchLabel(index);
+ let descriptor = existing.get(pinId);
+ if (!descriptor) {
+ descriptor = {
+ id: pinId,
+ name: label,
+ direction: "output",
+ kind: "exec",
+ };
+ pinsChanged = true;
+ } else {
+ if (descriptor.name !== label) {
+ descriptor.name = label;
+ pinsChanged = true;
+ }
+ if (descriptor.direction !== "output") {
+ descriptor.direction = "output";
+ pinsChanged = true;
+ }
+ if (descriptor.kind !== "exec") {
+ descriptor.kind = "exec";
+ pinsChanged = true;
+ }
+ }
+ desired.push(descriptor);
+ });
+
+ node.outputs.forEach((pin) => {
+ const keep = branches.some((branch) => branch.id === pin.id);
+ if (!keep) {
+ this.graph.removeConnectionsForPin({
+ nodeId: node.id,
+ pinId: pin.id,
+ });
+ connectionsMutated = true;
+ }
+ });
+
+ if (pinsChanged || node.outputs.length !== desired.length) {
+ node.outputs = desired;
+ }
+
+ return { pinsChanged, connectionsMutated };
+ }
+
+ /** Adds an execution branch to a sequence node. */
+ #addSequenceBranch(nodeId) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "sequence") {
+ return;
+ }
+ const { branches } = this.#ensureSequenceBranches(node);
+ const nextBranches = branches.map((entry) => ({ id: entry.id }));
+ const branchId = this.#allocateSequenceBranchId(node);
+ nextBranches.push({ id: branchId });
+ this.graph.setNodeProperty(nodeId, "branches", nextBranches);
+ }
+
+ /** Removes an execution branch from a sequence node when possible. */
+ #removeSequenceBranch(nodeId, branchId) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "sequence") {
+ return;
+ }
+ const { branches } = this.#ensureSequenceBranches(node);
+ if (branches.length <= 1) {
+ return;
+ }
+ const nextBranches = branches
+ .filter((entry) => entry.id !== branchId)
+ .map((entry) => ({ id: entry.id }));
+ if (nextBranches.length === branches.length) {
+ return;
+ }
+ this.graph.setNodeProperty(nodeId, "branches", nextBranches);
+ }
+
+ /**
+ * Enumerates custom event nodes within the active graph.
+ *
+ * @returns {Array}
+ */
+ #getCustomEventNodes() {
+ return [...this.graph.nodes.values()].filter(
+ (node) => node.type === "custom_event"
+ );
+ }
+
+ /**
+ * Generates a unique custom event name based on the provided base string.
+ *
+ * @param {string} baseName Desired base name.
+ * @param {string} [excludeNodeId] Optional node identifier to exclude from duplicate checks.
+ * @returns {string}
+ */
+ #generateUniqueCustomEventName(baseName, excludeNodeId) {
+ const trimmedBase = baseName.trim();
+ const base = trimmedBase || "CustomEvent";
+ const normalizedBase = base.toLowerCase();
+ const existing = new Set();
+
+ this.graph.nodes.forEach((candidate) => {
+ if (candidate.type !== "custom_event") {
+ return;
+ }
+ if (excludeNodeId && candidate.id === excludeNodeId) {
+ return;
+ }
+ const rawName =
+ typeof candidate.properties.name === "string"
+ ? candidate.properties.name.trim()
+ : "";
+ if (rawName) {
+ existing.add(rawName.toLowerCase());
+ }
+ });
+
+ if (!existing.has(normalizedBase)) {
+ return base;
+ }
+
+ let suffix = 1;
+ while (existing.has(`${base}${suffix}`.toLowerCase())) {
+ suffix += 1;
+ }
+ return `${base}${suffix}`;
+ }
+
+ /**
+ * Derives the user-facing label for a custom event node.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference.
+ * @returns {string}
+ */
+ #resolveCustomEventLabel(node) {
+ const raw =
+ typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "";
+ return raw || "CustomEvent";
+ }
+
+ /**
+ * Produces a stable output pin identifier for the supplied parameter.
+ *
+ * @param {string} parameterId Custom event parameter identifier.
+ * @returns {string}
+ */
+ #customEventOutputPinId(parameterId) {
+ return `param_${parameterId}`;
+ }
+
+ /**
+ * Produces a stable input pin identifier for call nodes invoking the supplied parameter.
+ *
+ * @param {string} parameterId Custom event parameter identifier.
+ * @returns {string}
+ */
+ #callCustomEventInputPinId(parameterId) {
+ return `arg_${parameterId}`;
+ }
+
+ /**
+ * Allocates a unique custom event parameter identifier for the provided node.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Target custom event node.
+ * @returns {string}
+ */
+ #allocateCustomEventParameterId(node) {
+ let counter = Number.isFinite(node.properties.parameterCounter)
+ ? Number(node.properties.parameterCounter)
+ : 0;
+ counter += 1;
+ node.properties.parameterCounter = counter;
+ return `param_${String(counter).padStart(2, "0")}`;
+ }
+
+ /**
+ * Ensures the custom event parameter collection is valid and normalized.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Target custom event node.
+ * @returns {{ parameters: Array, mutated: boolean }}
+ */
+ #sanitizeCustomEventParameters(node) {
+ const existing = Array.isArray(node.properties.parameters)
+ ? /** @type {Array} */ (node.properties.parameters)
+ : [];
+ const normalizeOptional = (value) => {
+ if (typeof value === "boolean") {
+ return value;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim().toLowerCase();
+ if (trimmed === "true") {
+ return true;
+ }
+ if (!trimmed || trimmed === "false") {
+ return false;
+ }
+ }
+ return Boolean(value);
+ };
+
+ const sanitized = [];
+ const usedIds = new Set();
+ const usedNames = new Set();
+ let mutated = !Array.isArray(node.properties.parameters);
+
+ let maxCounter = Number.isFinite(node.properties.parameterCounter)
+ ? Number(node.properties.parameterCounter)
+ : 0;
+
+ existing.forEach((entry, index) => {
+ const rawName = typeof entry?.name === "string" ? entry.name.trim() : "";
+ const baseName = rawName || `Param${index + 1}`;
+ if (!rawName) {
+ mutated = true;
+ }
+
+ let candidateName = baseName;
+ let suffix = 2;
+ while (usedNames.has(candidateName.toLowerCase())) {
+ candidateName = `${baseName}${suffix}`;
+ suffix += 1;
+ mutated = true;
+ }
+ usedNames.add(candidateName.toLowerCase());
+
+ const rawType = typeof entry?.type === "string" ? entry.type : "";
+ const type = this.#normalizeVariableType(rawType);
+ if (type !== rawType) {
+ mutated = true;
+ }
+
+ const optional = normalizeOptional(entry?.optional);
+
+ let identifier = typeof entry?.id === "string" ? entry.id.trim() : "";
+ if (!identifier || usedIds.has(identifier)) {
+ identifier = this.#allocateCustomEventParameterId(node);
+ mutated = true;
+ }
+ usedIds.add(identifier);
+
+ const numericMatch = /_(\d+)$/.exec(identifier);
+ if (numericMatch) {
+ const numeric = Number.parseInt(numericMatch[1], 10);
+ if (Number.isFinite(numeric)) {
+ maxCounter = Math.max(maxCounter, numeric);
+ }
+ }
+
+ sanitized.push({ id: identifier, name: candidateName, type, optional });
+ });
+
+ if (sanitized.length !== existing.length) {
+ mutated = true;
+ } else if (!mutated) {
+ for (let index = 0; index < sanitized.length; index += 1) {
+ const current = existing[index];
+ const next = sanitized[index];
+ if (
+ !current ||
+ current.id !== next.id ||
+ current.name !== next.name ||
+ current.type !== next.type ||
+ normalizeOptional(current?.optional) !== next.optional
+ ) {
+ mutated = true;
+ break;
+ }
+ }
+ }
+
+ if (mutated) {
+ node.properties.parameters = sanitized.map((parameter) => ({
+ ...parameter,
+ }));
+ }
+
+ if (
+ !Number.isFinite(node.properties.parameterCounter) ||
+ node.properties.parameterCounter < maxCounter
+ ) {
+ node.properties.parameterCounter = maxCounter;
+ mutated = true;
+ }
+
+ const stored = /** @type {Array} */ (
+ Array.isArray(node.properties.parameters)
+ ? node.properties.parameters
+ : []
+ );
+
+ return {
+ parameters: stored.map((parameter) => ({ ...parameter })),
+ mutated,
+ };
+ }
+
+ /**
+ * Aligns the node output pins with the supplied custom event parameters.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference.
+ * @param {Array} parameters Normalized parameter list.
+ * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }}
+ */
+ #applyCustomEventNodePins(node, parameters) {
+ const existingOutputs = new Map(node.outputs.map((pin) => [pin.id, pin]));
+
+ let pinsChanged = false;
+ let connectionsMutated = false;
+
+ const desiredIds = new Set(["exec_out"]);
+ const desiredOutputs = [];
+
+ const execPin = existingOutputs.get("exec_out") ?? {
+ id: "exec_out",
+ name: "Exec",
+ direction: "output",
+ kind: "exec",
+ };
+ execPin.name = "Exec";
+ execPin.direction = "output";
+ execPin.kind = "exec";
+ desiredOutputs.push(execPin);
+
+ parameters.forEach((parameter) => {
+ const pinId = this.#customEventOutputPinId(parameter.id);
+ desiredIds.add(pinId);
+ let descriptor = existingOutputs.get(pinId);
+ if (!descriptor) {
+ descriptor = {
+ id: pinId,
+ name: parameter.name,
+ direction: "output",
+ kind: parameter.type,
+ };
+ pinsChanged = true;
+ } else {
+ if (descriptor.name !== parameter.name) {
+ descriptor.name = parameter.name;
+ pinsChanged = true;
+ }
+ if (descriptor.kind !== parameter.type) {
+ descriptor.kind = parameter.type;
+ pinsChanged = true;
+ }
+ descriptor.direction = "output";
+ }
+ desiredOutputs.push(descriptor);
+ });
+
+ node.outputs
+ .filter((pin) => !desiredIds.has(pin.id))
+ .forEach((pin) => {
+ this.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id });
+ connectionsMutated = true;
+ pinsChanged = true;
+ });
+
+ if (pinsChanged || node.outputs.length !== desiredOutputs.length) {
+ node.outputs = desiredOutputs;
+ }
+
+ parameters.forEach((parameter) => {
+ const pinId = this.#customEventOutputPinId(parameter.id);
+ if (this.#ensureConnectionsReflectPinKinds(node.id, pinId)) {
+ connectionsMutated = true;
+ }
+ });
+
+ return { pinsChanged, connectionsMutated };
+ }
+
+ /**
+ * Aligns a call node's input pins with its target custom event parameters.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Call custom event node.
+ * @param {Array} parameters Target parameter list.
+ * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }}
+ */
+ #applyCallCustomEventNodePins(node, parameters) {
+ const existingInputs = new Map(node.inputs.map((pin) => [pin.id, pin]));
+
+ let pinsChanged = false;
+ let connectionsMutated = false;
+
+ const desiredIds = new Set(["exec_in"]);
+ const desiredInputs = [];
+
+ const execPin = existingInputs.get("exec_in") ?? {
+ id: "exec_in",
+ name: "Exec",
+ direction: "input",
+ kind: "exec",
+ };
+ execPin.name = "Exec";
+ execPin.direction = "input";
+ execPin.kind = "exec";
+ desiredInputs.push(execPin);
+
+ parameters.forEach((parameter) => {
+ const pinId = this.#callCustomEventInputPinId(parameter.id);
+ desiredIds.add(pinId);
+ let descriptor = existingInputs.get(pinId);
+ if (!descriptor) {
+ descriptor = {
+ id: pinId,
+ name: parameter.name,
+ direction: "input",
+ kind: parameter.type,
+ };
+ pinsChanged = true;
+ } else {
+ if (descriptor.name !== parameter.name) {
+ descriptor.name = parameter.name;
+ pinsChanged = true;
+ }
+ if (descriptor.kind !== parameter.type) {
+ descriptor.kind = parameter.type;
+ pinsChanged = true;
+ }
+ descriptor.direction = "input";
+ }
+ desiredInputs.push(descriptor);
+ });
+
+ node.inputs
+ .filter((pin) => !desiredIds.has(pin.id))
+ .forEach((pin) => {
+ this.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id });
+ connectionsMutated = true;
+ pinsChanged = true;
+ });
+
+ if (pinsChanged || node.inputs.length !== desiredInputs.length) {
+ node.inputs = desiredInputs;
+ }
+
+ parameters.forEach((parameter) => {
+ const pinId = this.#callCustomEventInputPinId(parameter.id);
+ if (this.#ensureConnectionsReflectPinKinds(node.id, pinId)) {
+ connectionsMutated = true;
+ }
+ });
+
+ return { pinsChanged, connectionsMutated };
+ }
+
+ /**
+ * Guarantees connection compatibility after a pin kind update.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier to reconcile.
+ * @returns {boolean} Whether any connections were mutated.
+ */
+ #ensureConnectionsReflectPinKinds(nodeId, pinId) {
+ const node = this.graph.nodes.get(nodeId);
+ const pin = node?.getPin(pinId);
+ if (!node || !pin) {
+ return false;
+ }
+
+ const connections = this.graph.getConnectionsForNode(nodeId, pinId);
+ let mutated = false;
+ connections.forEach((connection) => {
+ const fromNode = this.graph.nodes.get(connection.from.nodeId);
+ const toNode = this.graph.nodes.get(connection.to.nodeId);
+ const fromPin = fromNode?.getPin(connection.from.pinId);
+ const toPin = toNode?.getPin(connection.to.pinId);
+ if (!fromNode || !toNode || !fromPin || !toPin) {
+ this.graph.removeConnection(connection.id);
+ mutated = true;
+ return;
+ }
+ if (!this.#arePinKindsCompatible(fromPin.kind, toPin.kind)) {
+ this.graph.removeConnection(connection.id);
+ mutated = true;
+ return;
+ }
+ const expectedKind = toPin.kind === "any" ? fromPin.kind : toPin.kind;
+ if (connection.kind !== expectedKind) {
+ connection.kind = expectedKind;
+ mutated = true;
+ }
+ });
+ return mutated;
+ }
+
+ /**
+ * Ensures the call node arguments map contains entries for each parameter.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Call node reference.
+ * @param {Array} parameters Parameter metadata.
+ */
+ #syncCallCustomEventArguments(node, parameters) {
+ const current = node.properties.arguments;
+ const record =
+ current && typeof current === "object" && !Array.isArray(current)
+ ? /** @type {Record} */ (current)
+ : {};
+
+ const validIds = new Set(parameters.map((parameter) => parameter.id));
+
+ Object.keys(record).forEach((key) => {
+ if (!validIds.has(key)) {
+ delete record[key];
+ }
+ });
+
+ parameters.forEach((parameter) => {
+ if (!(parameter.id in record)) {
+ record[parameter.id] = null;
+ }
+ });
+
+ node.properties.arguments = record;
+ }
+
+ /**
+ * Appends a new parameter to the provided custom event node.
+ *
+ * @param {string} nodeId Target custom event node identifier.
+ * @param {{ name?: string, type?: VariableType }} [options] Optional overrides.
+ * @returns {CustomEventParameter | null}
+ */
+ #addCustomEventParameter(nodeId, options = {}) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "custom_event") {
+ return null;
+ }
+
+ const { parameters } = this.#sanitizeCustomEventParameters(node);
+ const identifier = this.#allocateCustomEventParameterId(node);
+ const baseName =
+ typeof options.name === "string" ? options.name.trim() : "";
+ const parameter = {
+ id: identifier,
+ name: baseName || `Param${parameters.length + 1}`,
+ type: this.#normalizeVariableType(options.type ?? "any"),
+ optional: Boolean(options.optional),
+ };
+
+ const nextParameters = parameters.concat(parameter);
+ this.graph.setNodeProperty(nodeId, "parameters", nextParameters);
+ return parameter;
+ }
+
+ /**
+ * Removes a parameter from a custom event node if present.
+ *
+ * @param {string} nodeId Target custom event node identifier.
+ * @param {string} parameterId Parameter identifier to remove.
+ * @returns {boolean} Whether a parameter was removed.
+ */
+ #removeCustomEventParameter(nodeId, parameterId) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "custom_event") {
+ return false;
+ }
+ const { parameters } = this.#sanitizeCustomEventParameters(node);
+ const nextParameters = parameters.filter(
+ (parameter) => parameter.id !== parameterId
+ );
+ if (nextParameters.length === parameters.length) {
+ return false;
+ }
+ this.graph.setNodeProperty(nodeId, "parameters", nextParameters);
+ return true;
+ }
+
+ /**
+ * Updates the display name for a custom event parameter.
+ *
+ * @param {string} nodeId Target custom event node identifier.
+ * @param {string} parameterId Parameter identifier to rename.
+ * @param {string} name Desired display name.
+ */
+ #renameCustomEventParameter(nodeId, parameterId, name) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "custom_event") {
+ return;
+ }
+ const trimmed = typeof name === "string" ? name.trim() : "";
+ const { parameters } = this.#sanitizeCustomEventParameters(node);
+ const nextParameters = parameters.map((parameter) =>
+ parameter.id === parameterId
+ ? { ...parameter, name: trimmed || parameter.name }
+ : parameter
+ );
+ this.graph.setNodeProperty(nodeId, "parameters", nextParameters);
+ }
+
+ /**
+ * Adjusts the type for a custom event parameter.
+ *
+ * @param {string} nodeId Target custom event node identifier.
+ * @param {string} parameterId Parameter identifier to modify.
+ * @param {VariableType} nextType Desired parameter type.
+ */
+ #setCustomEventParameterType(nodeId, parameterId, nextType) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "custom_event") {
+ return;
+ }
+ const resolvedType = this.#normalizeVariableType(nextType);
+ const { parameters } = this.#sanitizeCustomEventParameters(node);
+ const nextParameters = parameters.map((parameter) =>
+ parameter.id === parameterId
+ ? { ...parameter, type: resolvedType }
+ : parameter
+ );
+ this.graph.setNodeProperty(nodeId, "parameters", nextParameters);
+ }
+
+ /**
+ * Toggles whether a custom event parameter is optional.
+ *
+ * @param {string} nodeId Target custom event node identifier.
+ * @param {string} parameterId Parameter identifier to update.
+ * @param {boolean} isOptional Desired optional state.
+ */
+ #setCustomEventParameterOptional(nodeId, parameterId, isOptional) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "custom_event") {
+ return;
+ }
+ const flag = Boolean(isOptional);
+ const { parameters } = this.#sanitizeCustomEventParameters(node);
+ const nextParameters = parameters.map((parameter) =>
+ parameter.id === parameterId
+ ? { ...parameter, optional: flag }
+ : parameter
+ );
+ this.graph.setNodeProperty(nodeId, "parameters", nextParameters);
+ }
+
+ /**
+ * Guarantees the custom event structure matches its parameter list without touching call nodes.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference.
+ * @returns {{ parameters: Array, connectionsMutated: boolean }}
+ */
+ #ensureCustomEventStructure(node) {
+ const { parameters } = this.#sanitizeCustomEventParameters(node);
+ const pinResult = this.#applyCustomEventNodePins(node, parameters);
+ if (pinResult.pinsChanged) {
+ this.nodeRenderer.rebuildNodePins(node, { inputs: false, outputs: true });
+ }
+ return {
+ parameters,
+ connectionsMutated: pinResult.connectionsMutated,
+ };
+ }
+
+ /**
+ * Refreshes the DOM representation of a custom event node and synchronizes call references.
+ *
+ * @param {string} nodeId Target node identifier.
+ * @returns {boolean} Whether any connections were mutated.
+ */
+ #refreshCustomEventNode(nodeId) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "custom_event") {
+ return false;
+ }
+
+ const { parameters, connectionsMutated: eventConnectionsMutated } =
+ this.#ensureCustomEventStructure(node);
+
+ const label = this.#resolveCustomEventLabel(node);
+ const titleText = `Custom Event: ${label}`;
+ node.title = titleText;
+
+ const article = this.nodeElements.get(nodeId);
+ if (article) {
+ article.classList.add("blueprint-node--custom-event");
+ article.dataset.customEventName = label;
+ article.dataset.customEventParamCount = String(parameters.length);
+ const titleElement = article.querySelector(".node-title");
+ if (titleElement instanceof HTMLElement) {
+ titleElement.textContent = titleText;
+ }
+ }
+
+ const callConnectionsMutated = this.#syncCallNodesForEvent(
+ nodeId,
+ parameters
+ );
+ return eventConnectionsMutated || callConnectionsMutated;
+ }
+
+ /**
+ * Synchronizes all call nodes targeting the specified custom event.
+ *
+ * @param {string} eventNodeId Custom event identifier.
+ * @param {Array} parameters Normalized parameter definitions.
+ * @returns {boolean} Whether any connections were mutated.
+ */
+ #syncCallNodesForEvent(eventNodeId, parameters) {
+ const eventNode = this.graph.nodes.get(eventNodeId);
+ let connectionsMutated = false;
+ this.graph.nodes.forEach((candidate) => {
+ if (candidate.type !== "call_custom_event") {
+ return;
+ }
+ const targetId =
+ typeof candidate.properties.eventId === "string"
+ ? candidate.properties.eventId
+ : "";
+ if (targetId !== eventNodeId) {
+ return;
+ }
+ const mutated = this.#refreshCustomEventCallNode(candidate.id, {
+ eventNode:
+ eventNode && eventNode.type === "custom_event" ? eventNode : null,
+ parameters,
+ });
+ connectionsMutated = mutated || connectionsMutated;
+ });
+ return connectionsMutated;
+ }
+
+ /**
+ * Refreshes the display metadata and pins for a call custom event node.
+ *
+ * @param {string} nodeId Target node identifier.
+ * @param {{ eventNode?: import('../core/BlueprintNode.js').BlueprintNode | null, parameters?: Array }} [options]
+ * @returns {boolean} Whether any connections were mutated.
+ */
+ #refreshCustomEventCallNode(nodeId, options = {}) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node || node.type !== "call_custom_event") {
+ return false;
+ }
+
+ const eventId =
+ typeof node.properties.eventId === "string"
+ ? node.properties.eventId
+ : "";
+ let eventNode = options.eventNode ?? null;
+ if (!eventNode && eventId) {
+ const fetched = this.graph.nodes.get(eventId);
+ if (fetched && fetched.type === "custom_event") {
+ eventNode = fetched;
+ }
+ }
+
+ let parameters = Array.isArray(options.parameters)
+ ? options.parameters.map((parameter) => ({ ...parameter }))
+ : [];
+ if (eventNode && eventNode.type === "custom_event" && !options.parameters) {
+ const structure = this.#ensureCustomEventStructure(eventNode);
+ parameters = structure.parameters.map((parameter) => ({ ...parameter }));
+ }
+
+ const pinResult = this.#applyCallCustomEventNodePins(node, parameters);
+ if (pinResult.pinsChanged) {
+ this.nodeRenderer.rebuildNodePins(node, { inputs: true, outputs: false });
+ }
+ this.#syncCallCustomEventArguments(node, parameters);
+
+ let label = "";
+ if (eventNode && eventNode.type === "custom_event") {
+ label = this.#resolveCustomEventLabel(eventNode);
+ }
+
+ const titleText = label ? `Call ${label}` : "Call Custom Event";
+ node.title = titleText;
+
+ const article = this.nodeElements.get(nodeId);
+ if (article) {
+ article.classList.add("blueprint-node--custom-event-call");
+ if (label && eventId) {
+ article.dataset.targetEventId = eventId;
+ article.dataset.targetEventName = label;
+ article.dataset.targetEventParamCount = String(parameters.length);
+ } else {
+ delete article.dataset.targetEventId;
+ delete article.dataset.targetEventName;
+ delete article.dataset.targetEventParamCount;
+ }
+ const titleElement = article.querySelector(".node-title");
+ if (titleElement instanceof HTMLElement) {
+ titleElement.textContent = titleText;
+ }
+ }
+
+ return pinResult.connectionsMutated;
+ }
+
+ /**
+ * Re-renders the inspector panel when the supplied node is the active selection.
+ *
+ * @param {string} nodeId Node identifier to check.
+ */
+ #refreshInspectorIfActive(nodeId, propertyKey) {
+ if (
+ this.selectedNodeIds.size === 1 &&
+ this.selectedNodeId === nodeId &&
+ this.inspectorView === "default"
+ ) {
+ if (propertyKey) {
+ this.pendingInspectorFocus = { nodeId, property: propertyKey };
+ }
+ this.#renderInspector(nodeId);
+ }
+ }
+
+ /**
+ * Updates every call node referencing the specified custom event.
+ *
+ * @param {string} eventNodeId Custom event node identifier.
+ */
+ #refreshCallNodesForEvent(eventNodeId) {
+ const eventNode = this.graph.nodes.get(eventNodeId);
+ const structure =
+ eventNode && eventNode.type === "custom_event"
+ ? this.#ensureCustomEventStructure(eventNode)
+ : { parameters: [], connectionsMutated: false };
+ this.#syncCallNodesForEvent(eventNodeId, structure.parameters);
+ }
+
+ /**
+ * Clears references to a custom event that has been removed from the graph.
+ *
+ * @param {string} eventNodeId Identifier for the removed event node.
+ */
+ #handleCustomEventRemoval(eventNodeId) {
+ this.graph.nodes.forEach((node) => {
+ if (node.type !== "call_custom_event") {
+ return;
+ }
+ const targetId =
+ typeof node.properties.eventId === "string"
+ ? node.properties.eventId
+ : "";
+ if (targetId !== eventNodeId) {
+ return;
+ }
+ this.graph.setNodeProperty(node.id, "eventId", "");
+ });
+ }
+
+ /**
+ * Renders the event list summarizing entry nodes.
+ */
+ #renderEventList() {
+ renderEventList({
+ element: this.eventListElement,
+ registry: this.registry,
+ graph: this.graph,
+ focusNode: (nodeId) => {
+ this.navigationManager.focusNode(nodeId);
+ },
+ renderEmpty: (message) => {
+ if (this.eventListElement) {
+ this.#renderOverviewEmpty(this.eventListElement, message);
+ }
+ },
+ isEventNode: (node, entryTypes) => this.#isEventNode(node, entryTypes),
+ });
+ }
+
+ /**
+ * Renders the variable list summarizing set/get nodes.
+ */
+ #renderVariableList() {
+ renderVariableList({
+ element: this.variableListElement,
+ variableOrder: this.variableOrder,
+ variables: this.variables,
+ graph: this.graph,
+ cancelVariableRename: () => this.#cancelVariableRename(),
+ closeVariableTypeDropdown: () => this.#closeTypeDropdown(),
+ renderEmpty: (message) => {
+ if (this.variableListElement) {
+ this.#renderOverviewEmpty(this.variableListElement, message);
+ }
+ },
+ normalizeVariableName: (name) => this.#normalizeVariableName(name),
+ formatVariableType: (type) => this.#formatVariableType(type),
+ selectVariable: (variableId) => this.#selectVariable(variableId),
+ handleVariableDragStart: (event, variableId) =>
+ this.dropManager.handleVariableDragStart(event, variableId),
+ handleVariableDragEnd: () => this.dropManager.handleVariableDragEnd(),
+ reorderVariable: (variableId, targetIndex) =>
+ this.#reorderVariable(variableId, targetIndex),
+ openVariableTypeDropdown: (variableId, button, anchor) =>
+ this.#openVariableTypeDropdown(variableId, button, anchor),
+ deleteVariable: (variableId) => this.#deleteVariable(variableId),
+ startVariableRename: (variableId, label) =>
+ this.#startVariableRename(variableId, label),
+ highlightVariableSelection: () => this.#highlightVariableSelection(),
+ isVariableDropdownOpen: (variableId) =>
+ this.#isTypeDropdownOpen({ kind: "variable", variableId }),
+ });
+ }
+
+ /**
+ * Appends an empty-state message to the provided overview list.
+ *
+ * @param {HTMLUListElement} list Target list element.
+ * @param {string} message Empty-state message.
+ */
+ #renderOverviewEmpty(list, message) {
+ const item = document.createElement("li");
+ const placeholder = document.createElement("p");
+ placeholder.className = "overview-empty";
+ placeholder.textContent = message;
+ item.appendChild(placeholder);
+ list.appendChild(item);
+ }
+
+ /**
+ * Determines whether a node qualifies as an event for the overview list.
+ *
+ * @param {BlueprintNode} node Node to inspect.
+ * @param {Set} entryTypes Node types designated as entry points.
+ * @returns {boolean}
+ */
+ #isEventNode(node, entryTypes) {
+ const definition = this.registry.get(node.type);
+ if (!definition) {
+ return false;
+ }
+
+ if (entryTypes.has(node.type) && node.type !== "call_custom_event") {
+ return true;
+ }
+
+ if (node.type === "custom_event") {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Prepares drag metadata for variable overview interactions.
+ *
+ * @param {DragEvent} event Drag event payload.
+ * @param {string} variableId Variable identifier being dragged.
+ */
+ /**
+ * Handles pointer interactions with rendered connection paths, enabling quick removal.
+ *
+ * @param {PointerEvent} event Pointer interaction payload.
+ */
+ #handleConnectionPointerDown(event) {
+ if (!(event.target instanceof SVGPathElement)) {
+ return;
+ }
+
+ if (event.button !== 0 || !event.altKey) {
+ return;
+ }
+
+ const path = /** @type {SVGPathElement} */ (event.target);
+ const connectionId = path.dataset.connectionId;
+ if (!connectionId) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ this.graph.removeConnection(connectionId);
+ }
+
+ /**
+ * Creates a node instance from a registry definition or shortcut entry.
+ *
+ * @param {PaletteNodeDefinition} definition Definition describing the node to spawn.
+ * @param {{ position?: { x: number, y: number } }} [options] Spawn options.
+ * @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
+ */
+ createNodeFromDefinition(definition, options = {}) {
+ if (!definition) {
+ return null;
+ }
+
+ const shortcut = definition.shortcut;
+ if (shortcut?.type === "variable") {
+ const targetId = shortcut.action === "set" ? "set_var" : "get_var";
+ const node = this.addNode(targetId, options.position);
+ if (!node) {
+ return null;
+ }
+
+ const variable = this.variables.get(shortcut.variableId);
+ const rawName = variable?.name ?? "";
+ const label = rawName.trim() || variable?.id || shortcut.variableId;
+ this.graph.setNodeProperty(node.id, "variableId", shortcut.variableId);
+ this.graph.setNodeProperty(node.id, "name", label);
+ this.#refreshVariableNode(node.id);
+ return node;
+ }
+
+ let node = null;
+ if (shortcut?.type === "custom_event_call") {
+ node = this.addNode("call_custom_event", options.position);
+ if (!node) {
+ return null;
+ }
+ this.graph.setNodeProperty(node.id, "eventId", shortcut.eventNodeId);
+ } else {
+ node = this.addNode(definition.id, options.position);
+ if (!node) {
+ return null;
+ }
+ }
+
+ if (node.type === "custom_event") {
+ const currentName =
+ typeof node.properties.name === "string" ? node.properties.name : "";
+ const uniqueName = this.#generateUniqueCustomEventName(
+ currentName || "CustomEvent",
+ node.id
+ );
+ if (uniqueName !== currentName) {
+ this.graph.setNodeProperty(node.id, "name", uniqueName);
+ } else {
+ this.#refreshCustomEventNode(node.id);
+ }
+ this.pendingInspectorFocus = { nodeId: node.id, property: "name" };
+ } else if (node.type === "call_custom_event") {
+ if (!shortcut || shortcut.type !== "custom_event_call") {
+ this.pendingInspectorFocus = { nodeId: node.id, property: "eventId" };
+ }
+ this.#refreshCustomEventCallNode(node.id);
+ }
+
+ return node;
+ }
+
+ /**
+ * Spawns a node at the requested context menu position.
+ *
+ * @param {PaletteNodeDefinition} definition Node definition to instantiate.
+ * @param {{ x: number, y: number }} spawnPosition Workspace-relative spawn coordinates.
+ * @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
+ */
+ spawnNodeFromContextMenu(definition, spawnPosition) {
+ const clamped = {
+ x: Math.max(0, spawnPosition.x),
+ y: Math.max(0, spawnPosition.y),
+ };
+ const worldSpawn = this.navigationManager.screenPointToWorld(clamped);
+ const spawn = WorkspaceGeometry.snapPositionToGrid(this, worldSpawn);
+ const pendingConnectionSnapshot = this.pendingConnectionSpawn
+ ? {
+ startRef: {
+ nodeId: this.pendingConnectionSpawn.startRef.nodeId,
+ pinId: this.pendingConnectionSpawn.startRef.pinId,
+ },
+ direction: this.pendingConnectionSpawn.direction,
+ kind: this.pendingConnectionSpawn.kind,
+ }
+ : null;
+ this.pendingConnectionSpawn = null;
+ const node = this.createNodeFromDefinition(definition, { position: spawn });
+ if (
+ node &&
+ this.pendingVariableSpawn &&
+ (definition.id === "set_var" || definition.id === "get_var")
+ ) {
+ const variable = this.variables.get(this.pendingVariableSpawn.variableId);
+ if (variable) {
+ const nodeName = variable.name || variable.id;
+ this.graph.setNodeProperty(node.id, "name", nodeName);
+ this.graph.setNodeProperty(node.id, "variableId", variable.id);
+ }
+ this.pendingVariableSpawn = null;
+ }
+ if (node && pendingConnectionSnapshot) {
+ this.#connectSpawnedNodeToPending(node, pendingConnectionSnapshot);
+ }
+ return node;
+ }
+
+ /**
+ * Computes a spawn position based on the workspace size and spawn index.
+ *
+ * @param {string} definitionId Node definition identifier.
+ * @param {number} index Spawn index.
+ * @returns {{x:number,y:number}}
+ */
+ #computeSpawnPosition(definitionId, index) {
+ const bounds = this.workspaceElement.getBoundingClientRect();
+ const zoom = this.zoomLevel || 1;
+ const width = bounds.width / zoom;
+ const height = bounds.height / zoom;
+ const offset = (index % 5) * 30;
+ const baseX = width * 0.3;
+ const baseY = height * 0.3;
+ const jitterX = (index * 37) % 120;
+ const jitterY = (index * 53) % 120;
+ const categoryOffset =
+ this.registry.get(definitionId)?.category.length ?? 0;
+ return WorkspaceGeometry.snapPositionToGrid(this, {
+ x: Math.max(40, baseX + offset + jitterX + categoryOffset),
+ y: Math.max(40, baseY + offset + jitterY),
+ });
+ }
+
+ /**
+ * Initiates a node drag operation.
+ *
+ * @param {PointerEvent} event Pointer event.
+ * @param {string} nodeId Dragged node identifier.
+ */
+ #startDraggingNode(event, nodeId) {
+ if (!this.dragManager) {
+ return;
+ }
+ this.dragManager.startNodeDrag(event, nodeId);
+ }
+
+ /**
+ * Attempts to connect the selected node to the nearest compatible pin.
+ *
+ * @returns {boolean}
+ */
+ #tryAutoConnectSelectedNode() {
+ if (this.selectedNodeIds.size !== 1 || !this.selectedNodeId) {
+ return false;
+ }
+
+ const node = this.graph.nodes.get(this.selectedNodeId);
+ if (!node) {
+ return false;
+ }
+
+ let connected = false;
+
+ const pins = [
+ ...node.outputs.filter((pin) =>
+ this.#isAutoConnectStartPinAvailable(node.id, pin)
+ ),
+ ...node.inputs.filter((pin) =>
+ this.#isAutoConnectStartPinAvailable(node.id, pin)
+ ),
+ ];
+
+ for (const pin of pins) {
+ const candidates = this.#findAutoConnectTargets(node, pin);
+ for (const candidate of candidates) {
+ if (candidate.pruneExistingConnections) {
+ this.graph.removeConnectionsForPin({
+ nodeId: candidate.nodeId,
+ pinId: candidate.pinId,
+ });
+ }
+ const success =
+ pin.direction === "output"
+ ? this.graph.connect(
+ { nodeId: node.id, pinId: pin.id },
+ { nodeId: candidate.nodeId, pinId: candidate.pinId }
+ )
+ : this.graph.connect(
+ { nodeId: candidate.nodeId, pinId: candidate.pinId },
+ { nodeId: node.id, pinId: pin.id }
+ );
+ if (success) {
+ connected = true;
+ break;
+ }
+ }
+ }
+
+ return connected;
+ }
+
+ /**
+ * Evaluates if a pin is eligible to initiate an auto-connect attempt.
+ *
+ * @param {string} nodeId Node identifier for the pin owner.
+ * @param {import('../core/BlueprintNode.js').PinDescriptor} pin Pin descriptor to inspect.
+ * @returns {boolean}
+ */
+ #isAutoConnectStartPinAvailable(nodeId, pin) {
+ const connections = this.graph.getConnectionsForNode(nodeId, pin.id);
+ return connections.length === 0;
+ }
+
+ /**
+ * Gathers compatible target pins ordered by proximity to the starting pin.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} startNode Node containing the starting pin.
+ * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor.
+ * @returns {Array<{ nodeId: string, pinId: string, distance: number }>}
+ */
+ #findAutoConnectTargets(startNode, startPin) {
+ const startCenter = this.#getPinHandleCenter(
+ startNode.id,
+ startPin.id,
+ startPin.direction
+ );
+ if (!startCenter) {
+ return [];
+ }
+
+ const expectedDirection =
+ startPin.direction === "output" ? "input" : "output";
+ /** @type {Array<{ nodeId: string, distance: number, pins: Array<{ pinId: string, pruneExistingConnections: boolean }> }> } */
+ const nodes = [];
+
+ this.graph.nodes.forEach((candidateNode) => {
+ if (candidateNode.id === startNode.id) {
+ return;
+ }
+
+ const pins =
+ expectedDirection === "input"
+ ? candidateNode.inputs
+ : candidateNode.outputs;
+ /** @type {Array<{ pinId: string, pruneExistingConnections: boolean }>} */
+ const compatiblePins = [];
+ let nodeDistance = Infinity;
+
+ pins.forEach((candidatePin) => {
+ if (
+ !this.#canPinsConnect(
+ startNode,
+ startPin,
+ startPin.direction,
+ candidateNode,
+ candidatePin
+ )
+ ) {
+ return;
+ }
+
+ const candidateConnections = this.graph.getConnectionsForNode(
+ candidateNode.id,
+ candidatePin.id
+ );
+
+ let pruneExistingConnections = false;
+
+ if (startPin.direction === "output") {
+ if (candidateConnections.length > 0) {
+ if (candidatePin.kind === "exec") {
+ pruneExistingConnections = true;
+ } else {
+ return;
+ }
+ }
+ } else if (
+ candidatePin.kind !== "exec" &&
+ candidateConnections.length > 0
+ ) {
+ return;
+ }
+
+ const candidateCenter = this.#getPinHandleCenter(
+ candidateNode.id,
+ candidatePin.id,
+ candidatePin.direction
+ );
+ if (!candidateCenter) {
+ return;
+ }
+
+ const distance = Math.hypot(
+ candidateCenter.x - startCenter.x,
+ candidateCenter.y - startCenter.y
+ );
+ if (distance > AUTO_CONNECT_DISTANCE) {
+ return;
+ }
+
+ if (distance < nodeDistance) {
+ nodeDistance = distance;
+ }
+
+ compatiblePins.push({
+ pinId: candidatePin.id,
+ pruneExistingConnections,
+ });
+ });
+
+ if (compatiblePins.length) {
+ nodes.push({
+ nodeId: candidateNode.id,
+ distance: nodeDistance,
+ pins: compatiblePins,
+ });
+ }
+ });
+ /** @type {Array<{ nodeId: string, pinId: string, distance: number, pruneExistingConnections: boolean }> } */
+ nodes.sort((a, b) => a.distance - b.distance);
+
+ /** @type {Array<{ nodeId: string, pinId: string, distance: number }> } */
+ const ordered = [];
+ nodes.forEach((entry) => {
+ entry.pins.forEach((pin) => {
+ ordered.push({
+ pruneExistingConnections: pin.pruneExistingConnections,
+ nodeId: entry.nodeId,
+ pinId: pin.pinId,
+ distance: entry.distance,
+ });
+ });
+ });
+
+ return ordered;
+ }
+
+ /**
+ * Begins a connection gesture from the provided pin.
+ *
+ * @param {PointerEvent} event Pointer event reference.
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @param {'input'|'output'} direction Pin direction.
+ */
+ #beginConnection(event, nodeId, pinId, direction) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node) {
+ return;
+ }
+
+ const pin = node.getPin(pinId);
+ if (!pin) {
+ return;
+ }
+
+ event.stopPropagation();
+ this.pendingConnectionSpawn = null;
+ const path = this.#createConnectionPath(pin.kind);
+ this.pendingConnection = {
+ direction,
+ source: direction === "output" ? { nodeId, pinId } : undefined,
+ target: direction === "input" ? { nodeId, pinId } : undefined,
+ path,
+ };
+
+ this.#highlightPinsForPending(nodeId, pinId, direction);
+
+ const handlePointerMove = (moveEvent) => {
+ this.#updatePendingConnectionPath(
+ moveEvent.clientX,
+ moveEvent.clientY,
+ nodeId,
+ pinId,
+ direction
+ );
+ };
+
+ const handlePointerUp = (upEvent) => {
+ if (upEvent.pointerId !== event.pointerId) {
+ return;
+ }
+
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerUp);
+
+ const openedMenu = this.#tryOpenPendingConnectionMenu(upEvent);
+ if (!openedMenu) {
+ this.#cancelPendingConnection();
+ }
+ };
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerUp);
+ }
+
+ /**
+ * Updates the temporary connection path during a drag gesture.
+ *
+ * @param {number} clientX Pointer X coordinate.
+ * @param {number} clientY Pointer Y coordinate.
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @param {'input'|'output'} direction Pin direction.
+ */
+ #updatePendingConnectionPath(clientX, clientY, nodeId, pinId, direction) {
+ if (!this.pendingConnection) {
+ return;
+ }
+
+ const startHandle = this.#getPinHandle(nodeId, pinId, direction);
+ if (!startHandle) {
+ return;
+ }
+
+ const workspaceRect = this.workspaceElement.getBoundingClientRect();
+ const startRect = startHandle.getBoundingClientRect();
+ const zoom = this.zoomLevel || 1;
+ const anchor = {
+ x:
+ (startRect.left - workspaceRect.left + startRect.width / 2) /
+ zoom,
+ y:
+ (startRect.top - workspaceRect.top + startRect.height / 2) /
+ zoom,
+ };
+ const pointer = {
+ x: (clientX - workspaceRect.left) / zoom,
+ y: (clientY - workspaceRect.top) / zoom,
+ };
+
+ const start = direction === "output" ? anchor : pointer;
+ const end = direction === "output" ? pointer : anchor;
+
+ const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5);
+ const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
+ start.y
+ } ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
+ this.pendingConnection.path.dataset.active = "true";
+ this.pendingConnection.path.setAttribute("d", d);
+ }
+
+ /**
+ * Finalizes a pending connection if the gesture ends on a compatible pin.
+ *
+ * @param {string} nodeId Node identifier for the target pin.
+ * @param {string} pinId Pin identifier for the target pin.
+ * @param {'input'|'output'} direction Pin direction.
+ */
+ #finalizeConnection(nodeId, pinId, direction) {
+ if (!this.pendingConnection) {
+ return;
+ }
+
+ const { source, target } = this.pendingConnection;
+ if (direction === "input" && source) {
+ this.graph.connect(source, { nodeId, pinId });
+ } else if (direction === "output" && target) {
+ this.graph.connect({ nodeId, pinId }, target);
+ }
+
+ this.#cancelPendingConnection();
+ }
+
+ /**
+ * Cancels the current pending connection gesture.
+ */
+ #cancelPendingConnection() {
+ if (this.pendingConnection) {
+ this.pendingConnection.path.remove();
+ this.pendingConnection = null;
+ }
+ this.#clearPinHighlights();
+ }
+
+ /**
+ * Opens the context menu using compatible node definitions when a connection drag ends without a target.
+ *
+ * @param {PointerEvent} upEvent Pointer event containing the release coordinates.
+ * @returns {boolean} Whether a menu was shown.
+ */
+ #tryOpenPendingConnectionMenu(upEvent) {
+ if (!this.pendingConnection) {
+ return false;
+ }
+
+ const startRef =
+ this.pendingConnection.source ?? this.pendingConnection.target;
+ if (!startRef) {
+ return false;
+ }
+
+ const startNode = this.graph.nodes.get(startRef.nodeId);
+ if (!startNode) {
+ return false;
+ }
+
+ const startPin = startNode.getPin(startRef.pinId);
+ if (!startPin) {
+ return false;
+ }
+
+ const compatibleDefinitions = this.#getCompatibleDefinitionsForPending(
+ startPin,
+ this.pendingConnection.direction
+ );
+ if (!compatibleDefinitions.length) {
+ return false;
+ }
+
+ this.pendingConnectionSpawn = {
+ startRef: { nodeId: startRef.nodeId, pinId: startRef.pinId },
+ direction: this.pendingConnection.direction,
+ kind: startPin.kind,
+ };
+
+ const kindLabel = typeof startPin.kind === "string" ? startPin.kind : "";
+ const friendlyKind = kindLabel
+ ? kindLabel.charAt(0).toUpperCase() + kindLabel.slice(1)
+ : "";
+ const placeholder = friendlyKind
+ ? `Compatible ${friendlyKind} nodes`
+ : "Search nodes";
+
+ this.#cancelPendingConnection();
+ this.contextMenuManager.show(upEvent.clientX, upEvent.clientY, {
+ definitions: compatibleDefinitions,
+ query: kindLabel,
+ mode: "connection",
+ placeholder,
+ });
+
+ return true;
+ }
+
+ /**
+ * Identifies node definitions that can connect to the active pending connection.
+ *
+ * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor.
+ * @param {'input'|'output'} startDirection Starting pin direction.
+ * @returns {Array}
+ */
+ #getCompatibleDefinitionsForPending(startPin, startDirection) {
+ const expectedDirection = startDirection === "output" ? "input" : "output";
+ return this.registry.list().filter((definition) => {
+ const pins =
+ expectedDirection === "input" ? definition.inputs : definition.outputs;
+ return pins.some((candidatePin) => {
+ if (startDirection === "output") {
+ return this.#arePinKindsCompatible(startPin.kind, candidatePin.kind);
+ }
+ return this.#arePinKindsCompatible(candidatePin.kind, startPin.kind);
+ });
+ });
+ }
+
+ /**
+ * Connects a newly spawned node to the pin that initiated the spawn request.
+ *
+ * @param {BlueprintNode} node Newly created node.
+ * @param {{ startRef: { nodeId: string, pinId: string }, direction: 'input'|'output', kind: string }} spawnInfo Connection spawn metadata.
+ */
+ #connectSpawnedNodeToPending(node, spawnInfo) {
+ if (!spawnInfo) {
+ return;
+ }
+
+ const { startRef, direction } = spawnInfo;
+ const startNode = this.graph.nodes.get(startRef.nodeId);
+ if (!startNode) {
+ return;
+ }
+
+ const startPin = startNode.getPin(startRef.pinId);
+ if (!startPin) {
+ return;
+ }
+
+ const candidatePins = direction === "output" ? node.inputs : node.outputs;
+ const compatiblePin = candidatePins.find((pin) => {
+ if (direction === "output") {
+ return this.#arePinKindsCompatible(startPin.kind, pin.kind);
+ }
+ return this.#arePinKindsCompatible(pin.kind, startPin.kind);
+ });
+
+ if (!compatiblePin) {
+ return;
+ }
+
+ if (direction === "output") {
+ this.graph.connect(
+ { nodeId: startRef.nodeId, pinId: startRef.pinId },
+ { nodeId: node.id, pinId: compatiblePin.id }
+ );
+ } else {
+ this.graph.connect(
+ { nodeId: node.id, pinId: compatiblePin.id },
+ { nodeId: startRef.nodeId, pinId: startRef.pinId }
+ );
+ }
+ }
+
+ /**
+ * Creates a connection path element styled for the given kind.
+ *
+ * @param {string} kind Pin kind.
+ * @returns {SVGPathElement}
+ */
+ #createConnectionPath(kind) {
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.classList.add("connection-path");
+ path.dataset.type = kind;
+ this.connectionLayer.appendChild(path);
+ return path;
+ }
+
+ /**
+ * Requests a connection re-render on the next animation frame.
+ */
+ scheduleConnectionRefresh() {
+ this.#scheduleConnectionRefresh();
+ }
+
+ /**
+ * Cancels any pending connection refresh.
+ */
+ cancelConnectionRefresh() {
+ this.#cancelConnectionRefresh();
+ }
+
+ /**
+ * Forces a connection refresh immediately.
+ */
+ flushConnectionRefresh() {
+ this.#flushConnectionRefresh();
+ }
+
+ /**
+ * Requests a connection re-render on the next animation frame.
+ */
+ #scheduleConnectionRefresh() {
+ if (this.connectionRefreshFrame !== null) {
+ return;
+ }
+
+ this.connectionRefreshFrame = window.requestAnimationFrame(() => {
+ this.connectionRefreshFrame = null;
+ this.#renderConnections();
+ });
+ }
+
+ /**
+ * Cancels a pending connection refresh request.
+ */
+ #cancelConnectionRefresh() {
+ if (this.connectionRefreshFrame !== null) {
+ window.cancelAnimationFrame(this.connectionRefreshFrame);
+ this.connectionRefreshFrame = null;
+ }
+ }
+
+ /**
+ * Executes any pending connection refresh immediately.
+ */
+ #flushConnectionRefresh() {
+ if (this.connectionRefreshFrame !== null) {
+ window.cancelAnimationFrame(this.connectionRefreshFrame);
+ this.connectionRefreshFrame = null;
+ }
+ this.#renderConnections();
+ }
+
+ /**
+ * Re-renders all connection paths to match their origin nodes.
+ */
+ #renderConnections() {
+ const zoom = this.zoomLevel || 1;
+ const workspaceRect = this.workspaceElement.getBoundingClientRect();
+ if (this.connectionLayer instanceof SVGElement) {
+ const targetWidth = Math.max(1, workspaceRect.width / zoom);
+ const targetHeight = Math.max(1, workspaceRect.height / zoom);
+ this.connectionLayer.style.width = `${targetWidth}px`;
+ this.connectionLayer.style.height = `${targetHeight}px`;
+ if (this.connectionLayer.hasAttribute("viewBox")) {
+ this.connectionLayer.removeAttribute("viewBox");
+ }
+ if (this.connectionLayer.hasAttribute("preserveAspectRatio")) {
+ this.connectionLayer.removeAttribute("preserveAspectRatio");
+ }
+ }
+ const activeIds = new Set();
+ this.graph.getConnections().forEach((connection) => {
+ activeIds.add(connection.id);
+ let path = this.connectionElements.get(connection.id);
+ if (!path) {
+ path = this.#createConnectionPath(connection.kind);
+ this.connectionElements.set(connection.id, path);
+ }
+
+ path.dataset.type = connection.kind;
+ path.dataset.connectionId = connection.id;
+ const geometry = this.#computeConnectionGeometry(
+ connection.from,
+ connection.to
+ );
+ if (!geometry) {
+ return;
+ }
+
+ const { start, end } = geometry;
+ const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5);
+ const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
+ start.y
+ } ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
+ path.dataset.active = "false";
+ path.setAttribute("d", d);
+ });
+
+ this.connectionElements.forEach((path, id) => {
+ if (!activeIds.has(id)) {
+ path.remove();
+ this.connectionElements.delete(id);
+ }
+ });
+ }
+
+ /**
+ * Computes the screen-space geometry for a connection between two pins.
+ *
+ * @param {{nodeId:string,pinId:string}} from Origin pin reference.
+ * @param {{nodeId:string,pinId:string}} to Target pin reference.
+ * @returns {{start:{x:number,y:number},end:{x:number,y:number}} | undefined}
+ */
+ #computeConnectionGeometry(from, to) {
+ const startHandle = this.#getPinHandle(from.nodeId, from.pinId, "output");
+ const endHandle = this.#getPinHandle(to.nodeId, to.pinId, "input");
+ if (!startHandle || !endHandle) {
+ return undefined;
+ }
+
+ const workspaceRect = this.workspaceElement.getBoundingClientRect();
+ const zoom = this.zoomLevel || 1;
+ const startRect = startHandle.getBoundingClientRect();
+ const endRect = endHandle.getBoundingClientRect();
+ return {
+ start: {
+ x:
+ (startRect.left - workspaceRect.left + startRect.width / 2) /
+ zoom,
+ y:
+ (startRect.top - workspaceRect.top + startRect.height / 2) /
+ zoom,
+ },
+ end: {
+ x:
+ (endRect.left - workspaceRect.left + endRect.width / 2) / zoom,
+ y:
+ (endRect.top - workspaceRect.top + endRect.height / 2) / zoom,
+ },
+ };
+ }
+
+ /**
+ * Resolves a pin handle element given node and pin identifiers.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @param {'input'|'output'} direction Pin direction.
+ * @returns {HTMLElement | null}
+ */
+ #getPinHandle(nodeId, pinId, direction) {
+ const element = this.nodeElements.get(nodeId);
+ if (!element) {
+ return null;
+ }
+
+ return element.querySelector(
+ `.pin[data-pin-id="${pinId}"][data-direction="${direction}"] .pin-handle`
+ );
+ }
+
+ /**
+ * Resolves the workspace-relative center point for a pin handle.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @param {'input'|'output'} direction Pin direction.
+ * @returns {{x:number,y:number} | null}
+ */
+ #getPinHandleCenter(nodeId, pinId, direction) {
+ const handle = this.#getPinHandle(nodeId, pinId, direction);
+ if (!handle) {
+ return null;
+ }
+
+ const rect = handle.getBoundingClientRect();
+ const workspaceRect = this.workspaceElement.getBoundingClientRect();
+ const zoom = this.zoomLevel || 1;
+
+ return {
+ x:
+ (rect.left - workspaceRect.left + rect.width / 2) /
+ zoom,
+ y:
+ (rect.top - workspaceRect.top + rect.height / 2) /
+ zoom,
+ };
+ }
+
+ /**
+ * Retrieves the pin container element for a given node and pin.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @param {'input'|'output'} direction Pin direction.
+ * @returns {HTMLElement | null}
+ */
+ #getPinElement(nodeId, pinId, direction) {
+ const element = this.nodeElements.get(nodeId);
+ if (!element) {
+ return null;
+ }
+
+ return element.querySelector(
+ `.pin[data-pin-id="${pinId}"][data-direction="${direction}"]`
+ );
+ }
+
+ /**
+ * Highlights valid drop targets for the active pending connection.
+ *
+ * @param {string} nodeId Starting node identifier.
+ * @param {string} pinId Starting pin identifier.
+ * @param {'input'|'output'} direction Pin direction used to initiate the drag.
+ */
+ #highlightPinsForPending(nodeId, pinId, direction) {
+ this.#clearPinHighlights();
+
+ const startNode = this.graph.nodes.get(nodeId);
+ if (!startNode) {
+ return;
+ }
+
+ const startPin = startNode.getPin(pinId);
+ if (!startPin) {
+ return;
+ }
+
+ const startElement = this.#getPinElement(nodeId, pinId, direction);
+ if (startElement) {
+ startElement.classList.add("is-drag-source");
+ this.highlightedPins.add(startElement);
+ }
+
+ const expectedDirection = direction === "output" ? "input" : "output";
+ this.graph.nodes.forEach((candidateNode) => {
+ if (candidateNode.id === nodeId) {
+ return;
+ }
+
+ const pins =
+ expectedDirection === "input"
+ ? candidateNode.inputs
+ : candidateNode.outputs;
+ pins.forEach((candidatePin) => {
+ if (
+ this.#canPinsConnect(
+ startNode,
+ startPin,
+ direction,
+ candidateNode,
+ candidatePin
+ )
+ ) {
+ const element = this.#getPinElement(
+ candidateNode.id,
+ candidatePin.id,
+ candidatePin.direction
+ );
+ if (element) {
+ element.classList.add("is-drop-target");
+ this.highlightedPins.add(element);
+ }
+ }
+ });
+ });
+ }
+
+ /**
+ * Determines whether a pin should display hover styling as a drop target.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @param {'input'|'output'} direction Pin direction.
+ * @returns {boolean}
+ */
+ #shouldHighlightHover(nodeId, pinId, direction) {
+ return this.#isPinCompatibleWithPending(nodeId, pinId, direction);
+ }
+
+ /**
+ * Checks if a pin is compatible with the current pending connection.
+ *
+ * @param {string} nodeId Candidate node identifier.
+ * @param {string} pinId Candidate pin identifier.
+ * @param {'input'|'output'} direction Candidate pin direction.
+ * @returns {boolean}
+ */
+ #isPinCompatibleWithPending(nodeId, pinId, direction) {
+ if (!this.pendingConnection) {
+ return false;
+ }
+
+ const startRef =
+ this.pendingConnection.source ?? this.pendingConnection.target;
+ if (!startRef) {
+ return false;
+ }
+
+ if (startRef.nodeId === nodeId && startRef.pinId === pinId) {
+ return false;
+ }
+
+ const startNode = this.graph.nodes.get(startRef.nodeId);
+ const candidateNode = this.graph.nodes.get(nodeId);
+ if (!startNode || !candidateNode) {
+ return false;
+ }
+
+ const startPin = startNode.getPin(startRef.pinId);
+ const candidatePin = candidateNode.getPin(pinId);
+ if (!startPin || !candidatePin) {
+ return false;
+ }
+
+ return this.#canPinsConnect(
+ startNode,
+ startPin,
+ this.pendingConnection.direction,
+ candidateNode,
+ candidatePin
+ );
+ }
+
+ /**
+ * Removes highlight classes from every tracked pin element.
+ */
+ #clearPinHighlights() {
+ this.highlightedPins.forEach((element) => {
+ element.classList.remove(
+ "is-drag-source",
+ "is-drop-target",
+ "is-drop-hover"
+ );
+ });
+ this.highlightedPins.clear();
+ }
+
+ /**
+ * Determines if two pins can connect based on direction and kind.
+ *
+ * @param {BlueprintNode} startNode Starting node.
+ * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor.
+ * @param {'input'|'output'} startDirection Direction of the starting pin.
+ * @param {BlueprintNode} candidateNode Candidate node.
+ * @param {import('../core/BlueprintNode.js').PinDescriptor} candidatePin Candidate pin descriptor.
+ * @returns {boolean}
+ */
+ #canPinsConnect(
+ startNode,
+ startPin,
+ startDirection,
+ candidateNode,
+ candidatePin
+ ) {
+ if (startNode.id === candidateNode.id) {
+ return false;
+ }
+
+ const expectedDirection = startDirection === "output" ? "input" : "output";
+ if (candidatePin.direction !== expectedDirection) {
+ return false;
+ }
+
+ let outputKind;
+ let inputKind;
+ if (startDirection === "output") {
+ outputKind = startPin.kind;
+ inputKind = candidatePin.kind;
+ } else {
+ outputKind = candidatePin.kind;
+ inputKind = startPin.kind;
+ }
+
+ return this.#arePinKindsCompatible(outputKind, inputKind);
+ }
+
+ /**
+ * Determines if the output and input kinds are compatible under blueprint rules.
+ *
+ * @param {string} outputKind Output pin kind.
+ * @param {string} inputKind Input pin kind.
+ * @returns {boolean}
+ */
+ #arePinKindsCompatible(outputKind, inputKind) {
+ if (inputKind === "any" || outputKind === "any") {
+ return true;
+ }
+ return outputKind === inputKind;
+ }
+
+ /**
+ * Renders inspector controls for the selected node.
+ *
+ * @param {string} nodeId Node identifier.
+ */
+ #renderInspector(nodeId) {
+ const node = this.graph.nodes.get(nodeId);
+ if (!node) {
+ this.inspectorContent.innerHTML = "";
+ return;
+ }
+
+ const definition = this.registry.get(node.type);
+ if (!definition) {
+ return;
+ }
+
+ this.inspectorContent.innerHTML = "";
+
+ if (node.type === "custom_event") {
+ this.#renderCustomEventInspector(node);
+ return;
+ }
+
+ if (node.type === "sequence") {
+ this.#renderSequenceInspector(node);
+ return;
+ }
+
+ if (node.type === "call_custom_event") {
+ this.#renderCallCustomEventInspector(node);
+ return;
+ }
+
+ if (!definition.properties.length) {
+ const empty = document.createElement("p");
+ empty.textContent = "No editable properties.";
+ empty.className = "inspector-empty";
+ this.inspectorContent.appendChild(empty);
+ return;
+ }
+
+ definition.properties.forEach((schema) => {
+ const field = document.createElement("div");
+ field.className = "inspector-field";
+ const label = document.createElement("label");
+ label.textContent = schema.label;
+ label.setAttribute("for", `${nodeId}_${schema.key}`);
+ field.appendChild(label);
+
+ let control;
+ switch (schema.type) {
+ case "string":
+ control = document.createElement("input");
+ control.type = "text";
+ control.value = String(
+ node.properties[schema.key] ?? schema.defaultValue ?? ""
+ );
+ if (schema.placeholder) {
+ control.placeholder = schema.placeholder;
+ }
+ break;
+ case "number":
+ control = document.createElement("input");
+ control.type = "number";
+ control.value = String(
+ node.properties[schema.key] ?? schema.defaultValue ?? 0
+ );
+ break;
+ case "boolean":
+ control = document.createElement("input");
+ control.type = "checkbox";
+ control.checked = Boolean(node.properties[schema.key]);
+ break;
+ case "enum":
+ control = document.createElement("select");
+ (schema.options ?? []).forEach((option) => {
+ const opt = document.createElement("option");
+ opt.value = option.value;
+ opt.textContent = option.label;
+ control.appendChild(opt);
+ });
+ control.value = String(
+ node.properties[schema.key] ?? schema.defaultValue ?? ""
+ );
+ break;
+ case "multiline":
+ control = document.createElement("textarea");
+ control.rows = 3;
+ control.value = String(
+ node.properties[schema.key] ?? schema.defaultValue ?? ""
+ );
+ break;
+ default:
+ control = document.createElement("input");
+ control.type = "text";
+ control.value = String(node.properties[schema.key] ?? "");
+ break;
+ }
+
+ control.id = `${nodeId}_${schema.key}`;
+ field.appendChild(control);
+ this.inspectorContent.appendChild(field);
+
+ control.addEventListener("change", () => {
+ let value = control.value;
+ if (schema.type === "number") {
+ value = Number(control.value);
+ } else if (schema.type === "boolean") {
+ value = control.checked;
+ }
+ this.graph.setNodeProperty(nodeId, schema.key, value);
+ definition.onPropertiesChanged?.(node.properties);
+ if (schema.type === "enum") {
+ window.requestAnimationFrame(() => {
+ if (document.activeElement === control && typeof control.blur === "function") {
+ control.blur();
+ }
+ this.#focusWorkspaceSurface({ force: true });
+ });
+ }
+ });
+
+ if (schema.type === "enum") {
+ control.addEventListener("blur", () => {
+ this.#focusWorkspaceSurface();
+ });
+ }
+
+ const shouldFocus =
+ this.pendingInspectorFocus &&
+ this.pendingInspectorFocus.nodeId === nodeId &&
+ this.pendingInspectorFocus.property === schema.key;
+ if (shouldFocus && typeof control.focus === "function") {
+ requestAnimationFrame(() => {
+ control.focus();
+ if ("select" in control && typeof control.select === "function") {
+ control.select();
+ }
+ });
+ this.pendingInspectorFocus = null;
+ }
+ });
+ }
+
+ /**
+ * Renders inspector controls for sequence nodes.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference.
+ */
+ #renderSequenceInspector(node) {
+ this.inspectorContent.innerHTML = "";
+
+ const { branches } = this.#ensureSequenceBranches(node);
+
+ const header = document.createElement("div");
+ header.className = "sequence-branch-header";
+
+ const title = document.createElement("div");
+ title.className = "inspector-section-title";
+ title.textContent = "Execution Outputs";
+ header.appendChild(title);
+
+ const addButton = document.createElement("button");
+ addButton.type = "button";
+ addButton.className = "sequence-branch-add";
+ addButton.textContent = "Add Output";
+ addButton.addEventListener("click", () => {
+ this.#addSequenceBranch(node.id);
+ });
+ header.appendChild(addButton);
+
+ this.inspectorContent.appendChild(header);
+
+ const list = document.createElement("div");
+ list.className = "sequence-branch-list";
+
+ if (!branches.length) {
+ const empty = document.createElement("p");
+ empty.className = "inspector-empty";
+ empty.textContent = "No outputs configured.";
+ list.appendChild(empty);
+ } else {
+ branches.forEach((branch, index) => {
+ const row = document.createElement("div");
+ row.className = "sequence-branch-row";
+ row.dataset.branchId = branch.id;
+
+ const label = document.createElement("span");
+ label.className = "sequence-branch-label";
+ label.textContent = this.#formatSequenceBranchLabel(index);
+ row.appendChild(label);
+
+ const removeButton = document.createElement("button");
+ removeButton.type = "button";
+ removeButton.className =
+ "overview-item-action overview-item-action--remove sequence-branch-remove";
+ removeButton.title = `Delete output ${label.textContent}`;
+ removeButton.setAttribute(
+ "aria-label",
+ `Delete output ${label.textContent}`
+ );
+ removeButton.disabled = branches.length <= 1;
+ removeButton.addEventListener("click", () => {
+ this.#removeSequenceBranch(node.id, branch.id);
+ });
+ removeButton.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+ row.appendChild(removeButton);
+
+ list.appendChild(row);
+ });
+ }
+
+ this.inspectorContent.appendChild(list);
+ }
+
+ /**
+ * Renders inspector controls for a custom event node.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Node instance to inspect.
+ */
+ #renderCustomEventInspector(node) {
+ this.inspectorContent.innerHTML = "";
+
+ const pendingFocus =
+ this.pendingInspectorFocus &&
+ this.pendingInspectorFocus.nodeId === node.id
+ ? this.pendingInspectorFocus.property
+ : null;
+
+ const nameField = document.createElement("div");
+ nameField.className = "inspector-field";
+
+ const nameLabel = document.createElement("label");
+ nameLabel.setAttribute("for", `${node.id}_eventName`);
+ nameLabel.textContent = "Event Name";
+ nameField.appendChild(nameLabel);
+
+ const nameInput = document.createElement("input");
+ nameInput.type = "text";
+ nameInput.id = `${node.id}_eventName`;
+ nameInput.placeholder = "Enter event name";
+ nameInput.value =
+ typeof node.properties.name === "string" ? node.properties.name : "";
+ nameInput.addEventListener("change", () => {
+ this.graph.setNodeProperty(node.id, "name", nameInput.value);
+ });
+ nameField.appendChild(nameInput);
+
+ this.inspectorContent.appendChild(nameField);
+
+ if (pendingFocus === "name" && typeof nameInput.focus === "function") {
+ requestAnimationFrame(() => {
+ nameInput.focus();
+ if (typeof nameInput.select === "function") {
+ nameInput.select();
+ }
+ });
+ this.pendingInspectorFocus = null;
+ }
+
+ const header = document.createElement("div");
+ header.className = "custom-event-parameter-header";
+
+ const title = document.createElement("div");
+ title.className = "inspector-section-title";
+ title.textContent = "Parameters";
+ header.appendChild(title);
+
+ const addButton = document.createElement("button");
+ addButton.type = "button";
+ addButton.className = "custom-event-parameter-add";
+ addButton.textContent = "Add Parameter";
+ addButton.addEventListener("click", () => {
+ const created = this.#addCustomEventParameter(node.id);
+ if (created) {
+ this.pendingInspectorFocus = {
+ nodeId: node.id,
+ property: `parameter:${created.id}`,
+ };
+ }
+ });
+ header.appendChild(addButton);
+
+ this.inspectorContent.appendChild(header);
+
+ const list = document.createElement("div");
+ list.className = "custom-event-parameter-list";
+
+ const { parameters } = this.#sanitizeCustomEventParameters(node);
+
+ if (!parameters.length) {
+ const empty = document.createElement("p");
+ empty.className = "inspector-empty";
+ empty.textContent = "No parameters defined.";
+ list.appendChild(empty);
+ }
+
+ parameters.forEach((parameter) => {
+ const row = document.createElement("div");
+ row.className = "custom-event-parameter-row";
+ row.dataset.parameterId = parameter.id;
+ row.dataset.parameterType = parameter.type;
+
+ const typeButton = document.createElement("button");
+ typeButton.type = "button";
+ typeButton.className =
+ "variable-type-button custom-event-parameter-type-button";
+ typeButton.dataset.variableType = parameter.type;
+ typeButton.setAttribute("aria-haspopup", "menu");
+ typeButton.setAttribute("aria-expanded", "false");
+ row.appendChild(typeButton);
+
+ const nameInputElement = document.createElement("input");
+ nameInputElement.type = "text";
+ nameInputElement.value = parameter.name;
+ nameInputElement.placeholder = "Parameter name";
+ row.appendChild(nameInputElement);
+
+ const refreshTypeLabel = (rawType) => {
+ const normalized = this.#normalizeVariableType(rawType);
+ const display = this.#formatVariableType(normalized);
+ const referenceName = nameInputElement.value.trim() || parameter.id;
+ row.dataset.parameterType = normalized;
+ typeButton.dataset.variableType = normalized;
+ typeButton.title = `Type: ${display}`;
+ typeButton.setAttribute(
+ "aria-label",
+ `Change type for ${referenceName} (currently ${display})`
+ );
+ };
+
+ refreshTypeLabel(parameter.type);
+
+ const dropdownContext = {
+ kind: "custom_event_parameter",
+ nodeId: node.id,
+ parameterId: parameter.id,
+ };
+
+ const openDropdown = () => {
+ this.#openCustomEventParameterTypeDropdown(
+ node.id,
+ parameter.id,
+ typeButton,
+ row,
+ (nextType) => {
+ refreshTypeLabel(nextType);
+ }
+ );
+ };
+
+ typeButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const isOpen = this.#isTypeDropdownOpen(dropdownContext);
+ if (isOpen) {
+ this.#closeTypeDropdown();
+ } else {
+ openDropdown();
+ }
+ });
+ typeButton.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+ typeButton.addEventListener("keydown", (event) => {
+ const isToggleKey =
+ event.key === "Enter" || event.key === " " || event.key === "Spacebar";
+ if (isToggleKey || event.key === "ArrowDown") {
+ event.preventDefault();
+ openDropdown();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ this.#closeTypeDropdown();
+ }
+ });
+
+ const optionalLabel = document.createElement("label");
+ optionalLabel.className = "custom-event-parameter-optional";
+ optionalLabel.title = "Optional";
+ optionalLabel.setAttribute(
+ "aria-label",
+ `Toggle optional state for ${parameter.name || parameter.id}`
+ );
+
+ const optionalCheckbox = document.createElement("input");
+ optionalCheckbox.type = "checkbox";
+ optionalCheckbox.checked = Boolean(parameter.optional);
+ optionalCheckbox.setAttribute(
+ "aria-label",
+ `Optional parameter ${parameter.name || parameter.id}`
+ );
+ optionalCheckbox.title = "Optional";
+ optionalCheckbox.addEventListener("change", () => {
+ this.#setCustomEventParameterOptional(
+ node.id,
+ parameter.id,
+ optionalCheckbox.checked
+ );
+ });
+ optionalLabel.appendChild(optionalCheckbox);
+ row.appendChild(optionalLabel);
+
+ const removeButton = document.createElement("button");
+ removeButton.type = "button";
+ removeButton.className =
+ "overview-item-action overview-item-action--remove custom-event-parameter-action custom-event-parameter-action--remove";
+ removeButton.setAttribute(
+ "aria-label",
+ `Delete parameter ${parameter.name || parameter.id}`
+ );
+ removeButton.title = `Delete ${parameter.name || parameter.id}`;
+ removeButton.addEventListener("click", () => {
+ this.#closeTypeDropdown();
+ this.#removeCustomEventParameter(node.id, parameter.id);
+ });
+ removeButton.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+ row.appendChild(removeButton);
+
+ nameInputElement.addEventListener("change", () => {
+ this.#renameCustomEventParameter(
+ node.id,
+ parameter.id,
+ nameInputElement.value
+ );
+ refreshTypeLabel(row.dataset.parameterType ?? parameter.type);
+ const trimmed = nameInputElement.value.trim();
+ const labelText = trimmed || parameter.id;
+ removeButton.setAttribute(
+ "aria-label",
+ `Delete parameter ${labelText}`
+ );
+ removeButton.title = `Delete ${labelText}`;
+ optionalLabel.setAttribute(
+ "aria-label",
+ `Toggle optional state for ${labelText}`
+ );
+ optionalCheckbox.setAttribute(
+ "aria-label",
+ `Optional parameter ${labelText}`
+ );
+ });
+
+ if (
+ pendingFocus === `parameter:${parameter.id}` &&
+ typeof nameInputElement.focus === "function"
+ ) {
+ requestAnimationFrame(() => {
+ nameInputElement.focus();
+ if (typeof nameInputElement.select === "function") {
+ nameInputElement.select();
+ }
+ });
+ this.pendingInspectorFocus = null;
+ }
+
+ list.appendChild(row);
+ });
+
+ this.inspectorContent.appendChild(list);
+ }
+
+ /**
+ * Renders inspector controls for a call custom event node.
+ *
+ * @param {import('../core/BlueprintNode.js').BlueprintNode} node Node instance to inspect.
+ */
+ #renderCallCustomEventInspector(node) {
+ const field = document.createElement("div");
+ field.className = "inspector-field";
+ const controlId = `${node.id}_eventId`;
+
+ const label = document.createElement("label");
+ label.setAttribute("for", controlId);
+ label.textContent = "Target Event";
+ field.appendChild(label);
+
+ const select = document.createElement("select");
+ select.id = controlId;
+
+ const events = this.#getCustomEventNodes()
+ .map((eventNode) => ({
+ id: eventNode.id,
+ label: this.#resolveCustomEventLabel(eventNode),
+ }))
+ .sort((a, b) => a.label.localeCompare(b.label));
+
+ const placeholder = document.createElement("option");
+ placeholder.value = "";
+ placeholder.textContent = events.length
+ ? "Select event"
+ : "No custom events available";
+ select.appendChild(placeholder);
+
+ events.forEach((entry) => {
+ const option = document.createElement("option");
+ option.value = entry.id;
+ option.textContent = entry.label;
+ select.appendChild(option);
+ });
+
+ const storedEventId =
+ typeof node.properties.eventId === "string"
+ ? node.properties.eventId
+ : "";
+ if (storedEventId && events.some((entry) => entry.id === storedEventId)) {
+ select.value = storedEventId;
+ } else {
+ select.value = "";
+ }
+
+ select.disabled = events.length === 0;
+ select.addEventListener("change", () => {
+ const next = select.value;
+ const current =
+ typeof node.properties.eventId === "string"
+ ? node.properties.eventId
+ : "";
+ if (next === current) {
+ return;
+ }
+ this.graph.setNodeProperty(node.id, "eventId", next);
+ });
+
+ field.appendChild(select);
+ this.inspectorContent.appendChild(field);
+
+ if (!events.length) {
+ const helper = document.createElement("p");
+ helper.className = "inspector-empty";
+ helper.textContent = "Add a Custom Event node to call it here.";
+ this.inspectorContent.appendChild(helper);
+ }
+
+ const shouldFocus =
+ this.pendingInspectorFocus &&
+ this.pendingInspectorFocus.nodeId === node.id &&
+ this.pendingInspectorFocus.property === "eventId";
+ if (shouldFocus) {
+ requestAnimationFrame(() => {
+ select.focus();
+ });
+ this.pendingInspectorFocus = null;
+ }
+ }
+
+ /**
+ * Regenerates and caches the Lua output for the current graph state.
+ *
+ * @returns {string}
+ */
+ #refreshLuaOutput() {
+ try {
+ this.luaOutput = this.generator.generate(this.graph, {
+ variables: this.#getGeneratorVariableMetadata(),
+ });
+ } catch (error) {
+ console.error("Failed to generate Lua", error);
+ this.luaOutput = "-- generation error";
+ }
+ return this.luaOutput;
+ }
+
+ /**
+ * Creates a serializable snapshot of the current workspace.
+ *
+ * @returns {SerializedWorkspace}
+ */
+ serializeWorkspace() {
+ return this.persistenceManager.serializeWorkspace();
+ }
+
+ /**
+ * Applies a persisted workspace payload to the live workspace.
+ *
+ * @param {SerializedWorkspace} payload Persisted workspace data.
+ */
+ applySerializedWorkspace(payload) {
+ this.isRestoring = true;
+ try {
+ this.persistenceManager.cancelScheduledPersist();
+
+ this.contextMenuManager.hide();
+ this.#closeTypeDropdown();
+ this.dragManager.removePaletteGhost();
+ this.dragManager.removeNodeGhost();
+
+ this.nodeElements.forEach((element) => element.remove());
+ this.connectionElements.forEach((path) => path.remove());
+ this.nodeElements.clear();
+ this.connectionElements.clear();
+ this.inlineEditorManager.clear();
+ this.nodeDragRotation.clear();
+ this.dragManager.stopNodeRotationDecay();
+ this.marqueeManager?.clear();
+ this.#cancelConnectionRefresh();
+ this.isDraggingNodes = false;
+ this.highlightedPins.clear();
+
+ this.pendingConnection = null;
+ this.pendingConnectionSpawn = null;
+ this.pendingVariableSpawn = null;
+ this.selectedNodeIds.clear();
+ this.selectedNodeId = undefined;
+ this.selectedVariableId = null;
+ this.activeVariableRename = null;
+
+ this.variables.clear();
+ this.variableOrder = [];
+ this.variableCounter = 0;
+
+ const variablesPayload =
+ payload && typeof payload.variables === "object"
+ ? payload.variables
+ : null;
+ if (variablesPayload && Array.isArray(variablesPayload.entries)) {
+ variablesPayload.entries.forEach((entry) => {
+ if (!entry || typeof entry !== "object") {
+ return;
+ }
+ const id = typeof entry.id === "string" ? entry.id : "";
+ if (!id) {
+ return;
+ }
+ const name =
+ typeof entry.name === "string" && entry.name.trim()
+ ? entry.name
+ : id;
+ const type =
+ entry.type === "number" ||
+ entry.type === "string" ||
+ entry.type === "boolean" ||
+ entry.type === "any" ||
+ entry.type === "table"
+ ? entry.type
+ : "any";
+ const defaultValue = this.#normalizeVariableDefault(
+ type,
+ entry.defaultValue
+ );
+ this.variables.set(id, { id, name, type, defaultValue });
+ this.variableOrder.push(id);
+ });
+ const storedCounter =
+ typeof variablesPayload.counter === "number" &&
+ Number.isFinite(variablesPayload.counter)
+ ? variablesPayload.counter
+ : this.variableOrder.length;
+ this.variableCounter = Math.max(
+ storedCounter,
+ this.variableOrder.length
+ );
+ }
+
+ const graphPayload =
+ payload && typeof payload.graph === "object"
+ ? payload.graph
+ : { nodes: [], connections: [] };
+ this.graph = NodeGraph.fromJSON(graphPayload);
+ this.#bindGraphEvents();
+
+ this.spawnIndex =
+ typeof payload?.spawnIndex === "number" &&
+ Number.isFinite(payload.spawnIndex)
+ ? payload.spawnIndex
+ : this.graph.nodes.size;
+
+ this.#rebuildNodeCounters();
+
+ this.graph.nodes.forEach((node) => {
+ this.nodeRenderer.renderNode(node);
+ if (node.type === "sequence") {
+ this.#refreshSequenceNode(node.id);
+ } else if (node.type === "custom_event") {
+ this.#refreshCustomEventNode(node.id);
+ } else if (node.type === "call_custom_event") {
+ this.#refreshCustomEventCallNode(node.id);
+ }
+ });
+ this.#renderConnections();
+
+ const settings =
+ payload && typeof payload.projectSettings === "object"
+ ? payload.projectSettings
+ : null;
+ if (settings) {
+ this.projectSettings = {
+ use60Fps: Boolean(settings.use60Fps),
+ };
+ }
+ if (typeof this.generator.setProjectSettings === "function") {
+ this.generator.setProjectSettings({ ...this.projectSettings });
+ }
+
+ const uiSettings =
+ payload && typeof payload.ui === "object" ? payload.ui : null;
+ const paletteVisible =
+ typeof uiSettings?.paletteVisible === "boolean"
+ ? uiSettings.paletteVisible
+ : true;
+ this.isPaletteVisible = paletteVisible;
+ this.#applyPaletteVisibility(this.isPaletteVisible);
+
+ this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
+ this.#updateProjectSettingsToggle();
+ this.#renderOverview();
+ this.#setInspectorView("default");
+ this.#applySelectionState();
+ this.nodeRenderer.refreshAllPinConnections();
+ this.#refreshLuaOutput();
+ } finally {
+ this.isRestoring = false;
+ }
+ }
+
+ /**
+ * Restores camera/view-related state from a history snapshot.
+ *
+ * @param {{ backgroundOffset: { x: number, y: number }, zoom: number }} view Snapshot view payload.
+ */
+ applyHistoryView(view) {
+ if (!view) {
+ return;
+ }
+
+ const normalizedZoom = Number.isFinite(view.zoom) ? view.zoom : this.zoomLevel;
+ const normalizedOffset = {
+ x: Number.isFinite(view.backgroundOffset?.x)
+ ? view.backgroundOffset.x
+ : this.workspaceBackgroundOffset?.x ?? 0,
+ y: Number.isFinite(view.backgroundOffset?.y)
+ ? view.backgroundOffset.y
+ : this.workspaceBackgroundOffset?.y ?? 0,
+ };
+
+ if (this.navigationManager) {
+ this.navigationManager.setZoomLevel(normalizedZoom, { silent: false });
+ this.navigationManager.setBackgroundOffset(normalizedOffset);
+ this.navigationManager.updateZoomDisplay();
+ } else {
+ this.zoomLevel = normalizedZoom;
+ this.workspaceBackgroundOffset = normalizedOffset;
+ }
+ }
+
+ /**
+ * Restores selection state from a history snapshot.
+ *
+ * @param {{ nodes: Array, primary: string | null, variable: string | null }} selection Snapshot selection payload.
+ */
+ applyHistorySelection(selection) {
+ if (!selection) {
+ return;
+ }
+
+ const nodes = Array.isArray(selection.nodes) ? selection.nodes : [];
+ const primary = typeof selection.primary === "string" ? selection.primary : undefined;
+ const variable = typeof selection.variable === "string" ? selection.variable : null;
+
+ if (variable) {
+ const exists = this.variables.has(variable);
+ this.selectedVariableId = exists ? variable : null;
+ } else {
+ this.selectedVariableId = null;
+ }
+
+ this.setSelectionState(nodes, primary);
+ }
+
+ /**
+ * Rebuilds node identifier counters from the current graph state.
+ */
+ #rebuildNodeCounters() {
+ const counters = new Map();
+ this.graph.nodes.forEach((node) => {
+ const match = /_(\d+)$/.exec(node.id);
+ const numeric = match ? Number.parseInt(match[1], 10) : 0;
+ const current = counters.get(node.type) ?? 0;
+ counters.set(node.type, Math.max(current, numeric));
+ });
+ this.graph.nodeCounters = counters;
+ }
+}
diff --git a/scripts/ui/BlueprintWorkspaceConstants.js b/scripts/ui/BlueprintWorkspaceConstants.js
new file mode 100644
index 0000000..f1c3147
--- /dev/null
+++ b/scripts/ui/BlueprintWorkspaceConstants.js
@@ -0,0 +1,5 @@
+export const DEFAULT_GRID_SIZE = 20;
+export const PALETTE_DRAG_MIME = "application/x-blueprint-node";
+export const VARIABLE_DRAG_MIME = "application/x-blueprint-variable";
+export const VARIABLE_SORT_MIME = "application/x-blueprint-variable-sort";
+export const WORKSPACE_STORAGE_KEY = "picograph:workspace";
diff --git a/scripts/ui/WorkspaceSpawnShortcuts.js b/scripts/ui/WorkspaceSpawnShortcuts.js
new file mode 100644
index 0000000..14c262d
--- /dev/null
+++ b/scripts/ui/WorkspaceSpawnShortcuts.js
@@ -0,0 +1,72 @@
+/**
+ * @typedef {'background'|'workspace'} WorkspaceShortcutContext
+ * @typedef {'shift'|'ctrl'|'alt'|'meta'} WorkspaceShortcutModifier
+ * @typedef {'spawnNode'|'command'} WorkspaceShortcutAction
+ *
+ * @typedef {Object} WorkspaceShortcut
+ * @property {string} key Primary keyboard key that must be held to activate the shortcut.
+ * @property {WorkspaceShortcutContext} context Interaction context in which the shortcut is valid.
+ * @property {WorkspaceShortcutAction} action Action the shortcut executes.
+ * @property {string} [definitionId] Node definition identifier (required when {@link action} is `spawnNode`).
+ * @property {string} [command] Workspace command identifier (required when {@link action} is `command`).
+ * @property {number} [button] Pointer button required for pointer-based shortcuts. Defaults to the primary button.
+ * @property {Array} [modifiers] Modifier keys that must be held alongside the primary key.
+ */
+
+/** @type {Array} */
+export const WORKSPACE_SHORTCUTS = [
+ {
+ key: "s",
+ context: "background",
+ action: "spawnNode",
+ definitionId: "sequence",
+ },
+ {
+ key: "c",
+ context: "workspace",
+ action: "command",
+ command: "copy",
+ modifiers: ["ctrl"],
+ },
+ {
+ key: "x",
+ context: "workspace",
+ action: "command",
+ command: "cut",
+ modifiers: ["ctrl"],
+ },
+ {
+ key: "v",
+ context: "workspace",
+ action: "command",
+ command: "paste",
+ modifiers: ["ctrl"],
+ },
+ {
+ key: "z",
+ context: "workspace",
+ action: "command",
+ command: "undo",
+ modifiers: ["ctrl"],
+ },
+ {
+ key: "z",
+ context: "workspace",
+ action: "command",
+ command: "redo",
+ modifiers: ["ctrl", "shift"],
+ },
+ {
+ key: "y",
+ context: "workspace",
+ action: "command",
+ command: "redo",
+ modifiers: ["ctrl"],
+ },
+ {
+ key: "delete",
+ context: "workspace",
+ action: "command",
+ command: "delete",
+ },
+];
diff --git a/scripts/ui/workspace/WorkspaceClipboardManager.js b/scripts/ui/workspace/WorkspaceClipboardManager.js
new file mode 100644
index 0000000..b400fd2
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceClipboardManager.js
@@ -0,0 +1,289 @@
+/**
+ * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
+ * @typedef {import('./WorkspaceHistoryManager.js').WorkspaceHistoryManager} WorkspaceHistoryManager
+ * @typedef {ReturnType} GraphSnapshot
+ */
+
+const CLIPBOARD_OFFSET_STEP = 32;
+
+/**
+ * Manages copy and paste operations for selected nodes.
+ */
+export class WorkspaceClipboardManager {
+ #workspace;
+ #history;
+ /** @type {{ nodes: Array, connections: Array, pasteCount: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } | null } | null} */
+ #payload;
+ /** @type {{ x: number, y: number } | null} */
+ #lastPointerPosition;
+
+ /**
+ * @param {BlueprintWorkspace} workspace Owning workspace instance.
+ * @param {WorkspaceHistoryManager} history History manager for undo integration.
+ */
+ constructor(workspace, history) {
+ this.#workspace = workspace;
+ this.#history = history;
+ this.#payload = null;
+ /** @type {{ x: number, y: number } | null} */
+ this.#lastPointerPosition = null;
+ }
+
+ /**
+ * Clears the clipboard payload.
+ */
+ reset() {
+ this.#payload = null;
+ this.#lastPointerPosition = null;
+ }
+
+ /**
+ * Copies the current node selection into the clipboard payload.
+ *
+ * @returns {boolean} Whether a payload was captured.
+ */
+ copySelection() {
+ if (!this.#workspace.selectedNodeIds.size) {
+ return false;
+ }
+
+ /** @type {Array} */
+ const nodes = [];
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+ let hasBounds = false;
+
+ this.#workspace.selectedNodeIds.forEach((id) => {
+ const node = this.#workspace.graph.nodes.get(id);
+ if (!node) {
+ return;
+ }
+
+ nodes.push(node.toJSON());
+
+ const pos = node.position;
+ if (!Number.isFinite(pos.x) || !Number.isFinite(pos.y)) {
+ return;
+ }
+
+ const element = this.#workspace.nodeElements.get(id) ?? null;
+ const size = this.#measureNodeSize(element);
+ hasBounds = true;
+ minX = Math.min(minX, pos.x);
+ minY = Math.min(minY, pos.y);
+ maxX = Math.max(maxX, pos.x + size.width);
+ maxY = Math.max(maxY, pos.y + size.height);
+ });
+
+ if (!nodes.length) {
+ return false;
+ }
+
+ const selectedIds = new Set(nodes.map((node) => node.id));
+ const connections = this.#workspace.graph
+ .getConnections()
+ .filter(
+ (connection) =>
+ selectedIds.has(connection.from.nodeId) &&
+ selectedIds.has(connection.to.nodeId)
+ )
+ .map((connection) => connection.toJSON());
+
+ const bounds = hasBounds ? { minX, maxX, minY, maxY } : null;
+
+ this.#payload = { nodes, connections, pasteCount: 0, bounds };
+ return true;
+ }
+
+ /**
+ * Captures the latest pointer position within the workspace.
+ *
+ * @param {{ x: number, y: number } | null} position Workspace-space pointer coordinates.
+ */
+ updatePointerPosition(position) {
+ if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
+ this.#lastPointerPosition = null;
+ return;
+ }
+
+ this.#lastPointerPosition = { x: position.x, y: position.y };
+ }
+
+ /**
+ * Clears any stored pointer position hints.
+ */
+ clearPointerPosition() {
+ this.#lastPointerPosition = null;
+ }
+
+ /**
+ * Cuts the current node selection: copies it to the clipboard and removes the originals.
+ *
+ * @returns {boolean} Whether any nodes were cut.
+ */
+ cutSelection() {
+ const copied = this.copySelection();
+ if (!copied) {
+ return false;
+ }
+
+ const targets = Array.from(this.#workspace.selectedNodeIds);
+ if (!targets.length) {
+ return false;
+ }
+
+ this.#history.withSuspended(
+ () => {
+ targets.forEach((id) => this.#workspace.removeNode(id));
+ },
+ { suppressAutoCommit: true }
+ );
+
+ this.#history.commitCheckpoint("cut");
+ return true;
+ }
+
+ /**
+ * Pastes the clipboard payload, centering at the pointer when available.
+ */
+ paste() {
+ if (!this.#payload || !this.#payload.nodes.length) {
+ return;
+ }
+
+ const clipboard = this.#payload;
+ const bounds = clipboard.bounds ?? this.#calculatePayloadBounds(clipboard.nodes);
+ const anchor = this.#lastPointerPosition;
+ let delta;
+
+ if (anchor && bounds) {
+ const center = {
+ x: (bounds.minX + bounds.maxX) / 2,
+ y: (bounds.minY + bounds.maxY) / 2,
+ };
+ delta = {
+ x: anchor.x - center.x,
+ y: anchor.y - center.y,
+ };
+ } else {
+ const offsetMultiplier = CLIPBOARD_OFFSET_STEP * (clipboard.pasteCount + 1);
+ delta = { x: offsetMultiplier, y: offsetMultiplier };
+ }
+
+ /** @type {Array} */
+ const newNodeIds = [];
+ const idMap = new Map();
+
+ this.#history.withSuspended(
+ () => {
+ clipboard.nodes.forEach((nodeData) => {
+ const definition = this.#workspace.registry.get(nodeData.type);
+ if (!definition) {
+ return;
+ }
+
+ const newId = this.#workspace.graph.createNodeId(nodeData.type);
+ const position = {
+ x: nodeData.position.x + delta.x,
+ y: nodeData.position.y + delta.y,
+ };
+
+ const node = this.#workspace.registry.createNode(nodeData.type, {
+ id: newId,
+ position,
+ });
+ node.title = nodeData.title;
+ node.properties = { ...nodeData.properties };
+ node.inputs = nodeData.inputs.map((pin) => ({ ...pin }));
+ node.outputs = nodeData.outputs.map((pin) => ({ ...pin }));
+
+ this.#workspace.graph.addNode(node);
+ this.#workspace.renderNode(node);
+ idMap.set(nodeData.id, newId);
+ newNodeIds.push(newId);
+ });
+
+ clipboard.connections.forEach((connection) => {
+ const fromId = idMap.get(connection.from.nodeId);
+ const toId = idMap.get(connection.to.nodeId);
+ if (!fromId || !toId) {
+ return;
+ }
+ this.#workspace.graph.connect(
+ { nodeId: fromId, pinId: connection.from.pinId },
+ { nodeId: toId, pinId: connection.to.pinId }
+ );
+ });
+ },
+ { suppressAutoCommit: true }
+ );
+
+ if (newNodeIds.length) {
+ clipboard.pasteCount += 1;
+ this.#workspace.setSelectionState(newNodeIds, newNodeIds[0]);
+ this.#history.commitCheckpoint("paste");
+ }
+ }
+
+ /**
+ * Computes the bounding extents of nodes stored in the clipboard payload.
+ *
+ * @param {Array} nodes Clipboard node entries.
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number } | null}
+ */
+ #calculatePayloadBounds(nodes) {
+ if (!nodes.length) {
+ return null;
+ }
+
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+ let hasValid = false;
+
+ nodes.forEach((node) => {
+ const pos = node?.position;
+ if (!pos || !Number.isFinite(pos.x) || !Number.isFinite(pos.y)) {
+ return;
+ }
+ hasValid = true;
+ minX = Math.min(minX, pos.x);
+ minY = Math.min(minY, pos.y);
+ maxX = Math.max(maxX, pos.x);
+ maxY = Math.max(maxY, pos.y);
+ });
+
+ if (!hasValid) {
+ return null;
+ }
+
+ return { minX, maxX, minY, maxY };
+ }
+
+ /**
+ * Measures a node element's size in workspace units.
+ *
+ * @param {HTMLElement | null} element Node DOM element reference.
+ * @returns {{ width: number, height: number }}
+ */
+ #measureNodeSize(element) {
+ if (!element) {
+ return { width: 1, height: 1 };
+ }
+
+ const zoom = this.#workspace.zoomLevel || 1;
+ const rect = element.getBoundingClientRect();
+ const width = rect.width / zoom;
+ const height = rect.height / zoom;
+ const fallbackWidth = element.offsetWidth || width || 1;
+ const fallbackHeight = element.offsetHeight || height || 1;
+
+ return {
+ width: Number.isFinite(width) && width > 0 ? width : Math.max(1, fallbackWidth),
+ height: Number.isFinite(height) && height > 0 ? height : Math.max(1, fallbackHeight),
+ };
+ }
+}
diff --git a/scripts/ui/workspace/WorkspaceContextMenuManager.js b/scripts/ui/workspace/WorkspaceContextMenuManager.js
new file mode 100644
index 0000000..c31a76f
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceContextMenuManager.js
@@ -0,0 +1,620 @@
+/**
+ * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
+ * @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition
+ * @typedef {import('../../nodes/nodeTypes.js').NodeDefinition & {
+ * shortcut?: (
+ * { type: 'variable', action: 'get'|'set', variableId: string } |
+ * { type: 'custom_event_call', eventNodeId: string }
+ * )
+ * }} PaletteNodeDefinition
+ */
+
+const DEFAULT_PLACEHOLDER = "Search nodes";
+
+/**
+ * Handles DOM management and interactions for the workspace context menu.
+ */
+export class WorkspaceContextMenuManager {
+ #workspace;
+ /** @type {HTMLDivElement | null} */
+ #container;
+ /** @type {HTMLInputElement | null} */
+ #searchInput;
+ /** @type {HTMLDivElement | null} */
+ #list;
+ /** @type {boolean} */
+ #isVisible;
+ /** @type {{ x: number, y: number }} */
+ #spawnPosition;
+ /** @type {Array} */
+ #results;
+ /** @type {Array | null} */
+ #customResults;
+ /** @type {string} */
+ #preSearchNormalized;
+ /** @type {'default'|'connection'|'variable'} */
+ #mode;
+ /** @type {number} */
+ #selectedIndex;
+ /** @type {string | null} */
+ #selectedDefinitionId;
+ /** @type {Array} */
+ #itemElements;
+ /** @type {string} */
+ #lastNormalizedQuery;
+
+ /**
+ * @param {BlueprintWorkspace} workspace Owning workspace instance.
+ */
+ constructor(workspace) {
+ this.#workspace = workspace;
+ this.#container = null;
+ this.#searchInput = null;
+ this.#list = null;
+ this.#isVisible = false;
+ this.#spawnPosition = { x: 0, y: 0 };
+ this.#results = [];
+ this.#customResults = null;
+ this.#preSearchNormalized = "";
+ this.#mode = "default";
+ this.#selectedIndex = -1;
+ this.#selectedDefinitionId = null;
+ this.#itemElements = [];
+ this.#lastNormalizedQuery = "";
+ }
+
+ /**
+ * Builds the context menu DOM if it does not already exist.
+ */
+ initialize() {
+ if (this.#container) {
+ return;
+ }
+
+ const container = document.createElement("div");
+ container.className = "workspace-context-menu";
+ container.setAttribute("role", "dialog");
+ container.setAttribute("aria-label", "Add node");
+
+ const search = document.createElement("input");
+ search.type = "search";
+ search.className = "context-menu-search";
+ search.placeholder = DEFAULT_PLACEHOLDER;
+ search.setAttribute("aria-label", "Search nodes");
+ container.appendChild(search);
+
+ const list = document.createElement("div");
+ list.className = "context-menu-list";
+ list.setAttribute("role", "listbox");
+ container.appendChild(list);
+
+ this.#workspace.workspaceElement.appendChild(container);
+
+ search.addEventListener("input", () => {
+ this.#renderResults(search.value);
+ });
+
+ search.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ this.hide();
+ return;
+ }
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ this.#moveSelection(1);
+ this.#focusSelection();
+ return;
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ this.#moveSelection(-1);
+ this.#focusSelection();
+ return;
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ this.#selectSelection();
+ }
+ });
+
+ document.addEventListener("pointerdown", (event) => {
+ if (!this.#isVisible) {
+ return;
+ }
+ if (!(event.target instanceof Node)) {
+ this.hide();
+ return;
+ }
+ if (!container.contains(event.target)) {
+ this.hide();
+ }
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (!this.#isVisible) {
+ return;
+ }
+ if (event.key === "Escape") {
+ event.preventDefault();
+ this.hide();
+ }
+ });
+
+ this.#container = container;
+ this.#searchInput = search;
+ this.#list = list;
+ }
+
+ /**
+ * Determines whether the context menu is currently displayed.
+ *
+ * @returns {boolean}
+ */
+ isVisible() {
+ return this.#isVisible;
+ }
+
+ /**
+ * Checks whether the provided target node resides inside the context menu.
+ *
+ * @param {Node | null} target Target node.
+ * @returns {boolean}
+ */
+ isTargetInside(target) {
+ return Boolean(
+ this.#container && target && this.#container.contains(target)
+ );
+ }
+
+ /**
+ * Displays the context menu at the given viewport coordinates.
+ *
+ * @param {number} clientX Viewport X coordinate.
+ * @param {number} clientY Viewport Y coordinate.
+ * @param {{ definitions?: Array, placeholder?: string, query?: string, mode?: 'default'|'connection'|'variable' }} [options]
+ */
+ show(clientX, clientY, options = {}) {
+ this.initialize();
+
+ if (!this.#container || !this.#searchInput) {
+ return;
+ }
+
+ const workspaceRect =
+ this.#workspace.workspaceElement.getBoundingClientRect();
+ const relativeX = Math.max(0, clientX - workspaceRect.left);
+ const relativeY = Math.max(0, clientY - workspaceRect.top);
+
+ this.#spawnPosition = { x: relativeX, y: relativeY };
+ this.#customResults = options.definitions ?? null;
+ this.#preSearchNormalized = (options.query ?? "").trim().toLowerCase();
+ this.#mode = options.mode ?? "default";
+ this.#selectedIndex = -1;
+ this.#selectedDefinitionId = null;
+
+ if (this.#mode !== "connection") {
+ this.#workspace.pendingConnectionSpawn = null;
+ }
+ if (this.#mode !== "variable") {
+ this.#workspace.pendingVariableSpawn = null;
+ }
+
+ const placeholder = options.placeholder ?? DEFAULT_PLACEHOLDER;
+ if (this.#searchInput.placeholder !== placeholder) {
+ this.#searchInput.placeholder = placeholder;
+ }
+
+ this.#searchInput.value = options.query ?? "";
+ this.#renderResults(this.#searchInput.value);
+
+ this.#container.classList.add("is-visible");
+ this.#container.style.left = `${relativeX}px`;
+ this.#container.style.top = `${relativeY}px`;
+ this.#isVisible = true;
+
+ const menuRect = this.#container.getBoundingClientRect();
+ let adjustedLeft = relativeX;
+ let adjustedTop = relativeY;
+
+ if (menuRect.right > workspaceRect.right) {
+ adjustedLeft -= menuRect.right - workspaceRect.right;
+ }
+ if (menuRect.bottom > workspaceRect.bottom) {
+ adjustedTop -= menuRect.bottom - workspaceRect.bottom;
+ }
+
+ adjustedLeft = Math.max(0, adjustedLeft);
+ adjustedTop = Math.max(0, adjustedTop);
+
+ this.#container.style.left = `${adjustedLeft}px`;
+ this.#container.style.top = `${adjustedTop}px`;
+
+ requestAnimationFrame(() => {
+ this.#searchInput?.focus();
+ this.#searchInput?.select();
+ });
+ }
+
+ /**
+ * Conceals the context menu and clears its state.
+ */
+ hide() {
+ if (!this.#container || !this.#isVisible) {
+ return;
+ }
+
+ this.#container.classList.remove("is-visible");
+ this.#isVisible = false;
+ this.#customResults = null;
+ this.#mode = "default";
+ this.#preSearchNormalized = "";
+ this.#selectedIndex = -1;
+ this.#selectedDefinitionId = null;
+ this.#itemElements = [];
+ this.#results = [];
+ this.#spawnPosition = { x: 0, y: 0 };
+ if (this.#searchInput) {
+ this.#searchInput.placeholder = DEFAULT_PLACEHOLDER;
+ this.#searchInput.value = "";
+ }
+ this.#workspace.pendingConnectionSpawn = null;
+ }
+
+ /**
+ * Renders context menu results using the supplied query.
+ *
+ * @param {string} query Search query.
+ */
+ #renderResults(query) {
+ if (!this.#list) {
+ return;
+ }
+
+ let effectiveQuery = query ?? "";
+ const normalizedQuery = effectiveQuery.trim().toLowerCase();
+ const queryChanged = normalizedQuery !== this.#lastNormalizedQuery;
+ if (
+ this.#customResults &&
+ this.#preSearchNormalized &&
+ normalizedQuery === this.#preSearchNormalized
+ ) {
+ effectiveQuery = "";
+ this.#preSearchNormalized = "";
+ }
+
+ const results = this.#computeDefinitions(effectiveQuery);
+ this.#results = results;
+ this.#list.innerHTML = "";
+ this.#list.scrollTop = 0;
+ this.#itemElements = [];
+ if (queryChanged) {
+ this.#selectedDefinitionId = null;
+ }
+ this.#lastNormalizedQuery = normalizedQuery;
+
+ if (!results.length) {
+ this.#selectedIndex = -1;
+ this.#selectedDefinitionId = null;
+ const empty = document.createElement("p");
+ empty.className = "context-menu-empty";
+ empty.textContent = "No matching nodes";
+ this.#list.appendChild(empty);
+ return;
+ }
+
+ const section = document.createElement("div");
+ section.className = "context-menu-section";
+
+ const body = document.createElement("div");
+ body.className = "context-menu-section-body";
+ section.appendChild(body);
+
+ results.forEach((definition) => {
+ const index = this.#itemElements.length;
+ const item = this.#createDefinitionButton(definition, () => {
+ this.#updateSelection(index, { scrollIntoView: false });
+ this.#selectSelection();
+ });
+
+ item.addEventListener("pointerenter", () => {
+ this.#updateSelection(index, { scrollIntoView: false });
+ });
+ item.addEventListener("pointerdown", () => {
+ this.#updateSelection(index, { scrollIntoView: false });
+ });
+ item.addEventListener("focus", () => {
+ this.#updateSelection(index, { scrollIntoView: false });
+ });
+ item.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ this.#moveSelection(1);
+ this.#focusSelection();
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ this.#moveSelection(-1);
+ this.#focusSelection();
+ }
+ });
+
+ body.appendChild(item);
+ this.#itemElements.push(item);
+ });
+
+ this.#list.appendChild(section);
+
+ if (this.#selectedDefinitionId) {
+ const index = results.findIndex(
+ (definition) => definition.id === this.#selectedDefinitionId
+ );
+ if (index >= 0) {
+ this.#updateSelection(index, { scrollIntoView: false });
+ return;
+ }
+ }
+
+ this.#updateSelection(results.length ? 0 : -1, { scrollIntoView: false });
+ }
+
+ /**
+ * Computes palette definitions for the provided query, including shortcuts.
+ *
+ * @param {string} query Search query.
+ * @returns {Array}
+ */
+ #computeDefinitions(query) {
+ const hasQuery = query.trim().length > 0;
+ if (this.#customResults) {
+ if (!hasQuery) {
+ return this.#customResults.slice();
+ }
+ return this.#customResults.filter((definition) =>
+ this.#workspace.registry.matchesDefinition(definition, query)
+ );
+ }
+
+ const registryMatches = this.#workspace.registry.search(query);
+ const filteredBase = registryMatches.filter(
+ (definition) => definition.id !== "get_var" && definition.id !== "set_var"
+ );
+ const customEventShortcuts = this.#buildCustomEventShortcuts(query);
+ const variableShortcuts = this.#buildVariableShortcuts(query);
+
+ return filteredBase.concat(customEventShortcuts, variableShortcuts);
+ }
+
+ /**
+ * Moves the highlighted selection by the provided offset.
+ *
+ * @param {number} offset Offset delta.
+ */
+ #moveSelection(offset) {
+ const total = this.#results.length;
+ if (!total || !Number.isFinite(offset) || offset === 0) {
+ return;
+ }
+
+ let index = this.#selectedIndex;
+ if (index < 0) {
+ index = offset > 0 ? 0 : total - 1;
+ } else {
+ index = (index + offset + total) % total;
+ }
+
+ this.#updateSelection(index);
+ }
+
+ /**
+ * Applies visual selection state for the supplied menu index.
+ *
+ * @param {number} index Target index.
+ * @param {{ scrollIntoView?: boolean }} [options]
+ */
+ #updateSelection(index, options = {}) {
+ const { scrollIntoView = true } = options;
+ const previousIndex = this.#selectedIndex;
+ const items = this.#itemElements;
+
+ if (previousIndex >= 0 && items[previousIndex]) {
+ items[previousIndex].classList.remove("is-selected");
+ items[previousIndex].setAttribute("aria-selected", "false");
+ }
+
+ if (
+ typeof index !== "number" ||
+ index < 0 ||
+ index >= this.#results.length
+ ) {
+ this.#selectedIndex = -1;
+ this.#selectedDefinitionId = null;
+ return;
+ }
+
+ this.#selectedIndex = index;
+ this.#selectedDefinitionId = this.#results[index]?.id ?? null;
+ const element = items[index];
+ if (element) {
+ element.classList.add("is-selected");
+ element.setAttribute("aria-selected", "true");
+ if (scrollIntoView) {
+ element.scrollIntoView({ block: "nearest" });
+ }
+ }
+ }
+
+ /**
+ * Attempts to focus the currently highlighted menu element.
+ */
+ #focusSelection() {
+ const element = this.#itemElements[this.#selectedIndex];
+ if (!element || typeof element.focus !== "function") {
+ return;
+ }
+ try {
+ element.focus({ preventScroll: true });
+ } catch {
+ element.focus();
+ }
+ }
+
+ /**
+ * Spawns a node for the currently highlighted context menu result.
+ */
+ #selectSelection() {
+ if (
+ this.#selectedIndex < 0 ||
+ this.#selectedIndex >= this.#results.length
+ ) {
+ return;
+ }
+
+ const definition = this.#results[this.#selectedIndex];
+ const spawn = { ...this.#spawnPosition };
+ const node = this.#workspace.spawnNodeFromContextMenu(definition, spawn);
+ if (node) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Builds buttons for context menu rows with an attached selection handler.
+ *
+ * @param {PaletteNodeDefinition} definition Palette definition entry.
+ * @param {() => void} onSelect Selection callback.
+ * @returns {HTMLButtonElement}
+ */
+ #createDefinitionButton(definition, onSelect) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "context-menu-item";
+ button.dataset.definitionId = definition.id;
+ button.setAttribute("role", "option");
+ button.setAttribute("aria-selected", "false");
+
+ const title = document.createElement("span");
+ title.className = "context-menu-item-name";
+ title.textContent = definition.title;
+ button.appendChild(title);
+
+ button.addEventListener("click", () => {
+ onSelect();
+ });
+
+ return button;
+ }
+
+ /**
+ * Generates variable shortcut entries matching the provided query.
+ *
+ * @param {string} query Search query.
+ * @returns {Array}
+ */
+ #buildVariableShortcuts(query) {
+ const normalizedQuery = (query ?? "").trim().toLowerCase();
+ const includeAll = normalizedQuery.length === 0;
+ const entries = [];
+
+ this.#workspace.variableOrder.forEach((variableId) => {
+ const variable = this.#workspace.variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+
+ const displayName = (variable.name ?? "").trim() || variable.id;
+ const normalizedName = displayName.toLowerCase();
+ const tokens = [
+ normalizedName,
+ variableId.toLowerCase(),
+ `get ${normalizedName}`,
+ `set ${normalizedName}`,
+ ];
+
+ if (
+ !includeAll &&
+ !tokens.some((token) => token.includes(normalizedQuery))
+ ) {
+ return;
+ }
+
+ entries.push({
+ id: `variable_get_${variableId}`,
+ title: `Get ${displayName}`,
+ category: "Variables",
+ description: `Get ${displayName}`,
+ shortcut: { type: "variable", action: "get", variableId },
+ });
+
+ entries.push({
+ id: `variable_set_${variableId}`,
+ title: `Set ${displayName}`,
+ category: "Variables",
+ description: `Set ${displayName}`,
+ shortcut: { type: "variable", action: "set", variableId },
+ });
+ });
+
+ return entries;
+ }
+
+ /**
+ * Generates custom event shortcut entries matching the provided query.
+ *
+ * @param {string} query Search query.
+ * @returns {Array}
+ */
+ #buildCustomEventShortcuts(query) {
+ const normalizedQuery = (query ?? "").trim().toLowerCase();
+ const includeAll = normalizedQuery.length === 0;
+
+ const events = [...this.#workspace.graph.nodes.values()]
+ .filter((node) => node.type === "custom_event")
+ .sort((a, b) =>
+ this.#resolveCustomEventLabel(a).localeCompare(
+ this.#resolveCustomEventLabel(b)
+ )
+ );
+
+ const entries = [];
+ events.forEach((eventNode) => {
+ const label = this.#resolveCustomEventLabel(eventNode);
+ const normalizedName = label.toLowerCase();
+ const tokens = [
+ normalizedName,
+ eventNode.id.toLowerCase(),
+ `call ${normalizedName}`,
+ ];
+
+ if (
+ !includeAll &&
+ !tokens.some((token) => token.includes(normalizedQuery))
+ ) {
+ return;
+ }
+
+ entries.push({
+ id: `custom_event_call_${eventNode.id}`,
+ title: `Call ${label}`,
+ category: "Custom Events",
+ description: `Call ${label}`,
+ shortcut: { type: "custom_event_call", eventNodeId: eventNode.id },
+ });
+ });
+
+ return entries;
+ }
+
+ /**
+ * Resolves a display label for the provided custom event node.
+ *
+ * @param {import('../../core/BlueprintNode.js').BlueprintNode} node Custom event node.
+ * @returns {string}
+ */
+ #resolveCustomEventLabel(node) {
+ const raw =
+ typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "";
+ return raw || "CustomEvent";
+ }
+}
diff --git a/scripts/ui/workspace/WorkspaceDragManager.js b/scripts/ui/workspace/WorkspaceDragManager.js
new file mode 100644
index 0000000..280cd1d
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceDragManager.js
@@ -0,0 +1,652 @@
+import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
+
+/**
+ * Handles drag interactions, ghost previews, and drag-induced transforms.
+ */
+export class WorkspaceDragManager {
+ /**
+ * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Owning workspace instance.
+ */
+ constructor(workspace) {
+ this.workspace = workspace;
+ }
+
+ /**
+ * Ensures a ghost preview element exists for palette drags.
+ *
+ * @param {string | null} definitionId Dragged node definition identifier.
+ */
+ ensurePaletteGhost(definitionId) {
+ const { workspace } = this;
+ const existing = workspace.paletteDragGhost;
+ if (existing && existing.element.isConnected) {
+ if (existing.definitionId === definitionId) {
+ return;
+ }
+ this.removePaletteGhost();
+ }
+
+ const element = document.createElement("div");
+ element.className = "workspace-drop-ghost";
+ element.setAttribute("role", "presentation");
+
+ const label = document.createElement("span");
+ label.className = "workspace-drop-ghost__label";
+ const definition = definitionId
+ ? workspace.registry.get(definitionId)
+ : null;
+ label.textContent = definition?.title ?? "Node Preview";
+ element.appendChild(label);
+
+ workspace.nodeLayer.appendChild(element);
+ workspace.paletteDragGhost = {
+ element,
+ width: element.offsetWidth,
+ height: element.offsetHeight,
+ definitionId: definitionId ?? null,
+ };
+ }
+
+ /**
+ * Updates the palette ghost transform to match the supplied position.
+ *
+ * @param {{ x: number, y: number }} position Snapped workspace position.
+ */
+ updatePaletteGhost(position) {
+ const { paletteDragGhost } = this.workspace;
+ if (!paletteDragGhost) {
+ return;
+ }
+ paletteDragGhost.element.style.transform =
+ WorkspaceGeometry.positionToTransform(position);
+ }
+
+ /**
+ * Removes the active palette drag ghost.
+ */
+ removePaletteGhost() {
+ const { workspace } = this;
+ if (
+ workspace.paletteDragGhost?.element &&
+ workspace.paletteDragGhost.element.isConnected
+ ) {
+ workspace.paletteDragGhost.element.remove();
+ }
+ workspace.paletteDragGhost = null;
+ }
+
+ /**
+ * Ensures a ghost preview exists for node drag operations.
+ *
+ * @param {string} nodeId Active node identifier.
+ */
+ ensureNodeGhost(nodeId) {
+ const { workspace } = this;
+ const nodeElement = workspace.nodeElements.get(nodeId);
+ if (!nodeElement) {
+ return;
+ }
+
+ const existing = workspace.nodeDragGhost;
+ if (
+ existing &&
+ existing.nodeId === nodeId &&
+ existing.element.isConnected
+ ) {
+ return;
+ }
+
+ this.removeNodeGhost();
+
+ const ghost = document.createElement("div");
+ ghost.className = "workspace-node-ghost";
+ const zoom = this.workspace.zoomLevel || 1;
+ const rect = nodeElement.getBoundingClientRect();
+ const unscaledWidth = rect.width || nodeElement.offsetWidth;
+ const unscaledHeight = rect.height || nodeElement.offsetHeight;
+ const width = Math.max(1, unscaledWidth / zoom);
+ const height = Math.max(1, unscaledHeight / zoom);
+ ghost.style.width = `${width}px`;
+ ghost.style.height = `${height}px`;
+ ghost.setAttribute("role", "presentation");
+
+ if (nodeElement.parentElement) {
+ nodeElement.parentElement.insertBefore(ghost, nodeElement);
+ } else {
+ workspace.nodeLayer.appendChild(ghost);
+ }
+
+ workspace.nodeDragGhost = {
+ element: ghost,
+ width,
+ height,
+ nodeId,
+ };
+ }
+
+ /**
+ * Updates the node drag ghost to the provided position.
+ *
+ * @param {{ x: number, y: number }} position Snapped workspace position.
+ */
+ updateNodeGhost(position) {
+ const ghost = this.workspace.nodeDragGhost;
+ if (!ghost) {
+ return;
+ }
+ ghost.element.style.transform =
+ WorkspaceGeometry.positionToTransform(position);
+ }
+
+ /**
+ * Removes the node drag ghost preview if active.
+ */
+ removeNodeGhost() {
+ const { workspace } = this;
+ if (
+ workspace.nodeDragGhost?.element &&
+ workspace.nodeDragGhost.element.isConnected
+ ) {
+ workspace.nodeDragGhost.element.remove();
+ }
+ workspace.nodeDragGhost = null;
+ }
+
+ /**
+ * Adjusts active drag offsets when the workspace shifts.
+ *
+ * @param {{ x: number, y: number }} delta Workspace-space translation applied externally.
+ */
+ applyActiveDragShift(delta) {
+ if (!delta || (!delta.x && !delta.y)) {
+ return;
+ }
+
+ const context = this.workspace.activeNodeDragContext;
+ if (!context) {
+ return;
+ }
+
+ if (context.lastPointerWorld) {
+ context.lastPointerWorld = {
+ x: context.lastPointerWorld.x + delta.x,
+ y: context.lastPointerWorld.y + delta.y,
+ };
+ }
+
+ if (!context.multiDrag) {
+ const ghost = this.workspace.nodeDragGhost;
+ const pointer = context.lastPointerWorld;
+ if (ghost?.nodeId && pointer) {
+ const anchor = context.anchors.get(ghost.nodeId);
+ if (anchor) {
+ this.updateNodeGhost({
+ x: pointer.x - anchor.ratioX * anchor.width,
+ y: pointer.y - anchor.ratioY * anchor.height,
+ });
+ }
+ }
+ }
+ }
+
+ /**
+ * Updates the cached pointer state used during drag operations.
+ *
+ * @param {{ clientX?: number, clientY?: number } | null | undefined} pointer Pointer-like payload.
+ */
+ rebaseActiveDragPointer(pointer) {
+ if (!pointer) {
+ return;
+ }
+
+ const context = this.workspace.activeNodeDragContext;
+ if (!context) {
+ return;
+ }
+
+ const hasClientX = Number.isFinite(pointer.clientX);
+ const hasClientY = Number.isFinite(pointer.clientY);
+ if (!hasClientX && !hasClientY) {
+ return;
+ }
+
+ const pointerWorld = this.#clientToWorkspace(
+ hasClientX ? pointer.clientX : context.previousClientX,
+ hasClientY ? pointer.clientY : context.previousClientY
+ );
+ context.lastPointerWorld = pointerWorld;
+ if (hasClientX) {
+ context.previousClientX = pointer.clientX;
+ }
+ if (hasClientY) {
+ context.previousClientY = pointer.clientY;
+ }
+
+ if (!context.multiDrag) {
+ const ghost = this.workspace.nodeDragGhost;
+ if (ghost?.nodeId) {
+ const anchor = context.anchors.get(ghost.nodeId);
+ if (anchor) {
+ this.updateNodeGhost({
+ x: pointerWorld.x - anchor.ratioX * anchor.width,
+ y: pointerWorld.y - anchor.ratioY * anchor.height,
+ });
+ }
+ }
+ }
+ }
+
+ /**
+ * Applies a snapped position to a node with a subtle easing transition.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {{ x: number, y: number }} position Target snapped position.
+ */
+ applyNodeSnapPosition(nodeId, position) {
+ const { workspace } = this;
+ const element = workspace.nodeElements.get(nodeId);
+ if (element) {
+ element.style.transition =
+ "transform 140ms cubic-bezier(0.22, 0.61, 0.36, 1)";
+ const handleTransitionEnd = () => {
+ element.style.transition = "";
+ workspace.flushConnectionRefresh();
+ if (workspace.nodeDragGhost?.nodeId === nodeId) {
+ this.removeNodeGhost();
+ }
+ };
+ element.addEventListener("transitionend", handleTransitionEnd, {
+ once: true,
+ });
+ window.setTimeout(() => {
+ element.removeEventListener("transitionend", handleTransitionEnd);
+ element.style.transition = "";
+ workspace.flushConnectionRefresh();
+ if (workspace.nodeDragGhost?.nodeId === nodeId) {
+ this.removeNodeGhost();
+ }
+ }, 200);
+ }
+
+ this.updateNodeDomPosition(nodeId, position);
+ workspace.graph.setNodePosition(nodeId, position);
+ workspace.scheduleConnectionRefresh();
+ }
+
+ /**
+ * Updates the DOM transform for a node element.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {{x:number,y:number}} position Coordinates.
+ */
+ updateNodeDomPosition(nodeId, position) {
+ const { workspace } = this;
+ const element = workspace.nodeElements.get(nodeId);
+ if (!element) {
+ return;
+ }
+ const rotation = workspace.nodeDragRotation.get(nodeId) ?? 0;
+ element.style.transform = WorkspaceGeometry.positionToTransform(
+ position,
+ rotation
+ );
+ }
+
+ /**
+ * Initiates a node drag operation.
+ *
+ * @param {PointerEvent} event Pointer event.
+ * @param {string} nodeId Dragged node identifier.
+ */
+ startNodeDrag(event, nodeId) {
+ const { workspace } = this;
+ const node = workspace.graph.nodes.get(nodeId);
+ if (!node) {
+ return;
+ }
+
+ const isMouse = event.pointerType === "mouse" || event.pointerType === "";
+ if (isMouse && event.button !== 0) {
+ return;
+ }
+ if (
+ !isMouse &&
+ event.pointerType !== "touch" &&
+ event.pointerType !== "pen"
+ ) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ const selectedIds = workspace.selectedNodeIds.has(nodeId)
+ ? [...workspace.selectedNodeIds]
+ : [nodeId];
+
+ const pointerWorld = this.#eventToWorkspacePoint(event);
+ const anchors = new Map();
+ selectedIds.forEach((id) => {
+ const target = workspace.graph.nodes.get(id);
+ if (!target) {
+ return;
+ }
+ const element = workspace.nodeElements.get(id) ?? null;
+ const { width, height } = this.#measureNodeSize(element);
+ const ratioX = width > 0 ? (pointerWorld.x - target.position.x) / width : 0;
+ const ratioY = height > 0 ? (pointerWorld.y - target.position.y) / height : 0;
+ anchors.set(id, {
+ ratioX,
+ ratioY,
+ width,
+ height,
+ });
+ });
+
+ if (!anchors.has(nodeId)) {
+ const element = workspace.nodeElements.get(nodeId) ?? null;
+ const { width, height } = this.#measureNodeSize(element);
+ const ratioX = width > 0 ? (pointerWorld.x - node.position.x) / width : 0;
+ const ratioY = height > 0 ? (pointerWorld.y - node.position.y) / height : 0;
+ anchors.set(nodeId, {
+ ratioX,
+ ratioY,
+ width,
+ height,
+ });
+ }
+
+ const multiDrag = anchors.size > 1;
+ workspace.draggingNode = {
+ nodeId,
+ pointerId: event.pointerId,
+ selection: new Set(anchors.keys()),
+ };
+ workspace.isDraggingNodes = true;
+ workspace.activeNodeDragContext = {
+ anchors,
+ multiDrag,
+ lastPointerWorld: pointerWorld,
+ previousClientX: event.clientX,
+ previousClientY: event.clientY,
+ };
+
+ if (multiDrag) {
+ this.removeNodeGhost();
+ } else {
+ this.ensureNodeGhost(nodeId);
+ const anchor = anchors.get(nodeId);
+ if (anchor) {
+ const initialPosition = {
+ x: pointerWorld.x - anchor.ratioX * anchor.width,
+ y: pointerWorld.y - anchor.ratioY * anchor.height,
+ };
+ this.updateNodeGhost(WorkspaceGeometry.snapPositionToGrid(workspace, initialPosition));
+ }
+ }
+
+ const draggedElement = workspace.nodeElements.get(nodeId);
+ if (draggedElement) {
+ draggedElement.style.transition = "";
+ }
+
+ const handlePointerMove = (moveEvent) => {
+ if (moveEvent.pointerId !== event.pointerId) {
+ return;
+ }
+ const context = workspace.activeNodeDragContext;
+ if (!context) {
+ return;
+ }
+ const pointer = this.#eventToWorkspacePoint(moveEvent);
+ context.lastPointerWorld = pointer;
+
+ const pendingPositions = new Map();
+ context.anchors.forEach((anchor, id) => {
+ const width = anchor.width || 1;
+ const height = anchor.height || 1;
+ const updated = {
+ x: pointer.x - anchor.ratioX * width,
+ y: pointer.y - anchor.ratioY * height,
+ };
+ this.updateNodeDomPosition(id, updated);
+ pendingPositions.set(id, updated);
+ if (!context.multiDrag && id === nodeId) {
+ this.updateNodeGhost(WorkspaceGeometry.snapPositionToGrid(workspace, updated));
+ }
+ });
+
+ pendingPositions.forEach((position, id) => {
+ workspace.graph.setNodePosition(id, position);
+ });
+
+ const screenDeltaX = Number.isFinite(moveEvent.movementX)
+ ? moveEvent.movementX
+ : moveEvent.clientX - context.previousClientX;
+ const velocityX = Number.isFinite(moveEvent.movementX)
+ ? moveEvent.movementX
+ : screenDeltaX;
+ pendingPositions.forEach((_, id) => {
+ this.nudgeNodeDragRotation(id, velocityX);
+ });
+
+ context.previousClientX = moveEvent.clientX;
+ context.previousClientY = moveEvent.clientY;
+ };
+
+ const finalizeSelection = (pointer) => {
+ const context = workspace.activeNodeDragContext;
+ if (!context) {
+ return;
+ }
+ const targetPointer = pointer ?? context.lastPointerWorld;
+ if (!targetPointer) {
+ return;
+ }
+
+ context.anchors.forEach((anchor, id) => {
+ const width = anchor.width || 1;
+ const height = anchor.height || 1;
+ const raw = {
+ x: targetPointer.x - anchor.ratioX * width,
+ y: targetPointer.y - anchor.ratioY * height,
+ };
+ const snapped = WorkspaceGeometry.snapPositionToGrid(workspace, raw);
+ this.applyNodeSnapPosition(id, snapped);
+ this.setNodeDragRotation(id, 0);
+ if (!context.multiDrag && id === nodeId) {
+ this.updateNodeGhost(snapped);
+ }
+ });
+
+ if (context.multiDrag) {
+ this.removeNodeGhost();
+ } else {
+ window.setTimeout(() => {
+ this.removeNodeGhost();
+ }, 160);
+ }
+
+ workspace.isDraggingNodes = false;
+ workspace.activeNodeDragContext = null;
+ workspace.flushConnectionRefresh();
+ };
+
+ const handlePointerFinish = (finishEvent) => {
+ if (finishEvent.pointerId !== event.pointerId) {
+ return;
+ }
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerFinish);
+ window.removeEventListener("pointercancel", handlePointerFinish);
+
+ const context = workspace.activeNodeDragContext;
+ let pointer = null;
+ if (context) {
+ const hasClient = Number.isFinite(finishEvent.clientX);
+ const hasClientY = Number.isFinite(finishEvent.clientY);
+ if (hasClient || hasClientY) {
+ pointer = this.#eventToWorkspacePoint(finishEvent);
+ }
+ }
+
+ finalizeSelection(pointer);
+
+ workspace.draggingNode = null;
+ workspace.activeNodeDragContext = null;
+ };
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerFinish);
+ window.addEventListener("pointercancel", handlePointerFinish);
+ }
+
+ /**
+ * Applies a momentum impulse into the current drag rotation state.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {number} horizontalVelocity Horizontal pointer velocity.
+ */
+ nudgeNodeDragRotation(nodeId, horizontalVelocity) {
+ if (!Number.isFinite(horizontalVelocity)) {
+ return;
+ }
+
+ const { workspace } = this;
+ const existing = workspace.nodeDragRotation.get(nodeId) ?? 0;
+ const impulse = horizontalVelocity * 0.9;
+ const blended = existing * 0.75 + impulse;
+ const clamped = Math.max(-12, Math.min(12, blended));
+ this.setNodeDragRotation(nodeId, clamped);
+ }
+
+ /**
+ * Converts pointer event coordinates into workspace-space values without clamping.
+ *
+ * @param {PointerEvent} event Pointer event reference.
+ * @returns {{ x: number, y: number }}
+ */
+ #eventToWorkspacePoint(event) {
+ return this.#clientToWorkspace(event.clientX, event.clientY);
+ }
+
+ /**
+ * Measures a node element and returns its workspace-space dimensions.
+ *
+ * @param {HTMLElement | null} element Node DOM element.
+ * @returns {{ width: number, height: number }}
+ */
+ #measureNodeSize(element) {
+ const zoom = this.workspace.zoomLevel || 1;
+ if (!element) {
+ return { width: 1, height: 1 };
+ }
+
+ const rect = element.getBoundingClientRect();
+ const rawWidth = rect.width / zoom;
+ const rawHeight = rect.height / zoom;
+ const fallbackWidth = element.offsetWidth || rawWidth;
+ const fallbackHeight = element.offsetHeight || rawHeight;
+
+ return {
+ width: Math.max(1, Number.isFinite(rawWidth) ? rawWidth : fallbackWidth || 1),
+ height: Math.max(1, Number.isFinite(rawHeight) ? rawHeight : fallbackHeight || 1),
+ };
+ }
+
+ /**
+ * Converts client coordinates to workspace-space units.
+ *
+ * @param {number | undefined} clientX Viewport X coordinate.
+ * @param {number | undefined} clientY Viewport Y coordinate.
+ * @returns {{ x: number, y: number }}
+ */
+ #clientToWorkspace(clientX, clientY) {
+ const rect = this.workspace.workspaceElement.getBoundingClientRect();
+ const zoom = this.workspace.zoomLevel || 1;
+ const safeX = Number.isFinite(clientX) ? clientX : rect.left;
+ const safeY = Number.isFinite(clientY) ? clientY : rect.top;
+ return {
+ x: (safeX - rect.left) / zoom,
+ y: (safeY - rect.top) / zoom,
+ };
+ }
+
+ /**
+ * Applies a temporary rotation used during drag interactions.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {number} angle Rotation in degrees.
+ */
+ setNodeDragRotation(nodeId, angle) {
+ const { workspace } = this;
+ const sanitized = Number.isFinite(angle) ? angle : 0;
+ const clamped = Math.max(-12, Math.min(12, sanitized));
+ if (Math.abs(clamped) < 0.01) {
+ workspace.nodeDragRotation.delete(nodeId);
+ } else {
+ workspace.nodeDragRotation.set(nodeId, clamped);
+ }
+
+ const node = workspace.graph.nodes.get(nodeId);
+ if (node) {
+ this.updateNodeDomPosition(nodeId, node.position);
+ }
+
+ if (workspace.nodeDragRotation.size > 0) {
+ this.ensureNodeRotationDecay();
+ } else {
+ this.stopNodeRotationDecay();
+ }
+ }
+
+ /**
+ * Ensures the drag rotation damping loop is active.
+ */
+ ensureNodeRotationDecay() {
+ const { workspace } = this;
+ if (workspace.nodeDragRotationDecayFrame !== null) {
+ return;
+ }
+
+ const step = () => {
+ let hasActive = false;
+ workspace.nodeDragRotation.forEach((angle, id) => {
+ const node = workspace.graph.nodes.get(id);
+ if (!node) {
+ workspace.nodeDragRotation.delete(id);
+ return;
+ }
+
+ const damped = angle * 0.85;
+ if (Math.abs(damped) < 0.05) {
+ workspace.nodeDragRotation.delete(id);
+ } else {
+ workspace.nodeDragRotation.set(id, damped);
+ hasActive = true;
+ }
+
+ this.updateNodeDomPosition(id, node.position);
+ });
+
+ if (hasActive) {
+ workspace.nodeDragRotationDecayFrame =
+ window.requestAnimationFrame(step);
+ } else {
+ workspace.nodeDragRotationDecayFrame = null;
+ }
+ };
+
+ workspace.nodeDragRotationDecayFrame = window.requestAnimationFrame(step);
+ }
+
+ /**
+ * Stops the drag rotation damping loop if one is queued.
+ */
+ stopNodeRotationDecay() {
+ const { workspace } = this;
+ if (workspace.nodeDragRotationDecayFrame !== null) {
+ window.cancelAnimationFrame(workspace.nodeDragRotationDecayFrame);
+ workspace.nodeDragRotationDecayFrame = null;
+ }
+ }
+}
diff --git a/scripts/ui/workspace/WorkspaceDropManager.js b/scripts/ui/workspace/WorkspaceDropManager.js
new file mode 100644
index 0000000..18414c8
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceDropManager.js
@@ -0,0 +1,326 @@
+/**
+ * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
+ * @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition
+ */
+
+import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
+import {
+ PALETTE_DRAG_MIME,
+ VARIABLE_DRAG_MIME,
+} from "../BlueprintWorkspaceConstants.js";
+
+/**
+ * Manages drag-and-drop interactions for palette and variable entries.
+ */
+export class WorkspaceDropManager {
+ #workspace;
+
+ /**
+ * @param {BlueprintWorkspace} workspace Owning workspace instance.
+ */
+ constructor(workspace) {
+ this.#workspace = workspace;
+ }
+
+ /**
+ * Hooks workspace-level drag events.
+ */
+ initialize() {
+ const element = this.#workspace.workspaceElement;
+ element.addEventListener("dragenter", (event) => {
+ this.#handleDragEnter(event);
+ });
+ element.addEventListener("dragover", (event) => {
+ this.#handleDragOver(event);
+ });
+ element.addEventListener("dragleave", (event) => {
+ this.#handleDragLeave(event);
+ });
+ element.addEventListener("drop", (event) => {
+ this.#handleDrop(event);
+ });
+ }
+
+ /**
+ * Begins a variable drag gesture from the overview panel.
+ *
+ * @param {DragEvent} event Drag event payload.
+ * @param {string} variableId Variable identifier being dragged.
+ */
+ handleVariableDragStart(event, variableId) {
+ if (!event.dataTransfer) {
+ return;
+ }
+
+ event.dataTransfer.setData(VARIABLE_DRAG_MIME, variableId);
+ event.dataTransfer.setData("text/plain", variableId);
+ event.dataTransfer.effectAllowed = "copy";
+
+ this.#workspace.workspaceDragDepth = 0;
+ const element = this.#workspace.workspaceElement;
+ element.classList.add("is-variable-dragging");
+ element.classList.remove("is-palette-dragging");
+ }
+
+ /**
+ * Clears workspace drag styling for variable gestures.
+ */
+ handleVariableDragEnd() {
+ this.#workspace.workspaceDragDepth = 0;
+ const element = this.#workspace.workspaceElement;
+ element.classList.remove("is-variable-dragging");
+ element.classList.remove("is-drag-target");
+ this.#workspace.dragManager.removePaletteGhost();
+ }
+
+ /**
+ * Responds to dragenter events and prepares drop feedback.
+ *
+ * @param {DragEvent} event Drag enter event.
+ */
+ #handleDragEnter(event) {
+ const isPalette = this.#isPaletteDrag(event);
+ const isVariable = this.#isVariableDrag(event);
+ if (!isPalette && !isVariable) {
+ return;
+ }
+
+ event.preventDefault();
+ this.#workspace.workspaceDragDepth += 1;
+ const element = this.#workspace.workspaceElement;
+ element.classList.add("is-drag-target");
+
+ if (isPalette) {
+ const definitionId = this.#resolvePaletteDefinitionId(event);
+ this.#workspace.dragManager.ensurePaletteGhost(definitionId ?? null);
+ const point = WorkspaceGeometry.snapPositionToGrid(
+ this.#workspace,
+ WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
+ );
+ this.#workspace.dragManager.updatePaletteGhost(point);
+ } else {
+ this.#workspace.dragManager.removePaletteGhost();
+ }
+ }
+
+ /**
+ * Keeps drop feedback active while objects move across the workspace.
+ *
+ * @param {DragEvent} event Drag over event.
+ */
+ #handleDragOver(event) {
+ const isPalette = this.#isPaletteDrag(event);
+ const isVariable = this.#isVariableDrag(event);
+ if (!isPalette && !isVariable) {
+ return;
+ }
+
+ event.preventDefault();
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = "copy";
+ }
+
+ if (isPalette) {
+ const definitionId = this.#resolvePaletteDefinitionId(event);
+ this.#workspace.dragManager.ensurePaletteGhost(definitionId ?? null);
+ const point = WorkspaceGeometry.snapPositionToGrid(
+ this.#workspace,
+ WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
+ );
+ this.#workspace.dragManager.updatePaletteGhost(point);
+ } else {
+ this.#workspace.dragManager.removePaletteGhost();
+ }
+ }
+
+ /**
+ * Clears drop feedback once the drag leaves workspace bounds.
+ *
+ * @param {DragEvent} event Drag leave event.
+ */
+ #handleDragLeave(event) {
+ const isPalette = this.#isPaletteDrag(event);
+ const isVariable = this.#isVariableDrag(event);
+ if (!isPalette && !isVariable) {
+ return;
+ }
+
+ this.#workspace.workspaceDragDepth = Math.max(
+ 0,
+ this.#workspace.workspaceDragDepth - 1
+ );
+ if (this.#workspace.workspaceDragDepth === 0) {
+ const element = this.#workspace.workspaceElement;
+ element.classList.remove("is-drag-target");
+ this.#workspace.dragManager.removePaletteGhost();
+ }
+ }
+
+ /**
+ * Handles finalized drops for palette nodes and variables.
+ *
+ * @param {DragEvent} event Drop event containing pointer details.
+ */
+ #handleDrop(event) {
+ const isPalette = this.#isPaletteDrag(event);
+ const isVariable = this.#isVariableDrag(event);
+ if (!isPalette && !isVariable) {
+ return;
+ }
+
+ event.preventDefault();
+ const transfer = event.dataTransfer;
+ if (!transfer) {
+ return;
+ }
+
+ if (isVariable) {
+ this.handleVariableDragEnd();
+ this.#handleVariableDrop(event, transfer);
+ return;
+ }
+
+ const definitionId =
+ transfer.getData(PALETTE_DRAG_MIME) || transfer.getData("text/plain");
+ if (!definitionId) {
+ return;
+ }
+
+ this.#workspace.paletteManager.handleDragEnd();
+
+ const spawn = WorkspaceGeometry.snapPositionToGrid(
+ this.#workspace,
+ WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
+ );
+
+ this.#workspace.addNode(definitionId, spawn);
+ }
+
+ /**
+ * Prompts for node selection when a variable is dropped onto the workspace.
+ *
+ * @param {DragEvent} event Drop event payload.
+ * @param {DataTransfer} transfer Drag data transfer object.
+ */
+ #handleVariableDrop(event, transfer) {
+ const variableId =
+ transfer.getData(VARIABLE_DRAG_MIME) || transfer.getData("text/plain");
+ const variable = variableId
+ ? this.#workspace.variables.get(variableId)
+ : undefined;
+ if (!variableId || !variable) {
+ return;
+ }
+
+ const definitions = this.#getVariableSpawnDefinitions(variableId);
+ if (!definitions.length) {
+ const spawn = WorkspaceGeometry.snapPositionToGrid(
+ this.#workspace,
+ WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
+ );
+ const fallback = this.#workspace.addNode("get_var", spawn);
+ if (fallback) {
+ const nodeName = variable.name || variable.id;
+ this.#workspace.graph.setNodeProperty(fallback.id, "name", nodeName);
+ this.#workspace.graph.setNodeProperty(
+ fallback.id,
+ "variableId",
+ variable.id
+ );
+ }
+ return;
+ }
+
+ this.#workspace.pendingVariableSpawn = { variableId };
+ const placeholderName = variable.name || "variable";
+ this.#workspace.contextMenuManager.show(event.clientX, event.clientY, {
+ definitions,
+ mode: "variable",
+ placeholder: `Create node for ${placeholderName}`,
+ });
+ }
+
+ /**
+ * Resolves potential variable spawn definitions from the registry.
+ *
+ * @param {string} variableId Variable identifier.
+ * @returns {Array}
+ */
+ #getVariableSpawnDefinitions(_variableId) {
+ const definitions = [];
+ const getDefinition = this.#workspace.registry.get("get_var");
+ const setDefinition = this.#workspace.registry.get("set_var");
+ if (getDefinition) {
+ definitions.push(getDefinition);
+ }
+ if (setDefinition) {
+ definitions.push(setDefinition);
+ }
+ return definitions;
+ }
+
+ /**
+ * Determines whether the drag originates from the palette interface.
+ *
+ * @param {DragEvent} event Drag event to inspect.
+ * @returns {boolean}
+ */
+ #isPaletteDrag(event) {
+ const transfer = event.dataTransfer;
+ if (!transfer) {
+ return false;
+ }
+
+ const types = Array.from(transfer.types ?? []);
+ if (types.includes(PALETTE_DRAG_MIME)) {
+ return true;
+ }
+
+ return this.#workspace.workspaceElement.classList.contains(
+ "is-palette-dragging"
+ );
+ }
+
+ /**
+ * Determines whether the drag originates from the variable overview.
+ *
+ * @param {DragEvent} event Drag event to inspect.
+ * @returns {boolean}
+ */
+ #isVariableDrag(event) {
+ const transfer = event.dataTransfer;
+ if (!transfer) {
+ return false;
+ }
+
+ const types = Array.from(transfer.types ?? []);
+ if (types.includes(VARIABLE_DRAG_MIME)) {
+ return true;
+ }
+
+ return this.#workspace.workspaceElement.classList.contains(
+ "is-variable-dragging"
+ );
+ }
+
+ /**
+ * Resolves the palette definition identifier associated with the active drag.
+ *
+ * @param {DragEvent} event Drag event payload.
+ * @returns {string | null}
+ */
+ #resolvePaletteDefinitionId(event) {
+ if (this.#workspace.activePaletteDefinitionId) {
+ return this.#workspace.activePaletteDefinitionId;
+ }
+
+ const transfer = event.dataTransfer;
+ if (!transfer) {
+ return null;
+ }
+
+ const identifier =
+ transfer.getData(PALETTE_DRAG_MIME) || transfer.getData("text/plain");
+ return identifier || null;
+ }
+}
diff --git a/scripts/ui/workspace/WorkspaceGeometry.js b/scripts/ui/workspace/WorkspaceGeometry.js
new file mode 100644
index 0000000..4fdcd2d
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceGeometry.js
@@ -0,0 +1,85 @@
+import { DEFAULT_GRID_SIZE } from "../BlueprintWorkspaceConstants.js";
+
+/**
+ * Provides workspace geometry helpers for coordinate conversion and transforms.
+ */
+export class WorkspaceGeometry {
+ /**
+ * Resolves the active grid size from workspace styles.
+ *
+ * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
+ * @returns {number}
+ */
+ static resolveGridSize(workspace) {
+ const { workspaceElement } = workspace;
+ if (!workspaceElement || !workspaceElement.isConnected) {
+ return DEFAULT_GRID_SIZE;
+ }
+
+ const computed = window.getComputedStyle(workspaceElement);
+ const raw = computed.getPropertyValue("--grid-size");
+ const parsed = Number.parseFloat(raw);
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return parsed;
+ }
+ return DEFAULT_GRID_SIZE;
+ }
+
+ /**
+ * Converts viewport coordinates into workspace-relative coordinates.
+ *
+ * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
+ * @param {{ clientX: number, clientY: number }} event Pointer-like event payload.
+ * @returns {{ x: number, y: number }}
+ */
+ static eventToWorkspacePoint(workspace, event) {
+ const rect = workspace.workspaceElement.getBoundingClientRect();
+ const rawX = event?.clientX ?? 0;
+ const rawY = event?.clientY ?? 0;
+ const x = rawX - rect.left;
+ const y = rawY - rect.top;
+ const zoom = workspace.zoomLevel || 1;
+ return {
+ x: Math.max(0, Math.min(rect.width, x)) / zoom,
+ y: Math.max(0, Math.min(rect.height, y)) / zoom,
+ };
+ }
+
+ /**
+ * Snaps a workspace position to the configured grid size.
+ *
+ * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
+ * @param {{ x: number, y: number }} position Raw workspace position.
+ * @returns {{ x: number, y: number }}
+ */
+ static snapPositionToGrid(workspace, position) {
+ const size = workspace.gridSize || DEFAULT_GRID_SIZE;
+ const offsetX = workspace.workspaceBackgroundOffset?.x ?? 0;
+ const offsetY = workspace.workspaceBackgroundOffset?.y ?? 0;
+ const snap = (value, offset) => {
+ const local = value - offset;
+ const snapped = Math.round(local / size) * size;
+ return snapped + offset;
+ };
+ return { x: snap(position.x, offsetX), y: snap(position.y, offsetY) };
+ }
+
+ /**
+ * Converts a position vector into a CSS transform.
+ *
+ * @param {{x:number,y:number}} position Position vector.
+ * @param {number} rotation Rotation in degrees around the Z axis.
+ * @returns {string}
+ */
+ static positionToTransform(position, rotation = 0) {
+ const transforms = [`translate3d(${position.x}px, ${position.y}px, 0)`];
+ const rotationZ = Number.isFinite(rotation) ? rotation : 0;
+ if (rotationZ) {
+ const tilt = Math.max(-10, Math.min(10, rotationZ * 0.8));
+ transforms.push(`rotate3d(0, 1, 0, ${tilt}deg)`);
+ transforms.push(`rotate(${rotationZ}deg)`);
+ }
+
+ return transforms.join(" ");
+ }
+}
diff --git a/scripts/ui/workspace/WorkspaceHistoryManager.js b/scripts/ui/workspace/WorkspaceHistoryManager.js
new file mode 100644
index 0000000..278e907
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceHistoryManager.js
@@ -0,0 +1,310 @@
+/**
+ * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
+ * @typedef {ReturnType} GraphSnapshot
+ */
+
+/**
+ * @typedef {Object} WorkspaceSnapshotSelection
+ * @property {Array} nodes
+ * @property {string | null} primary
+ * @property {string | null} variable
+ */
+
+/**
+ * @typedef {Object} WorkspaceSnapshotView
+ * @property {{ x: number, y: number }} backgroundOffset
+ * @property {number} zoom
+ */
+
+/**
+ * @typedef {Object} SerializedWorkspace
+ * @property {number} version
+ * @property {{ use60Fps: boolean }} projectSettings
+ * @property {GraphSnapshot} graph
+ * @property {{ entries: Array<{ id: string, name: string, type: import('../BlueprintWorkspace.js').VariableType, defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue | null }>, counter: number }} variables
+ * @property {number} spawnIndex
+ * @property {{ paletteVisible: boolean }} [ui]
+ */
+
+/**
+ * @typedef {Object} WorkspaceSnapshot
+ * @property {SerializedWorkspace} workspace
+ * @property {WorkspaceSnapshotSelection} selection
+ * @property {WorkspaceSnapshotView} view
+ */
+
+const HISTORY_MAX_ENTRIES = 200;
+const HISTORY_DEBOUNCE_MS = 200;
+
+/**
+ * Coordinates undo/redo history for the blueprint workspace.
+ */
+export class WorkspaceHistoryManager {
+ #workspace;
+ #timerId;
+ #lastSerialized;
+ #pendingDirty;
+ #pendingReason;
+ #suspendDepth;
+
+ /**
+ * @param {BlueprintWorkspace} workspace Owning workspace instance.
+ */
+ constructor(workspace) {
+ this.#workspace = workspace;
+ /** @type {Array} */
+ this.undoStack = [];
+ /** @type {Array} */
+ this.redoStack = [];
+ this.isReady = false;
+ this.#timerId = null;
+ this.#lastSerialized = null;
+ this.#pendingDirty = false;
+ this.#pendingReason = null;
+ this.#suspendDepth = 0;
+ }
+
+ /**
+ * Resets undo/redo buffers and records the current workspace state.
+ */
+ initialize() {
+ this.#clearTimer();
+ this.undoStack = [];
+ this.redoStack = [];
+ this.#pendingDirty = false;
+ this.#pendingReason = null;
+ this.#suspendDepth = 0;
+
+ const snapshot = this.#captureSnapshot();
+ this.undoStack.push(snapshot);
+ this.#lastSerialized = JSON.stringify(snapshot);
+ this.isReady = true;
+ }
+
+ /**
+ * Clears undo/redo buffers without recording a snapshot. Used when the workspace is about to be rebuilt entirely.
+ */
+ handleReset() {
+ this.isReady = false;
+ this.#clearTimer();
+ this.undoStack = [];
+ this.redoStack = [];
+ this.#pendingDirty = false;
+ this.#pendingReason = null;
+ this.#lastSerialized = null;
+ this.#suspendDepth = 0;
+ }
+
+ /**
+ * Records a mutation for undo/redo bookkeeping.
+ *
+ * @param {'nodeschanged'|'nodepropertychanged'|'connectionschanged'|'nodepositionchanged'} kind Mutation descriptor.
+ */
+ registerMutation(kind) {
+ if (!this.isReady || this.#workspace.isRestoring) {
+ return;
+ }
+
+ if (kind === "nodepositionchanged") {
+ this.markDirty(kind, HISTORY_DEBOUNCE_MS);
+ return;
+ }
+
+ if (kind === "nodeschanged") {
+ this.markDirty(kind, 0);
+ return;
+ }
+
+ this.commitCheckpoint(kind);
+ }
+
+ /**
+ * Schedules a deferred history checkpoint to coalesce rapid mutations.
+ *
+ * @param {string} reason Descriptive label for the mutation.
+ * @param {number} [delay=HISTORY_DEBOUNCE_MS] Debounce duration.
+ */
+ markDirty(reason, delay = HISTORY_DEBOUNCE_MS) {
+ if (!this.isReady || this.#workspace.isRestoring) {
+ return;
+ }
+
+ if (this.#suspendDepth > 0) {
+ this.#pendingDirty = true;
+ this.#pendingReason = reason;
+ return;
+ }
+
+ if (delay <= 0 || typeof window === "undefined") {
+ this.commitCheckpoint(reason);
+ return;
+ }
+
+ this.#clearTimer();
+ this.#pendingDirty = true;
+ this.#pendingReason = reason;
+ this.#timerId = window.setTimeout(() => {
+ this.#timerId = null;
+ const pendingReason = this.#pendingReason ?? reason;
+ this.#pendingDirty = false;
+ this.#pendingReason = null;
+ this.commitCheckpoint(pendingReason);
+ }, delay);
+ }
+
+ /**
+ * Captures and stores a new undo checkpoint immediately.
+ *
+ * @param {string} reason Descriptive label for the mutation.
+ */
+ commitCheckpoint(reason) {
+ if (!this.isReady || this.#workspace.isRestoring) {
+ return;
+ }
+
+ if (this.#suspendDepth > 0) {
+ this.#pendingDirty = true;
+ this.#pendingReason = reason;
+ return;
+ }
+
+ this.#clearTimer();
+ const snapshot = this.#captureSnapshot();
+ const serialized = JSON.stringify(snapshot);
+ if (serialized === this.#lastSerialized) {
+ return;
+ }
+
+ this.undoStack.push(snapshot);
+ if (this.undoStack.length > HISTORY_MAX_ENTRIES + 1) {
+ this.undoStack.shift();
+ }
+ this.#lastSerialized = serialized;
+ this.redoStack = [];
+ this.#pendingDirty = false;
+ this.#pendingReason = null;
+ }
+
+ /**
+ * Executes a callback while temporarily suppressing automatic history commits.
+ *
+ * @template T
+ * @param {() => T} callback Callback executed while suspended.
+ * @param {{ suppressAutoCommit?: boolean }} [options] Behaviour overrides.
+ * @returns {T}
+ */
+ withSuspended(callback, options = {}) {
+ const suppressAutoCommit = Boolean(options.suppressAutoCommit);
+ this.#suspendDepth += 1;
+ try {
+ return callback();
+ } finally {
+ this.#suspendDepth = Math.max(0, this.#suspendDepth - 1);
+ if (this.#suspendDepth === 0 && this.#pendingDirty) {
+ if (suppressAutoCommit) {
+ this.#pendingDirty = false;
+ this.#pendingReason = null;
+ } else {
+ const reason = this.#pendingReason ?? "batched";
+ this.#pendingDirty = false;
+ this.#pendingReason = null;
+ this.markDirty(reason, 0);
+ }
+ }
+ }
+ }
+
+ /**
+ * Reverts the workspace to the previous history entry, if available.
+ */
+ undo() {
+ if (!this.isReady || this.undoStack.length <= 1) {
+ return;
+ }
+
+ this.#clearTimer();
+ const current = this.undoStack.pop();
+ if (!current) {
+ return;
+ }
+
+ this.redoStack.push(current);
+ const previous = this.undoStack[this.undoStack.length - 1];
+ if (!previous) {
+ return;
+ }
+
+ this.#restoreSnapshot(previous);
+ this.#lastSerialized = JSON.stringify(previous);
+ }
+
+ /**
+ * Re-applies the most recently undone history entry, if available.
+ */
+ redo() {
+ if (!this.isReady || !this.redoStack.length) {
+ return;
+ }
+
+ this.#clearTimer();
+ const snapshot = this.redoStack.pop();
+ if (!snapshot) {
+ return;
+ }
+
+ this.undoStack.push(snapshot);
+ this.#restoreSnapshot(snapshot);
+ this.#lastSerialized = JSON.stringify(snapshot);
+ }
+
+ /**
+ * Cancels any pending history timers.
+ */
+ #clearTimer() {
+ if (this.#timerId !== null && typeof window !== "undefined") {
+ window.clearTimeout(this.#timerId);
+ this.#timerId = null;
+ }
+ }
+
+ /**
+ * Captures a complete workspace snapshot, including selection and view state.
+ *
+ * @returns {WorkspaceSnapshot}
+ */
+ #captureSnapshot() {
+ const workspacePayload = /** @type {SerializedWorkspace} */ (
+ this.#workspace.serializeWorkspace()
+ );
+ const selection = {
+ nodes: [...this.#workspace.selectedNodeIds],
+ primary: this.#workspace.selectedNodeId ?? null,
+ variable: this.#workspace.selectedVariableId ?? null,
+ };
+ const view = {
+ backgroundOffset: { ...this.#workspace.workspaceBackgroundOffset },
+ zoom: this.#workspace.zoomLevel,
+ };
+
+ return { workspace: workspacePayload, selection, view };
+ }
+
+ /**
+ * Restores the workspace to a previously captured snapshot.
+ *
+ * @param {WorkspaceSnapshot} snapshot Snapshot to restore.
+ */
+ #restoreSnapshot(snapshot) {
+ this.#clearTimer();
+ const wasReady = this.isReady;
+ this.isReady = false;
+ this.#pendingDirty = false;
+ this.#pendingReason = null;
+
+ this.#workspace.applySerializedWorkspace(snapshot.workspace);
+ this.#workspace.applyHistoryView(snapshot.view);
+ this.#workspace.applyHistorySelection(snapshot.selection);
+
+ this.isReady = wasReady;
+ }
+}
diff --git a/scripts/ui/workspace/WorkspaceInlineEditorManager.js b/scripts/ui/workspace/WorkspaceInlineEditorManager.js
new file mode 100644
index 0000000..42c158a
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceInlineEditorManager.js
@@ -0,0 +1,941 @@
+/** @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode */
+/** @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor */
+/** @typedef {import('../../core/NodeGraph.js').NodeGraph} NodeGraph */
+/** @typedef {import('../../nodes/NodeRegistry.js').NodeRegistry} NodeRegistry */
+/** @typedef {import('../BlueprintWorkspace.js').VariableType} VariableType */
+
+/**
+ * Manages inline literal and pin editors for workspace nodes.
+ */
+export class WorkspaceInlineEditorManager {
+ /**
+ * @param {{
+ * getGraph: () => NodeGraph,
+ * registry: NodeRegistry,
+ * formatVariableType?: (type: VariableType) => string,
+ * openLocalVariableTypeDropdown?: (
+ * nodeId: string,
+ * trigger: HTMLButtonElement,
+ * anchor: HTMLElement,
+ * currentType: VariableType,
+ * onSelect: (type: VariableType) => void
+ * ) => void,
+ * closeTypeDropdown?: () => void,
+ * isTypeDropdownOpen?: (context: { kind: 'local_variable', nodeId: string }) => boolean,
+ * }} options Inline editor dependencies.
+ */
+ constructor(options) {
+ this.#getGraph = options.getGraph;
+ this.registry = options.registry;
+ this.#formatVariableType =
+ typeof options.formatVariableType === "function"
+ ? (type) => options.formatVariableType(type)
+ : (type) => {
+ const value = typeof type === "string" ? type : "";
+ if (!value.length) {
+ return "";
+ }
+ return value.charAt(0).toUpperCase() + value.slice(1);
+ };
+ this.#openLocalTypeDropdown =
+ typeof options.openLocalVariableTypeDropdown === "function"
+ ? options.openLocalVariableTypeDropdown
+ : null;
+ this.#closeTypeDropdown =
+ typeof options.closeTypeDropdown === "function"
+ ? options.closeTypeDropdown
+ : () => {};
+ this.#isTypeDropdownOpen =
+ typeof options.isTypeDropdownOpen === "function"
+ ? options.isTypeDropdownOpen
+ : () => false;
+ /**
+ * @type {Map void },
+ * pinEditors: Map void, setConnected: (connected: boolean) => void }>
+ * }>}
+ */
+ this.inlineEditors = new Map();
+ }
+
+ /**
+ * Creates inline literal editor controls for supported nodes.
+ *
+ * @param {BlueprintNode} node Target node instance.
+ * @param {HTMLElement} article Node root article element.
+ * @param {HTMLElement} inputContainer Container holding input pins.
+ * @returns {{ setValue: (value: unknown) => void } | null}
+ */
+ setupLiteralEditor(node, article, inputContainer) {
+ if (node.type === "set_local_var" || node.type === "get_local_var") {
+ return this.#setupLocalVariableEditor(node, article, inputContainer);
+ }
+
+ const supported = new Set([
+ "number_literal",
+ "string_literal",
+ "boolean_literal",
+ ]);
+ if (!supported.has(node.type)) {
+ return null;
+ }
+
+ const existing = article.querySelector(".node-body");
+ const body = existing ?? document.createElement("div");
+ body.className = "node-body";
+ if (!existing) {
+ article.insertBefore(body, inputContainer);
+ }
+ body.innerHTML = "";
+
+ const editor = document.createElement("div");
+ editor.className = "literal-inline-editor";
+ body.appendChild(editor);
+
+ const controlId = `${node.id}_inline_value`;
+ const label = document.createElement("label");
+ label.className = "literal-inline-label";
+ label.setAttribute("for", controlId);
+ label.textContent = "Value";
+ editor.appendChild(label);
+
+ const graph = this.#getGraph();
+ let control;
+ const commitNumber = () => {
+ const numeric = Number(control.value);
+ const value = Number.isFinite(numeric) ? numeric : 0;
+ graph.setNodeProperty(node.id, "value", value);
+ };
+
+ switch (node.type) {
+ case "number_literal": {
+ control = document.createElement("input");
+ control.type = "number";
+ control.step = "any";
+ control.className = "literal-inline-input";
+ control.addEventListener("change", commitNumber);
+ control.addEventListener("blur", commitNumber);
+ break;
+ }
+ case "string_literal": {
+ control = document.createElement("input");
+ control.type = "text";
+ control.className = "literal-inline-input";
+ control.spellcheck = false;
+ control.addEventListener("input", () => {
+ graph.setNodeProperty(node.id, "value", control.value);
+ });
+ break;
+ }
+ case "boolean_literal": {
+ control = document.createElement("input");
+ control.type = "checkbox";
+ control.className = "literal-inline-checkbox";
+ control.addEventListener("change", () => {
+ graph.setNodeProperty(node.id, "value", control.checked);
+ });
+ break;
+ }
+ default:
+ return null;
+ }
+
+ control.id = controlId;
+ editor.appendChild(control);
+
+ const setValue = (rawValue) => {
+ switch (node.type) {
+ case "number_literal": {
+ const numeric = Number(rawValue);
+ control.value = Number.isFinite(numeric) ? String(numeric) : "0";
+ break;
+ }
+ case "string_literal": {
+ control.value = rawValue == null ? "" : String(rawValue);
+ break;
+ }
+ case "boolean_literal": {
+ control.checked = this.#isTruthy(rawValue);
+ break;
+ }
+ default:
+ break;
+ }
+ };
+
+ setValue(node.properties.value);
+
+ const entry = this.#ensureEntry(node.id);
+ entry.literal = { setValue };
+ return entry.literal;
+ }
+
+ #setupLocalVariableEditor(node, article, inputContainer) {
+ const typeOptions = [
+ { value: "number", label: "Number" },
+ { value: "string", label: "String" },
+ { value: "boolean", label: "Boolean" },
+ { value: "table", label: "Table" },
+ ];
+
+ const ensureBody = () => {
+ const existing = article.querySelector(".node-body");
+ if (existing) {
+ existing.innerHTML = "";
+ return existing;
+ }
+ const body = document.createElement("div");
+ body.className = "node-body";
+ article.insertBefore(body, inputContainer);
+ return body;
+ };
+
+ const body = ensureBody();
+ const editor = document.createElement("div");
+ editor.className = "local-variable-editor";
+ body.appendChild(editor);
+
+ const headerRow = document.createElement("div");
+ headerRow.className = "local-variable-row local-variable-row--header";
+ editor.appendChild(headerRow);
+
+ const typeAnchor = document.createElement("div");
+ typeAnchor.className = "local-variable-type";
+ headerRow.appendChild(typeAnchor);
+
+ const typeButton = document.createElement("button");
+ typeButton.type = "button";
+ typeButton.className = "variable-type-button local-variable-type-button";
+ typeButton.setAttribute("aria-haspopup", "menu");
+ typeButton.setAttribute("aria-expanded", "false");
+ typeAnchor.appendChild(typeButton);
+
+ const nameInput = document.createElement("input");
+ nameInput.type = "text";
+ nameInput.className = "local-variable-name-input";
+ nameInput.placeholder = "localVar";
+ nameInput.setAttribute("spellcheck", "false");
+ nameInput.setAttribute("aria-label", "Local variable name");
+ headerRow.appendChild(nameInput);
+
+ const valueContainer = document.createElement("div");
+ valueContainer.className = "local-variable-inline-value";
+ const valueField = document.createElement("div");
+ valueField.className = "local-variable-inline-value-field";
+ valueContainer.appendChild(valueField);
+
+ const attachValueContainer = () => {
+ if (node.type !== "set_local_var") {
+ if (valueContainer.isConnected) {
+ valueContainer.remove();
+ }
+ return;
+ }
+ const valuePin = article.querySelector(
+ ".node-inputs .pin[data-pin-id='value']"
+ );
+ if (!valuePin) {
+ return;
+ }
+
+ const parent = valuePin.parentElement;
+ if (!parent) {
+ return;
+ }
+
+ if (valueContainer.parentElement !== parent) {
+ valuePin.insertAdjacentElement("afterend", valueContainer);
+ return;
+ }
+
+ if (valuePin.nextElementSibling !== valueContainer) {
+ valuePin.insertAdjacentElement("afterend", valueContainer);
+ }
+ };
+
+ const graph = this.#getGraph();
+
+ const normalizeType = (value) => {
+ const candidate = typeof value === "string" ? value : "number";
+ return typeOptions.some((option) => option.value === candidate)
+ ? candidate
+ : "number";
+ };
+
+ const formatType = (type) => {
+ return this.#formatVariableType(normalizeType(type));
+ };
+
+ const resolveValuePropertyKey = (type) => {
+ switch (type) {
+ case "string":
+ return "valueString";
+ case "boolean":
+ return "valueBoolean";
+ case "table":
+ return "valueTable";
+ case "number":
+ default:
+ return "valueNumber";
+ }
+ };
+
+ const resolveValueControlKind = (type) => {
+ switch (type) {
+ case "boolean":
+ return "boolean";
+ case "string":
+ return "string";
+ case "table":
+ return "table";
+ default:
+ return "number";
+ }
+ };
+
+ const defaultValueForType = (type) => {
+ switch (type) {
+ case "string":
+ return "";
+ case "boolean":
+ return false;
+ case "table":
+ return "{}";
+ case "number":
+ default:
+ return 0;
+ }
+ };
+
+ let activeType = normalizeType(node.properties.variableType);
+
+ const updateTypeButton = (type) => {
+ const normalized = normalizeType(type);
+ activeType = normalized;
+ typeButton.dataset.variableType = normalized;
+ const label = formatType(normalized);
+ typeButton.title = `Type: ${label}`;
+ typeButton.setAttribute(
+ "aria-label",
+ `Change local variable type (currently ${label})`
+ );
+ };
+
+ const renderValueControl = (currentNode, type) => {
+ if (currentNode.type !== "set_local_var") {
+ if (valueContainer.isConnected) {
+ valueContainer.remove();
+ }
+ valueField.innerHTML = "";
+ return;
+ }
+ const key = resolveValuePropertyKey(type);
+ const current = currentNode.properties?.[key];
+ const ensureValueControl = () => {
+ const desiredKind = resolveValueControlKind(type);
+ const existing = valueField.firstElementChild;
+ if (
+ existing instanceof HTMLElement &&
+ existing.dataset.localValueKind === desiredKind
+ ) {
+ return /** @type {HTMLElement} */ (existing);
+ }
+
+ valueField.innerHTML = "";
+ let control;
+ if (desiredKind === "boolean") {
+ const select = document.createElement("select");
+ select.className = "local-variable-boolean";
+ select.setAttribute("aria-label", "Local variable value");
+ const trueOption = document.createElement("option");
+ trueOption.value = "true";
+ trueOption.textContent = "True";
+ const falseOption = document.createElement("option");
+ falseOption.value = "false";
+ falseOption.textContent = "False";
+ select.appendChild(trueOption);
+ select.appendChild(falseOption);
+ select.addEventListener("change", () => {
+ const next = select.value === "true";
+ graph.setNodeProperty(currentNode.id, key, next);
+ });
+ control = select;
+ } else if (desiredKind === "string") {
+ const input = document.createElement("input");
+ input.type = "text";
+ input.className = "local-variable-text";
+ input.setAttribute("aria-label", "Local variable value");
+ input.addEventListener("input", () => {
+ graph.setNodeProperty(currentNode.id, key, input.value);
+ });
+ control = input;
+ } else if (desiredKind === "table") {
+ const textarea = document.createElement("textarea");
+ textarea.className = "local-variable-textarea";
+ textarea.rows = 2;
+ textarea.setAttribute("aria-label", "Local variable value");
+ textarea.addEventListener("input", () => {
+ graph.setNodeProperty(currentNode.id, key, textarea.value);
+ });
+ control = textarea;
+ } else {
+ const numberInput = document.createElement("input");
+ numberInput.type = "number";
+ numberInput.step = "any";
+ numberInput.className = "local-variable-number";
+ numberInput.setAttribute("aria-label", "Local variable value");
+ const commit = () => {
+ const value = Number(numberInput.value);
+ const next = Number.isFinite(value) ? value : 0;
+ numberInput.value = String(next);
+ graph.setNodeProperty(currentNode.id, key, next);
+ };
+ numberInput.addEventListener("change", commit);
+ numberInput.addEventListener("blur", commit);
+ control = numberInput;
+ }
+
+ control.dataset.localValueKind = desiredKind;
+ valueField.appendChild(control);
+ return control;
+ };
+
+ const control = ensureValueControl();
+
+ if (control instanceof HTMLSelectElement) {
+ const nextValue = current ? "true" : "false";
+ if (control.value !== nextValue) {
+ control.value = nextValue;
+ }
+ } else if (control instanceof HTMLTextAreaElement) {
+ const nextValue = typeof current === "string" ? current : "{}";
+ if (control.value !== nextValue) {
+ control.value = nextValue;
+ }
+ } else if (control instanceof HTMLInputElement) {
+ if (control.type === "text") {
+ const nextValue = typeof current === "string" ? current : "";
+ if (control.value !== nextValue) {
+ control.value = nextValue;
+ }
+ } else {
+ const numeric = Number(current);
+ const nextValue = Number.isFinite(numeric) ? String(numeric) : "0";
+ if (control.value !== nextValue) {
+ control.value = nextValue;
+ }
+ }
+ }
+
+ attachValueContainer();
+ };
+
+ const applyTypeChange = (rawType) => {
+ const nextType = normalizeType(rawType);
+ const key = resolveValuePropertyKey(nextType);
+ const currentNode = graph.nodes.get(node.id) ?? node;
+ const existing = currentNode.properties?.[key];
+ const fallback = existing ?? defaultValueForType(nextType);
+ updateTypeButton(nextType);
+ graph.setNodeProperty(currentNode.id, "variableType", nextType);
+ graph.setNodeProperty(currentNode.id, key, fallback);
+ const refreshed = graph.nodes.get(node.id) ?? currentNode;
+ renderValueControl(refreshed, nextType);
+ };
+
+ const entry = this.#ensureEntry(node.id);
+
+ const setState = (currentNode) => {
+ const currentType = normalizeType(currentNode.properties.variableType);
+ updateTypeButton(currentType);
+ const currentName =
+ typeof currentNode.properties.name === "string"
+ ? currentNode.properties.name
+ : "localVar";
+ if (nameInput.value !== currentName) {
+ nameInput.value = currentName;
+ }
+ renderValueControl(currentNode, currentType);
+ };
+
+ const openDropdown = () => {
+ if (!this.#openLocalTypeDropdown) {
+ return;
+ }
+ this.#openLocalTypeDropdown(
+ node.id,
+ typeButton,
+ typeAnchor,
+ activeType,
+ (selectedType) => {
+ applyTypeChange(selectedType);
+ }
+ );
+ };
+
+ typeButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!this.#openLocalTypeDropdown) {
+ return;
+ }
+ const context = { kind: "local_variable", nodeId: node.id };
+ const isOpen = this.#isTypeDropdownOpen(context);
+ if (isOpen) {
+ this.#closeTypeDropdown();
+ return;
+ }
+ openDropdown();
+ });
+
+ typeButton.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+
+ typeButton.addEventListener("keydown", (event) => {
+ const isToggle =
+ event.key === "Enter" || event.key === " " || event.key === "Spacebar";
+ if (isToggle || event.key === "ArrowDown") {
+ event.preventDefault();
+ openDropdown();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ this.#closeTypeDropdown();
+ }
+ });
+
+ nameInput.addEventListener("blur", () => {
+ graph.setNodeProperty(node.id, "name", nameInput.value);
+ });
+
+ nameInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ graph.setNodeProperty(node.id, "name", nameInput.value);
+ nameInput.blur();
+ }
+ });
+
+ entry.literal = { setState };
+ setState(node);
+ return entry.literal;
+ }
+
+ /**
+ * Creates inline pin editors when supported.
+ *
+ * @param {BlueprintNode} node Current node instance.
+ * @param {PinDescriptor} pin Pin descriptor.
+ * @param {HTMLElement} container Pin container element.
+ */
+ setupPinEditor(node, pin, container) {
+ if (pin.direction !== "input") {
+ return;
+ }
+
+ const isIfCondition = node.type === "if" && pin.id === "condition";
+ const isExtcmdCommand =
+ node.type === "system_extcmd" && pin.id === "command";
+ const isNamedButtonSelector =
+ node.type === "input_button_constants" && pin.id === "button";
+ const isNamedPlayerSelector =
+ node.type === "input_button_constants" && pin.id === "player";
+ if (
+ !isIfCondition &&
+ !isExtcmdCommand &&
+ !isNamedButtonSelector &&
+ !isNamedPlayerSelector
+ ) {
+ container.classList.remove("has-inline-dropdown");
+ return;
+ }
+
+ const entry = this.#ensureEntry(node.id);
+ const propertyKey = this.#pinPropertyKey(pin.id);
+ const graph = this.#getGraph();
+
+ if (!(propertyKey in node.properties)) {
+ if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
+ const definition = this.registry.get(node.type);
+ const targetKey = isExtcmdCommand
+ ? "command"
+ : isNamedButtonSelector
+ ? "button"
+ : "player";
+ const schema = definition?.properties?.find(
+ (prop) => prop.key === targetKey
+ );
+ const options = Array.isArray(schema?.options) ? schema.options : [];
+ const defaultOption =
+ typeof node.properties[targetKey] === "string"
+ ? node.properties[targetKey]
+ : String(schema?.defaultValue ?? options[0]?.value ?? "");
+ if (typeof node.properties[targetKey] !== "string") {
+ node.properties[targetKey] = defaultOption;
+ }
+ if (isExtcmdCommand) {
+ node.properties[propertyKey] = defaultOption;
+ } else {
+ const numericDefault = Number.parseInt(defaultOption, 10);
+ node.properties[propertyKey] = Number.isFinite(numericDefault)
+ ? numericDefault
+ : 0;
+ }
+ } else {
+ node.properties[propertyKey] = this.#defaultValueForPin(pin);
+ }
+ }
+
+ container.querySelectorAll(".pin-inline-control").forEach((element) => {
+ element.remove();
+ });
+
+ let wrapper;
+ if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
+ wrapper = document.createElement("div");
+ wrapper.className = "pin-inline-control pin-inline-dropdown";
+ container.appendChild(wrapper);
+ } else {
+ wrapper = document.createElement("span");
+ wrapper.className = "pin-inline-control";
+ container.classList.remove("has-inline-dropdown");
+ const label = container.querySelector(".pin-label");
+ if (label) {
+ container.insertBefore(wrapper, label);
+ } else {
+ container.appendChild(wrapper);
+ }
+ }
+
+ if (isIfCondition && pin.kind === "boolean") {
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.className = "pin-inline-checkbox";
+ wrapper.appendChild(checkbox);
+
+ const setValue = (value) => {
+ checkbox.checked = this.#isTruthy(value);
+ };
+
+ const setConnected = (connected) => {
+ wrapper.classList.toggle("is-hidden", connected);
+ };
+
+ checkbox.addEventListener("change", () => {
+ graph.setNodeProperty(node.id, propertyKey, checkbox.checked);
+ });
+
+ entry.pinEditors.set(pin.id, { setValue, setConnected });
+ setValue(node.properties[propertyKey]);
+ setConnected(this.#hasConnection(node.id, pin.id));
+ return;
+ }
+
+ if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
+ const definition = this.registry.get(node.type);
+ const targetKey = isExtcmdCommand
+ ? "command"
+ : isNamedButtonSelector
+ ? "button"
+ : "player";
+ const schema = definition?.properties?.find(
+ (prop) => prop.key === targetKey
+ );
+ const options = Array.isArray(schema?.options) ? schema.options : [];
+
+ const select = document.createElement("select");
+ select.className = "pin-inline-select";
+ const ariaLabel = isExtcmdCommand
+ ? "Command"
+ : isNamedButtonSelector
+ ? "Button"
+ : "Player";
+ select.setAttribute("aria-label", ariaLabel);
+
+ const values = options.map((option) => String(option.value));
+ options.forEach((option) => {
+ const element = document.createElement("option");
+ element.value = String(option.value);
+ element.textContent = option.label ?? String(option.value);
+ select.appendChild(element);
+ });
+
+ const normalizeValue = (value) => {
+ const candidate =
+ typeof value === "string" ? value : String(value ?? "");
+ if (values.length === 0) {
+ return candidate;
+ }
+ if (values.includes(candidate)) {
+ return candidate;
+ }
+ const schemaDefault =
+ schema?.defaultValue != null ? String(schema.defaultValue) : null;
+ if (schemaDefault && values.includes(schemaDefault)) {
+ return schemaDefault;
+ }
+ return values[0];
+ };
+
+ const setValue = (value) => {
+ const normalized = normalizeValue(
+ (isNamedButtonSelector || isNamedPlayerSelector) &&
+ typeof node.properties[targetKey] === "string"
+ ? node.properties[targetKey]
+ : value
+ );
+ select.value = normalized;
+ };
+
+ const setConnected = (connected) => {
+ wrapper.classList.toggle("is-hidden", connected);
+ container.classList.toggle("has-inline-dropdown", !connected);
+ };
+
+ select.addEventListener("change", () => {
+ const nextValue = select.value;
+ if (isExtcmdCommand) {
+ graph.setNodeProperty(node.id, propertyKey, nextValue);
+ graph.setNodeProperty(node.id, "command", nextValue);
+ } else {
+ const normalized = normalizeValue(nextValue);
+ const numeric = Number.parseInt(normalized, 10);
+ graph.setNodeProperty(
+ node.id,
+ propertyKey,
+ Number.isFinite(numeric) ? numeric : 0
+ );
+ graph.setNodeProperty(node.id, targetKey, normalized);
+ }
+ });
+
+ wrapper.appendChild(select);
+
+ entry.pinEditors.set(pin.id, { setValue, setConnected });
+ setValue(node.properties[propertyKey]);
+ setConnected(this.#hasConnection(node.id, pin.id));
+ }
+ }
+
+ /**
+ * Removes inline editors for pins no longer present on a node.
+ *
+ * @param {BlueprintNode} node Target node reference.
+ */
+ prunePinEditors(node) {
+ const entry = this.inlineEditors.get(node.id);
+ if (!entry?.pinEditors) {
+ return;
+ }
+ const activePins = new Set(node.inputs.map((pin) => pin.id));
+ [...entry.pinEditors.keys()].forEach((pinId) => {
+ if (!activePins.has(pinId)) {
+ entry.pinEditors.delete(pinId);
+ }
+ });
+ }
+
+ /**
+ * Synchronizes inline editor controls with the graph state for the supplied node.
+ *
+ * @param {string} nodeId Node identifier.
+ */
+ syncNode(nodeId) {
+ const entry = this.inlineEditors.get(nodeId);
+ if (!entry) {
+ return;
+ }
+
+ const graph = this.#getGraph();
+ const node = graph.nodes.get(nodeId);
+ if (!node) {
+ return;
+ }
+
+ if (entry.literal) {
+ if (typeof entry.literal.setState === "function") {
+ entry.literal.setState(node);
+ } else if (typeof entry.literal.setValue === "function") {
+ entry.literal.setValue(node.properties.value);
+ }
+ }
+
+ entry.pinEditors.forEach((pinEditor, pinId) => {
+ const pin = node.getPin(pinId);
+ const propertyKey = this.#pinPropertyKey(pinId);
+ let value;
+
+ if (node.type === "system_extcmd" && pinId === "command") {
+ const commandValue =
+ typeof node.properties.command === "string"
+ ? node.properties.command
+ : null;
+ if (
+ commandValue !== null &&
+ node.properties[propertyKey] !== commandValue
+ ) {
+ node.properties[propertyKey] = commandValue;
+ }
+ } else if (node.type === "input_button_constants") {
+ if (pinId === "button") {
+ const rawButton =
+ typeof node.properties.button === "string"
+ ? node.properties.button
+ : null;
+ if (rawButton !== null) {
+ const numericButton = Number.parseInt(rawButton, 10);
+ const safeButton = Number.isFinite(numericButton)
+ ? numericButton
+ : 0;
+ if (node.properties[propertyKey] !== safeButton) {
+ node.properties[propertyKey] = safeButton;
+ }
+ }
+ } else if (pinId === "player") {
+ const rawPlayer =
+ typeof node.properties.player === "string"
+ ? node.properties.player
+ : null;
+ if (rawPlayer !== null) {
+ const numericPlayer = Number.parseInt(rawPlayer, 10);
+ const safePlayer = Number.isFinite(numericPlayer)
+ ? numericPlayer
+ : 0;
+ if (node.properties[propertyKey] !== safePlayer) {
+ node.properties[propertyKey] = safePlayer;
+ }
+ }
+ }
+ }
+
+ if (propertyKey in node.properties) {
+ value = node.properties[propertyKey];
+ } else if (pin) {
+ value = this.#defaultValueForPin(pin);
+ } else {
+ value = null;
+ }
+
+ pinEditor.setValue(value);
+ pinEditor.setConnected(this.#hasConnection(nodeId, pinId));
+ });
+ }
+
+ /** Synchronizes inline editors for every tracked node. */
+ syncAll() {
+ this.inlineEditors.forEach((_, nodeId) => this.syncNode(nodeId));
+ }
+
+ /**
+ * Removes inline editor tracking for a node.
+ *
+ * @param {string} nodeId Node identifier.
+ */
+ removeNode(nodeId) {
+ this.inlineEditors.delete(nodeId);
+ }
+
+ /** Clears all inline editor state. */
+ clear() {
+ this.inlineEditors.clear();
+ }
+
+ /**
+ * Ensures inline editor entry exists for a node.
+ *
+ * @param {string} nodeId Node identifier.
+ * @returns {{ literal?: { setValue: (value: unknown) => void }, pinEditors: Map void, setConnected: (connected: boolean) => void }> }}
+ */
+ #ensureEntry(nodeId) {
+ let entry = this.inlineEditors.get(nodeId);
+ if (!entry) {
+ entry = { pinEditors: new Map() };
+ this.inlineEditors.set(nodeId, entry);
+ } else if (!entry.pinEditors) {
+ entry.pinEditors = new Map();
+ }
+ return entry;
+ }
+
+ /**
+ * Generates the property key used for storing inline pin overrides.
+ *
+ * @param {string} pinId Pin identifier.
+ * @returns {string}
+ */
+ #pinPropertyKey(pinId) {
+ return `pin:${pinId}`;
+ }
+
+ /**
+ * Determines whether a pin has any connections.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @returns {boolean}
+ */
+ #hasConnection(nodeId, pinId) {
+ const graph = this.#getGraph();
+ return graph.getConnectionsForNode(nodeId, pinId).some((connection) => {
+ const isTarget =
+ connection.to.nodeId === nodeId && connection.to.pinId === pinId;
+ const isSource =
+ connection.from.nodeId === nodeId && connection.from.pinId === pinId;
+ return isTarget || isSource;
+ });
+ }
+
+ /**
+ * Provides a reasonable default value for the supplied pin.
+ *
+ * @param {PinDescriptor} pin Pin descriptor.
+ * @returns {unknown}
+ */
+ #defaultValueForPin(pin) {
+ if (!pin) {
+ return null;
+ }
+ if (pin.defaultValue !== undefined) {
+ return pin.defaultValue;
+ }
+ switch (pin.kind) {
+ case "boolean":
+ return false;
+ case "number":
+ return 0;
+ case "string":
+ return "";
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Normalizes truthy values for inline boolean controls.
+ *
+ * @param {unknown} value Candidate value.
+ * @returns {boolean}
+ */
+ #isTruthy(value) {
+ if (typeof value === "string") {
+ return value.toLowerCase() !== "false";
+ }
+ return Boolean(value);
+ }
+
+ /** @type {() => NodeGraph} */
+ #getGraph;
+
+ /** @type {(type: VariableType) => string} */
+ #formatVariableType;
+
+ /** @type {(nodeId: string, trigger: HTMLButtonElement, anchor: HTMLElement, currentType: VariableType, onSelect: (type: VariableType) => void) => void} */
+ #openLocalTypeDropdown;
+
+ /** @type {() => void} */
+ #closeTypeDropdown;
+
+ /** @type {(context: { kind: 'local_variable', nodeId: string }) => boolean} */
+ #isTypeDropdownOpen;
+
+}
diff --git a/scripts/ui/workspace/WorkspaceMarqueeManager.js b/scripts/ui/workspace/WorkspaceMarqueeManager.js
new file mode 100644
index 0000000..6d75ed8
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceMarqueeManager.js
@@ -0,0 +1,228 @@
+/**
+ * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
+ */
+
+/**
+ * Coordinates marquee-based selection gestures within the workspace.
+ */
+export class WorkspaceMarqueeManager {
+ /** @type {BlueprintWorkspace} */
+ #workspace;
+ /** @type {HTMLDivElement | null} */
+ #element;
+ /** @type {{ pointerId: number, origin: { x: number, y: number }, current: { x: number, y: number }, hasDragged: boolean, moveHandler: (event: PointerEvent) => void, upHandler: (event: PointerEvent) => void } | null} */
+ #state;
+
+ /**
+ * @param {BlueprintWorkspace} workspace Owning workspace instance.
+ */
+ constructor(workspace) {
+ this.#workspace = workspace;
+ this.#element = null;
+ this.#state = null;
+ }
+
+ /**
+ * Ensures the marquee overlay element exists within the workspace DOM.
+ */
+ ensureElement() {
+ if (this.#element) {
+ return;
+ }
+
+ const marquee = document.createElement("div");
+ marquee.className = "workspace-marquee";
+ marquee.style.display = "none";
+ marquee.setAttribute("role", "presentation");
+ this.#workspace.workspaceElement.appendChild(marquee);
+ this.#element = marquee;
+ }
+
+ /**
+ * Indicates whether the provided element is the marquee overlay.
+ *
+ * @param {Element | null} target Potential marquee element.
+ * @returns {boolean}
+ */
+ isMarqueeElement(target) {
+ return Boolean(target && this.#element && target === this.#element);
+ }
+
+ /**
+ * Initiates a marquee selection gesture at the supplied pointer event.
+ *
+ * @param {PointerEvent} event Pointer event initiating the marquee.
+ */
+ beginSelection(event) {
+ this.ensureElement();
+ if (!this.#element) {
+ return;
+ }
+
+ const bounds = this.#workspace.workspaceElement.getBoundingClientRect();
+ const origin = {
+ x: Math.max(0, event.clientX - bounds.left),
+ y: Math.max(0, event.clientY - bounds.top),
+ };
+
+ this.#element.style.display = "block";
+ this.#element.style.left = `${origin.x}px`;
+ this.#element.style.top = `${origin.y}px`;
+ this.#element.style.width = "0px";
+ this.#element.style.height = "0px";
+
+ const handlePointerMove = (moveEvent) => {
+ if (!this.#state || moveEvent.pointerId !== this.#state.pointerId) {
+ return;
+ }
+ this.#updateSelection(moveEvent);
+ };
+
+ const handlePointerUp = (upEvent) => {
+ if (!this.#state || upEvent.pointerId !== this.#state.pointerId) {
+ return;
+ }
+
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+ this.#finalizeSelection();
+ };
+
+ this.#state = {
+ pointerId: event.pointerId,
+ origin,
+ current: { ...origin },
+ hasDragged: false,
+ moveHandler: handlePointerMove,
+ upHandler: handlePointerUp,
+ };
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
+
+ event.preventDefault();
+ }
+
+ /**
+ * Clears any ongoing marquee gesture and hides the overlay.
+ */
+ clear() {
+ if (this.#state) {
+ window.removeEventListener("pointermove", this.#state.moveHandler);
+ window.removeEventListener("pointerup", this.#state.upHandler);
+ window.removeEventListener("pointercancel", this.#state.upHandler);
+ this.#state = null;
+ }
+
+ if (this.#element) {
+ this.#element.style.display = "none";
+ this.#element.style.width = "0px";
+ this.#element.style.height = "0px";
+ }
+ }
+
+ /**
+ * Updates the marquee overlay to reflect the latest pointer location.
+ *
+ * @param {PointerEvent} event Active pointer event.
+ */
+ #updateSelection(event) {
+ if (!this.#state || !this.#element) {
+ return;
+ }
+
+ const bounds = this.#workspace.workspaceElement.getBoundingClientRect();
+ const clampedX = Math.min(
+ Math.max(0, event.clientX - bounds.left),
+ bounds.width
+ );
+ const clampedY = Math.min(
+ Math.max(0, event.clientY - bounds.top),
+ bounds.height
+ );
+
+ this.#state.current = { x: clampedX, y: clampedY };
+
+ const { origin, current } = this.#state;
+ const left = Math.min(origin.x, current.x);
+ const top = Math.min(origin.y, current.y);
+ const width = Math.abs(current.x - origin.x);
+ const height = Math.abs(current.y - origin.y);
+
+ if (!this.#state.hasDragged && (width > 2 || height > 2)) {
+ this.#state.hasDragged = true;
+ }
+
+ this.#element.style.left = `${left}px`;
+ this.#element.style.top = `${top}px`;
+ this.#element.style.width = `${width}px`;
+ this.#element.style.height = `${height}px`;
+ }
+
+ /**
+ * Finishes the marquee gesture and updates the workspace selection.
+ */
+ #finalizeSelection() {
+ if (!this.#state || !this.#element) {
+ return;
+ }
+
+ const { origin, current, hasDragged } = this.#state;
+ this.clear();
+
+ if (!hasDragged) {
+ return;
+ }
+
+ const bounds = this.#workspace.workspaceElement.getBoundingClientRect();
+ const selectionBounds = {
+ left: Math.min(origin.x, current.x) + bounds.left,
+ top: Math.min(origin.y, current.y) + bounds.top,
+ right: Math.max(origin.x, current.x) + bounds.left,
+ bottom: Math.max(origin.y, current.y) + bounds.top,
+ };
+
+ this.#selectNodesWithinRect(selectionBounds);
+ }
+
+ /**
+ * Selects nodes that intersect the provided screen-space bounds.
+ *
+ * @param {{ left: number, top: number, right: number, bottom: number }} bounds Selection bounds.
+ */
+ #selectNodesWithinRect(bounds) {
+ /** @type {Array} */
+ const selected = [];
+ this.#workspace.nodeElements.forEach((element, nodeId) => {
+ const rect = element.getBoundingClientRect();
+ if (this.#rectanglesOverlap(bounds, rect)) {
+ selected.push(nodeId);
+ }
+ });
+
+ if (selected.length) {
+ const primary = selected[selected.length - 1];
+ this.#workspace.setSelectionState(selected, primary);
+ } else {
+ this.#workspace.clearSelection();
+ }
+ }
+
+ /**
+ * Determines whether two rectangles overlap (inclusive of borders).
+ *
+ * @param {{ left: number, top: number, right: number, bottom: number }} a First rectangle.
+ * @param {{ left: number, top: number, right: number, bottom: number }} b Second rectangle.
+ * @returns {boolean}
+ */
+ #rectanglesOverlap(a, b) {
+ return !(
+ b.left > a.right ||
+ b.right < a.left ||
+ b.top > a.bottom ||
+ b.bottom < a.top
+ );
+ }
+}
diff --git a/scripts/ui/workspace/WorkspaceNavigationManager.js b/scripts/ui/workspace/WorkspaceNavigationManager.js
new file mode 100644
index 0000000..24180fe
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceNavigationManager.js
@@ -0,0 +1,597 @@
+import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
+
+/**
+ * Coordinates workspace navigation behaviour including pan, zoom, and coordinate transforms.
+ */
+export class WorkspaceNavigationManager {
+ #workspace;
+ #renderConnections;
+ #markViewDirty;
+ #schedulePersist;
+ #backgroundOffset;
+ #zoomLevel;
+ #zoomConfig;
+ #panState;
+ #lastPanTimestamp;
+
+ /**
+ * @param {import("../BlueprintWorkspace.js").BlueprintWorkspace} workspace Owning workspace instance.
+ * @param {{ renderConnections?: () => void, markViewDirty?: (reason: string, delay?: number) => void, schedulePersist?: () => void }} [callbacks]
+ * Hook callbacks invoked during navigation changes.
+ */
+ constructor(workspace, callbacks = {}) {
+ this.#workspace = workspace;
+ this.#renderConnections =
+ typeof callbacks.renderConnections === "function"
+ ? callbacks.renderConnections
+ : () => {};
+ this.#markViewDirty =
+ typeof callbacks.markViewDirty === "function"
+ ? callbacks.markViewDirty
+ : () => {};
+ this.#schedulePersist =
+ typeof callbacks.schedulePersist === "function"
+ ? callbacks.schedulePersist
+ : () => {};
+
+ this.#backgroundOffset = { x: 0, y: 0 };
+ this.#zoomLevel = 1;
+ this.#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
+ this.#panState = null;
+ this.#lastPanTimestamp = 0;
+ }
+
+ /**
+ * @returns {number} Active zoom multiplier.
+ */
+ getZoomLevel() {
+ return this.#zoomLevel;
+ }
+
+ /**
+ * Adjusts the zoom level within configured bounds.
+ *
+ * @param {number} nextZoom Requested zoom multiplier.
+ * @param {{ silent?: boolean }} [options] Behaviour overrides.
+ * @returns {boolean} Whether the zoom value changed.
+ */
+ setZoomLevel(nextZoom, options = {}) {
+ if (!Number.isFinite(nextZoom)) {
+ return false;
+ }
+ const { silent = false } = options;
+ const clamped = Math.max(
+ this.#zoomConfig.min,
+ Math.min(this.#zoomConfig.max, nextZoom)
+ );
+ if (Math.abs(clamped - this.#zoomLevel) < 0.0001) {
+ return false;
+ }
+
+ this.#zoomLevel = clamped;
+ this.updateZoomDisplay();
+ if (!silent) {
+ this.#markViewDirty("view", 200);
+ this.#schedulePersist();
+ }
+ return true;
+ }
+
+ /**
+ * @returns {{ min: number, max: number, step: number }} Current zoom configuration.
+ */
+ getZoomConfig() {
+ return { ...this.#zoomConfig };
+ }
+
+ /**
+ * Updates workspace transforms to match the stored zoom level.
+ */
+ updateZoomDisplay() {
+ const zoom = this.#zoomLevel || 1;
+ const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
+
+ if (nodeLayer) {
+ nodeLayer.style.transformOrigin = "0 0";
+ nodeLayer.style.transform = `scale(${zoom})`;
+ }
+
+ if (connectionLayer) {
+ connectionLayer.style.transformOrigin = "0 0";
+ connectionLayer.style.transform = `scale(${zoom})`;
+ }
+
+ workspaceElement.style.setProperty("--workspace-zoom", `${zoom}`);
+ this.#applyBackgroundOffset();
+ this.#renderConnections();
+ }
+
+ /**
+ * Converts a screen-space point to workspace coordinates.
+ *
+ * @param {{ x: number, y: number }} point Screen-space point relative to the workspace element.
+ * @returns {{ x: number, y: number }}
+ */
+ screenPointToWorld(point) {
+ const zoom = this.#zoomLevel || 1;
+ return {
+ x: point.x / zoom,
+ y: point.y / zoom,
+ };
+ }
+
+ /**
+ * Converts a screen-space delta to workspace-space respecting the active zoom.
+ *
+ * @param {{ x: number, y: number }} delta Screen-space delta.
+ * @returns {{ x: number, y: number }}
+ */
+ screenDeltaToWorld(delta) {
+ const zoom = this.#zoomLevel || 1;
+ return {
+ x: delta.x / zoom,
+ y: delta.y / zoom,
+ };
+ }
+
+ /**
+ * Applies zoom centred at a screen-space location.
+ *
+ * @param {{ x: number, y: number }} screenPoint Screen coordinates inside the workspace.
+ * @param {number} direction Positive to zoom in, negative to zoom out.
+ * @param {{ clientX?: number, clientY?: number }} [pointer] Optional pointer coordinates relative to the viewport.
+ */
+ zoomAt(screenPoint, direction, pointer = {}) {
+ if (!direction) {
+ return;
+ }
+
+ const previousZoom = this.#zoomLevel || 1;
+ const worldPoint = this.screenPointToWorld(screenPoint);
+ const nextZoom = previousZoom + direction * this.#zoomConfig.step;
+ const changed = this.setZoomLevel(nextZoom);
+ if (!changed) {
+ return;
+ }
+
+ const currentZoom = this.#zoomLevel || 1;
+ if (Math.abs(currentZoom - previousZoom) < 0.0001) {
+ return;
+ }
+
+ const scale = previousZoom / currentZoom;
+ const shift = {
+ x: worldPoint.x * (scale - 1),
+ y: worldPoint.y * (scale - 1),
+ };
+
+ if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
+ this.translateWorkspace(shift);
+ const { dragManager } = this.#workspace;
+ if (dragManager?.rebaseActiveDragPointer) {
+ dragManager.rebaseActiveDragPointer(pointer);
+ }
+ this.#rebasePanGestureAfterZoom(pointer);
+ this.#renderConnections();
+ }
+ }
+
+ /**
+ * Updates the persisted background offset and re-renders the tiled background.
+ *
+ * @param {{ x: number, y: number }} offset Workspace-space offset.
+ */
+ setBackgroundOffset(offset) {
+ const next = offset ?? { x: 0, y: 0 };
+ this.#backgroundOffset = {
+ x: Number.isFinite(next.x) ? next.x : 0,
+ y: Number.isFinite(next.y) ? next.y : 0,
+ };
+ this.#applyBackgroundOffset();
+ }
+
+ /**
+ * @returns {{ x: number, y: number }} Current background offset reference.
+ */
+ getBackgroundOffset() {
+ return this.#backgroundOffset;
+ }
+
+ /**
+ * Applies a workspace translation to all nodes and the background.
+ *
+ * @param {{ x: number, y: number }} delta Translation amount in workspace units.
+ */
+ translateWorkspace(delta) {
+ if (!delta || (delta.x === 0 && delta.y === 0)) {
+ return;
+ }
+
+ this.setBackgroundOffset({
+ x: this.#backgroundOffset.x + delta.x,
+ y: this.#backgroundOffset.y + delta.y,
+ });
+
+ const { graph, dragManager } = this.#workspace;
+ const panState = this.#panState;
+ const panDelta = panState ? panState.lastDelta : null;
+
+ graph.nodes.forEach((node) => {
+ const origin = panState?.nodeOrigins.get(node.id) ?? node.position;
+ const baseDelta = panDelta ?? { x: 0, y: 0 };
+ const next = {
+ x: origin.x + baseDelta.x + delta.x,
+ y: origin.y + baseDelta.y + delta.y,
+ };
+ graph.setNodePosition(node.id, next);
+ if (dragManager?.updateNodeDomPosition) {
+ dragManager.updateNodeDomPosition(node.id, next);
+ }
+ });
+
+ if (dragManager?.applyActiveDragShift) {
+ dragManager.applyActiveDragShift(delta);
+ }
+
+ this.#syncPanStateAfterShift(delta);
+ }
+
+ /**
+ * Recentres the viewport on the supplied node if available.
+ *
+ * @param {string} nodeId Target node identifier.
+ */
+ focusNode(nodeId) {
+ const element = this.#workspace.nodeElements.get(nodeId);
+ if (!element) {
+ return;
+ }
+
+ const workspaceRect = this.#workspace.workspaceElement.getBoundingClientRect();
+ const nodeRect = element.getBoundingClientRect();
+ if (!workspaceRect.width || !workspaceRect.height) {
+ this.#workspace.selectNode(nodeId);
+ return;
+ }
+
+ const workspaceCenterX = workspaceRect.left + workspaceRect.width / 2;
+ const workspaceCenterY = workspaceRect.top + workspaceRect.height / 2;
+ const nodeCenterX = nodeRect.left + nodeRect.width / 2;
+ const nodeCenterY = nodeRect.top + nodeRect.height / 2;
+
+ const deltaXScreen = workspaceCenterX - nodeCenterX;
+ const deltaYScreen = workspaceCenterY - nodeCenterY;
+
+ if (Math.abs(deltaXScreen) < 1 && Math.abs(deltaYScreen) < 1) {
+ this.#workspace.selectNode(nodeId);
+ return;
+ }
+
+ const delta = this.screenDeltaToWorld({
+ x: deltaXScreen,
+ y: deltaYScreen,
+ });
+
+ this.translateWorkspace(delta);
+ this.#workspace.selectNode(nodeId);
+ }
+
+ /**
+ * Frames the supplied node identifiers within the viewport.
+ *
+ * @param {Array} nodeIds Node identifiers to include when framing.
+ */
+ frameNodes(nodeIds) {
+ const { workspaceElement, nodeElements, graph } = this.#workspace;
+ const rect = workspaceElement.getBoundingClientRect();
+ if (!rect.width || !rect.height) {
+ return;
+ }
+
+ /** @type {Array<{ id: string, x: number, y: number, width: number, height: number }>} */
+ const targets = [];
+ const collect = (id) => {
+ const node = graph.nodes.get(id);
+ const element = nodeElements.get(id);
+ if (!node || !element || !element.isConnected) {
+ return;
+ }
+ const width = Math.max(1, element.offsetWidth);
+ const height = Math.max(1, element.offsetHeight);
+ targets.push({
+ id,
+ x: node.position.x,
+ y: node.position.y,
+ width,
+ height,
+ });
+ };
+
+ if (Array.isArray(nodeIds) && nodeIds.length) {
+ nodeIds.forEach((id) => collect(id));
+ } else {
+ nodeElements.forEach((_, id) => collect(id));
+ }
+
+ if (!targets.length) {
+ return;
+ }
+
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ targets.forEach((entry) => {
+ minX = Math.min(minX, entry.x);
+ minY = Math.min(minY, entry.y);
+ maxX = Math.max(maxX, entry.x + entry.width);
+ maxY = Math.max(maxY, entry.y + entry.height);
+ });
+
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
+ return;
+ }
+
+ const boundsWidth = Math.max(1, maxX - minX);
+ const boundsHeight = Math.max(1, maxY - minY);
+ const viewportPadding = 200;
+ const availableWidth = Math.max(120, rect.width - viewportPadding);
+ const availableHeight = Math.max(120, rect.height - viewportPadding);
+ let targetZoom = Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight);
+
+ if (!Number.isFinite(targetZoom) || targetZoom <= 0) {
+ targetZoom = this.#zoomLevel || 1;
+ }
+
+ targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));
+ this.setZoomLevel(targetZoom);
+
+ const contentCenter = {
+ x: minX + boundsWidth / 2,
+ y: minY + boundsHeight / 2,
+ };
+
+ const viewportCenter = this.screenPointToWorld({
+ x: rect.width / 2,
+ y: rect.height / 2,
+ });
+
+ const delta = {
+ x: viewportCenter.x - contentCenter.x,
+ y: viewportCenter.y - contentCenter.y,
+ };
+
+ if (Math.abs(delta.x) > 0.0001 || Math.abs(delta.y) > 0.0001) {
+ this.translateWorkspace(delta);
+ this.#renderConnections();
+ }
+
+ this.#markViewDirty("view", 200);
+ this.#schedulePersist();
+ }
+
+ /**
+ * Begins a pan gesture driven by the supplied pointer event.
+ *
+ * @param {PointerEvent} event Pointer event initiating the pan.
+ */
+ beginPan(event) {
+ if (this.#panState) {
+ return;
+ }
+
+ const { dragManager, contextMenuManager, graph, nodeElements } =
+ this.#workspace;
+
+ dragManager?.removeNodeGhost();
+ dragManager?.removePaletteGhost();
+
+ const nodeOrigins = new Map();
+ graph.nodes.forEach((node) => {
+ nodeOrigins.set(node.id, { x: node.position.x, y: node.position.y });
+ });
+
+ const state = {
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ lastDelta: { x: 0, y: 0 },
+ hasMoved: false,
+ nodeOrigins,
+ backgroundOrigin: { ...this.#backgroundOffset },
+ };
+
+ const handlePointerMove = (moveEvent) => {
+ if (!this.#panState || moveEvent.pointerId !== this.#panState.pointerId) {
+ return;
+ }
+
+ const deltaXScreen = moveEvent.clientX - this.#panState.startX;
+ const deltaYScreen = moveEvent.clientY - this.#panState.startY;
+ const worldDelta = this.screenDeltaToWorld({
+ x: deltaXScreen,
+ y: deltaYScreen,
+ });
+ this.#panState.lastDelta = worldDelta;
+
+ if (!this.#panState.hasMoved) {
+ const distance = Math.hypot(deltaXScreen, deltaYScreen);
+ if (distance < 3) {
+ return;
+ }
+ moveEvent.preventDefault();
+ this.#panState.hasMoved = true;
+ contextMenuManager?.hide();
+ }
+
+ if (!this.#panState.hasMoved) {
+ return;
+ }
+
+ const backgroundOffset = {
+ x: this.#panState.backgroundOrigin.x + worldDelta.x,
+ y: this.#panState.backgroundOrigin.y + worldDelta.y,
+ };
+ this.setBackgroundOffset(backgroundOffset);
+
+ this.#panState.nodeOrigins.forEach((origin, nodeId) => {
+ const element = nodeElements.get(nodeId);
+ if (!element) {
+ return;
+ }
+ const next = {
+ x: origin.x + worldDelta.x,
+ y: origin.y + worldDelta.y,
+ };
+ element.style.transform = WorkspaceGeometry.positionToTransform(next);
+ });
+
+ this.#renderConnections();
+ };
+
+ const handlePointerUp = (upEvent) => {
+ if (!this.#panState || upEvent.pointerId !== this.#panState.pointerId) {
+ return;
+ }
+
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+
+ const finalState = this.#panState;
+ this.#panState = null;
+
+ if (!finalState.hasMoved) {
+ return;
+ }
+
+ this.#lastPanTimestamp = this.#timestamp();
+ const delta = finalState.lastDelta;
+ this.setBackgroundOffset({
+ x: finalState.backgroundOrigin.x + delta.x,
+ y: finalState.backgroundOrigin.y + delta.y,
+ });
+ finalState.nodeOrigins.forEach((origin, nodeId) => {
+ graph.setNodePosition(nodeId, {
+ x: origin.x + delta.x,
+ y: origin.y + delta.y,
+ });
+ });
+ };
+
+ this.#panState = state;
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
+ }
+
+ /**
+ * Applies persisted view state values.
+ *
+ * @param {{ backgroundOffset?: { x: number, y: number }, zoom?: number } | null | undefined} view View state payload.
+ */
+ applyViewState(view) {
+ const offset = view?.backgroundOffset ?? { x: 0, y: 0 };
+ this.setBackgroundOffset(offset);
+ if (typeof view?.zoom === "number" && Number.isFinite(view.zoom)) {
+ this.setZoomLevel(view.zoom, { silent: true });
+ } else {
+ this.updateZoomDisplay();
+ }
+ }
+
+ /**
+ * Determines whether a pan gesture concluded recently.
+ *
+ * @param {number} thresholdMs Time window in milliseconds.
+ * @returns {boolean}
+ */
+ isPanRecent(thresholdMs) {
+ if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) {
+ return false;
+ }
+ return this.#timestamp() - this.#lastPanTimestamp < thresholdMs;
+ }
+
+ /**
+ * @returns {number} Timestamp of the most recent completed pan gesture.
+ */
+ getLastPanTimestamp() {
+ return this.#lastPanTimestamp;
+ }
+
+ /**
+ * Applies the current background offset to the workspace element styles.
+ */
+ #applyBackgroundOffset() {
+ const zoom = this.#zoomLevel || 1;
+ const scaledX = this.#backgroundOffset.x * zoom;
+ const scaledY = this.#backgroundOffset.y * zoom;
+ const position = `${scaledX}px ${scaledY}px`;
+ this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
+ }
+
+ /**
+ * Aligns any active pan state with a programmatic workspace translation.
+ *
+ * @param {{ x: number, y: number }} shift Workspace-space delta applied externally.
+ */
+ #syncPanStateAfterShift(shift) {
+ if (!this.#panState) {
+ return;
+ }
+
+ if (!shift || (shift.x === 0 && shift.y === 0)) {
+ return;
+ }
+
+ this.#panState.backgroundOrigin = {
+ x: this.#panState.backgroundOrigin.x + shift.x,
+ y: this.#panState.backgroundOrigin.y + shift.y,
+ };
+
+ this.#panState.nodeOrigins.forEach((origin, nodeId, map) => {
+ map.set(nodeId, {
+ x: origin.x + shift.x,
+ y: origin.y + shift.y,
+ });
+ });
+ }
+
+ /**
+ * Rebases the active pan gesture to keep world deltas stable after zoom changes.
+ *
+ * @param {{ clientX?: number, clientY?: number }} [pointer] Pointer coordinates supplied by the zoom trigger.
+ */
+ #rebasePanGestureAfterZoom(pointer) {
+ if (!this.#panState) {
+ return;
+ }
+
+ const zoom = this.#zoomLevel || 1;
+ const { clientX, clientY } = pointer ?? {};
+
+ if (Number.isFinite(clientX)) {
+ this.#panState.startX = clientX - this.#panState.lastDelta.x * zoom;
+ }
+
+ if (Number.isFinite(clientY)) {
+ this.#panState.startY = clientY - this.#panState.lastDelta.y * zoom;
+ }
+ }
+
+ /**
+ * @returns {number} High-resolution timestamp when available.
+ */
+ #timestamp() {
+ if (
+ typeof performance !== "undefined" &&
+ typeof performance.now === "function"
+ ) {
+ return performance.now();
+ }
+ return Date.now();
+ }
+}
diff --git a/scripts/ui/workspace/WorkspaceNodeRenderer.js b/scripts/ui/workspace/WorkspaceNodeRenderer.js
new file mode 100644
index 0000000..8cd31ea
--- /dev/null
+++ b/scripts/ui/workspace/WorkspaceNodeRenderer.js
@@ -0,0 +1,420 @@
+/**
+ * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
+ * @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode
+ * @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor
+ * @typedef {import('./WorkspaceInlineEditorManager.js').WorkspaceInlineEditorManager} WorkspaceInlineEditorManager
+ */
+
+import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
+
+/**
+ * @typedef {"input"|"output"} PinDirection
+ */
+
+/**
+ * Coordinates node DOM rendering, pin creation, and connection class updates.
+ */
+export class WorkspaceNodeRenderer {
+ #workspace;
+ #inlineEditors;
+ #callbacks;
+
+ /**
+ * @param {BlueprintWorkspace} workspace Owning workspace instance.
+ * @param {{
+ * inlineEditorManager: WorkspaceInlineEditorManager,
+ * applySelectionState: () => void,
+ * selectNode: (nodeId: string) => void,
+ * startNodeDrag: (event: PointerEvent, nodeId: string) => void,
+ * refreshVariableNode: (nodeId: string) => boolean,
+ * refreshLocalVariableNode: (nodeId: string) => boolean,
+ * refreshCustomEventNode: (nodeId: string) => boolean,
+ * refreshCustomEventCallNode: (nodeId: string) => boolean,
+ * renderConnections: () => void,
+ * shouldHighlightPin: (nodeId: string, pinId: string, direction: PinDirection) => boolean,
+ * beginConnection: (event: PointerEvent, nodeId: string, pinId: string, direction: PinDirection) => void,
+ * finalizeConnection: (nodeId: string, pinId: string, direction: PinDirection) => void,
+ * getPinElement: (nodeId: string, pinId: string, direction: PinDirection) => HTMLElement | null,
+ * }} options Manager dependencies.
+ */
+ constructor(workspace, options) {
+ this.#workspace = workspace;
+ this.#inlineEditors = options.inlineEditorManager;
+ this.#callbacks = {
+ applySelectionState: options.applySelectionState,
+ selectNode: options.selectNode,
+ startNodeDrag: options.startNodeDrag,
+ refreshVariableNode: options.refreshVariableNode,
+ refreshLocalVariableNode: options.refreshLocalVariableNode,
+ refreshCustomEventNode: options.refreshCustomEventNode,
+ refreshCustomEventCallNode: options.refreshCustomEventCallNode,
+ renderConnections: options.renderConnections,
+ shouldHighlightPin: options.shouldHighlightPin,
+ beginConnection: options.beginConnection,
+ finalizeConnection: options.finalizeConnection,
+ getPinElement: options.getPinElement,
+ };
+ }
+
+ /**
+ * Renders a node article, wires pin events, and updates inline editors.
+ *
+ * @param {BlueprintNode} node Target node.
+ */
+ renderNode(node) {
+ const fragment = this.#workspace.nodeTemplate.content.cloneNode(true);
+ const article = /** @type {HTMLElement} */ (
+ fragment.querySelector(".blueprint-node")
+ );
+ const title = /** @type {HTMLElement} */ (
+ article.querySelector(".node-title")
+ );
+ const inputContainer = /** @type {HTMLElement} */ (
+ article.querySelector(".node-inputs")
+ );
+ const outputContainer = /** @type {HTMLElement} */ (
+ article.querySelector(".node-outputs")
+ );
+
+ title.textContent = node.title;
+ article.dataset.nodeId = node.id;
+ article.dataset.nodeType = node.type;
+ article.style.transform = WorkspaceGeometry.positionToTransform(
+ node.position
+ );
+
+ this.#inlineEditors.setupLiteralEditor(node, article, inputContainer);
+
+ inputContainer.innerHTML = "";
+ node.inputs.forEach((pin) => {
+ const pinElement = this.#createPinElement(node, pin);
+ pinElement.dataset.direction = "input";
+ inputContainer.appendChild(pinElement);
+ this.#inlineEditors.setupPinEditor(node, pin, pinElement);
+ });
+
+ outputContainer.innerHTML = "";
+ node.outputs.forEach((pin) => {
+ const pinElement = this.#createPinElement(node, pin);
+ pinElement.dataset.direction = "output";
+ outputContainer.appendChild(pinElement);
+ this.#inlineEditors.setupPinEditor(node, pin, pinElement);
+ });
+
+ article.addEventListener("pointerdown", (event) => {
+ const target = /** @type {EventTarget | null} */ (event.target);
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ if (target.closest(".pin-handle")) {
+ return;
+ }
+
+ const selectedIds = this.#workspace.selectedNodeIds;
+ if (selectedIds.has(node.id)) {
+ if (this.#workspace.selectedNodeId !== node.id) {
+ this.#workspace.selectedNodeId = node.id;
+ this.#callbacks.applySelectionState();
+ }
+ } else {
+ this.#callbacks.selectNode(node.id);
+ }
+
+ const isMouse =
+ event.pointerType === "mouse" || event.pointerType === "";
+ if (isMouse && event.button !== 0) {
+ return;
+ }
+ if (
+ !isMouse &&
+ event.pointerType !== "touch" &&
+ event.pointerType !== "pen"
+ ) {
+ return;
+ }
+
+ if (target.closest(".pin")) {
+ return;
+ }
+
+ if (this.#isInteractiveElement(target)) {
+ return;
+ }
+
+ this.#callbacks.startNodeDrag(event, node.id);
+ });
+
+ article.addEventListener("pointerenter", () => {
+ article.classList.add("is-hovered");
+ });
+
+ article.addEventListener("pointerleave", () => {
+ article.classList.remove("is-hovered");
+ });
+
+ this.#workspace.nodeLayer.appendChild(article);
+ this.#workspace.nodeElements.set(node.id, article);
+ this.#inlineEditors.syncNode(node.id);
+
+ let connectionsMutated = false;
+ connectionsMutated =
+ this.#callbacks.refreshVariableNode(node.id) || connectionsMutated;
+ if (this.#callbacks.refreshLocalVariableNode) {
+ connectionsMutated =
+ this.#callbacks.refreshLocalVariableNode(node.id) ||
+ connectionsMutated;
+ }
+ if (node.type === "custom_event") {
+ connectionsMutated =
+ this.#callbacks.refreshCustomEventNode(node.id) || connectionsMutated;
+ } else if (node.type === "call_custom_event") {
+ connectionsMutated =
+ this.#callbacks.refreshCustomEventCallNode(node.id) ||
+ connectionsMutated;
+ }
+ if (connectionsMutated) {
+ this.#callbacks.renderConnections();
+ }
+
+ this.updatePinConnectionsForNode(node.id);
+ }
+
+ /**
+ * Rebuilds DOM pin containers for a node when its descriptors change.
+ *
+ * @param {BlueprintNode} node Target node.
+ * @param {{ inputs?: boolean, outputs?: boolean }} [options] Pin refresh options.
+ */
+ rebuildNodePins(node, options = {}) {
+ const { inputs = true, outputs = true } = options;
+ const article = this.#workspace.nodeElements.get(node.id);
+ if (!article) {
+ return;
+ }
+
+ if (inputs) {
+ const inputContainer = article.querySelector(".node-inputs");
+ if (inputContainer instanceof HTMLElement) {
+ inputContainer.innerHTML = "";
+ node.inputs.forEach((pin) => {
+ const pinElement = this.#createPinElement(node, pin);
+ pinElement.dataset.direction = "input";
+ inputContainer.appendChild(pinElement);
+ this.#inlineEditors.setupPinEditor(node, pin, pinElement);
+ });
+ }
+ this.#inlineEditors.prunePinEditors(node);
+ }
+
+ if (outputs) {
+ const outputContainer = article.querySelector(".node-outputs");
+ if (outputContainer instanceof HTMLElement) {
+ outputContainer.innerHTML = "";
+ node.outputs.forEach((pin) => {
+ const pinElement = this.#createPinElement(node, pin);
+ pinElement.dataset.direction = "output";
+ outputContainer.appendChild(pinElement);
+ this.#inlineEditors.setupPinEditor(node, pin, pinElement);
+ });
+ }
+ }
+
+ this.updatePinConnectionsForNode(node.id);
+ }
+
+ /**
+ * Applies connection state classes to every pin associated with a node.
+ *
+ * @param {string} nodeId Node identifier.
+ */
+ updatePinConnectionsForNode(nodeId) {
+ const node = this.#workspace.graph.nodes.get(nodeId);
+ if (!node) {
+ return;
+ }
+
+ node.inputs.forEach((pin) => {
+ this.#applyPinConnectionState(nodeId, pin.id, "input");
+ });
+ node.outputs.forEach((pin) => {
+ this.#applyPinConnectionState(nodeId, pin.id, "output");
+ });
+ }
+
+ /**
+ * Refreshes connection state classes for every pin in the workspace.
+ */
+ refreshAllPinConnections() {
+ this.#workspace.graph.nodes.forEach((node) => {
+ this.updatePinConnectionsForNode(node.id);
+ });
+ }
+
+ /**
+ * Creates and wires a pin element for the provided descriptor.
+ *
+ * @param {BlueprintNode} node Parent node reference.
+ * @param {PinDescriptor} pin Pin descriptor.
+ * @returns {HTMLElement}
+ */
+ #createPinElement(node, pin) {
+ const fragment = this.#workspace.pinTemplate.content.cloneNode(true);
+ const container = /** @type {HTMLElement} */ (fragment.firstElementChild);
+ const label = /** @type {HTMLElement} */ (
+ container.querySelector(".pin-label")
+ );
+ const handle = /** @type {HTMLElement} */ (
+ container.querySelector(".pin-handle")
+ );
+
+ container.dataset.pinId = pin.id;
+ container.dataset.nodeId = node.id;
+ container.dataset.type = pin.kind;
+ const isStandardExec =
+ pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
+ if (isStandardExec) {
+ label.textContent = "";
+ label.classList.add("is-hidden");
+ } else {
+ label.textContent = pin.name;
+ }
+
+ const shouldIgnoreForConnection = (target) => {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+ return Boolean(target.closest(".pin-inline-control"));
+ };
+
+ const handlePointerDown = (event) => {
+ if (event.button !== 0) {
+ return;
+ }
+ if (shouldIgnoreForConnection(event.target)) {
+ return;
+ }
+
+ if (event.altKey) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.#workspace.graph.removeConnectionsForPin({
+ nodeId: node.id,
+ pinId: pin.id,
+ });
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ this.#callbacks.beginConnection(event, node.id, pin.id, pin.direction);
+ };
+
+ const finalizeConnection = (event) => {
+ if (shouldIgnoreForConnection(event.target)) {
+ return;
+ }
+ if (!this.#workspace.pendingConnection) {
+ return;
+ }
+ event.stopPropagation();
+ this.#callbacks.finalizeConnection(node.id, pin.id, pin.direction);
+ };
+
+ handle.addEventListener("pointerdown", handlePointerDown);
+ handle.addEventListener("pointerup", finalizeConnection);
+
+ container.addEventListener("pointerdown", handlePointerDown);
+ container.addEventListener("pointerup", finalizeConnection);
+
+ container.addEventListener("pointerenter", () => {
+ container.classList.add("is-hovered");
+ if (
+ this.#callbacks.shouldHighlightPin(node.id, pin.id, pin.direction)
+ ) {
+ container.classList.add("is-drop-hover");
+ }
+ });
+
+ container.addEventListener("pointerleave", () => {
+ container.classList.remove("is-hovered");
+ container.classList.remove("is-drop-hover");
+ });
+
+ const handleContextMenu = (event) => {
+ if (shouldIgnoreForConnection(event.target)) {
+ return;
+ }
+ event.preventDefault();
+ this.#workspace.graph.removeConnectionsForPin({
+ nodeId: node.id,
+ pinId: pin.id,
+ });
+ };
+
+ handle.addEventListener("contextmenu", handleContextMenu);
+ container.addEventListener("contextmenu", handleContextMenu);
+
+ this.#applyPinConnectionState(node.id, pin.id, pin.direction);
+ return container;
+ }
+
+ /**
+ * Applies connection state classes to the targeted pin element.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @param {PinDirection} direction Pin direction.
+ */
+ #applyPinConnectionState(nodeId, pinId, direction) {
+ const element = this.#callbacks.getPinElement(nodeId, pinId, direction);
+ if (!element) {
+ return;
+ }
+
+ const connected = this.#hasConnection(nodeId, pinId);
+ element.classList.toggle("is-disconnected", !connected);
+ element.classList.toggle("is-connected", connected);
+ }
+
+ /**
+ * Determines whether a pin currently participates in any connection.
+ *
+ * @param {string} nodeId Node identifier.
+ * @param {string} pinId Pin identifier.
+ * @returns {boolean}
+ */
+ #hasConnection(nodeId, pinId) {
+ return this.#workspace.graph
+ .getConnectionsForNode(nodeId, pinId)
+ .some((connection) => {
+ const isTarget =
+ connection.to.nodeId === nodeId && connection.to.pinId === pinId;
+ const isSource =
+ connection.from.nodeId === nodeId &&
+ connection.from.pinId === pinId;
+ return isTarget || isSource;
+ });
+ }
+
+ /**
+ * Determines whether a node event target should block drag initiation.
+ *
+ * @param {HTMLElement} element Candidate event target.
+ * @returns {boolean}
+ */
+ #isInteractiveElement(element) {
+ if (element.closest(".pin-inline-control")) {
+ return true;
+ }
+ if (element.closest(".literal-inline-editor")) {
+ return true;
+ }
+ if (element.closest('[contenteditable="true"]')) {
+ return true;
+ }
+ if (element.closest("input, textarea, select, button")) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/scripts/ui/workspace/WorkspacePaletteManager.js b/scripts/ui/workspace/WorkspacePaletteManager.js
new file mode 100644
index 0000000..13d8e79
--- /dev/null
+++ b/scripts/ui/workspace/WorkspacePaletteManager.js
@@ -0,0 +1,295 @@
+/**
+ * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
+ * @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition
+ * @typedef {import('../BlueprintWorkspace.js').PaletteNodeDefinition} PaletteNodeDefinition
+ */
+
+import { PALETTE_DRAG_MIME } from "../BlueprintWorkspaceConstants.js";
+
+/**
+ * Coordinates palette rendering, search handling, and palette drag state.
+ */
+export class WorkspacePaletteManager {
+ #workspace;
+ /** @type {Set} */
+ #collapsedCategories;
+
+ /**
+ * @param {BlueprintWorkspace} workspace Owning workspace instance.
+ */
+ constructor(workspace) {
+ this.#workspace = workspace;
+ this.#collapsedCategories = new Set();
+ }
+
+ /**
+ * Wires palette UI listeners and renders initial content.
+ */
+ initialize() {
+ const search = this.#workspace.paletteSearch;
+ search.addEventListener("input", () => {
+ this.render(search.value);
+ });
+
+ this.render();
+ }
+
+ /**
+ * Recreates palette DOM structure using the provided search filter.
+ *
+ * @param {string} [query] Optional search query.
+ */
+ render(query) {
+ const searchValue = query ?? "";
+ const baseDefinitions = this.#workspace.registry.search(searchValue);
+ const filteredBase = baseDefinitions.filter(
+ (definition) => definition.id !== "get_var" && definition.id !== "set_var"
+ );
+ const variableShortcuts =
+ this.#buildVariableShortcutDefinitions(searchValue);
+ const definitions = filteredBase.concat(variableShortcuts);
+
+ const list = this.#workspace.paletteList;
+ list.innerHTML = "";
+
+ if (!definitions.length) {
+ const empty = document.createElement("p");
+ empty.className = "palette-empty";
+ empty.textContent = "No matching nodes";
+ list.appendChild(empty);
+ return;
+ }
+
+ const grouped = new Map();
+ definitions.forEach((definition) => {
+ if (!grouped.has(definition.category)) {
+ grouped.set(definition.category, []);
+ }
+ grouped.get(definition.category).push(definition);
+ });
+
+ const isSearchActive = Boolean(searchValue && searchValue.trim().length);
+ const categories = [...grouped.keys()].sort((a, b) => a.localeCompare(b));
+
+ categories.forEach((category) => {
+ const entries = grouped
+ .get(category)
+ .slice()
+ .sort((a, b) => a.title.localeCompare(b.title));
+
+ const section = document.createElement("section");
+ section.className = "palette-section";
+ section.dataset.category = category;
+
+ const header = document.createElement("button");
+ header.type = "button";
+ header.className = "palette-section-header";
+ header.textContent = category;
+ const slug = category.toLowerCase().replace(/[^a-z0-9]+/g, "_");
+ const headerId = `palette_section_${slug}`;
+ const bodyId = `${headerId}_body`;
+ header.id = headerId;
+ header.setAttribute("aria-controls", bodyId);
+ const collapsed =
+ !isSearchActive && this.#collapsedCategories.has(category);
+ header.setAttribute("aria-expanded", String(!collapsed));
+ header.addEventListener("click", () => {
+ if (this.#collapsedCategories.has(category)) {
+ this.#collapsedCategories.delete(category);
+ } else {
+ this.#collapsedCategories.add(category);
+ }
+ this.render(this.#workspace.paletteSearch.value);
+ requestAnimationFrame(() => {
+ const refreshedSection = [
+ ...this.#workspace.paletteList.querySelectorAll(".palette-section"),
+ ].find((element) => element.dataset.category === category);
+ const refreshedHeader = refreshedSection?.querySelector(
+ ".palette-section-header"
+ );
+ if (refreshedHeader instanceof HTMLButtonElement) {
+ refreshedHeader.focus();
+ }
+ });
+ });
+ section.appendChild(header);
+
+ const body = document.createElement("div");
+ body.className = "palette-section-body";
+ body.setAttribute("role", "group");
+ body.id = bodyId;
+ body.setAttribute("aria-labelledby", headerId);
+ body.dataset.category = category;
+ if (collapsed) {
+ body.hidden = true;
+ section.classList.add("is-collapsed");
+ }
+
+ entries.forEach((definition) => {
+ const isVariableShortcut = Boolean(
+ definition.shortcut?.type === "variable"
+ );
+ const item = this.#createDefinitionButton(definition, {
+ className: "palette-item",
+ showCategory: false,
+ draggable: !isVariableShortcut,
+ onSelect: () => {
+ this.#workspace.createNodeFromDefinition(definition);
+ },
+ onDragStart: isVariableShortcut
+ ? undefined
+ : (event) => {
+ this.#handlePaletteDragStart(event, definition.id);
+ },
+ onDragEnd: isVariableShortcut
+ ? undefined
+ : () => {
+ this.#handlePaletteDragEnd();
+ },
+ });
+ body.appendChild(item);
+ });
+
+ section.appendChild(body);
+ list.appendChild(section);
+ });
+ }
+
+ /**
+ * Allows external callers to cancel palette drag state.
+ */
+ handleDragEnd() {
+ this.#handlePaletteDragEnd();
+ }
+
+ /**
+ * Builds a button node for palette entries with attached drag/click handlers.
+ *
+ * @param {PaletteNodeDefinition} definition Target definition.
+ * @param {{ className?: string, showCategory?: boolean, draggable?: boolean, onSelect: () => void, onDragStart?: (event: DragEvent) => void, onDragEnd?: (event: DragEvent) => void }} options Button configuration.
+ * @returns {HTMLButtonElement}
+ */
+ #createDefinitionButton(definition, options) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = options?.className ?? "palette-item";
+ button.dataset.definitionId = definition.id;
+
+ const showCategory = options?.showCategory ?? true;
+
+ if (showCategory) {
+ const category = document.createElement("span");
+ category.className = "palette-item-category";
+ category.textContent = definition.category;
+ button.appendChild(category);
+ }
+
+ const title = document.createElement("span");
+ title.className = "palette-item-name";
+ title.textContent = definition.title;
+ button.appendChild(title);
+
+ if (definition.description) {
+ button.title = definition.description;
+ }
+
+ button.addEventListener("click", () => {
+ options.onSelect();
+ });
+
+ if (options?.draggable) {
+ button.draggable = true;
+ button.addEventListener("dragstart", (event) => {
+ options.onDragStart?.(event);
+ });
+ button.addEventListener("dragend", (event) => {
+ options.onDragEnd?.(event);
+ });
+ }
+
+ return button;
+ }
+
+ /**
+ * Prepares drag metadata when beginning a palette drag interaction.
+ *
+ * @param {DragEvent} event Drag event payload.
+ * @param {string} definitionId Node definition identifier being dragged.
+ */
+ #handlePaletteDragStart(event, definitionId) {
+ if (!event.dataTransfer) {
+ return;
+ }
+
+ event.dataTransfer.setData(PALETTE_DRAG_MIME, definitionId);
+ event.dataTransfer.setData("text/plain", definitionId);
+ event.dataTransfer.effectAllowed = "copy";
+ this.#workspace.workspaceDragDepth = 0;
+ this.#workspace.workspaceElement.classList.add("is-palette-dragging");
+ this.#workspace.activePaletteDefinitionId = definitionId;
+ }
+
+ /**
+ * Cleans up workspace styling once a palette drag interaction ends.
+ */
+ #handlePaletteDragEnd() {
+ this.#workspace.workspaceDragDepth = 0;
+ this.#workspace.workspaceElement.classList.remove("is-palette-dragging");
+ this.#workspace.workspaceElement.classList.remove("is-drag-target");
+ this.#workspace.activePaletteDefinitionId = null;
+ this.#workspace.dragManager.removePaletteGhost();
+ }
+
+ /**
+ * Builds shortcut node definitions for existing workspace variables.
+ *
+ * @param {string} [query] Optional search filter.
+ * @returns {Array}
+ */
+ #buildVariableShortcutDefinitions(query) {
+ const normalizedQuery = (query ?? "").trim().toLowerCase();
+ const includeAll = normalizedQuery.length === 0;
+ const entries = [];
+
+ this.#workspace.variableOrder.forEach((variableId) => {
+ const variable = this.#workspace.variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+
+ const displayName = (variable.name ?? "").trim() || variable.id;
+ const normalizedName = displayName.toLowerCase();
+ const tokens = [
+ normalizedName,
+ variableId.toLowerCase(),
+ `get ${normalizedName}`,
+ `set ${normalizedName}`,
+ ];
+
+ if (
+ !includeAll &&
+ !tokens.some((token) => token.includes(normalizedQuery))
+ ) {
+ return;
+ }
+
+ entries.push({
+ id: `variable_get_${variableId}`,
+ title: `Get ${displayName}`,
+ category: "Variables",
+ description: `Get ${displayName}`,
+ shortcut: { type: "variable", action: "get", variableId },
+ });
+
+ entries.push({
+ id: `variable_set_${variableId}`,
+ title: `Set ${displayName}`,
+ category: "Variables",
+ description: `Set ${displayName}`,
+ shortcut: { type: "variable", action: "set", variableId },
+ });
+ });
+
+ return entries;
+ }
+}
diff --git a/scripts/ui/workspace/WorkspacePersistenceManager.js b/scripts/ui/workspace/WorkspacePersistenceManager.js
new file mode 100644
index 0000000..5d66c1b
--- /dev/null
+++ b/scripts/ui/workspace/WorkspacePersistenceManager.js
@@ -0,0 +1,213 @@
+/**
+ * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
+ * @typedef {import('./WorkspaceHistoryManager.js').SerializedWorkspace} SerializedWorkspace
+ */
+
+import { WORKSPACE_STORAGE_KEY } from "../BlueprintWorkspaceConstants.js";
+
+/**
+ * Handles saving and restoring workspace state using localStorage.
+ */
+export class WorkspacePersistenceManager {
+ #workspace;
+ #timerId;
+ #storageAvailable;
+
+ /**
+ * @param {BlueprintWorkspace} workspace Owning workspace instance.
+ */
+ constructor(workspace) {
+ this.#workspace = workspace;
+ this.#timerId = null;
+ this.#storageAvailable = this.#detectLocalStorage();
+ }
+
+ /**
+ * Indicates whether localStorage is available for persistence.
+ *
+ * @returns {boolean}
+ */
+ get isStorageAvailable() {
+ return this.#storageAvailable;
+ }
+
+ /**
+ * Clears any pending persist timer.
+ */
+ cancelScheduledPersist() {
+ if (this.#timerId !== null && typeof window !== "undefined") {
+ window.clearTimeout(this.#timerId);
+ this.#timerId = null;
+ }
+ }
+
+ /**
+ * Persists workspace state after a small debounce to reduce write frequency.
+ */
+ schedulePersist() {
+ if (
+ !this.#storageAvailable ||
+ this.#workspace.isRestoring ||
+ typeof window === "undefined"
+ ) {
+ return;
+ }
+
+ this.cancelScheduledPersist();
+ this.#timerId = window.setTimeout(() => {
+ this.#timerId = null;
+ this.persistImmediately();
+ }, 200);
+ }
+
+ /**
+ * Serializes current workspace state and writes it to localStorage immediately.
+ */
+ persistImmediately() {
+ if (!this.#storageAvailable || typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ const payload = this.serializeWorkspace();
+ window.localStorage.setItem(
+ WORKSPACE_STORAGE_KEY,
+ JSON.stringify(payload)
+ );
+ } catch (error) {
+ console.error("Failed to persist workspace state", error);
+ }
+ }
+
+ /**
+ * Creates a serializable snapshot of the workspace.
+ *
+ * @returns {SerializedWorkspace}
+ */
+ serializeWorkspace() {
+ const variableEntries = this.#workspace.variableOrder
+ .map((variableId) => this.#workspace.variables.get(variableId))
+ .filter((entry) => Boolean(entry))
+ .map((entry) => {
+ const variable =
+ /** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ (
+ entry
+ );
+ return {
+ id: variable.id,
+ name: variable.name,
+ type: variable.type,
+ defaultValue:
+ variable.defaultValue === undefined
+ ? null
+ : this.#cloneVariableDefault(variable.defaultValue),
+ };
+ });
+
+ return {
+ version: 4,
+ projectSettings: { ...this.#workspace.projectSettings },
+ graph: this.#workspace.graph.toJSON(),
+ variables: {
+ entries: variableEntries,
+ counter: this.#workspace.variableCounter,
+ },
+ spawnIndex: this.#workspace.spawnIndex,
+ ui: {
+ paletteVisible: Boolean(this.#workspace.isPaletteVisible),
+ },
+ };
+ }
+
+ /**
+ * Attempts to load workspace state from localStorage.
+ *
+ * @returns {boolean} Whether a valid payload was applied.
+ */
+ restoreFromStorage() {
+ if (!this.#storageAvailable || typeof window === "undefined") {
+ return false;
+ }
+
+ let raw = null;
+ try {
+ raw = window.localStorage.getItem(WORKSPACE_STORAGE_KEY);
+ } catch (error) {
+ console.error("Failed to access stored workspace state", error);
+ return false;
+ }
+
+ if (!raw) {
+ return false;
+ }
+
+ try {
+ const payload = JSON.parse(raw);
+ const hasGraph =
+ payload &&
+ typeof payload === "object" &&
+ payload.graph &&
+ typeof payload.graph === "object" &&
+ Array.isArray(payload.graph.nodes) &&
+ Array.isArray(payload.graph.connections);
+ if (!hasGraph) {
+ return false;
+ }
+
+ this.#workspace.applySerializedWorkspace(payload);
+ return true;
+ } catch (error) {
+ console.error("Failed to restore workspace state", error);
+ return false;
+ }
+ }
+
+ /**
+ * Detects whether localStorage is available in the execution environment.
+ *
+ * @returns {boolean}
+ */
+ #detectLocalStorage() {
+ try {
+ if (typeof window === "undefined" || !("localStorage" in window)) {
+ return false;
+ }
+ const probeKey = "__picograph_probe__";
+ window.localStorage.setItem(probeKey, "1");
+ window.localStorage.removeItem(probeKey);
+ return true;
+ } catch (error) {
+ return false;
+ }
+ }
+
+ /**
+ * Produces a JSON-safe clone of a variable default value.
+ *
+ * @param {import('../BlueprintWorkspace.js').VariableDefaultValue} value Default value to clone.
+ * @returns {import('../BlueprintWorkspace.js').VariableDefaultValue}
+ */
+ #cloneVariableDefault(value) {
+ if (Array.isArray(value)) {
+ return value.map((entry) => ({
+ id: typeof entry?.id === "string" ? entry.id : "",
+ key: typeof entry?.key === "string" ? entry.key : "",
+ value: typeof entry?.value === "string" ? entry.value : "",
+ }));
+ }
+
+ if (value === undefined || value === null) {
+ return null;
+ }
+
+ if (typeof value === "object") {
+ try {
+ return JSON.parse(JSON.stringify(value));
+ } catch (_error) {
+ return null;
+ }
+ }
+
+ return value;
+ }
+}
diff --git a/scripts/ui/workspace/renderEventList.js b/scripts/ui/workspace/renderEventList.js
new file mode 100644
index 0000000..14d09e2
--- /dev/null
+++ b/scripts/ui/workspace/renderEventList.js
@@ -0,0 +1,108 @@
+/**
+ * Renders the event summary list.
+ *
+ * @param {object} options Rendering options.
+ * @param {HTMLUListElement | null} options.element Target list element.
+ * @param {import('../../nodes/NodeRegistry.js').NodeRegistry} options.registry Node registry instance.
+ * @param {import('../../core/NodeGraph.js').NodeGraph} options.graph Active node graph.
+ * @param {(nodeId: string) => void} options.focusNode Callback to focus a node.
+ * @param {(message: string) => void} options.renderEmpty Callback to render empty state.
+ * @param {(node: import('../../core/BlueprintNode.js').BlueprintNode, entryTypes: Set) => boolean} options.isEventNode Predicate to determine if node represents an event.
+ */
+export function renderEventList({
+ element,
+ registry,
+ graph,
+ focusNode,
+ renderEmpty,
+ isEventNode,
+}) {
+ if (!element) {
+ return;
+ }
+
+ element.innerHTML = "";
+
+ const entryTypes = new Set(registry.getEntryNodeTypes());
+ const events = [...graph.nodes.values()].filter((node) =>
+ isEventNode(node, entryTypes)
+ );
+ const builtInEvents = [];
+ const customEvents = [];
+
+ events.forEach((node) => {
+ if (node.type === "custom_event") {
+ customEvents.push(node);
+ } else {
+ builtInEvents.push(node);
+ }
+ });
+
+ if (!builtInEvents.length && !customEvents.length) {
+ renderEmpty("No events yet");
+ return;
+ }
+
+ const appendHeading = (title) => {
+ const headingItem = document.createElement("li");
+ headingItem.className = "overview-subheading";
+ headingItem.textContent = title;
+ element.appendChild(headingItem);
+ };
+
+ const appendEventItem = (node, displayName) => {
+ const item = document.createElement("li");
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "overview-item";
+ button.dataset.nodeId = node.id;
+ button.addEventListener("click", () => {
+ focusNode(node.id);
+ });
+
+ const label = document.createElement("span");
+ label.className = "overview-item-label";
+ label.textContent = displayName;
+ button.appendChild(label);
+
+ const meta = document.createElement("span");
+ meta.className = "overview-item-meta";
+ meta.textContent = node.id;
+ button.appendChild(meta);
+
+ item.appendChild(button);
+ element.appendChild(item);
+ };
+
+ if (builtInEvents.length) {
+ appendHeading("Events");
+ builtInEvents
+ .slice()
+ .sort((a, b) => a.title.localeCompare(b.title))
+ .forEach((node) => appendEventItem(node, node.title));
+ }
+
+ if (customEvents.length) {
+ appendHeading("Custom Events");
+ customEvents
+ .slice()
+ .sort((a, b) => {
+ const nameA =
+ (typeof a.properties.name === "string"
+ ? a.properties.name.trim()
+ : "") || "CustomEvent";
+ const nameB =
+ (typeof b.properties.name === "string"
+ ? b.properties.name.trim()
+ : "") || "CustomEvent";
+ return nameA.localeCompare(nameB);
+ })
+ .forEach((node) => {
+ const displayName =
+ (typeof node.properties.name === "string"
+ ? node.properties.name.trim()
+ : "") || "CustomEvent";
+ appendEventItem(node, displayName);
+ });
+ }
+}
diff --git a/scripts/ui/workspace/renderVariableList.js b/scripts/ui/workspace/renderVariableList.js
new file mode 100644
index 0000000..98af967
--- /dev/null
+++ b/scripts/ui/workspace/renderVariableList.js
@@ -0,0 +1,442 @@
+import { VARIABLE_SORT_MIME } from "../BlueprintWorkspaceConstants.js";
+
+/**
+ * Renders the global variable summary list.
+ *
+ * @param {object} options Rendering options.
+ * @param {HTMLUListElement | null} options.element Target list element.
+ * @param {Array} options.variableOrder Display order of variable identifiers.
+ * @param {Map} options.variables Registered variables.
+ * @param {import('../../core/NodeGraph.js').NodeGraph} options.graph Active node graph.
+ * @param {() => void} options.cancelVariableRename Callback to cancel an in-progress rename.
+ * @param {() => void} options.closeVariableTypeDropdown Callback to close the variable type dropdown.
+ * @param {(message: string) => void} options.renderEmpty Renders an empty-state message.
+ * @param {(name: string) => string} options.normalizeVariableName Normalizes variable names for comparisons.
+ * @param {(type: import('../BlueprintWorkspace.js').VariableType) => string} options.formatVariableType Creates a human-readable type label.
+ * @param {(variableId: string) => void} options.selectVariable Activates a variable selection.
+ * @param {(event: DragEvent, variableId: string) => void} options.handleVariableDragStart Prepares workspace drag metadata for a variable.
+ * @param {() => void} options.handleVariableDragEnd Clears workspace drag feedback for variables.
+ * @param {(variableId: string, targetIndex: number) => void} options.reorderVariable Reorders a variable in the list.
+ * @param {(variableId: string, button: HTMLButtonElement, anchor: HTMLElement) => void} options.openVariableTypeDropdown Opens the variable type dropdown.
+ * @param {(variableId: string) => void} options.deleteVariable Removes the selected variable.
+ * @param {(variableId: string, label: HTMLElement) => void} options.startVariableRename Begins inline rename mode.
+ * @param {() => void} options.highlightVariableSelection Refreshes selection highlight state.
+ * @param {(variableId: string) => boolean} options.isVariableDropdownOpen Determines whether the dropdown is already open for a variable.
+ */
+export function renderVariableList({
+ element,
+ variableOrder,
+ variables,
+ graph,
+ cancelVariableRename,
+ closeVariableTypeDropdown,
+ renderEmpty,
+ normalizeVariableName,
+ formatVariableType,
+ selectVariable,
+ handleVariableDragStart,
+ handleVariableDragEnd,
+ reorderVariable,
+ openVariableTypeDropdown,
+ deleteVariable,
+ startVariableRename,
+ highlightVariableSelection,
+ isVariableDropdownOpen,
+}) {
+ if (!element) {
+ return;
+ }
+
+ cancelVariableRename();
+ closeVariableTypeDropdown();
+ element.innerHTML = "";
+
+ if (!variableOrder.length) {
+ renderEmpty("No variables yet");
+ return;
+ }
+
+ const SORT_MIME = VARIABLE_SORT_MIME;
+ let draggedVariableId = null;
+
+ /**
+ * Determines whether a drag event carries the variable sort payload.
+ *
+ * @param {DragEvent} event Drag event payload.
+ * @returns {boolean}
+ */
+ const hasSortPayload = (event) => {
+ if (!event.dataTransfer) {
+ return false;
+ }
+ const types = event.dataTransfer.types;
+ if (!types) {
+ return false;
+ }
+ return Array.from(types).includes(SORT_MIME);
+ };
+
+ const clearDropIndicators = () => {
+ const rows = element.querySelectorAll(
+ ".overview-item--variable.is-drop-before, .overview-item--variable.is-drop-after"
+ );
+ rows.forEach((node) => {
+ node.classList.remove("is-drop-before", "is-drop-after");
+ });
+ };
+
+ const clearDragState = () => {
+ clearDropIndicators();
+ const rows = element.querySelectorAll(
+ ".overview-item--variable.is-dragging"
+ );
+ rows.forEach((node) => {
+ node.classList.remove("is-dragging");
+ });
+ draggedVariableId = null;
+ };
+
+ const setDropIndicator = (row, position) => {
+ clearDropIndicators();
+ if (!row) {
+ return;
+ }
+ if (position === "before") {
+ row.classList.add("is-drop-before");
+ } else if (position === "after") {
+ row.classList.add("is-drop-after");
+ }
+ };
+
+ element.addEventListener("dragover", (event) => {
+ if (!draggedVariableId || !hasSortPayload(event)) {
+ return;
+ }
+ const targetRow =
+ event.target instanceof HTMLElement
+ ? event.target.closest(".overview-item--variable")
+ : null;
+ if (targetRow) {
+ return;
+ }
+ event.preventDefault();
+ const lastRow = element.querySelector(
+ ".overview-item--variable:last-of-type"
+ );
+ if (!lastRow) {
+ return;
+ }
+ setDropIndicator(lastRow, "after");
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = "move";
+ }
+ });
+
+ element.addEventListener("drop", (event) => {
+ if (!draggedVariableId || !hasSortPayload(event)) {
+ return;
+ }
+ const targetRow =
+ event.target instanceof HTMLElement
+ ? event.target.closest(".overview-item--variable")
+ : null;
+ if (targetRow) {
+ return;
+ }
+ event.preventDefault();
+ const sourceId =
+ (event.dataTransfer && event.dataTransfer.getData(SORT_MIME)) ||
+ draggedVariableId;
+ const sourceIndex = variableOrder.indexOf(sourceId);
+ if (sourceIndex === -1) {
+ if (typeof handleVariableDragEnd === "function") {
+ handleVariableDragEnd();
+ }
+ clearDragState();
+ return;
+ }
+ let targetIndex = variableOrder.length;
+ if (targetIndex > sourceIndex) {
+ targetIndex -= 1;
+ }
+ if (targetIndex !== sourceIndex) {
+ reorderVariable(sourceId, targetIndex);
+ }
+ if (typeof handleVariableDragEnd === "function") {
+ handleVariableDragEnd();
+ }
+ clearDragState();
+ });
+
+ element.addEventListener("dragleave", (event) => {
+ if (!draggedVariableId) {
+ return;
+ }
+ const related = event.relatedTarget;
+ if (related instanceof HTMLElement && element.contains(related)) {
+ return;
+ }
+ clearDropIndicators();
+ });
+
+ /** @type {Map} */
+ const usageById = new Map();
+ /** @type {Map} */
+ const usageByName = new Map();
+
+ graph.nodes.forEach((node) => {
+ if (node.type !== "set_var" && node.type !== "get_var") {
+ return;
+ }
+
+ const nodeVariableId =
+ typeof node.properties.variableId === "string" &&
+ node.properties.variableId
+ ? node.properties.variableId
+ : "";
+ const raw =
+ typeof node.properties.name === "string" ? node.properties.name : "";
+ const normalized = normalizeVariableName(raw) || "__blank__";
+ const usageMap =
+ nodeVariableId && variables.has(nodeVariableId) ? usageById : usageByName;
+ const key =
+ nodeVariableId && variables.has(nodeVariableId)
+ ? nodeVariableId
+ : normalized;
+ const usage = usageMap.get(key) ?? { sets: 0, gets: 0 };
+ if (node.type === "set_var") {
+ usage.sets += 1;
+ } else {
+ usage.gets += 1;
+ }
+ usageMap.set(key, usage);
+ });
+
+ variableOrder.forEach((variableId) => {
+ const variable = variables.get(variableId);
+ if (!variable) {
+ return;
+ }
+
+ const item = document.createElement("li");
+ const row = document.createElement("div");
+ row.className = "overview-item overview-item--variable";
+ row.tabIndex = 0;
+ row.setAttribute("role", "button");
+ row.dataset.variableId = variable.id;
+ row.dataset.variableType = variable.type;
+ row.draggable = true;
+ row.setAttribute("aria-pressed", "false");
+
+ const activate = () => {
+ selectVariable(variable.id);
+ };
+
+ row.addEventListener("click", () => {
+ activate();
+ });
+
+ row.addEventListener("dragstart", (event) => {
+ activate();
+ draggedVariableId = variable.id;
+ row.classList.add("is-dragging");
+ if (typeof handleVariableDragStart === "function") {
+ handleVariableDragStart(event, variable.id);
+ }
+ if (event.dataTransfer) {
+ event.dataTransfer.setData(SORT_MIME, variable.id);
+ event.dataTransfer.effectAllowed = "copyMove";
+ }
+ });
+
+ row.addEventListener("dragend", () => {
+ if (typeof handleVariableDragEnd === "function") {
+ handleVariableDragEnd();
+ }
+ clearDragState();
+ });
+
+ row.addEventListener("dragover", (event) => {
+ if (!draggedVariableId || !hasSortPayload(event)) {
+ return;
+ }
+ event.preventDefault();
+ const rect = row.getBoundingClientRect();
+ const midpoint = rect.top + rect.height / 2;
+ const position = event.clientY <= midpoint ? "before" : "after";
+ setDropIndicator(row, position);
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = "move";
+ }
+ });
+
+ row.addEventListener("dragleave", (event) => {
+ if (!draggedVariableId) {
+ return;
+ }
+ const related = event.relatedTarget;
+ if (related instanceof HTMLElement && row.contains(related)) {
+ return;
+ }
+ clearDropIndicators();
+ });
+
+ row.addEventListener("drop", (event) => {
+ if (!draggedVariableId || !hasSortPayload(event)) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ const sourceId =
+ (event.dataTransfer && event.dataTransfer.getData(SORT_MIME)) ||
+ draggedVariableId;
+ const sourceIndex = variableOrder.indexOf(sourceId);
+ const targetId = row.dataset.variableId || "";
+ const targetIndexRaw = variableOrder.indexOf(targetId);
+ if (sourceIndex === -1 || targetIndexRaw === -1) {
+ if (typeof handleVariableDragEnd === "function") {
+ handleVariableDragEnd();
+ }
+ clearDragState();
+ return;
+ }
+ const rect = row.getBoundingClientRect();
+ const midpoint = rect.top + rect.height / 2;
+ const dropPosition = event.clientY <= midpoint ? "before" : "after";
+ let targetIndex = targetIndexRaw;
+ if (dropPosition === "after") {
+ targetIndex += 1;
+ }
+ if (targetIndex > variableOrder.length) {
+ targetIndex = variableOrder.length;
+ }
+ if (targetIndex > sourceIndex) {
+ targetIndex -= 1;
+ }
+ if (targetIndex !== sourceIndex) {
+ reorderVariable(sourceId, targetIndex);
+ }
+ if (typeof handleVariableDragEnd === "function") {
+ handleVariableDragEnd();
+ }
+ clearDragState();
+ });
+
+ const layout = document.createElement("div");
+ layout.className = "variable-overview-row";
+ row.appendChild(layout);
+
+ const typeButton = document.createElement("button");
+ typeButton.type = "button";
+ typeButton.className = "variable-type-button";
+ typeButton.dataset.variableType = variable.type;
+ typeButton.setAttribute("aria-haspopup", "menu");
+ typeButton.setAttribute("aria-expanded", "false");
+ typeButton.title = `Type: ${formatVariableType(variable.type)}`;
+ typeButton.setAttribute(
+ "aria-label",
+ `Change type for ${
+ variable.name || variable.id
+ } (currently ${formatVariableType(variable.type)})`
+ );
+ typeButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ activate();
+ const isOpen = isVariableDropdownOpen(variable.id);
+ if (isOpen) {
+ closeVariableTypeDropdown();
+ } else {
+ openVariableTypeDropdown(variable.id, typeButton, row);
+ }
+ });
+ typeButton.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+ typeButton.addEventListener("keydown", (event) => {
+ const isToggleKey =
+ event.key === "Enter" || event.key === " " || event.key === "Spacebar";
+ if (isToggleKey || event.key === "ArrowDown") {
+ event.preventDefault();
+ activate();
+ openVariableTypeDropdown(variable.id, typeButton, row);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ closeVariableTypeDropdown();
+ }
+ });
+ layout.appendChild(typeButton);
+
+ const label = document.createElement("span");
+ label.className = "overview-item-label variable-name-display";
+ label.tabIndex = 0;
+ label.textContent = variable.name || "Unnamed";
+ label.title = variable.name || "Unnamed";
+ label.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ activate();
+ startVariableRename(variable.id, label);
+ });
+ label.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ activate();
+ startVariableRename(variable.id, label);
+ }
+ });
+ layout.appendChild(label);
+
+ const meta = document.createElement("span");
+ meta.className = "overview-item-meta";
+ const fallbackKey = normalizeVariableName(variable.name) || "__blank__";
+ const usage = usageById.get(variable.id) ?? usageByName.get(fallbackKey);
+ const summary = [formatVariableType(variable.type)];
+ if (usage) {
+ if (usage.sets) {
+ summary.push(`${usage.sets} set${usage.sets === 1 ? "" : "s"}`);
+ }
+ if (usage.gets) {
+ summary.push(`${usage.gets} get${usage.gets === 1 ? "" : "s"}`);
+ }
+ }
+ meta.textContent = summary.join(" • ");
+ layout.appendChild(meta);
+
+ const removeButton = document.createElement("button");
+ removeButton.type = "button";
+ removeButton.className = "overview-item-action overview-item-action--remove";
+ const removeLabel = variable.name || variable.id;
+ removeButton.setAttribute(
+ "aria-label",
+ `Delete variable ${removeLabel}`
+ );
+ removeButton.title = `Delete ${removeLabel}`;
+ removeButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ deleteVariable(variable.id);
+ });
+ removeButton.addEventListener("pointerdown", (event) => {
+ event.stopPropagation();
+ });
+ layout.appendChild(removeButton);
+
+ row.addEventListener("keydown", (event) => {
+ const isSpace =
+ event.key === " " || event.key === "Space" || event.key === "Spacebar";
+ if (event.key === "Enter" || isSpace) {
+ event.preventDefault();
+ activate();
+ } else if (event.key === "F2") {
+ event.preventDefault();
+ startVariableRename(variable.id, label);
+ }
+ });
+
+ item.appendChild(row);
+ element.appendChild(item);
+ });
+
+ highlightVariableSelection();
+}
diff --git a/styles/main.css b/styles/main.css
new file mode 100644
index 0000000..91aaf16
--- /dev/null
+++ b/styles/main.css
@@ -0,0 +1,2323 @@
+:root {
+ color-scheme: dark;
+ font-family: "Segoe UI", "Trebuchet MS", sans-serif;
+ --surface-0: #101218;
+ --surface-1: #161921;
+ --surface-2: #1d2029;
+ --surface-3: #242833;
+ --accent: #ff7f11;
+ --accent-muted: #f0a202;
+ --edge: #2f3544;
+ --text-primary: #f6f9ff;
+ --text-secondary: #a8b2cc;
+ --pin-exec: #ffffff;
+ --pin-number: #3ee581;
+ --pin-boolean: #ff4f4f;
+ --pin-string: #ff66ff;
+ --pin-table: #5ec4ff;
+ --pin-any: #8c919d;
+ --variable-color-any: #ff7f11;
+ --variable-color-number: #3ee581;
+ --variable-color-string: #ff66ff;
+ --variable-color-boolean: #ff4f4f;
+ --variable-color-table: #5ec4ff;
+ --shadow-0: 0 8px 18px rgba(0, 0, 0, 0.35);
+ --grid-color: rgba(255, 255, 255, 0.04);
+ --grid-color-strong: rgba(255, 255, 255, 0.1);
+ --grid-size: 20px;
+}
+
+* {
+ box-sizing: border-box;
+ user-select: none;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(246, 249, 255, 0.18) transparent;
+}
+
+*::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+*::-webkit-scrollbar-thumb {
+ background-color: rgba(246, 249, 255, 0.18);
+ border-radius: 999px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(246, 249, 255, 0.3);
+}
+
+*::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ background: var(--surface-0);
+ color: var(--text-primary);
+ overflow: hidden;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ color: var(--text-primary);
+ border-radius: 4px;
+ padding: 0.4rem 0.75rem;
+ cursor: pointer;
+}
+
+button:hover:enabled,
+button:focus-visible:enabled {
+ background: var(--surface-3);
+ border-color: var(--accent);
+ outline: none;
+}
+
+button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+input[type='search'] {
+ width: 100%;
+ padding: 0.4rem 0.6rem;
+ border-radius: 4px;
+ border: 1px solid var(--edge);
+ background: var(--surface-2);
+ color: var(--text-primary);
+}
+
+.app-header {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ justify-content: stretch;
+ gap: 1rem;
+ padding: 0.75rem 1.25rem;
+ background: linear-gradient(90deg, var(--surface-1), var(--surface-2));
+ border-bottom: 1px solid var(--edge);
+ box-shadow: var(--shadow-0);
+ z-index: 10;
+}
+
+.header-primary-actions {
+ display: flex;
+ align-items: center;
+}
+
+.header-title {
+ font-size: 1.1rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ justify-self: center;
+ text-align: center;
+}
+
+.header-primary-button {
+ min-width: 7rem;
+ padding: 0.55rem 1.25rem;
+ border: none;
+ border-radius: 999px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ background: var(--accent);
+ color: #1e1e28;
+ cursor: pointer;
+ transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
+ box-shadow: 0 0 0 0 rgba(255, 127, 17, 0.45);
+}
+
+.header-primary-button:hover:enabled,
+.header-primary-button:focus-visible:enabled {
+ transform: translateY(-1px);
+ box-shadow: 0 10px 18px rgba(255, 127, 17, 0.35);
+ outline: none;
+}
+
+.header-primary-button:active:enabled {
+ transform: translateY(0);
+ box-shadow: 0 4px 10px rgba(255, 127, 17, 0.28);
+}
+
+.header-primary-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+.header-primary-button.is-busy:disabled {
+ cursor: progress;
+}
+
+.header-primary-button.is-success {
+ background: #3ee581;
+ color: #06100a;
+ box-shadow: 0 0 0 0 rgba(62, 229, 129, 0.45);
+}
+
+.header-primary-button.is-error {
+ background: #ff4f4f;
+ color: #1e0a0a;
+ box-shadow: 0 0 0 0 rgba(255, 79, 79, 0.45);
+}
+
+.header-actions {
+ display: inline-flex;
+ gap: 0.5rem;
+}
+
+.header-actions .header-icon-button {
+ min-width: 0;
+ padding: 0;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-primary);
+ transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
+}
+
+.header-icon-button .header-icon {
+ display: inline-block;
+ width: 1.3rem;
+ height: 1.3rem;
+ background-color: currentColor;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: contain;
+}
+
+.header-icon--settings {
+ mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/%3E%3Cpath d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .69.4 1.31 1.02 1.58.21.09.44.14.67.14H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/%3E%3C/svg%3E');
+}
+
+.header-icon--frame {
+ mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M4 9V5a1 1 0 0 1 1-1h4"/%3E%3Cpath d="M20 9V5a1 1 0 0 0-1-1h-4"/%3E%3Cpath d="M20 15v4a1 1 0 0 1-1 1h-4"/%3E%3Cpath d="M4 15v4a1 1 0 0 0 1 1h4"/%3E%3C/svg%3E');
+}
+
+.header-icon--palette {
+ mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 4a8 8 0 1 0 7.54 5.14 1.5 1.5 0 0 0-1.54-1.14h-1.38a1.5 1.5 0 0 1-1.5-1.5V5.5A1.5 1.5 0 0 0 12.62 4Z"/%3E%3Ccircle cx="7.5" cy="10.5" r="1"/%3E%3Ccircle cx="12" cy="7.5" r="1"/%3E%3Ccircle cx="16.5" cy="10.5" r="1"/%3E%3Ccircle cx="11" cy="14.5" r="1"/%3E%3C/svg%3E');
+}
+
+.header-icon--export {
+ mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 3v12"/%3E%3Cpath d="m8 11 4 4 4-4"/%3E%3Cpath d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/%3E%3C/svg%3E');
+}
+
+.header-icon-button.is-active {
+ background: rgba(255, 127, 17, 0.2);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.header-icon-button:hover:enabled,
+.header-icon-button:focus-visible:enabled {
+ color: var(--accent);
+ background: var(--surface-3);
+ border-color: var(--accent);
+ outline: none;
+}
+
+.header-actions button.is-active {
+ background: var(--accent-muted);
+ color: #1e1e28;
+ border-color: var(--accent);
+}
+
+.app-body {
+ flex: 1;
+ display: grid;
+ grid-template-columns: 240px minmax(0, 1fr) 280px 280px;
+ grid-template-rows: 1fr;
+ gap: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.app-body > * {
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
+}
+
+.app-body--palette-hidden {
+ grid-template-columns: 240px minmax(0, 1fr) 280px;
+}
+
+.app-body--palette-hidden .palette {
+ display: none;
+}
+
+.overview,
+.palette,
+.inspector {
+ display: flex;
+ flex-direction: column;
+ background: var(--surface-1);
+ overflow: hidden;
+}
+
+.overview {
+ border-right: 1px solid var(--edge);
+}
+
+.palette {
+ border-left: 1px solid var(--edge);
+}
+
+.inspector {
+ border-left: 1px solid var(--edge);
+}
+
+.palette-search {
+ padding: 0.75rem;
+ border-bottom: 1px solid var(--edge);
+}
+
+.palette-list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0.5rem 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.overview-section {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 0.75rem;
+ border-bottom: 1px solid var(--edge);
+ min-height: 0;
+}
+
+.overview-section:last-child {
+ border-bottom: none;
+}
+
+.overview-title {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+}
+
+.overview-subheading {
+ margin: 0.35rem 0 0.15rem;
+ padding: 0 0.25rem;
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+ list-style: none;
+}
+
+.overview-title--with-action {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.overview-action {
+ min-width: 1.75rem;
+ padding: 0.25rem 0;
+ border-radius: 999px;
+ font-size: 1rem;
+ line-height: 1;
+ background: var(--surface-3);
+ border-color: var(--edge);
+}
+
+.overview-action:hover,
+.overview-action:focus-visible {
+ border-color: var(--accent);
+}
+
+.overview-list {
+ flex: 1;
+ margin: 0.5rem 0 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ overflow-y: auto;
+}
+
+.overview-item {
+ --overview-variable-color: var(--variable-color-any);
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ border-radius: 6px;
+ padding: 0.45rem 0.6rem;
+ font-size: 0.8rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ text-align: left;
+ gap: 0.6rem;
+ cursor: pointer;
+ position: relative;
+}
+
+.overview-item.is-selected {
+ border-color: var(--overview-variable-color, #ff7f11);
+ box-shadow: 0 0 0 1px var(--overview-variable-color, #ff7f11);
+ background: var(--surface-3);
+}
+
+.overview-item:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.overview-item--variable {
+ gap: 0.35rem;
+ position: relative;
+}
+
+.overview-item--variable.is-dragging {
+ opacity: 0.6;
+}
+
+.overview-item--variable.is-drop-before::before,
+.overview-item--variable.is-drop-after::after {
+ content: '';
+ position: absolute;
+ left: 0.4rem;
+ right: 0.4rem;
+ height: 2px;
+ background: var(--accent);
+ border-radius: 999px;
+ pointer-events: none;
+}
+
+.overview-item--variable.is-drop-before::before {
+ top: -1px;
+}
+
+.overview-item--variable.is-drop-after::after {
+ bottom: -1px;
+}
+
+.variable-overview-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ width: 100%;
+ flex-wrap: wrap;
+}
+
+.variable-name-display {
+ display: block;
+ padding: 0.2rem 0.35rem;
+ border-radius: 4px;
+ border: 1px solid transparent;
+ background: transparent;
+ font-size: 0.78rem;
+ color: var(--text-primary);
+ cursor: text;
+ flex: 1;
+}
+
+.overview-item--variable:hover .variable-name-display,
+.variable-name-display:hover {
+ background: rgba(246, 249, 255, 0.06);
+}
+
+.variable-name-display:focus,
+.variable-name-display:focus-visible {
+ background: var(--surface-2);
+ border-color: var(--accent);
+ outline: none;
+}
+
+.variable-overview-row .overview-item-meta {
+ flex: 0 0 auto;
+}
+
+.overview-item--variable .overview-item-action,
+.custom-event-parameter-row .custom-event-parameter-action,
+.table-default-row .overview-item-action {
+ appearance: none;
+ border: none;
+ background: none;
+ padding: 0;
+ width: 1.6rem;
+ height: 1.6rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-secondary);
+ cursor: pointer;
+ flex-shrink: 0;
+ border-radius: 0;
+ transition: color 0.15s ease, transform 0.15s ease;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+}
+
+.overview-item--variable .overview-item-action:hover,
+.custom-event-parameter-row .custom-event-parameter-action:hover,
+.table-default-row .overview-item-action:hover {
+ background: none;
+ border: none;
+}
+
+.overview-item--variable .overview-item-action:focus-visible,
+.custom-event-parameter-row .custom-event-parameter-action:focus-visible,
+.table-default-row .overview-item-action:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ background: none;
+ border: none;
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+}
+
+.overview-item--variable .overview-item-action::before,
+.custom-event-parameter-row .custom-event-parameter-action::before,
+.table-default-row .overview-item-action::before {
+ content: '';
+ width: 1rem;
+ height: 1rem;
+ background-color: currentColor;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: contain;
+}
+
+.overview-item--variable .overview-item-action--remove::before,
+.custom-event-parameter-row .custom-event-parameter-action--remove::before,
+.table-default-row .overview-item-action--remove::before {
+ mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M3 6h18"/%3E%3Cpath d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/%3E%3Cpath d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/%3E%3Cpath d="M10 11v6"/%3E%3Cpath d="M14 11v6"/%3E%3C/svg%3E');
+}
+
+.overview-item--variable .overview-item-action--remove:hover,
+.overview-item--variable .overview-item-action--remove:focus-visible,
+.custom-event-parameter-row .custom-event-parameter-action--remove:hover,
+.custom-event-parameter-row .custom-event-parameter-action--remove:focus-visible,
+.table-default-row .overview-item-action--remove:hover,
+.table-default-row .overview-item-action--remove:focus-visible {
+ color: var(--pin-boolean);
+ transform: scale(1.05);
+}
+
+.overview-item--variable:hover .overview-item-action,
+.overview-item--variable:focus-within .overview-item-action {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+}
+
+.custom-event-parameter-row:hover .custom-event-parameter-action,
+.custom-event-parameter-row:focus-within .custom-event-parameter-action {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+}
+
+.table-default-row:hover .overview-item-action,
+.table-default-row:focus-within .overview-item-action {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+}
+
+.variable-type-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ padding: 0.25rem 0.45rem;
+ border-radius: 999px;
+ border: 1px solid var(--edge);
+ background: var(--surface-3);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
+ flex-shrink: 0;
+}
+
+.variable-type-button::before {
+ content: '';
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 999px;
+ background: var(--overview-variable-color, #ff7f11);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+}
+
+.variable-type-button[data-variable-type='table']::before {
+ border-radius: 0;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+ background-color: transparent;
+ background-image:
+ linear-gradient(var(--variable-color-table), var(--variable-color-table)),
+ linear-gradient(var(--variable-color-table), var(--variable-color-table)),
+ linear-gradient(var(--variable-color-table), var(--variable-color-table)),
+ linear-gradient(var(--variable-color-table), var(--variable-color-table));
+ background-size: calc(50% - 1px) calc(50% - 1px);
+ background-repeat: no-repeat;
+ background-position: left top, right top, left bottom, right bottom;
+}
+
+.variable-type-button::after {
+ content: '';
+ border-left: 0.3rem solid transparent;
+ border-right: 0.3rem solid transparent;
+ border-top: 0.35rem solid currentColor;
+}
+
+.variable-type-button:hover,
+.variable-type-button:focus-visible {
+ background: var(--surface-2);
+ border-color: var(--accent);
+ color: var(--text-primary);
+ outline: none;
+}
+
+.variable-type-button[aria-expanded='true'] {
+ background: var(--surface-2);
+ border-color: var(--accent);
+ color: var(--text-primary);
+}
+
+.overview-item[data-variable-type='number'] {
+ --overview-variable-color: var(--variable-color-number);
+}
+
+.overview-item[data-variable-type='string'] {
+ --overview-variable-color: var(--variable-color-string);
+}
+
+.overview-item[data-variable-type='boolean'] {
+ --overview-variable-color: var(--variable-color-boolean);
+}
+
+.overview-item[data-variable-type='any'] {
+ --overview-variable-color: var(--variable-color-any);
+}
+
+.overview-item[data-variable-type='table'] {
+ --overview-variable-color: var(--variable-color-table);
+}
+
+.overview-item[data-variable-type] .overview-item-label {
+ cursor: text;
+}
+
+.variable-type-dropdown {
+ position: absolute;
+ top: calc(100% + 0.35rem);
+ left: 0;
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ border-radius: 8px;
+ z-index: 0;
+ box-shadow: var(--shadow-0);
+ padding: 0.4rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ z-index: 10;
+ min-width: 8rem;
+}
+
+.variable-type-dropdown.is-above {
+ top: auto;
+ bottom: calc(100% + 0.35rem);
+}
+
+.variable-type-option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ padding: 0.3rem 0.45rem;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
+.variable-type-option::before {
+ content: '';
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 999px;
+ background: var(--variable-color-any);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+}
+
+.variable-type-option[data-variable-type='number']::before {
+ background: var(--variable-color-number);
+}
+
+.variable-type-option[data-variable-type='string']::before {
+ background: var(--variable-color-string);
+}
+
+.variable-type-option[data-variable-type='boolean']::before {
+ background: var(--variable-color-boolean);
+}
+
+.variable-type-option[data-variable-type='any']::before {
+ background: var(--variable-color-any);
+}
+
+.variable-type-option[data-variable-type='table']::before {
+ border-radius: 0;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+ background-color: transparent;
+ background-image:
+ linear-gradient(var(--variable-color-table), var(--variable-color-table)),
+ linear-gradient(var(--variable-color-table), var(--variable-color-table)),
+ linear-gradient(var(--variable-color-table), var(--variable-color-table)),
+ linear-gradient(var(--variable-color-table), var(--variable-color-table));
+ background-size: calc(50% - 1px) calc(50% - 1px);
+ background-repeat: no-repeat;
+ background-position: left top, right top, left bottom, right bottom;
+}
+
+.variable-type-option:hover,
+.variable-type-option:focus-visible {
+ background: var(--surface-3);
+ outline: none;
+}
+
+.variable-type-option[aria-checked='true'] {
+ background: rgba(255, 127, 17, 0.18);
+}
+
+.overview-item-rename {
+ flex: 1;
+ min-width: 0;
+ padding: 0.35rem 0.45rem;
+ border-radius: 4px;
+ border: 1px solid var(--accent);
+ background: var(--surface-0);
+ color: var(--text-primary);
+ font: inherit;
+ outline: none;
+}
+
+.overview-item-rename:focus {
+ box-shadow: 0 0 0 1px var(--accent);
+}
+
+.overview-item-label {
+ font-weight: 600;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.overview-item-meta {
+ font-size: 0.7rem;
+ color: var(--text-secondary);
+ visibility: collapse;
+}
+
+.overview-empty {
+ margin: 0;
+ padding: 0.35rem 0;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ opacity: 0.75;
+ text-align: center;
+}
+
+.workspace-context-menu {
+ position: absolute;
+ display: none;
+ flex-direction: column;
+ gap: 0.4rem;
+ min-width: 220px;
+ max-height: 400px;
+ padding: 0.5rem;
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ border-radius: 8px;
+ box-shadow: var(--shadow-0);
+ z-index: 30;
+}
+
+.workspace-context-menu.is-visible {
+ display: flex;
+}
+
+.context-menu-list {
+ flex: 1;
+ /* Critical for flexbox to allow the list to shrink and scroll */
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.context-menu-item {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ justify-content: flex-start;
+ gap: 0.4rem;
+ padding: 0.3rem 0.5rem;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 0.8rem;
+ text-align: left;
+ cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
+}
+
+.context-menu-item:hover,
+.context-menu-item:focus-visible {
+ background: var(--surface-3);
+ border-color: var(--surface-4, var(--edge));
+ color: var(--text-primary);
+ outline: none;
+}
+
+.context-menu-item:active {
+ background: var(--surface-4, var(--surface-3));
+}
+
+.context-menu-item .palette-item-name {
+ font-size: 0.8rem;
+ font-weight: 500;
+ display: block;
+ text-align: left;
+}
+
+.context-menu-item.is-selected,
+.context-menu-item[aria-selected='true'] {
+ background: var(--surface-3);
+ border-color: var(--accent);
+ box-shadow: inset 0.2rem 0 0 var(--accent);
+ color: var(--text-primary);
+}
+
+.context-menu-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.context-menu-section + .context-menu-section {
+ margin-top: 0.35rem;
+}
+
+.context-menu-section-header {
+ padding: 0.2rem 0.5rem;
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+}
+
+.context-menu-section-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.context-menu-item {
+ width: 100%;
+ background: var(--surface-2);
+ cursor: pointer;
+}
+
+.context-menu-item:active {
+ cursor: pointer;
+}
+
+.workspace-context-menu .palette-item-category {
+ font-size: 0.6rem;
+}
+
+.workspace-context-menu .palette-item-name {
+ font-size: 0.75rem;
+}
+
+.context-menu-empty {
+ margin: 0;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ text-align: center;
+ padding: 0.4rem 0;
+ opacity: 0.75;
+}
+
+.palette-section {
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-0);
+ flex: 0 0 auto;
+ width: 100%;
+}
+
+.palette-section.is-collapsed {
+ box-shadow: none;
+}
+
+.palette-section + .palette-section {
+ margin-top: 0.25rem;
+}
+
+.palette-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 0.55rem 0.75rem;
+ background: linear-gradient(90deg, var(--surface-2), var(--surface-3));
+ border: none;
+ color: var(--text-secondary);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ cursor: pointer;
+}
+
+.palette-section-header::after {
+ content: '▾';
+ font-size: 0.8rem;
+ transition: transform 120ms ease;
+}
+
+.palette-section.is-collapsed .palette-section-header::after {
+ transform: rotate(-90deg);
+}
+
+.palette-section-header:hover,
+.palette-section-header:focus-visible {
+ color: var(--accent);
+ outline: none;
+}
+
+.palette-section-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ padding: 0.5rem 0.75rem 0.75rem;
+}
+
+.palette-item {
+ background: var(--surface-1);
+ border-radius: 6px;
+ padding: 0.45rem 0.6rem;
+ border: 1px solid var(--edge);
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ cursor: grab;
+ transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
+}
+
+.palette-item:active {
+ cursor: grabbing;
+}
+
+.palette-item:hover,
+.palette-item:focus-visible {
+ transform: translateY(-2px);
+ border-color: var(--accent);
+ outline: none;
+}
+
+.palette-item-category {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+}
+
+.palette-item-name {
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.palette-empty {
+ margin: 0.75rem 0;
+ text-align: center;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ opacity: 0.75;
+}
+
+.workspace,
+.workspace:focus,
+.workspace:focus-visible,
+.workspace-canvas,
+.workspace-canvas:focus,
+.workspace-canvas:focus-visible {
+ outline: none;
+}
+
+.workspace:focus-visible,
+.workspace-canvas:focus-visible {
+ box-shadow: inset 0 0 0 2px rgba(255, 127, 17, 0.35);
+}
+
+.workspace {
+ position: relative;
+ background: var(--surface-0);
+ overflow: hidden;
+ height: 100%;
+}
+
+.workspace-canvas {
+ --workspace-zoom: 1;
+ position: relative;
+ width: 100%;
+ height: 100%;
+ perspective: 1400px;
+ perspective-origin: center center;
+ background-image:
+ linear-gradient(var(--grid-color-strong) 2px, transparent 2px),
+ linear-gradient(90deg, var(--grid-color-strong) 2px, transparent 2px),
+ linear-gradient(var(--grid-color) 1px, transparent 1px),
+ linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
+ background-size:
+ calc(var(--grid-size) * var(--workspace-zoom) * 5) calc(var(--grid-size) * var(--workspace-zoom) * 5),
+ calc(var(--grid-size) * var(--workspace-zoom) * 5) calc(var(--grid-size) * var(--workspace-zoom) * 5),
+ calc(var(--grid-size) * var(--workspace-zoom)) calc(var(--grid-size) * var(--workspace-zoom)),
+ calc(var(--grid-size) * var(--workspace-zoom)) calc(var(--grid-size) * var(--workspace-zoom));
+ background-repeat: repeat;
+ background-position:
+ 0 0,
+ 0 0,
+ 0 0,
+ 0 0;
+ cursor: grab;
+}
+
+.workspace-canvas:active {
+ cursor: grabbing;
+}
+
+.workspace-canvas.is-palette-dragging {
+ cursor: copy;
+}
+
+.workspace-canvas.is-variable-dragging {
+ cursor: copy;
+}
+
+.workspace-canvas.is-drag-target {
+ outline: 2px dashed var(--accent);
+ outline-offset: -4px;
+}
+
+.connection-layer {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ transform-origin: 0 0;
+}
+
+.node-layer {
+ position: absolute;
+ inset: 0;
+ transform-origin: 0 0;
+}
+
+.workspace-marquee {
+ position: absolute;
+ border: 1px solid var(--accent);
+ background: rgba(255, 127, 17, 0.16);
+ border-radius: 6px;
+ pointer-events: none;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
+ z-index: 35;
+}
+
+.workspace-drop-ghost,
+.workspace-node-ghost {
+ position: absolute;
+ pointer-events: none;
+ border-radius: 8px;
+ transform: translate(0, 0);
+ transition: transform 140ms cubic-bezier(0.22, 0.61, 0.36, 1);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
+}
+
+.workspace-drop-ghost {
+ min-width: 220px;
+ min-height: 140px;
+ border: 2px dashed var(--accent);
+ background: rgba(255, 127, 17, 0.08);
+ display: grid;
+ place-items: center;
+ color: var(--accent);
+ font-weight: 600;
+ z-index: 25;
+ opacity: 0.85;
+}
+
+.workspace-drop-ghost__label {
+ padding: 0.5rem 1rem;
+ text-align: center;
+ font-size: 0.75rem;
+}
+
+.workspace-node-ghost {
+ border: 2px solid rgba(255, 127, 17, 0.6);
+ background: rgba(255, 127, 17, 0.08);
+ z-index: 5;
+ opacity: 0.5;
+}
+
+.blueprint-node {
+ position: absolute;
+ min-width: 220px;
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ border-radius: 8px;
+ box-shadow: var(--shadow-0);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto auto 1fr;
+ grid-template-areas:
+ 'header header'
+ 'body body'
+ 'inputs outputs';
+ column-gap: 0.35rem;
+ overflow: hidden;
+ z-index: 10;
+ transition: box-shadow 160ms ease, border-color 160ms ease;
+}
+
+.blueprint-node::after {
+ content: '';
+ position: absolute;
+ inset: -4px;
+ border-radius: 12px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 160ms ease, transform 160ms ease;
+ transform: scale(0.97);
+ box-shadow: 0 0 0 2px rgba(255, 127, 17, 0.2);
+}
+
+.blueprint-node.is-hovered {
+ box-shadow: 0 14px 32px rgba(0, 0, 0, 0.42), 0 0 0 1px rgba(255, 127, 17, 0.16);
+}
+
+.blueprint-node.is-hovered::after {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.blueprint-node.is-selected::after {
+ box-shadow: 0 0 0 2px var(--accent);
+ opacity: 1;
+ transform: scale(1);
+}
+
+.blueprint-node.is-selected {
+ border-color: var(--accent);
+ z-index: 24;
+}
+
+.blueprint-node.is-selection-primary {
+ z-index: 28;
+}
+
+.node-header {
+ background: linear-gradient(90deg, var(--surface-2), var(--surface-3));
+ padding: 0.5rem 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ border-bottom: 1px solid var(--edge);
+ cursor: grab;
+ grid-area: header;
+ position: relative;
+}
+
+.node-title--variable {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.node-title-label {
+ font-weight: 600;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-title-type {
+ flex-shrink: 0;
+ font-size: 0.65rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ padding: 0.15rem 0.45rem;
+ border-radius: 999px;
+ border: 1px solid var(--edge);
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text-secondary);
+}
+
+.blueprint-node--variable {
+ --node-variable-color: var(--variable-color-any);
+}
+
+.blueprint-node[data-variable-scope='local'] {
+ overflow: visible;
+ z-index: 12;
+}
+
+.blueprint-node[data-variable-type='number'] {
+ --node-variable-color: var(--variable-color-number);
+}
+
+.blueprint-node[data-variable-type='string'] {
+ --node-variable-color: var(--variable-color-string);
+}
+
+.blueprint-node[data-variable-type='boolean'] {
+ --node-variable-color: var(--variable-color-boolean);
+}
+
+.blueprint-node[data-variable-type='any'] {
+ --node-variable-color: var(--variable-color-any);
+}
+
+.blueprint-node[data-variable-type='table'] {
+ --node-variable-color: var(--variable-color-table);
+}
+
+.blueprint-node--variable .node-header {
+ padding-left: 1.3rem;
+ border-bottom-color: rgba(0, 0, 0, 0.45);
+}
+
+.blueprint-node--variable .node-header::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ background: var(--node-variable-color);
+}
+
+.blueprint-node--variable .node-title-type {
+ border-color: var(--node-variable-color);
+ color: var(--node-variable-color);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.blueprint-node--variable.is-selected {
+ border-color: var(--node-variable-color);
+ box-shadow: 0 0 0 1px var(--node-variable-color);
+}
+
+.node-body {
+ grid-area: body;
+ padding: 0.5rem 0.6rem;
+ border-bottom: 1px solid var(--edge);
+ background: rgba(0, 0, 0, 0.08);
+}
+
+.node-io {
+ display: flex;
+ flex-direction: column;
+ padding: 0.5rem 0.6rem;
+ gap: 0.35rem;
+ min-height: 100%;
+}
+
+.node-inputs {
+ grid-area: inputs;
+ align-items: flex-start;
+}
+
+.node-outputs {
+ grid-area: outputs;
+ align-items: flex-end;
+ border-left: 1px solid var(--edge);
+ background: rgba(0, 0, 0, 0.12);
+}
+
+.node-outputs:empty {
+ display: none;
+}
+
+.literal-inline-editor {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.local-variable-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ background: transparent;
+ border: none;
+ padding: 0;
+ max-width: 240px;
+ align-self: flex-start;
+}
+
+.local-variable-row {
+ display: grid;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.local-variable-row--header {
+ grid-template-columns: auto minmax(0, 1fr);
+}
+
+.local-variable-row.is-hidden {
+ display: none;
+}
+
+.local-variable-label {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.local-variable-type {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
+.local-variable-type-button {
+ min-width: 0;
+ padding: 0.2rem 0.45rem;
+}
+
+.local-variable-select,
+.local-variable-name-input,
+.local-variable-number,
+.local-variable-text,
+.local-variable-textarea,
+.local-variable-boolean {
+ width: 100%;
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ border-radius: 4px;
+ color: var(--text-primary);
+ padding: 0.35rem 0.45rem;
+ font-size: 0.74rem;
+ min-width: 0;
+}
+
+.local-variable-select:focus,
+.local-variable-name-input:focus,
+.local-variable-number:focus,
+.local-variable-text:focus,
+.local-variable-textarea:focus,
+.local-variable-boolean:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px rgba(255, 127, 17, 0.35);
+}
+
+.local-variable-textarea {
+ resize: vertical;
+ min-height: 2.5rem;
+}
+
+.local-variable-inline-value {
+ display: flex;
+ margin-left: calc(18px + 0.6rem);
+ margin-top: 0.35rem;
+ min-width: 0;
+}
+
+.local-variable-inline-value-field {
+ flex: 1;
+ min-width: 0;
+}
+
+.literal-inline-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+}
+
+.literal-inline-input {
+ flex: 1;
+ min-width: 0;
+ padding: 0.3rem 0.4rem;
+ border-radius: 4px;
+ border: 1px solid var(--edge);
+ background: var(--surface-3);
+ color: var(--text-primary);
+}
+
+.literal-inline-input:focus-visible {
+ outline: 1px solid var(--accent);
+}
+
+.literal-inline-checkbox {
+ width: 16px;
+ height: 16px;
+ border-radius: 3px;
+ border: 1px solid var(--edge);
+ background: var(--surface-3);
+}
+
+.pin-inline-checkbox {
+ width: 16px;
+ height: 16px;
+ border-radius: 3px;
+ border: 1px solid var(--edge);
+ background: var(--surface-3);
+}
+
+.pin-inline-select {
+ width: auto;
+ min-width: 0;
+ max-width: 12rem;
+ padding: 0.3rem 0.4rem;
+ border-radius: 4px;
+ border: 1px solid var(--edge);
+ background: var(--surface-3);
+ color: var(--text-primary);
+ font-size: 0.75rem;
+}
+
+.pin-inline-select:focus-visible {
+ outline: 1px solid var(--accent);
+}
+
+.pin {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.8rem;
+ justify-content: flex-start;
+ cursor: pointer;
+ border-radius: 4px;
+ position: relative;
+ transition: background 120ms ease, transform 120ms ease, box-shadow 120ms ease;
+}
+
+.pin::after {
+ content: '';
+ position: absolute;
+ inset: -3px;
+ border-radius: 6px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
+}
+
+
+.pin.is-hovered::after {
+ opacity: 1;
+}
+
+.pin.is-hovered .pin-handle::after {
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
+}
+
+.pin-label {
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 0.68rem;
+ order: 1;
+}
+
+.pin-label.is-hidden {
+ display: none;
+}
+
+.pin-handle {
+ order: 2;
+ margin-bottom: 3px;
+ margin-top: 3px;
+}
+
+.pin-inline-control {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ order: 1;
+}
+
+.pin-inline-control.pin-inline-dropdown {
+ display: flex;
+ justify-content: flex-start;
+ width: auto;
+ min-width: 0;
+ margin-top: 0.25rem;
+}
+
+.pin-inline-control.is-hidden {
+ display: none;
+}
+
+.node-inputs .pin {
+ justify-content: flex-start;
+}
+
+.node-inputs .pin .pin-handle {
+ order: 0;
+}
+
+.node-inputs .pin .pin-inline-control {
+ order: 1;
+}
+
+.node-inputs .pin.has-inline-dropdown {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-auto-rows: auto;
+ column-gap: 0.4rem;
+ row-gap: 0.35rem;
+ align-items: center;
+}
+
+.node-inputs .pin.has-inline-dropdown .pin-handle {
+ grid-column: 1;
+ grid-row: 1;
+}
+
+.node-inputs .pin.has-inline-dropdown .pin-label {
+ grid-column: 2;
+ grid-row: 1;
+ align-self: center;
+}
+
+.node-inputs .pin.has-inline-dropdown .pin-inline-dropdown {
+ grid-column: 1 / span 2;
+ grid-row: 2;
+ justify-self: flex-start;
+ align-self: flex-start;
+ margin-top: 0;
+ padding-left: 0;
+}
+
+.node-inputs .pin.has-inline-dropdown .pin-inline-dropdown .pin-inline-select {
+ width: auto;
+ max-width: 12rem;
+}
+
+.node-inputs .pin .pin-label {
+ order: 2;
+ text-align: left;
+}
+
+.node-outputs .pin {
+ justify-content: flex-end;
+}
+
+.node-outputs .pin .pin-label {
+ order: 0;
+ text-align: right;
+}
+
+.node-outputs .pin .pin-inline-control {
+ order: 1;
+}
+
+.node-outputs .pin .pin-handle {
+ order: 2;
+}
+
+
+.pin-handle {
+ display: inline-flex;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 2px solid var(--edge);
+ background: var(--surface-3);
+ position: relative;
+ cursor: pointer;
+ transition: transform 120ms ease, border-color 120ms ease;
+}
+
+.pin-handle::after {
+ content: '';
+ position: absolute;
+ inset: 3px;
+ border-radius: 50%;
+ background: currentColor;
+ transition: transform 120ms ease;
+}
+
+.pin.is-disconnected .pin-handle {
+ background: transparent;
+ border-color: currentColor;
+ width: 16px;
+ height: 16px;
+}
+
+
+.pin.is-disconnected .pin-handle::after {
+ inset: 4px;
+ background: transparent;
+ box-shadow: inset 0 0 0 2.5px currentColor;
+}
+
+.pin.is-disconnected:not([data-type='exec']) .pin-handle::after {
+ background: transparent;
+ box-shadow: none;
+ opacity: 0;
+}
+
+.pin[data-type='exec'].is-disconnected .pin-handle::after {
+ inset: 0;
+ background: currentColor;
+ box-shadow: none;
+}
+
+.pin[data-type='exec'].is-disconnected .pin-handle::before {
+ content: '';
+ position: absolute;
+ inset: 2px;
+ clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%);
+ background: var(--surface-3);
+ pointer-events: none;
+ z-index: 1;
+}
+
+.pin-handle:hover,
+.pin-handle:focus-visible {
+ transform: scale(1.1);
+ border-color: currentColor;
+ outline: none;
+}
+
+.pin.is-drag-source {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.pin.is-drag-source .pin-handle::after {
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.25);
+}
+
+.pin.is-drop-target {
+ background: rgba(255, 127, 17, 0.18);
+}
+
+.pin.is-drop-target .pin-handle {
+ border-color: var(--accent);
+}
+
+.pin.is-drop-target .pin-handle::after {
+ box-shadow: 0 0 0 2px rgba(255, 127, 17, 0.35);
+}
+
+.pin.is-drop-hover {
+ background: rgba(255, 127, 17, 0.28);
+ transform: translateY(-1px);
+}
+
+.pin[data-type='exec'] .pin-handle {
+ width: 16px;
+ height: 12px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ margin: 4px 0;
+}
+
+.pin[data-type='exec'] .pin-handle::after {
+ inset: 0;
+ border-radius: 0;
+ clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%);
+ background: currentColor;
+ z-index: 0;
+}
+
+.pin[data-type='exec'] .pin-handle {
+ color: var(--pin-exec);
+}
+
+.pin[data-type='number'] .pin-handle {
+ color: var(--variable-color-number);
+}
+
+.pin[data-type='boolean'] .pin-handle {
+ color: var(--variable-color-boolean);
+}
+
+.pin[data-type='string'] .pin-handle {
+ color: var(--variable-color-string);
+}
+
+.pin[data-type='table'] .pin-handle {
+ color: var(--variable-color-table);
+ border-radius: 5px;
+ position: relative;
+}
+
+.pin[data-type='table']:not(.is-disconnected) .pin-handle {
+ border-color: var(--edge);
+ border-width: 1px;
+}
+
+.pin[data-type='table'] .pin-handle::after {
+ content: '';
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 4px;
+ height: 4px;
+ border-radius: 1px;
+ background-color: currentColor;
+ box-shadow:
+ 6px 0 0 0 currentColor,
+ 0 6px 0 0 currentColor,
+ 6px 6px 0 0 currentColor;
+}
+
+.inspector-header {
+ padding: 0.75rem 0.9rem;
+ border-bottom: 1px solid var(--edge);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+}
+
+.inspector-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.75rem 0.9rem;
+ display: grid;
+ gap: 0.65rem;
+ align-content: start;
+}
+
+.inspector-section-title {
+ margin: 0 0 0.25rem;
+ font-size: 0.8rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.inspector-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.custom-event-parameter-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.custom-event-parameter-add {
+ min-width: 0;
+ padding: 0.3rem 0.65rem;
+ font-size: 0.76rem;
+ border-radius: 6px;
+}
+
+.custom-event-parameter-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.55rem;
+}
+
+.custom-event-parameter-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ gap: 0.5rem;
+ align-items: center;
+ --overview-variable-color: var(--variable-color-any);
+ position: relative;
+}
+
+.custom-event-parameter-row .custom-event-parameter-action {
+ width: 1.35rem;
+ height: 1.35rem;
+}
+
+.custom-event-parameter-row .custom-event-parameter-action::before {
+ width: 0.9rem;
+ height: 0.9rem;
+}
+
+.custom-event-parameter-row[data-parameter-type='number'] {
+ --overview-variable-color: var(--variable-color-number);
+}
+
+.custom-event-parameter-row[data-parameter-type='string'] {
+ --overview-variable-color: var(--variable-color-string);
+}
+
+.custom-event-parameter-row[data-parameter-type='boolean'] {
+ --overview-variable-color: var(--variable-color-boolean);
+}
+
+.custom-event-parameter-row[data-parameter-type='table'] {
+ --overview-variable-color: var(--variable-color-table);
+}
+
+.custom-event-parameter-row input[type='text'] {
+ width: 100%;
+ padding: 0.2rem 0.35rem;
+ border-radius: 4px;
+ border: 1px solid transparent;
+ background: transparent;
+ font-size: 0.78rem;
+ color: var(--text-primary);
+}
+
+.custom-event-parameter-row input[type='text']:hover {
+ background: rgba(246, 249, 255, 0.06);
+}
+
+.custom-event-parameter-row input[type='text']:focus,
+.custom-event-parameter-row input[type='text']:focus-visible {
+ background: var(--surface-2);
+ border-color: var(--accent);
+ outline: none;
+}
+
+.custom-event-parameter-row .variable-type-button {
+ min-width: 0;
+}
+
+.table-default-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 0.55rem;
+}
+
+.table-default-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.table-default-add {
+ min-width: 0;
+ padding: 0.3rem 0.65rem;
+ font-size: 0.76rem;
+ border-radius: 6px;
+}
+
+.table-default-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+}
+
+.table-default-row {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) auto;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.table-default-row input[type='text'] {
+ width: 100%;
+ padding: 0.2rem 0.35rem;
+ border-radius: 4px;
+ border: 1px solid transparent;
+ background: transparent;
+ font-size: 0.78rem;
+ color: var(--text-primary);
+}
+
+.table-default-row input[type='text']:hover {
+ background: rgba(246, 249, 255, 0.06);
+}
+
+.table-default-row input[type='text']:focus,
+.table-default-row input[type='text']:focus-visible {
+ background: var(--surface-2);
+ border-color: var(--accent);
+ outline: none;
+}
+
+.table-default-empty {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ font-style: italic;
+}
+
+.custom-event-parameter-optional {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.72rem;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.custom-event-parameter-optional input {
+ margin: 0;
+}
+
+.custom-event-parameter-optional span {
+ pointer-events: none;
+}
+
+.inspector-field--checkbox {
+ flex-direction: row;
+ align-items: center;
+}
+
+.inspector-field--checkbox label {
+ margin: 0;
+}
+
+.inspector-field--checkbox input[type='checkbox'] {
+ width: auto;
+ align-self: center;
+}
+
+.inspector-tooltip {
+ position: relative;
+ display: inline-flex;
+ --tooltip-top: -9999px;
+ --tooltip-left: -9999px;
+ --tooltip-hidden-shift: -0.25rem;
+ --tooltip-arrow-offset: 16px;
+}
+
+.inspector-tooltip__trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ cursor: help;
+}
+
+.inspector-tooltip__trigger::after {
+ content: '';
+ display: inline-block;
+ width: 0.65rem;
+ height: 0.65rem;
+ background: var(--accent);
+ mask: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"%3E%3Cpath stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.879 7.879A3 3 0 0115 9c0 1.657-1.343 3-3 3v1m0 3h.01M21 12C21 6.477 16.523 2 11 2S1 6.477 1 12s4.477 10 10 10 10-4.477 10-10z"/%3E%3C/svg%3E') center/contain;
+ mask-repeat: no-repeat;
+}
+
+.inspector-tooltip__trigger:hover::after,
+.inspector-tooltip__trigger:focus-visible::after {
+ background: var(--accent-muted);
+}
+
+.inspector-tooltip__content {
+ position: fixed;
+ top: var(--tooltip-top);
+ left: var(--tooltip-left);
+ transform: translate3d(0, var(--tooltip-hidden-shift), 0);
+ max-width: min(340px, 48vw);
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ border-radius: 6px;
+ padding: 0.8rem;
+ box-shadow: var(--shadow-0);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease-out, transform 120ms ease-out;
+ z-index: 99;
+}
+
+.inspector-tooltip__content::after {
+ content: '';
+ position: absolute;
+ top: -6px;
+ left: var(--tooltip-arrow-offset, 16px);
+ border-width: 0 6px 6px 6px;
+ border-style: solid;
+ border-color: transparent transparent var(--surface-2) transparent;
+}
+
+.inspector-tooltip.is-active .inspector-tooltip__content {
+ opacity: 1;
+ pointer-events: auto;
+ transform: translate3d(0, 0, 0);
+}
+
+.inspector-tooltip[data-placement='top'] {
+ --tooltip-hidden-shift: 0.25rem;
+}
+
+.inspector-tooltip[data-placement='top'] .inspector-tooltip__content::after {
+ top: auto;
+ bottom: -6px;
+ left: var(--tooltip-arrow-offset, 16px);
+ border-width: 6px 6px 0 6px;
+ border-color: var(--surface-2) transparent transparent transparent;
+}
+
+.inspector-tooltip__content p {
+ margin: 0;
+ font-size: 0.78rem;
+ line-height: 1.45;
+ color: var(--text-secondary);
+}
+
+.inspector-tooltip__content p + p,
+.inspector-tooltip__content p + ul,
+.inspector-tooltip__list + .inspector-tooltip__note {
+ margin-top: 0.55rem;
+}
+
+.inspector-tooltip__list {
+ margin: 0;
+ padding-left: 1.15rem;
+ list-style: disc;
+ display: grid;
+ gap: 0.35rem;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+}
+
+.inspector-tooltip__note {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ opacity: 0.85;
+}
+
+.inspector-tooltip__content code {
+ font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
+ font-size: 0.72rem;
+ padding: 0.05rem 0.25rem;
+ border-radius: 3px;
+ border: 1px solid var(--edge);
+ background: var(--surface-1);
+}
+
+.inspector-field label {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.inspector-field input,
+.inspector-field select,
+.inspector-field textarea {
+ width: 100%;
+ padding: 0.4rem 0.5rem;
+ border-radius: 4px;
+ border: 1px solid var(--edge);
+ background: var(--surface-2);
+ color: var(--text-primary);
+ resize: vertical;
+}
+
+.inspector-note {
+ margin: 0;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.inspector-note + .inspector-note,
+.inspector-note + .inspector-note-list,
+.inspector-note-list + .inspector-note {
+ margin-top: 0.5rem;
+}
+
+.inspector-note-list {
+ margin: 0;
+ padding-left: 1.1rem;
+ list-style: disc;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ display: grid;
+ gap: 0.35rem;
+}
+
+.inspector-note code,
+.inspector-note-list code {
+ font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
+ font-size: 0.75rem;
+ background: var(--surface-2);
+ padding: 0.05rem 0.3rem;
+ border-radius: 3px;
+ border: 1px solid var(--edge);
+}
+
+.inspector-note--muted {
+ opacity: 0.85;
+}
+
+.inspector-footer {
+ padding: 0.75rem;
+ border-top: 1px solid var(--edge);
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+.inspector-empty {
+ margin: 0;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ opacity: 0.8;
+}
+
+body.modal-open {
+ overflow: hidden;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2.5rem;
+ z-index: 200;
+}
+
+.modal[hidden] {
+ display: none;
+}
+
+.modal__backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(6px);
+}
+
+.modal__panel {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ width: min(960px, calc(100vw - 4rem));
+ max-height: calc(100vh - 5rem);
+ background: var(--surface-1);
+ border: 1px solid var(--edge);
+ border-radius: 16px;
+ box-shadow: var(--shadow-0);
+ overflow: hidden;
+ z-index: 1;
+}
+
+.modal__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid var(--edge);
+ background: linear-gradient(135deg, rgba(36, 40, 51, 0.95), rgba(29, 32, 41, 0.95));
+}
+
+.modal__header h2 {
+ margin: 0;
+ font-size: 0.95rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.modal__actions {
+ display: inline-flex;
+ gap: 0.5rem;
+}
+
+.modal__icon-button {
+ width: 2.25rem;
+ height: 2.25rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 10px;
+ background: var(--surface-2);
+ border: 1px solid var(--edge);
+ color: var(--text-primary);
+ transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
+}
+
+.modal__icon-button:hover,
+.modal__icon-button:focus-visible {
+ background: var(--surface-3);
+ border-color: var(--accent);
+ color: var(--accent);
+ outline: none;
+}
+
+.modal__icon-button.is-success {
+ border-color: var(--pin-number);
+ color: var(--pin-number);
+}
+
+.modal-icon {
+ width: 1.15rem;
+ height: 1.15rem;
+ display: inline-block;
+ background-color: currentColor;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: contain;
+}
+
+.modal-icon--close {
+ mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M18 6 6 18"/%3E%3Cpath d="m6 6 12 12"/%3E%3C/svg%3E');
+}
+
+.modal-icon--copy {
+ mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Crect x="9" y="9" width="13" height="13" rx="2" ry="2"/%3E%3Cpath d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/%3E%3C/svg%3E');
+}
+
+.modal__body {
+ position: relative;
+ flex: 1;
+ overflow: hidden;
+ background: var(--surface-0);
+ display: flex;
+}
+
+.lua-modal__code {
+ margin: 0;
+ padding: 1.25rem 1.5rem;
+ flex: 1;
+ overflow: auto;
+ -moz-user-select: text;
+ font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
+ font-size: 0.82rem;
+ line-height: 1.6;
+ color: var(--text-primary);
+ background: transparent;
+ -moz-user-select: text;
+ border: none;
+ outline: none;
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+.lua-modal__code code {
+ display: block;
+ white-space: pre;
+}
+
+.lua-modal__code code span {
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+.lua-token {
+ color: inherit;
+}
+
+.lua-token--keyword {
+ color: var(--accent);
+}
+
+.lua-token--number {
+ color: var(--pin-number);
+}
+
+.lua-token--string {
+ color: #f4c95d;
+}
+
+.lua-token--comment {
+ color: var(--text-secondary);
+ opacity: 0.75;
+ font-style: italic;
+}
+
+.connection-path {
+ fill: none;
+ stroke-width: 3px;
+ stroke-linecap: round;
+ pointer-events: stroke;
+}
+
+.connection-path[data-type='exec'] {
+ stroke: var(--pin-exec);
+}
+
+.connection-path[data-type='number'] {
+ stroke: var(--variable-color-number);
+}
+
+.connection-path[data-type='boolean'] {
+ stroke: var(--variable-color-boolean);
+}
+
+.connection-path[data-type='string'] {
+ stroke: var(--variable-color-string);
+}
+
+.connection-path[data-type='table'] {
+ stroke: var(--variable-color-table);
+}
+
+.connection-path[data-type='any'] {
+ stroke: var(--variable-color-any);
+}
+
+.connection-path[data-active='true'] {
+ stroke-dasharray: 6;
+ animation: dash 600ms linear infinite;
+}
+
+@keyframes dash {
+ to {
+ stroke-dashoffset: 12;
+ }
+}
+
+@media (max-width: 1200px) {
+ .app-body {
+ grid-template-columns: 220px minmax(0, 1fr) 220px;
+ }
+
+ .blueprint-node {
+ min-width: 190px;
+ }
+}