From 3dd3f09e22062997b97d1e39a8e0053c4aa6aff2 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Sun, 8 Mar 2026 23:23:25 -0400 Subject: [PATCH 01/14] feat(sim): add playback speed control, edge animation, and template scenarios Add simulation playback polish with three major features: 1. Edge animation: animated signal/particle traverses edges using SMIL animateMotion with multi-layered energy bead (Catppuccin green). Pre-computed trace snapshots give O(1) highlight lookups per step. 2. Playback speed control: 0.5x-4x speed selector in SimulationPanel with interval scaling for auto-play and particle duration. 3. Template scenarios: 26 example scenarios across 10 templates with prefilled input, fixtures, and embedded rules data. Switch fixture support added to engine for label-based case matching. --- packages/app/src/App.tsx | 14 + .../app/src/components/SimulationPanel.tsx | 112 ++- .../app/src/data/template-scenarios.test.ts | 110 +++ packages/app/src/data/template-scenarios.ts | 836 ++++++++++++++++++ packages/app/src/hooks/useSimulation.test.ts | 134 +++ packages/app/src/hooks/useSimulation.ts | 122 ++- .../editor/src/components/FlowprintEditor.tsx | 25 +- .../src/contexts/SimulationContext.test.tsx | 74 +- .../editor/src/contexts/SimulationContext.tsx | 36 + packages/editor/src/edges/ConditionalEdge.tsx | 2 + .../src/edges/EdgeSimulationOverlay.tsx | 144 +++ packages/editor/src/edges/ErrorEdge.tsx | 2 + packages/editor/src/edges/SmoothstepEdge.tsx | 2 + packages/editor/src/index.ts | 17 +- packages/editor/src/styles/edges.css | 2 + packages/editor/src/styles/nodes.css | 19 +- .../engine/src/__tests__/simulator.test.ts | 101 ++- packages/engine/src/simulator/simulator.ts | 45 +- 18 files changed, 1772 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/data/template-scenarios.test.ts create mode 100644 packages/app/src/data/template-scenarios.ts create mode 100644 packages/editor/src/edges/EdgeSimulationOverlay.tsx diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 2804c6f..8fdf58a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -9,6 +9,8 @@ import { WelcomeScreen } from './components/WelcomeScreen' import { NewBlueprintWizard } from './components/NewBlueprintWizard' import { SettingsDialog } from './components/SettingsDialog' import { SimulationPanel } from './components/SimulationPanel' +import { getScenarios } from './data/template-scenarios' +import type { TemplateScenario } from './data/template-scenarios' import { UnsavedChangesGuard } from './components/UnsavedChangesGuard' import { useFileManager } from './hooks/useFileManager' import { useProjectDirectory } from './hooks/useProjectDirectory' @@ -36,6 +38,14 @@ export function App() { }) const simulation = useSimulation(doc, rulesDataMap) + const scenarios = doc ? getScenarios(doc.name) : [] + + const handleSelectScenario = useCallback( + (scenario: TemplateScenario | null) => { + setRulesDataMap(scenario?.rulesData ?? {}) + }, + [], + ) // Review #5: gate on doc !== null only, not on rules presence const canSimulate = doc !== null @@ -231,6 +241,8 @@ export function App() { symbolSearch={symbolSearch ?? undefined} rulesDataMap={rulesDataMap} nodeHighlights={simulation.nodeHighlights} + edgeHighlights={simulation.edgeHighlights} + simulationAnimation={simulation.simulationAnimation} showYamlPreview showExportButton style={{ width: '100%', height: '100%' }} @@ -245,6 +257,8 @@ export function App() { setShowSimPanel(false) }, }} + scenarios={scenarios} + onSelectScenario={handleSelectScenario} /> )} diff --git a/packages/app/src/components/SimulationPanel.tsx b/packages/app/src/components/SimulationPanel.tsx index 53376b9..60ff095 100644 --- a/packages/app/src/components/SimulationPanel.tsx +++ b/packages/app/src/components/SimulationPanel.tsx @@ -1,8 +1,11 @@ import { useState, useCallback, useEffect } from 'react' import type { UseSimulationReturn } from '../hooks/useSimulation' +import type { TemplateScenario } from '../data/template-scenarios' export interface SimulationPanelProps { simulation: UseSimulationReturn + scenarios?: TemplateScenario[] + onSelectScenario?: (scenario: TemplateScenario | null) => void } const panelStyle: React.CSSProperties = { @@ -114,12 +117,73 @@ function buildCumulativeContext( return ctx } -export function SimulationPanel({ simulation }: SimulationPanelProps) { +const selectStyle: React.CSSProperties = { + width: '100%', + padding: '6px 8px', + fontSize: 12, + fontFamily: 'var(--fp-font-sans, system-ui, sans-serif)', + background: '#1C1B25', + color: '#E8E7F4', + border: '1px solid #2E2D3D', + borderRadius: 6, + cursor: 'pointer', +} + +export function SimulationPanel({ simulation, scenarios, onSelectScenario }: SimulationPanelProps) { const [inputText, setInputText] = useState('{}') const [fixturesText, setFixturesText] = useState('') const [showFixtures, setShowFixtures] = useState(false) const [showContext, setShowContext] = useState(false) const [inputError, setInputError] = useState(null) + const [selectedScenarioId, setSelectedScenarioId] = useState(null) + + const hasScenarios = scenarios && scenarios.length > 0 + const selectedScenario = hasScenarios + ? scenarios.find((s) => s.id === selectedScenarioId) ?? null + : null + + const handleSelectScenario = useCallback( + (scenarioId: string) => { + if (scenarioId === '') { + setSelectedScenarioId(null) + onSelectScenario?.(null) + return + } + const scenario = scenarios?.find((s) => s.id === scenarioId) + if (!scenario) return + setSelectedScenarioId(scenarioId) + setInputText(JSON.stringify(scenario.input, null, 2)) + setFixturesText(JSON.stringify(scenario.fixtures ?? {}, null, 2)) + if (scenario.fixtures && Object.keys(scenario.fixtures).length > 0) { + setShowFixtures(true) + } + setInputError(null) + onSelectScenario?.(scenario) + }, + [scenarios, onSelectScenario], + ) + + const handleInputChange = useCallback( + (value: string) => { + setInputText(value) + if (selectedScenarioId) { + setSelectedScenarioId(null) + onSelectScenario?.(null) + } + }, + [selectedScenarioId, onSelectScenario], + ) + + const handleFixturesChange = useCallback( + (value: string) => { + setFixturesText(value) + if (selectedScenarioId) { + setSelectedScenarioId(null) + onSelectScenario?.(null) + } + }, + [selectedScenarioId, onSelectScenario], + ) const { isActive, @@ -136,6 +200,8 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) { reset, setAutoPlay, isAutoPlaying, + playbackSpeed, + setPlaybackSpeed, } = simulation const handleRun = useCallback(() => { @@ -207,13 +273,38 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) { + {hasScenarios && ( +
+ + + {selectedScenario && ( +
+ {selectedScenario.description} +
+ )} +
+ )} +