From 87cb4bbf823d5d9a446855c49c027cc5b8383068 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 11 Jul 2026 10:50:28 +0200 Subject: [PATCH 01/10] Move editor to /editor route, replace welcome modal with landing intents --- src/lib/components/WelcomeModal.svelte | 479 ------------------------- src/routes/{ => editor}/+page.svelte | 0 2 files changed, 479 deletions(-) delete mode 100644 src/lib/components/WelcomeModal.svelte rename src/routes/{ => editor}/+page.svelte (100%) diff --git a/src/lib/components/WelcomeModal.svelte b/src/lib/components/WelcomeModal.svelte deleted file mode 100644 index 22e1eaf8..00000000 --- a/src/lib/components/WelcomeModal.svelte +++ /dev/null @@ -1,479 +0,0 @@ - - - - - - - - - - - diff --git a/src/routes/+page.svelte b/src/routes/editor/+page.svelte similarity index 100% rename from src/routes/+page.svelte rename to src/routes/editor/+page.svelte From ecda88f4aa8a667c53f7b0a746a7afd609abc9c2 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 11 Jul 2026 10:50:43 +0200 Subject: [PATCH 02/10] Wire editor for landing page: home link, intent params, prepopulated-graph handling --- src/lib/constants/examples.ts | 30 +++++++ src/lib/schema/fileOps.ts | 26 ++++++- src/lib/tours/popoverButtons.ts | 2 +- src/lib/tours/scripts/start.ts | 4 +- src/routes/editor/+page.svelte | 133 +++++++++++++++++--------------- static/sitemap.xml | 5 ++ svelte.config.js | 6 ++ 7 files changed, 138 insertions(+), 68 deletions(-) create mode 100644 src/lib/constants/examples.ts diff --git a/src/lib/constants/examples.ts b/src/lib/constants/examples.ts new file mode 100644 index 00000000..ef081e04 --- /dev/null +++ b/src/lib/constants/examples.ts @@ -0,0 +1,30 @@ +/** + * Bundled example models shown on the landing page. + * + * The JSON files live in `static/examples/`, screenshots (per theme) in + * `static/examples/screenshots/{basename}-{dark|light}.png`. The separate + * `static/examples/manifest.json` lists the same files for the screenshot + * capture script — keep both in sync when adding examples. + */ + +export interface Example { + name: string; + description: string; + filename: string; + basename: string; +} + +export const EXAMPLES: Example[] = [ + { filename: 'feedback-system.json', basename: 'feedback-system', name: 'Feedback System', description: 'Linear feedback system with delayed step excitation' }, + { filename: 'harmonic-oscillator.json', basename: 'harmonic-oscillator', name: 'Harmonic Oscillator', description: 'Linear spring-mass-damper system' }, + { filename: 'squarewave-lpf.json', basename: 'squarewave-lpf', name: 'Squarewave LPF', description: 'Low pass filtering of a square wave' }, + { filename: 'pid-subsystem.json', basename: 'pid-subsystem', name: 'PID Loop', description: 'Classic PID control loop as subsystem' }, + { filename: 'thermostat.json', basename: 'thermostat', name: 'Thermostat', description: 'Relay based thermostat heating system' }, + { filename: 'cascade-subsystem.json', basename: 'cascade-subsystem', name: 'Cascade PI', description: 'Cascade PI controller with subsystems' }, + { filename: 'bouncing-ball.json', basename: 'bouncing-ball', name: 'Bouncing Ball', description: 'Bouncing ball with event-based collision' }, + { filename: 'fmcw-radar.json', basename: 'fmcw-radar', name: 'FMCW Radar', description: 'Frequency-modulated continuous wave radar' }, + { filename: 'vanderpol.json', basename: 'vanderpol', name: 'Van der Pol', description: 'Van der Pol oscillator system' } +]; + +/** Default example rendered in the landing hero when no autosave exists. */ +export const DEFAULT_EXAMPLE = EXAMPLES[0]; diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index 23e32397..17d40907 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -329,6 +329,26 @@ export async function loadGraphFile( } } +/** + * Install required toolboxes for the graph currently in the store, and warn + * about block types that remain unregistered. + * + * Used by the editor when it mounts with a pre-populated graph (SPA + * navigation from the landing page, whose preview loads files with the + * toolbox install deferred forever — no backend runs there). + */ +export async function installToolboxesForCurrentGraph(): Promise { + const { nodes } = graphStore.toJSON(); + if (nodes.length === 0) return; + await installRequiredToolboxes(collectRequiredToolboxes(nodes)); + const unknownTypes = validateNodeTypes(nodes); + if (unknownTypes.length > 0) { + consoleStore.warn( + `[toolbox] unknown block types in this file: ${unknownTypes.join(', ')}. They will render as placeholders.` + ); + } +} + /** * Save autosave snapshot to IndexedDB. Async because IDB is async; callers * fire-and-forget unless they need to chain off completion. @@ -917,8 +937,10 @@ export async function listRecentFiles(): Promise { * re-prompt the first time per session, then loads the file in place (same * code path as `openImportDialog`'s success branch). Stale entries (file * moved/deleted, permission denied) are evicted from the recents list. + * Import options (e.g. `deferToolboxInstall` on the landing page, where no + * backend runs) are forwarded to the import. */ -export async function openRecentFile(id: string): Promise { +export async function openRecentFile(id: string, options: ImportOptions = {}): Promise { if (!hasFileSystemAccess()) { return { success: false, type: 'model', error: 'File System Access API not available' }; } @@ -952,7 +974,7 @@ export async function openRecentFile(id: string): Promise { } } const file = await handle.getFile(); - return importFile(file, { fileHandle: handle, fileName: handle.name }); + return importFile(file, { ...options, fileHandle: handle, fileName: handle.name }); } catch (e: any) { // File was moved, deleted, or the user revoked permission — evict it await recentsRemove(id).catch(() => undefined); diff --git a/src/lib/tours/popoverButtons.ts b/src/lib/tours/popoverButtons.ts index 710bb992..9df03cef 100644 --- a/src/lib/tours/popoverButtons.ts +++ b/src/lib/tours/popoverButtons.ts @@ -3,7 +3,7 @@ * * Currently just `addNextTourButton`, which adds a "Continue with X →" button * between Back and Done so the user can chain into the next tour without - * going back to the welcome banner. + * going back to the landing page. */ import type { Driver } from 'driver.js'; diff --git a/src/lib/tours/scripts/start.ts b/src/lib/tours/scripts/start.ts index b373edca..bdd4a2d6 100644 --- a/src/lib/tours/scripts/start.ts +++ b/src/lib/tours/scripts/start.ts @@ -36,8 +36,8 @@ export const startTour: TourScript = { rawStep({ element: '[data-tour="welcome-banner-logo"]', - title: 'Welcome Banner', - body: `

Click the logo anytime to reopen the welcome banner — restart any tour, browse examples or jump to docs and GitHub.

`, + title: 'Home', + body: `

Click the home button anytime to return to the landing page — restart any tour, browse examples, open recent files or jump to docs and GitHub.

`, side: 'bottom', align: 'start' }), diff --git a/src/routes/editor/+page.svelte b/src/routes/editor/+page.svelte index 028d05a5..4202a9de 100644 --- a/src/routes/editor/+page.svelte +++ b/src/routes/editor/+page.svelte @@ -25,7 +25,6 @@ import PlotOptionsDialog from '$lib/components/dialogs/PlotOptionsDialog.svelte'; import SearchDialog from '$lib/components/dialogs/SearchDialog.svelte'; import ResizablePanel from '$lib/components/ResizablePanel.svelte'; - import WelcomeModal from '$lib/components/WelcomeModal.svelte'; import SubsystemBreadcrumb from '$lib/components/SubsystemBreadcrumb.svelte'; import Icon from '$lib/components/icons/Icon.svelte'; import { nodeRegistry } from '$lib/nodes'; @@ -49,7 +48,7 @@ import { resolveBackend } from '$lib/pyodide/backend'; import { runGraphStreamingSimulation, validateGraphSimulation, exportToPython } from '$lib/pyodide/pathsimRunner'; import { consoleStore } from '$lib/stores/console'; - import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName, loadGraphFile, listRecentFiles, openRecentFile, removeRecentFile } from '$lib/schema/fileOps'; + import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName, loadGraphFile, listRecentFiles, openRecentFile, removeRecentFile, installToolboxesForCurrentGraph } from '$lib/schema/fileOps'; import { AUTOSAVE_KEY, kvGet, hasFileSystemAccess, type RecentFile } from '$lib/schema/handleStore'; import type { GraphFile } from '$lib/nodes/types'; import { confirmationStore } from '$lib/stores/confirmation'; @@ -63,6 +62,8 @@ import Tooltip, { tooltip } from '$lib/components/Tooltip.svelte'; import { isInputFocused } from '$lib/utils/focus'; import { isTourActive } from '$lib/tours/inputMode'; + import { startGuidedTour } from '$lib/tours'; + import type { TourId } from '$lib/tours/types'; import { searchDialogStore } from '$lib/stores/searchDialog'; // Theme toggle button ref for radial transition origin @@ -149,7 +150,28 @@ return null; } const urlModelConfig = getUrlModelConfig(); - let showWelcomeModal = $state(!urlModelConfig); // Hide if loading from URL + + // One-shot intent params set by the landing page: `?new=1` starts with a + // blank canvas, `?tour=` starts a guided tour after mount. Both are + // removed from the URL once handled. + function getUrlIntent(): { isNew: boolean; tour: TourId | null } { + if (typeof window === 'undefined') return { isNew: false, tour: null }; + const params = new URLSearchParams(window.location.search); + const tour = params.get('tour'); + return { + isNew: params.get('new') === '1', + tour: tour === 'start' || tour === 'modeling' || tour === 'simulation' ? tour : null + }; + } + const urlIntent = getUrlIntent(); + + function clearIntentParams() { + const params = new URLSearchParams(window.location.search); + params.delete('new'); + params.delete('tour'); + const query = params.toString(); + window.history.replaceState({}, '', window.location.pathname + (query ? `?${query}` : '')); + } // Backend-ready promise (assigned in onMount). Component-scoped so client- // side example loading can gate its toolbox install on the running worker @@ -532,6 +554,12 @@ const continueTooltip = { text: "Continue", shortcut: "Shift+Enter" }; onMount(() => { + // Graph already populated at mount means we arrived via SPA navigation + // from the landing page (hero preview or recent file). The landing + // defers the toolbox install indefinitely (no backend runs there), so + // catch up once the backend is ready. + const prePopulated = graphStore.toJSON().nodes.length > 0; + // The URL-param model load runs *parallel* to backend startup: fetch, // parse, and graphStore.fromJSON happen immediately so the user sees // the model right away. The toolbox install step inside loadGraphFile @@ -554,7 +582,7 @@ console.error('[startup] backend init failed', e); throw e; } finally { - // Unlock the run button even if bootstrap failed — a broken + // Unlock the run button even if bootstrap failed — a broken // toolbox shouldn't leave the button stuck in its loading state. bootstrapComplete = true; } @@ -562,10 +590,31 @@ void loadFromUrlParam(backendReady).catch((e) => { console.error('[startup] URL-param model load failed', e); }); + if (prePopulated && !urlModelConfig && !urlIntent.isNew) { + void backendReady + .then(() => installToolboxesForCurrentGraph()) + .catch(() => { + // Backend failure already logged; placeholders stay visible. + }); + } + + // Landing-page intents. `?new=1` resets without the unsaved-changes + // confirm — the landing preview may have populated the store, and the + // user just chose "New" deliberately. + if (urlIntent.isNew || urlIntent.tour) { + if (urlIntent.isNew) { + newGraph(); + } + if (urlIntent.tour) { + const tourId: TourId = urlIntent.tour; + setTimeout(() => void startGuidedTour(tourId), 400); + } + clearIntentParams(); + } void backendReady.catch(() => { // Already logged above. Swallow here so the unhandled rejection // from this branch (independent of the loadFromUrlParam branch) - // doesn't trip console noise — loadFromUrlParam awaits the same + // doesn't trip console noise — loadFromUrlParam awaits the same // promise and surfaces install errors via consoleStore. }); @@ -689,11 +738,14 @@ window.addEventListener('run-simulation', handleRunSimulation); window.addEventListener('continue-simulation', handleContinueSimulation); - // Offer to restore the previous session's autosave (skip if a URL - // model is loading — that takes precedence over restore). + // Offer to restore the previous session's autosave. Skipped when a URL + // model is loading, when a landing intent (new/tour) applies, or when + // the graph is already populated — SPA navigation from the landing page + // arrives with the model (preview, recent file) already in the store. void (async () => { const snapshot = await initialAutosavePromise; - if (!snapshot || urlModelConfig) return; + if (!snapshot || urlModelConfig || urlIntent.isNew || urlIntent.tour) return; + if (nodeCount > 0) return; const ok = await confirmationStore.show({ title: 'Restore last session?', message: `${BRAND.name} found an autosaved version of your last session. Restore it?`, @@ -1087,7 +1139,7 @@ } } - // ── Recent files hover menu ────────────────────────────────────────────── + // ── Recent files hover menu ────────────────────────────────────────────── const recentFilesSupported = hasFileSystemAccess(); let recentFiles = $state([]); let recentFilesMenuOpen = $state(false); @@ -1198,32 +1250,6 @@ } } - // Load an example/model client-side — no page reload, so the running - // Pyodide worker is reused (no reinit). Reflects the model in the URL via - // replaceState, so deep-links (?model=) still work and the URL stays - // shareable. Used by the welcome modal's example cards. - async function loadExample(url: string): Promise { - showWelcomeModal = false; - // Clear previous results / REPL state; the worker stays up. - await resetSimulation(); - const result = await importFromUrl(url, { - deferToolboxInstall: true, - backendReady: backendReady ?? Promise.resolve() - }); - if (result.success) { - try { - history.replaceState(history.state, '', `?model=${encodeURIComponent(url)}`); - } catch { - /* replaceState can throw in odd embedding contexts; non-fatal */ - } - setTimeout(() => triggerFitView(), 100); - } else if (result.error) { - consoleStore.error(`Failed to load example: ${url}`); - consoleStore.error(result.error); - showConsole = true; - } - } - // Track placement offset for stacking prevention let placementOffset = 0; let lastPlacementTime = 0; @@ -1277,7 +1303,7 @@ {#if recentFilesSupported && recentFilesMenuOpen}