Appearance
Save / Load / Tabs
1. Identity
The save / load / tabs workflow is the per-tab persistence layer that autosaves every FEA scene to localStorage under a tab-scoped key, exposes a deferred-restore banner on page load, lets the user export / import .motordevs.json files, and warns on beforeunload while any tab is dirty.
2. When to use it
- The user refreshes the browser or closes the tab accidentally and expects their last scene to still be available.
- The user wants to start a new sketch from blank without losing the previous one (the archive slot is the recovery bridge).
- The user wants to ship a scene as a file (Save As) or open one a colleague sent.
- The user wants several independent sketches open side-by-side via the top tab strip, each with its own undo history and autosave key.
3. Inputs
- Tool button — File menu in
TopTabStripexposes Save / Open; per-tab close button triggers the deferred-restore lifecycle on next reload. - Keyboard shortcut —
Cmd/Ctrl+S→saveSceneFileaction (downloads<tab>.motordevs.json). Open has no shortcut wired today. - MotorScript builtin — N/A (persistence is a runtime concern, not script-addressable).
- Command Palette — not yet wired.
- Context menu — N/A.
- Page-load banner —
SavedSceneBanner(mounted inFeaEditor.tsx) auto-renders whenhasSavedScene(tabId)orhasArchivedScene(tabId)is true.
4. State machine
- App boot.
getTabStore(tabId, { deferRestore: true })(app/src/lib/fea2d/store/index.ts:979) constructs the per-tab store. IfloadSavedScene(tabId)returns an envelope, it is parked onctx._deferredRestoreEnvelope; the store stays empty. Preview: blank canvas withSavedSceneBannershowing "Saved N min ago available — [Load saved] [Discard]". Cancel: user closes the banner by acting on it. - Load saved. Click
[Load saved]→applyDeferredRestore(io.ts:728) sets{scene, script, params}from the cached envelope and clears history stacks. Banner mode →idle. Preview: scene materialises; autosave dirty bit stays false until the next mutation. - Discard. Click
[Discard]→discardActiveScene(io.ts:749) callsarchiveActiveScene(tabId)(envelope moved tomotordevs.scene.archived.<tabId>) thenclearSavedScene(tabId). Emitter firesscene-archived; banner flips tohas-archive. Preview: canvas stays blank; banner now reads "Scene archived — [Restore archive] [Permanently delete]". - Restore archive. Click
[Restore archive]→restoreArchivedScene(io.ts:759) callsmoveArchiveToActive(tabId)(archive key → active key) and hydrates the store. Emitter firesarchive-restored; banner →idle. The next autosave debounce will re-write the now-restored envelope under the active key. - Permanent delete. Click
[Permanently delete]→permanentlyDeleteArchive(io.ts:781) callsclearArchivedScene(tabId). Emitter firesarchive-cleared; banner →idle. The envelope is gone; no recovery. - Mutate. Any mutation that changes
scene,script, orparams(ZustandsubscribeinattachAutosave,autosave.ts:760) setsctx.dirty = trueand arms a 1.5 ssetTimeout. On expiry,flushcallssaveSceneNow(tabId, store)which performs an atomic three-step write (tmp key → read-back verify → commit → drop tmp). - Tab hide / pagehide.
visibilitychange === 'hidden'andpagehidelisteners (autosave.ts:786-803) flush immediately so mobile-Safari swipe-away never loses the last 1.5 s of edits. - Finalize. No explicit finalize step — autosave is continuous. Manual Save (
Cmd+S) callssaveSceneFile(io.ts:626) which serialises{schemaVersion: 2, scene, params, script}to a JSON blob and triggers a download.
5. Committed state
After a successful autosave tick, localStorage[motordevs.scene.<tabId>] holds:
- Envelope
{ v: 1, scene, script, params?, savedAt }(SavedScenePayload,autosave.ts:79). sceneis the output ofstate.serializeScenewith_slvs*WASM handles stripped.paramsisParamDef[](added in G-09-3; absent on v1-envelope blobs, defaulted to[]on read).savedAtisDate.nowat write time; the banner renders it viarelativeTime.- The archive slot, if populated, lives under
motordevs.scene.archived.<tabId>with the same shape. - File downloads (
saveSceneFile) emit a SEPARATE envelope produced byserializeSceneBundle, which carriesschemaVersion: 2plus a mirroredsolverStatus(the on-disk schema is NOT the autosave envelope — they overlap but differ; see §7).
6. Constraints / interactions
| Surface | Behavior |
|---|---|
getTabStore(tabId, { deferRestore }) | First call per tab creates store + attaches autosave; subsequent calls return the registered instance. deferRestore is honoured ONLY on the first call. |
attachAutosave debounce | Single trailing timer per tab; coalesces bursts. Re-armed on every subscribed mutation. |
ctx.dragFrameDepth > 0 | Autosave subscription returns early — 60 FPS drag-solve frames do not trip a save per frame. The post-drag commit fires one save for the steady state. |
ctx.txDepth > 0 | flush short-circuits mid-transaction; dirty bit stays set; next post-commit tick retries (autosave.ts:746). |
anyTabDirty(getAllTabStores) | Walks every registered tab's __sliceCtx.dirty; truthy → beforeunload returns a string and the browser shows its generic "Leave site?" dialog (App.tsx:42). |
Cross-tab storage event for motordevs.scene.<tabId> | Today: a sibling tab editing the same tabId emits a toast only. Banner reactivity across tabs is a known gap. |
| Cross-tab tabs-list reconciler | TopTabStrip watches motordevs.tabs.list.v2 via storage and diffs (TopTabStrip.tsx:390-456). Separate keyspace from scenes. |
| Schema migration | deserializeScene (load-scene.ts:84) detects schemaVersion !== 2, calls migrateV1ToV2 , then runs the allowlist validator. |
| Quota latch | Per-tab Set<string> in autosave.ts:129; module-state, reset only by reload. Latched tabs no-op every subsequent save. |
7. Failure modes
QuotaExceededErroronsetItem.tripQuotaLatch(autosave.ts:217) routes one destructive toast throughsurfaceError(STORAGE_QUOTA_EXCEEDED)AND emitsquota-disabledsoAutosaveStatusBannershows a sticky red strip until reload. Subsequent saves silently no-op — no toast spam.- Corrupt JSON in active key.
loadSavedScene(autosave.ts:410) catchesJSON.parsethrows and routes a silent toast (FILE_LOAD_SCHEMA_ERROR);isValidSavedScenePayloadalso rejects envelopes with wrongv, missingscene, or malformedparams. The blob is left in place (next autosave overwrites it). - Atomic-write verify mismatch. If
localStorage.getItem(tmpKey) !== serialised(Safari private-browsing corruption), the tmp key is dropped and the previous commit-key value remains intact (autosave.ts:323). - Orphan tmp keys from a crashed save.
pruneOrphanTmpScenes(autosave.ts:452) runs once per page load from the firstgetTabStorecall. If commit key exists → drop tmp; if commit absent + tmp parses → promote tmp to commit (recovery). - File open with
schemaVersion: 1.migrateV1ToV2runs full-scene + spline CP materialisation (migrations/v1-to-v2.ts). Constraints with unsupportedentityKindshapes are dropped silently and the user sees the migrated scene WITHOUT those constraints. - File open with invalid schema. The validator returns
{error};openSceneFile(io.ts:638) routes throughsurfaceError(FILE_LOAD_SCHEMA_ERROR)and the scene is unchanged. - Two tabs writing the same
tabId. Last-write-wins on localStorage. The receiving tab sees astorageevent and currently only toasts. The banner does NOT re-derive from the cross-tab write; an explicit reload is required to pick up the sibling's changes. - Invalid
tabId(empty / non-string). Every entry point inautosave.tsguards viaisValidTabIdand refuses to operate; this prevents amotordevs.scene.undefinedcollision key. - Mid-transaction flush attempt.
flushbails whenctx.txDepth > 0; the dirty bit stays set so the next commit triggers a retry.
8. Figures
┌──────────────────────────────────────────────────────────────┐
│ Saved 5 min ago available — start blank, or restore... │
│ [Load saved] [Discard] │
└──────────────────────────────────────────────────────────────┘Figure 1: SavedSceneBanner in has-saved mode at page load. Capture: refresh a tab that has an autosaved scene; the canvas behind is blank pending the user's choice.
┌──────────────────────────────────────────────────────────────┐
│ Scene archived — restore it, or delete it permanently. │
│ [Restore archive] [Permanently del] │
└──────────────────────────────────────────────────────────────┘Figure 2: SavedSceneBanner in has-archive mode after [Discard]. Capture: from Figure 1 state, click Discard; the banner re-renders in red-accented form.
mutate → debounce(1.5s) → flush
│ │
│ ├─ ctx.txDepth>0 ? bail, keep dirty
│ ├─ tmp setItem(stage)
│ ├─ verify read-back
│ ├─ commit setItem
│ └─ remove tmp
└─ visibilitychange/pagehide → flushFigure 3: Autosave debounce + atomic-write pipeline. ASCII diagram; no screenshot needed.
Figure 4 (has-saved): page-load banner offering Load saved / Discard above a blank canvas; the cached scene is parked on _deferredRestoreEnvelope until the user chooses.
Figure 5 (has-archive): after Discard, the banner re-renders with Restore archive / Permanently delete; the canvas now holds fresh user geometry as work continues.
9. Known bugs
Cross-tab
storageevents formotordevs.scene.<tabId>emit a toast only;SavedSceneBannerdoes not re-render when a sibling tab writes the same key. Last-write-wins; user must reload to see the sibling tab's state.migrateV1ToV2silently drops constraints whoseentityKindshape the migrator does not cover. No user-visible toast on the gap.
No P0/P1 bugs filed against the autosave / banner pipeline itself as of .
10. Class API
In the current model, the persisted envelope's scene field is the output of Scene.toJSON: SceneModelV2 (app/src/lib/fea2d/model/Scene.ts) rather than a hand-rolled snapshot serializer. On load, Scene.fromJSON(snap): Scene hydrates the class layer back from the same shape; loadScene (migrations/v1-to-v2.ts) runs first to bring legacy v1 envelopes forward to v2.
The v3 JSON persistence-stability invariant — Scene.fromJSON(scene.toJSON).toJSON the original scene.toJSON — is verified by scene-roundtrip.invariant.test.ts against both fixtures. This guarantees that the OOP refactor introduces zero byte drift in saved scene files.