From 5e5fe82e85addabd431b2bcadc49b8d96ddb0908 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 11:23:47 +0100 Subject: [PATCH 01/13] Named connection variables in code generation --- src/lib/pyodide/codeBuilder.ts | 36 ++++++++++++++++++++++- src/lib/pyodide/index.ts | 1 + src/lib/pyodide/pathsimRunner.ts | 49 ++++++++++++++++++++------------ 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/src/lib/pyodide/codeBuilder.ts b/src/lib/pyodide/codeBuilder.ts index 9122fa64..9867fc22 100644 --- a/src/lib/pyodide/codeBuilder.ts +++ b/src/lib/pyodide/codeBuilder.ts @@ -84,7 +84,7 @@ export function groupConnectionsBySource( } /** - * Generate connection lines from grouped connections + * Generate connection lines from grouped connections (anonymous, for inline use) * * @param connections - Array of connections * @param nodeVars - Map of nodeId to variable name @@ -107,6 +107,40 @@ export function generateConnectionLines( return lines; } +/** + * Generate named connection variable definitions. + * Each edge gets its own named variable for individual mutation support. + * + * @param connections - Array of connections + * @param nodeVars - Map of nodeId to variable name + * @param prefix - Variable name prefix (default 'conn') + * @returns Object with definition lines, variable names list, and id-to-varname map + */ +export function generateNamedConnections( + connections: Connection[], + nodeVars: Map, + prefix: string = 'conn' +): { lines: string[]; varNames: string[]; connVars: Map } { + const lines: string[] = []; + const varNames: string[] = []; + const connVars = new Map(); + + let idx = 0; + for (const conn of connections) { + const sourceVar = nodeVars.get(conn.sourceNodeId); + const targetVar = nodeVars.get(conn.targetNodeId); + if (!sourceVar || !targetVar) continue; + + const varName = `${prefix}_${idx}`; + lines.push(`${varName} = Connection(${sourceVar}[${conn.sourcePortIndex}], ${targetVar}[${conn.targetPortIndex}])`); + varNames.push(varName); + connVars.set(conn.id, varName); + idx++; + } + + return { lines, varNames, connVars }; +} + /** * Generate a Python list definition * diff --git a/src/lib/pyodide/index.ts b/src/lib/pyodide/index.ts index b80f30c2..858b62cd 100644 --- a/src/lib/pyodide/index.ts +++ b/src/lib/pyodide/index.ts @@ -23,6 +23,7 @@ export { // Code generation export { generatePythonCode, + type CodeGenResult, runGraphStreamingSimulation, exportToPython, validateGraphSimulation, diff --git a/src/lib/pyodide/pathsimRunner.ts b/src/lib/pyodide/pathsimRunner.ts index b78c1976..b98b6fdc 100644 --- a/src/lib/pyodide/pathsimRunner.ts +++ b/src/lib/pyodide/pathsimRunner.ts @@ -22,6 +22,7 @@ import { import { generateParamString, generateConnectionLines, + generateNamedConnections, generateListDefinition, sanitizeName } from './codeBuilder'; @@ -289,6 +290,13 @@ function generateSubsystemCode( } } + // Connection variables (named for mutation support) + const subConnPrefix = `${subsystemVarName}_conn`; + const subConnResult = generateNamedConnections(childConnections, internalNodeVars, subConnPrefix); + for (const line of subConnResult.lines) { + lines.push(line); + } + // Create Subsystem with inline blocks and connections using kwargs lines.push(`${subsystemVarName} = Subsystem(`); @@ -299,11 +307,10 @@ function generateSubsystemCode( } lines.push(' ],'); - // Connections list (grouped by source for multi-target syntax) + // Connections list (referencing named variables) lines.push(' connections=['); - const connLines = generateConnectionLines(childConnections, internalNodeVars, ' '); - for (const line of connLines) { - lines.push(line); + for (const connVarName of subConnResult.varNames) { + lines.push(` ${connVarName},`); } lines.push(' ],'); @@ -351,6 +358,13 @@ function groupNodesByCategory( * @param includeNodeIdMap - Include node ID mapping for web data extraction (default: true) * @param includeRun - Append sim.run() call (default: true, false for streaming) */ +/** Result of Python code generation, includes variable mappings for mutation support */ +export interface CodeGenResult { + code: string; + nodeVars: Map; // nodeId → Python variable name + connVars: Map; // connectionId → Python variable name +} + export function generatePythonCode( nodes: NodeInstance[], connections: Connection[], @@ -359,7 +373,7 @@ export function generatePythonCode( includeNodeIdMap: boolean = true, events: EventInstance[] = [], includeRun: boolean = true -): string { +): CodeGenResult { const lines: string[] = []; // Check if we have any subsystems @@ -465,14 +479,13 @@ export function generatePythonCode( lines.push(''); } - // 4. Connections (grouped by source for multi-target syntax) + // 4. Connections (named variables for mutation support) lines.push('# CONNECTIONS'); - lines.push('connections = ['); - const connLines = generateConnectionLines(connections, nodeVars, ' '); - for (const line of connLines) { + const connResult = generateNamedConnections(connections, nodeVars); + for (const line of connResult.lines) { lines.push(line); } - lines.push(']'); + lines.push(...generateListDefinition('connections', connResult.varNames)); lines.push(''); // 5. Events (if any) @@ -495,7 +508,7 @@ export function generatePythonCode( lines.push(`sim.run(duration=${getSettingOrDefault(settings, 'duration')}, reset=True)`); } - return lines.join('\n'); + return { code: lines.join('\n'), nodeVars, connVars: connResult.connVars }; } /** @@ -677,16 +690,16 @@ function generateFormattedPythonCode( lines.push(divider); lines.push(''); - // Connections (grouped by source for multi-target syntax) + // Connections (named variables for mutation support) if (connections.length === 0) { lines.push('connections = []'); } else { - lines.push('connections = ['); - const connLines = generateConnectionLines(connections, nodeVars, ' '); - for (const line of connLines) { + const connResult = generateNamedConnections(connections, nodeVars); + for (const line of connResult.lines) { lines.push(line); } - lines.push(']'); + lines.push(''); + lines.push(...generateListDefinition('connections', connResult.varNames)); } lines.push(''); @@ -741,9 +754,9 @@ export async function runGraphStreamingSimulation( onUpdate?: (result: SimulationResult) => void ): Promise { // Generate code without sim.run() - streaming will handle execution - const code = generatePythonCode(nodes, connections, settings, codeContext, true, events, false); + const result = generatePythonCode(nodes, connections, settings, codeContext, true, events, false); const duration = getSettingOrDefault(settings, 'duration'); - return runStreamingSimulation(code, String(duration), onUpdate); + return runStreamingSimulation(result.code, String(duration), onUpdate); } /** From 19437220149eacb25326a4068d765847ddb25386 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 11:27:17 +0100 Subject: [PATCH 02/13] Add mutation queue with continue flow integration --- src/lib/pyodide/bridge.ts | 21 +++- src/lib/pyodide/index.ts | 13 ++ src/lib/pyodide/mutationQueue.ts | 202 +++++++++++++++++++++++++++++++ src/lib/pyodide/pathsimRunner.ts | 2 +- 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 src/lib/pyodide/mutationQueue.ts diff --git a/src/lib/pyodide/bridge.ts b/src/lib/pyodide/bridge.ts index e9defab4..8f2d3e24 100644 --- a/src/lib/pyodide/bridge.ts +++ b/src/lib/pyodide/bridge.ts @@ -27,6 +27,9 @@ import { // Re-export for use in other modules export { execDuringStreaming }; +// Import mutation queue +import { initMappings, flushQueue, clearQueue } from './mutationQueue'; + // Re-export replState as pyodideState for backwards compatibility export { replState as pyodideState }; @@ -298,7 +301,9 @@ async function runStreamingLoop( export async function runStreamingSimulation( code: string, duration: string, - onUpdate?: (result: SimulationResult) => void + onUpdate?: (result: SimulationResult) => void, + nodeVars?: Map, + connVars?: Map ): Promise { // Ensure initialized const state = get(replState); @@ -306,6 +311,13 @@ export async function runStreamingSimulation( await initRepl(); } + // Initialize mutation queue mappings for this run + if (nodeVars && connVars) { + initMappings(nodeVars, connVars); + } else { + clearQueue(); + } + streamingActive = true; // Update simulation state - preserve result structure for smooth transition @@ -421,6 +433,13 @@ if 'sim' not in dir() or sim is None: raise RuntimeError("No simulation to continue. Run a simulation first.") `); + // Apply any pending graph mutations before continuing + const mutationCode = flushQueue(); + if (mutationCode) { + await exec(mutationCode); + consoleStore.info('Applied graph mutations'); + } + // Start streaming generator with reset=False and optimized tickrate await exec(generateStreamingStartCode(durationExpr, STREAMING_TICKRATE, false)); diff --git a/src/lib/pyodide/index.ts b/src/lib/pyodide/index.ts index 858b62cd..83c8c214 100644 --- a/src/lib/pyodide/index.ts +++ b/src/lib/pyodide/index.ts @@ -52,3 +52,16 @@ export { type Backend, type BackendState } from './backend'; + +// Mutation queue for runtime graph changes +export { + queueAddBlock, + queueRemoveBlock, + queueAddConnection, + queueRemoveConnection, + queueUpdateParam, + queueUpdateSetting, + hasPendingMutations, + getNodeVar, + getConnVar +} from './mutationQueue'; diff --git a/src/lib/pyodide/mutationQueue.ts b/src/lib/pyodide/mutationQueue.ts new file mode 100644 index 00000000..ba2dece4 --- /dev/null +++ b/src/lib/pyodide/mutationQueue.ts @@ -0,0 +1,202 @@ +/** + * Mutation Queue + * Tracks graph changes (add/remove blocks, connections, events, parameter/settings changes) + * as Python code strings to be applied to the worker namespace on "Continue". + * + * On "Run": queue is cleared (fresh namespace). + * On "Continue": queue is flushed → executed in worker → then new streaming generator starts. + */ + +import type { NodeInstance, Connection, SimulationSettings } from '$lib/nodes/types'; +import type { EventInstance } from '$lib/events/types'; +import { nodeRegistry } from '$lib/nodes/registry'; +import { sanitizeName, generateNamedConnections } from './codeBuilder'; + +/** + * A queued mutation — a Python code string to execute in the worker namespace. + */ +interface Mutation { + type: 'add-block' | 'remove-block' | 'add-connection' | 'remove-connection' | + 'add-event' | 'remove-event' | 'update-param' | 'update-setting'; + code: string; +} + +/** Active variable name mappings from the last run */ +let activeNodeVars = new Map(); // nodeId → Python var name +let activeConnVars = new Map(); // connectionId → Python var name + +/** Counter for dynamically added connections */ +let dynamicConnCounter = 0; + +/** The pending mutation queue */ +const queue: Mutation[] = []; + +/** + * Initialize mappings from a fresh run's code generation result. + * Called when "Run" starts. + */ +export function initMappings(nodeVars: Map, connVars: Map): void { + activeNodeVars = new Map(nodeVars); + activeConnVars = new Map(connVars); + dynamicConnCounter = 0; + queue.length = 0; +} + +/** + * Clear the mutation queue (on fresh Run). + */ +export function clearQueue(): void { + queue.length = 0; +} + +/** + * Get all pending mutations and clear the queue. + * Returns a single Python code string to execute. + */ +export function flushQueue(): string | null { + if (queue.length === 0) return null; + const code = queue.map(m => m.code).join('\n'); + queue.length = 0; + return code; +} + +/** + * Check if there are pending mutations. + */ +export function hasPendingMutations(): boolean { + return queue.length > 0; +} + +// --- Mutation generators --- + +/** + * Queue adding a new block to the simulation. + */ +export function queueAddBlock(node: NodeInstance, existingVarNames: string[]): void { + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) return; + + let varName = sanitizeName(node.name); + if (!varName || existingVarNames.includes(varName) || activeNodeVars.has(node.id)) { + varName = `block_dyn_${dynamicConnCounter++}`; + } + activeNodeVars.set(node.id, varName); + + const validParamNames = new Set(typeDef.params.map(p => p.name)); + const paramParts: string[] = []; + for (const [name, value] of Object.entries(node.params)) { + if (value === null || value === undefined || value === '') continue; + if (name.startsWith('_')) continue; + if (!validParamNames.has(name)) continue; + paramParts.push(`${name}=${value}`); + } + const params = paramParts.join(', '); + const constructor = params ? `${typeDef.blockClass}(${params})` : `${typeDef.blockClass}()`; + + queue.push({ + type: 'add-block', + code: [ + `${varName} = ${constructor}`, + `sim.add_block(${varName})`, + `blocks.append(${varName})`, + `_node_id_map[id(${varName})] = "${node.id}"`, + `_node_name_map["${node.id}"] = "${node.name.replace(/"/g, '\\"')}"` + ].join('\n') + }); +} + +/** + * Queue removing a block from the simulation. + */ +export function queueRemoveBlock(nodeId: string): void { + const varName = activeNodeVars.get(nodeId); + if (!varName) return; + + queue.push({ + type: 'remove-block', + code: [ + `sim.remove_block(${varName})`, + `blocks.remove(${varName})`, + `_node_id_map.pop(id(${varName}), None)`, + `_node_name_map.pop("${nodeId}", None)` + ].join('\n') + }); + + activeNodeVars.delete(nodeId); +} + +/** + * Queue adding a new connection to the simulation. + */ +export function queueAddConnection(conn: Connection): void { + const sourceVar = activeNodeVars.get(conn.sourceNodeId); + const targetVar = activeNodeVars.get(conn.targetNodeId); + if (!sourceVar || !targetVar) return; + + const varName = `conn_dyn_${dynamicConnCounter++}`; + activeConnVars.set(conn.id, varName); + + queue.push({ + type: 'add-connection', + code: [ + `${varName} = Connection(${sourceVar}[${conn.sourcePortIndex}], ${targetVar}[${conn.targetPortIndex}])`, + `sim.add_connection(${varName})`, + `connections.append(${varName})` + ].join('\n') + }); +} + +/** + * Queue removing a connection from the simulation. + */ +export function queueRemoveConnection(connId: string): void { + const varName = activeConnVars.get(connId); + if (!varName) return; + + queue.push({ + type: 'remove-connection', + code: [ + `sim.remove_connection(${varName})`, + `connections.remove(${varName})` + ].join('\n') + }); + + activeConnVars.delete(connId); +} + +/** + * Queue a block parameter change. + */ +export function queueUpdateParam(nodeId: string, paramName: string, value: string): void { + const varName = activeNodeVars.get(nodeId); + if (!varName) return; + + queue.push({ + type: 'update-param', + code: `${varName}.${paramName} = ${value}` + }); +} + +/** + * Queue a simulation setting change. + */ +export function queueUpdateSetting(settingName: string, value: string): void { + queue.push({ + type: 'update-setting', + code: `sim.${settingName} = ${value}` + }); +} + +/** + * Get the Python variable name for a node. + */ +export function getNodeVar(nodeId: string): string | undefined { + return activeNodeVars.get(nodeId); +} + +/** + * Get the Python variable name for a connection. + */ +export function getConnVar(connId: string): string | undefined { + return activeConnVars.get(connId); +} diff --git a/src/lib/pyodide/pathsimRunner.ts b/src/lib/pyodide/pathsimRunner.ts index b98b6fdc..9c83bb17 100644 --- a/src/lib/pyodide/pathsimRunner.ts +++ b/src/lib/pyodide/pathsimRunner.ts @@ -756,7 +756,7 @@ export async function runGraphStreamingSimulation( // Generate code without sim.run() - streaming will handle execution const result = generatePythonCode(nodes, connections, settings, codeContext, true, events, false); const duration = getSettingOrDefault(settings, 'duration'); - return runStreamingSimulation(result.code, String(duration), onUpdate); + return runStreamingSimulation(result.code, String(duration), onUpdate, result.nodeVars, result.connVars); } /** From 21679bc6f0caa3e5448d963ba9988b1919cc6ff4 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 11:39:06 +0100 Subject: [PATCH 03/13] Wire mutation queue to graph store and settings --- src/lib/pyodide/index.ts | 1 + src/lib/pyodide/mutationQueue.ts | 23 ++++++++++++++----- src/lib/stores/graph/connections.ts | 5 +++++ src/lib/stores/graph/nodes.ts | 35 +++++++++++++++++++++++++++++ src/lib/stores/settings.ts | 19 ++++++++++++++++ 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/lib/pyodide/index.ts b/src/lib/pyodide/index.ts index 83c8c214..7483c1a6 100644 --- a/src/lib/pyodide/index.ts +++ b/src/lib/pyodide/index.ts @@ -62,6 +62,7 @@ export { queueUpdateParam, queueUpdateSetting, hasPendingMutations, + isActive as isMutationQueueActive, getNodeVar, getConnVar } from './mutationQueue'; diff --git a/src/lib/pyodide/mutationQueue.ts b/src/lib/pyodide/mutationQueue.ts index ba2dece4..b451ac93 100644 --- a/src/lib/pyodide/mutationQueue.ts +++ b/src/lib/pyodide/mutationQueue.ts @@ -7,10 +7,9 @@ * On "Continue": queue is flushed → executed in worker → then new streaming generator starts. */ -import type { NodeInstance, Connection, SimulationSettings } from '$lib/nodes/types'; -import type { EventInstance } from '$lib/events/types'; +import type { NodeInstance, Connection } from '$lib/nodes/types'; import { nodeRegistry } from '$lib/nodes/registry'; -import { sanitizeName, generateNamedConnections } from './codeBuilder'; +import { sanitizeName } from './codeBuilder'; /** * A queued mutation — a Python code string to execute in the worker namespace. @@ -67,17 +66,27 @@ export function hasPendingMutations(): boolean { return queue.length > 0; } +/** + * Check if the mutation queue is active (a simulation has been run). + */ +export function isActive(): boolean { + return activeNodeVars.size > 0; +} + // --- Mutation generators --- /** * Queue adding a new block to the simulation. */ -export function queueAddBlock(node: NodeInstance, existingVarNames: string[]): void { +export function queueAddBlock(node: NodeInstance): void { + if (!isActive()) return; + const typeDef = nodeRegistry.get(node.type); if (!typeDef) return; + const existingNames = new Set(activeNodeVars.values()); let varName = sanitizeName(node.name); - if (!varName || existingVarNames.includes(varName) || activeNodeVars.has(node.id)) { + if (!varName || existingNames.has(varName)) { varName = `block_dyn_${dynamicConnCounter++}`; } activeNodeVars.set(node.id, varName); @@ -129,6 +138,8 @@ export function queueRemoveBlock(nodeId: string): void { * Queue adding a new connection to the simulation. */ export function queueAddConnection(conn: Connection): void { + if (!isActive()) return; + const sourceVar = activeNodeVars.get(conn.sourceNodeId); const targetVar = activeNodeVars.get(conn.targetNodeId); if (!sourceVar || !targetVar) return; @@ -181,6 +192,8 @@ export function queueUpdateParam(nodeId: string, paramName: string, value: strin * Queue a simulation setting change. */ export function queueUpdateSetting(settingName: string, value: string): void { + if (!isActive()) return; + queue.push({ type: 'update-setting', code: `sim.${settingName} = ${value}` diff --git a/src/lib/stores/graph/connections.ts b/src/lib/stores/graph/connections.ts index a3b60c35..e28e1d3e 100644 --- a/src/lib/stores/graph/connections.ts +++ b/src/lib/stores/graph/connections.ts @@ -11,6 +11,7 @@ import { getCurrentGraph, updateCurrentConnections } from './state'; +import { queueAddConnection, queueRemoveConnection } from '$lib/pyodide/mutationQueue'; /** * Add a connection between two ports @@ -56,6 +57,9 @@ export function addConnection( updateCurrentConnections(c => [...c, connection]); + // Queue mutation for runtime graph changes (no-op if no simulation active) + queueAddConnection(connection); + return connection; } @@ -63,6 +67,7 @@ export function addConnection( * Remove a connection */ export function removeConnection(id: string): void { + queueRemoveConnection(id); updateCurrentConnections(c => c.filter(conn => conn.id !== id)); } diff --git a/src/lib/stores/graph/nodes.ts b/src/lib/stores/graph/nodes.ts index 36071911..b6201e13 100644 --- a/src/lib/stores/graph/nodes.ts +++ b/src/lib/stores/graph/nodes.ts @@ -26,6 +26,7 @@ import { regenerateGraphIds, createPorts } from './helpers'; import { syncPortNamesFromLabels } from './ports'; import { triggerSelectNodes } from '$lib/stores/viewActions'; import { getPortLabelConfigs } from '$lib/nodes/uiConfig'; +import { queueAddBlock, queueRemoveBlock, queueAddConnection, queueRemoveConnection, queueUpdateParam } from '$lib/pyodide/mutationQueue'; /** * Add a new node to the current graph context @@ -72,6 +73,9 @@ export function addNode( addNodeToCurrentLevel(node); + // Queue mutation for runtime graph changes (no-op if no simulation active) + queueAddBlock(node); + return node; } @@ -89,6 +93,14 @@ export function removeNode(id: string): void { return; } + // Queue mutations for connections being removed with this node + for (const conn of currentGraph.connections) { + if (conn.sourceNodeId === id || conn.targetNodeId === id) { + queueRemoveConnection(conn.id); + } + } + queueRemoveBlock(id); + // Remove node and its connections updateCurrentNodesAndConnections( // Map updater for nodes (root) @@ -206,6 +218,13 @@ export function updateNodeColor(id: string, color: string | undefined): void { export function updateNodeParams(id: string, params: Record): void { updateNodeById(id, node => ({ ...node, params: { ...node.params, ...params } })); + // Queue parameter mutations (no-op if no simulation active) + for (const [paramName, value] of Object.entries(params)) { + if (value === null || value === undefined || value === '') continue; + if (paramName.startsWith('_')) continue; + queueUpdateParam(id, paramName, String(value)); + } + // Sync port names if this block has label-driven ports const node = getCurrentGraph().nodes.get(id); if (node) { @@ -349,6 +368,14 @@ export function duplicateSelected(): string[] { } } + // Queue mutations for duplicated nodes and connections + for (const node of nodesToAdd) { + queueAddBlock(node); + } + for (const conn of newConnections) { + queueAddConnection(conn); + } + // Add all nodes and connections in one update if (nodesToAdd.length > 0 || newConnections.length > 0) { updateCurrentNodesAndConnections( @@ -437,6 +464,14 @@ export function pasteNodes( const newNodeIds = nodes.map(n => n.id); + // Queue mutations for pasted nodes and connections + for (const node of nodes) { + queueAddBlock(node); + } + for (const conn of connections) { + queueAddConnection(conn); + } + // Add all nodes and connections updateCurrentNodesAndConnections( // Map updater for nodes (root) diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 4bf5119f..8dde8819 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -5,6 +5,17 @@ import { writable, get } from 'svelte/store'; import type { SimulationSettings, SolverType } from '$lib/nodes/types'; import { DEFAULT_SIMULATION_SETTINGS, INITIAL_SIMULATION_SETTINGS } from '$lib/nodes/types'; +import { queueUpdateSetting } from '$lib/pyodide/mutationQueue'; + +/** Map UI setting names to pathsim Simulation attributes */ +const SETTING_TO_PATHSIM: Record = { + dt: 'dt', + dt_min: 'dt_min', + dt_max: 'dt_max', + rtol: 'tolerance_lte_rel', + atol: 'tolerance_lte_abs', + ftol: 'tolerance_fpi' +}; const settings = writable({ ...INITIAL_SIMULATION_SETTINGS }); @@ -16,6 +27,14 @@ export const settingsStore = { */ update(newSettings: Partial): void { settings.update((s) => ({ ...s, ...newSettings })); + + // Queue setting mutations (no-op if no simulation active) + for (const [key, value] of Object.entries(newSettings)) { + const pathsimAttr = SETTING_TO_PATHSIM[key]; + if (pathsimAttr && value !== null && value !== undefined && value !== '') { + queueUpdateSetting(pathsimAttr, String(value)); + } + } }, /** From cbfc0dd2a18b8154a06984b29655e051b9c370d6 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 13:49:22 +0100 Subject: [PATCH 04/13] Improve mutation queue: coalescing, error isolation, solver support --- src/lib/pyodide/index.ts | 1 + src/lib/pyodide/mutationQueue.ts | 215 +++++++++++++++++++------------ src/lib/stores/settings.ts | 40 +++--- 3 files changed, 156 insertions(+), 100 deletions(-) diff --git a/src/lib/pyodide/index.ts b/src/lib/pyodide/index.ts index 7483c1a6..c634bc2a 100644 --- a/src/lib/pyodide/index.ts +++ b/src/lib/pyodide/index.ts @@ -63,6 +63,7 @@ export { queueUpdateSetting, hasPendingMutations, isActive as isMutationQueueActive, + pendingMutationCount, getNodeVar, getConnVar } from './mutationQueue'; diff --git a/src/lib/pyodide/mutationQueue.ts b/src/lib/pyodide/mutationQueue.ts index b451ac93..8d7f3f09 100644 --- a/src/lib/pyodide/mutationQueue.ts +++ b/src/lib/pyodide/mutationQueue.ts @@ -3,83 +3,130 @@ * Tracks graph changes (add/remove blocks, connections, events, parameter/settings changes) * as Python code strings to be applied to the worker namespace on "Continue". * - * On "Run": queue is cleared (fresh namespace). + * On "Run": queue is cleared, mappings initialized from code generation result. * On "Continue": queue is flushed → executed in worker → then new streaming generator starts. + * + * Design: + * - Structural mutations (add/remove block/connection) are queued in order. + * - Parameter and setting updates are coalesced: only the latest value per key is kept. + * - Each mutation is wrapped in try/except so one failure doesn't block the rest. + * - The queue count is exposed as a Svelte store for UI reactivity. */ +import { writable, get } from 'svelte/store'; import type { NodeInstance, Connection } from '$lib/nodes/types'; import { nodeRegistry } from '$lib/nodes/registry'; +import { isSubsystem } from '$lib/nodes/shapes'; import { sanitizeName } from './codeBuilder'; -/** - * A queued mutation — a Python code string to execute in the worker namespace. - */ -interface Mutation { - type: 'add-block' | 'remove-block' | 'add-connection' | 'remove-connection' | - 'add-event' | 'remove-event' | 'update-param' | 'update-setting'; - code: string; -} +// --- Internal state --- /** Active variable name mappings from the last run */ let activeNodeVars = new Map(); // nodeId → Python var name let activeConnVars = new Map(); // connectionId → Python var name -/** Counter for dynamically added connections */ -let dynamicConnCounter = 0; +/** Counter for dynamically added variables */ +let dynamicVarCounter = 0; + +/** Ordered structural mutations (add/remove block/connection) */ +const structuralQueue: string[] = []; + +/** Coalesced parameter updates: "nodeId:paramName" → Python assignment */ +const paramUpdates = new Map(); + +/** Coalesced setting updates: pathsimAttr → Python assignment */ +const settingUpdates = new Map(); + +/** Reactive store: number of pending mutations */ +export const pendingMutationCount = writable(0); + +function updateCount(): void { + pendingMutationCount.set( + structuralQueue.length + paramUpdates.size + settingUpdates.size + ); +} + +// --- Public API --- -/** The pending mutation queue */ -const queue: Mutation[] = []; +/** + * Check if the mutation queue is active (a simulation has been run). + */ +export function isActive(): boolean { + return activeNodeVars.size > 0; +} /** * Initialize mappings from a fresh run's code generation result. - * Called when "Run" starts. + * Called when "Run" starts. Clears all pending mutations. */ export function initMappings(nodeVars: Map, connVars: Map): void { activeNodeVars = new Map(nodeVars); activeConnVars = new Map(connVars); - dynamicConnCounter = 0; - queue.length = 0; + dynamicVarCounter = 0; + structuralQueue.length = 0; + paramUpdates.clear(); + settingUpdates.clear(); + updateCount(); } /** * Clear the mutation queue (on fresh Run). */ export function clearQueue(): void { - queue.length = 0; + structuralQueue.length = 0; + paramUpdates.clear(); + settingUpdates.clear(); + updateCount(); } /** - * Get all pending mutations and clear the queue. - * Returns a single Python code string to execute. + * Get all pending mutations as a Python code string and clear the queue. + * Each mutation is wrapped in try/except for error isolation. + * Order: settings first, then structural mutations, then parameter updates. */ export function flushQueue(): string | null { - if (queue.length === 0) return null; - const code = queue.map(m => m.code).join('\n'); - queue.length = 0; - return code; + const allCode: string[] = []; + + // 1. Settings (apply before structural changes) + for (const code of settingUpdates.values()) { + allCode.push(wrapTryExcept(code)); + } + + // 2. Structural mutations (add/remove in order) + for (const code of structuralQueue) { + allCode.push(wrapTryExcept(code)); + } + + // 3. Parameter updates (apply after blocks exist) + for (const code of paramUpdates.values()) { + allCode.push(wrapTryExcept(code)); + } + + structuralQueue.length = 0; + paramUpdates.clear(); + settingUpdates.clear(); + updateCount(); + + if (allCode.length === 0) return null; + return allCode.join('\n'); } /** * Check if there are pending mutations. */ export function hasPendingMutations(): boolean { - return queue.length > 0; -} - -/** - * Check if the mutation queue is active (a simulation has been run). - */ -export function isActive(): boolean { - return activeNodeVars.size > 0; + return structuralQueue.length > 0 || paramUpdates.size > 0 || settingUpdates.size > 0; } // --- Mutation generators --- /** * Queue adding a new block to the simulation. + * Skips subsystem nodes (they require recursive internal graph creation). */ export function queueAddBlock(node: NodeInstance): void { if (!isActive()) return; + if (isSubsystem(node)) return; const typeDef = nodeRegistry.get(node.type); if (!typeDef) return; @@ -87,7 +134,7 @@ export function queueAddBlock(node: NodeInstance): void { const existingNames = new Set(activeNodeVars.values()); let varName = sanitizeName(node.name); if (!varName || existingNames.has(varName)) { - varName = `block_dyn_${dynamicConnCounter++}`; + varName = `block_dyn_${dynamicVarCounter++}`; } activeNodeVars.set(node.id, varName); @@ -102,16 +149,14 @@ export function queueAddBlock(node: NodeInstance): void { const params = paramParts.join(', '); const constructor = params ? `${typeDef.blockClass}(${params})` : `${typeDef.blockClass}()`; - queue.push({ - type: 'add-block', - code: [ - `${varName} = ${constructor}`, - `sim.add_block(${varName})`, - `blocks.append(${varName})`, - `_node_id_map[id(${varName})] = "${node.id}"`, - `_node_name_map["${node.id}"] = "${node.name.replace(/"/g, '\\"')}"` - ].join('\n') - }); + structuralQueue.push([ + `${varName} = ${constructor}`, + `sim.add_block(${varName})`, + `blocks.append(${varName})`, + `_node_id_map[id(${varName})] = "${node.id}"`, + `_node_name_map["${node.id}"] = "${node.name.replace(/"/g, '\\"')}"` + ].join('\n')); + updateCount(); } /** @@ -121,17 +166,21 @@ export function queueRemoveBlock(nodeId: string): void { const varName = activeNodeVars.get(nodeId); if (!varName) return; - queue.push({ - type: 'remove-block', - code: [ - `sim.remove_block(${varName})`, - `blocks.remove(${varName})`, - `_node_id_map.pop(id(${varName}), None)`, - `_node_name_map.pop("${nodeId}", None)` - ].join('\n') - }); - + structuralQueue.push([ + `sim.remove_block(${varName})`, + `blocks.remove(${varName})`, + `_node_id_map.pop(id(${varName}), None)`, + `_node_name_map.pop("${nodeId}", None)` + ].join('\n')); activeNodeVars.delete(nodeId); + + // Remove any coalesced param updates for this block + for (const key of paramUpdates.keys()) { + if (key.startsWith(nodeId + ':')) { + paramUpdates.delete(key); + } + } + updateCount(); } /** @@ -144,17 +193,15 @@ export function queueAddConnection(conn: Connection): void { const targetVar = activeNodeVars.get(conn.targetNodeId); if (!sourceVar || !targetVar) return; - const varName = `conn_dyn_${dynamicConnCounter++}`; + const varName = `conn_dyn_${dynamicVarCounter++}`; activeConnVars.set(conn.id, varName); - queue.push({ - type: 'add-connection', - code: [ - `${varName} = Connection(${sourceVar}[${conn.sourcePortIndex}], ${targetVar}[${conn.targetPortIndex}])`, - `sim.add_connection(${varName})`, - `connections.append(${varName})` - ].join('\n') - }); + structuralQueue.push([ + `${varName} = Connection(${sourceVar}[${conn.sourcePortIndex}], ${targetVar}[${conn.targetPortIndex}])`, + `sim.add_connection(${varName})`, + `connections.append(${varName})` + ].join('\n')); + updateCount(); } /** @@ -164,52 +211,52 @@ export function queueRemoveConnection(connId: string): void { const varName = activeConnVars.get(connId); if (!varName) return; - queue.push({ - type: 'remove-connection', - code: [ - `sim.remove_connection(${varName})`, - `connections.remove(${varName})` - ].join('\n') - }); - + structuralQueue.push([ + `sim.remove_connection(${varName})`, + `connections.remove(${varName})` + ].join('\n')); activeConnVars.delete(connId); + updateCount(); } /** * Queue a block parameter change. + * Coalesced: only the latest value per (nodeId, paramName) is kept. */ export function queueUpdateParam(nodeId: string, paramName: string, value: string): void { const varName = activeNodeVars.get(nodeId); if (!varName) return; - queue.push({ - type: 'update-param', - code: `${varName}.${paramName} = ${value}` - }); + paramUpdates.set(`${nodeId}:${paramName}`, `${varName}.${paramName} = ${value}`); + updateCount(); } /** * Queue a simulation setting change. + * Coalesced: only the latest code per setting key is kept. + * @param key - Coalescing key (e.g. 'dt', 'solver') + * @param code - Full Python code to execute (e.g. 'sim.dt = 0.01') */ -export function queueUpdateSetting(settingName: string, value: string): void { +export function queueUpdateSetting(key: string, code: string): void { if (!isActive()) return; - queue.push({ - type: 'update-setting', - code: `sim.${settingName} = ${value}` - }); + settingUpdates.set(key, code); + updateCount(); } -/** - * Get the Python variable name for a node. - */ +// --- Lookup --- + export function getNodeVar(nodeId: string): string | undefined { return activeNodeVars.get(nodeId); } -/** - * Get the Python variable name for a connection. - */ export function getConnVar(connId: string): string | undefined { return activeConnVars.get(connId); } + +// --- Internal helpers --- + +function wrapTryExcept(code: string): string { + const indented = code.split('\n').map(line => ` ${line}`).join('\n'); + return `try:\n${indented}\nexcept Exception as _e:\n print(f"Mutation error: {_e}", file=__import__('sys').stderr)`; +} diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 8dde8819..97d90b8e 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -7,16 +7,28 @@ import type { SimulationSettings, SolverType } from '$lib/nodes/types'; import { DEFAULT_SIMULATION_SETTINGS, INITIAL_SIMULATION_SETTINGS } from '$lib/nodes/types'; import { queueUpdateSetting } from '$lib/pyodide/mutationQueue'; -/** Map UI setting names to pathsim Simulation attributes */ -const SETTING_TO_PATHSIM: Record = { - dt: 'dt', - dt_min: 'dt_min', - dt_max: 'dt_max', - rtol: 'tolerance_lte_rel', - atol: 'tolerance_lte_abs', - ftol: 'tolerance_fpi' +/** Map UI setting names to pathsim Simulation attribute mutations */ +const SETTING_MUTATIONS: Record { attr: string; code: string }> = { + dt: (v) => ({ attr: 'dt', code: `sim.dt = ${v}` }), + dt_min: (v) => ({ attr: 'dt_min', code: `sim.dt_min = ${v}` }), + dt_max: (v) => ({ attr: 'dt_max', code: `sim.dt_max = ${v}` }), + rtol: (v) => ({ attr: 'rtol', code: `sim.tolerance_lte_rel = ${v}` }), + atol: (v) => ({ attr: 'atol', code: `sim.tolerance_lte_abs = ${v}` }), + ftol: (v) => ({ attr: 'ftol', code: `sim.tolerance_fpi = ${v}` }), + solver: (v) => ({ attr: 'solver', code: `sim._set_solver(${v})` }) }; +function queueSettingChanges(newSettings: Partial): void { + for (const [key, value] of Object.entries(newSettings)) { + if (value === null || value === undefined || value === '') continue; + const mutationFn = SETTING_MUTATIONS[key]; + if (mutationFn) { + const { attr, code } = mutationFn(String(value)); + queueUpdateSetting(attr, code); // attr is the coalescing key, code is the Python to execute + } + } +} + const settings = writable({ ...INITIAL_SIMULATION_SETTINGS }); export const settingsStore = { @@ -27,14 +39,7 @@ export const settingsStore = { */ update(newSettings: Partial): void { settings.update((s) => ({ ...s, ...newSettings })); - - // Queue setting mutations (no-op if no simulation active) - for (const [key, value] of Object.entries(newSettings)) { - const pathsimAttr = SETTING_TO_PATHSIM[key]; - if (pathsimAttr && value !== null && value !== undefined && value !== '') { - queueUpdateSetting(pathsimAttr, String(value)); - } - } + queueSettingChanges(newSettings); }, /** @@ -42,6 +47,7 @@ export const settingsStore = { */ setDuration(duration: string): void { settings.update((s) => ({ ...s, duration })); + // Duration is not a sim attribute — it's passed to run_streaming() }, /** @@ -49,6 +55,7 @@ export const settingsStore = { */ setDt(dt: string): void { settings.update((s) => ({ ...s, dt })); + queueSettingChanges({ dt }); }, /** @@ -56,6 +63,7 @@ export const settingsStore = { */ setSolver(solver: SolverType): void { settings.update((s) => ({ ...s, solver })); + queueSettingChanges({ solver }); }, /** From 871e6f4a44cfc0a80707887218ad1071c845678f Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 13:52:15 +0100 Subject: [PATCH 05/13] Queue connection removals from port deletion --- src/lib/stores/graph/ports.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/stores/graph/ports.ts b/src/lib/stores/graph/ports.ts index 5ddd916f..18f77b2e 100644 --- a/src/lib/stores/graph/ports.ts +++ b/src/lib/stores/graph/ports.ts @@ -16,6 +16,7 @@ import { updateNodeById, updateCurrentNodesAndConnections } from './state'; +import { queueRemoveConnection } from '$lib/pyodide/mutationQueue'; type PortDirection = 'input' | 'output'; @@ -175,6 +176,17 @@ function removePort(nodeId: string, direction: PortDirection, syncFollowUp = fal // Remove port and affected connections const removedIndex = currentPorts.length - 1; + // Queue removal of connections affected by port removal + const graph = getCurrentGraph(); + for (const c of graph.connections) { + const isAffected = config.connectionKey === 'targetNodeId' + ? c.targetNodeId === nodeId && c.targetPortIndex >= removedIndex + : c.sourceNodeId === nodeId && c.sourcePortIndex >= removedIndex; + if (isAffected) { + queueRemoveConnection(c.id); + } + } + updateCurrentNodesAndConnections( // Map updater for nodes (root) nodes => { From 16cab04ed1a1f1dd86776f5359262d1954f1db39 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 14:05:59 +0100 Subject: [PATCH 06/13] Replace auto-apply with explicit stageMutations action --- src/lib/pyodide/bridge.ts | 20 ++++++++++++++++++++ src/lib/pyodide/index.ts | 1 + src/lib/pyodide/mutationQueue.ts | 18 ++++++++++-------- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/lib/pyodide/bridge.ts b/src/lib/pyodide/bridge.ts index 8f2d3e24..1c9a9c59 100644 --- a/src/lib/pyodide/bridge.ts +++ b/src/lib/pyodide/bridge.ts @@ -495,6 +495,26 @@ if 'sim' not in dir() or sim is None: } } +/** + * Stage pending graph mutations into the simulation. + * If streaming: injects via execDuringStreaming (applied between generator steps). + * If paused: executes directly via exec. + * Returns true if mutations were applied, false if nothing to stage. + */ +export async function stageMutations(): Promise { + const code = flushQueue(); + if (!code) return false; + + if (streamingActive) { + execDuringStreaming(code); + consoleStore.info('Staged changes (applied during streaming)'); + } else { + await exec(code); + consoleStore.info('Staged changes applied'); + } + return true; +} + /** * Reset simulation state completely. * Use when loading a new model or creating a new graph. diff --git a/src/lib/pyodide/index.ts b/src/lib/pyodide/index.ts index c634bc2a..3dae504d 100644 --- a/src/lib/pyodide/index.ts +++ b/src/lib/pyodide/index.ts @@ -8,6 +8,7 @@ export { initPyodide, runStreamingSimulation, continueStreamingSimulation, + stageMutations, resetSimulation, validateGraph, stopSimulation, diff --git a/src/lib/pyodide/mutationQueue.ts b/src/lib/pyodide/mutationQueue.ts index 8d7f3f09..978b100a 100644 --- a/src/lib/pyodide/mutationQueue.ts +++ b/src/lib/pyodide/mutationQueue.ts @@ -1,19 +1,21 @@ /** * Mutation Queue - * Tracks graph changes (add/remove blocks, connections, events, parameter/settings changes) - * as Python code strings to be applied to the worker namespace on "Continue". + * Collects graph changes (add/remove blocks, connections, parameter/setting changes) + * as Python code strings. Changes are NOT applied automatically — the user + * explicitly stages them via a "Stage Changes" action. * * On "Run": queue is cleared, mappings initialized from code generation result. - * On "Continue": queue is flushed → executed in worker → then new streaming generator starts. + * On "Stage": queue is flushed → applied to worker (via exec or execDuringStreaming). + * On "Continue": remaining queued mutations are flushed before the new generator starts. * * Design: * - Structural mutations (add/remove block/connection) are queued in order. - * - Parameter and setting updates are coalesced: only the latest value per key is kept. - * - Each mutation is wrapped in try/except so one failure doesn't block the rest. - * - The queue count is exposed as a Svelte store for UI reactivity. + * - Parameter and setting updates are coalesced: only the latest value per key. + * - Each mutation is wrapped in try/except for error isolation on flush. + * - pendingMutationCount is a Svelte store for UI reactivity (badge on stage button). */ -import { writable, get } from 'svelte/store'; +import { writable } from 'svelte/store'; import type { NodeInstance, Connection } from '$lib/nodes/types'; import { nodeRegistry } from '$lib/nodes/registry'; import { isSubsystem } from '$lib/nodes/shapes'; @@ -34,7 +36,7 @@ const structuralQueue: string[] = []; /** Coalesced parameter updates: "nodeId:paramName" → Python assignment */ const paramUpdates = new Map(); -/** Coalesced setting updates: pathsimAttr → Python assignment */ +/** Coalesced setting updates: key → Python code */ const settingUpdates = new Map(); /** Reactive store: number of pending mutations */ From 071ec68bb11cbfdaf53b8b41ede4ad7126e4fe26 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 14:11:27 +0100 Subject: [PATCH 07/13] Add Stage Changes button to toolbar with mutation badge --- src/lib/components/icons/Icon.svelte | 7 +++++ src/routes/+page.svelte | 39 +++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/lib/components/icons/Icon.svelte b/src/lib/components/icons/Icon.svelte index 336c3626..b849d22b 100644 --- a/src/lib/components/icons/Icon.svelte +++ b/src/lib/components/icons/Icon.svelte @@ -420,6 +420,13 @@ +{:else if name === 'stage'} + + + + + + {:else if name === 'font-size-increase'} A diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6cea56f1..05dc737c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -39,7 +39,8 @@ import { openNodeDialog } from '$lib/stores/nodeDialog'; import { openEventDialog } from '$lib/stores/eventDialog'; import type { MenuItemType } from '$lib/components/ContextMenu.svelte'; - import { pyodideState, simulationState, initPyodide, stopSimulation, continueStreamingSimulation } from '$lib/pyodide/bridge'; + import { pyodideState, simulationState, initPyodide, stopSimulation, continueStreamingSimulation, stageMutations } from '$lib/pyodide/bridge'; + import { pendingMutationCount } from '$lib/pyodide/mutationQueue'; import { initBackendFromUrl, autoDetectBackend } from '$lib/pyodide/backend'; import { runGraphStreamingSimulation, validateGraphSimulation } from '$lib/pyodide/pathsimRunner'; import { consoleStore } from '$lib/stores/console'; @@ -1008,6 +1009,21 @@ > + {#if hasRunSimulation} + {$pendingMutationCount} + {/if} + + {/if} @@ -1434,6 +1450,27 @@ background: color-mix(in srgb, var(--error) 15%, var(--surface-raised)); } + .toolbar-btn.stage-btn { + position: relative; + } + + .mutation-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: var(--accent); + color: var(--surface); + font-size: 10px; + font-weight: 600; + line-height: 16px; + text-align: center; + pointer-events: none; + } + .icon-crossfade { position: relative; display: flex; From 046fa9d03bf8847a981057277825f32d08a0eee6 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 14:46:30 +0100 Subject: [PATCH 08/13] Only show Stage Changes button when mutations are pending --- src/routes/+page.svelte | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 05dc737c..494da999 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1009,19 +1009,15 @@ > - {#if hasRunSimulation} + {#if hasRunSimulation && $pendingMutationCount > 0} {/if} From b3c90117643348da66fccaffcb063d73e7cf0652 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 15:25:07 +0100 Subject: [PATCH 09/13] Match stage icon to download icon style, queue code editor changes --- src/lib/components/icons/Icon.svelte | 6 +++--- src/lib/stores/codeContext.ts | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/components/icons/Icon.svelte b/src/lib/components/icons/Icon.svelte index b849d22b..9b1b8d33 100644 --- a/src/lib/components/icons/Icon.svelte +++ b/src/lib/components/icons/Icon.svelte @@ -423,9 +423,9 @@ {:else if name === 'stage'} - - - + + + {:else if name === 'font-size-increase'} diff --git a/src/lib/stores/codeContext.ts b/src/lib/stores/codeContext.ts index 17dbb867..d39e4a0a 100644 --- a/src/lib/stores/codeContext.ts +++ b/src/lib/stores/codeContext.ts @@ -4,6 +4,7 @@ */ import { writable, derived, get } from 'svelte/store'; +import { queueUpdateSetting, isActive as isMutationQueueActive } from '$lib/pyodide/mutationQueue'; const code = writable(''); const lastError = writable(null); @@ -40,6 +41,11 @@ export const codeContextStore = { setCode(newCode: string): void { code.set(newCode); lastError.set(null); + + // Queue code context re-execution as a mutation + if (isMutationQueueActive() && newCode.trim()) { + queueUpdateSetting('code_context', newCode.trim()); + } }, /** From 62728b9d44151ed7aa6b210d459380fe9e308093 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 15:26:30 +0100 Subject: [PATCH 10/13] Fix stage icon: arrow with large arrowhead into flat line --- src/lib/components/icons/Icon.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/icons/Icon.svelte b/src/lib/components/icons/Icon.svelte index 9b1b8d33..510f8f94 100644 --- a/src/lib/components/icons/Icon.svelte +++ b/src/lib/components/icons/Icon.svelte @@ -423,9 +423,9 @@ {:else if name === 'stage'} - + {:else if name === 'font-size-increase'} From 58ffa60f74056a8186291c6de8827ffae10f0298 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 15:32:29 +0100 Subject: [PATCH 11/13] Fix icon-crossfade clipping upload/save icons --- src/routes/+page.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 494da999..a115dbfa 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1474,11 +1474,13 @@ justify-content: center; width: 16px; height: 16px; + overflow: visible; } .icon-crossfade-item { position: absolute; display: flex; + overflow: visible; } /* Logo overlay */ From d31b513186c3035161eb530a05559d6c378e521d Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 15:33:50 +0100 Subject: [PATCH 12/13] Fix icon-crossfade sizing: use grid stacking instead of fixed box --- src/routes/+page.svelte | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a115dbfa..c1b5c8d3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1469,18 +1469,13 @@ .icon-crossfade { position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - overflow: visible; + display: grid; + place-items: center; } .icon-crossfade-item { - position: absolute; + grid-area: 1 / 1; display: flex; - overflow: visible; } /* Logo overlay */ From 7b9dff1a6e1f53c32d44e545bed75a9cbddb79c3 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Feb 2026 15:36:20 +0100 Subject: [PATCH 13/13] Remove icon-crossfade wrapper from save buttons, fix icon sizing --- src/routes/+page.svelte | 37 +++---------------------------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c1b5c8d3..1847eb93 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,7 @@ - {#if saveFlash === 'save'} - - - - {:else} - - - - {/if} - +