Autosave & New Panel
localStorage autosave, silent boot restore, the save-status chip, and the New panel reset.
zpd has no server-side project storage — the working document lives in the browser tab, and use-autosave.ts mirrors it into localStorage continuously so a reload (or an accidental tab close) doesn't lose work. This is local-only: nothing leaves the browser, and nothing syncs across browsers or devices.
Boot restore
On mount, Editor() reads the stored document once via a lazy useState initializer: readDoc() ?? createDemoDoc(). The current key is zpd.doc.v2; its envelope version is 2 and its config must be exactly the current PanelConfig v5 shape. A valid entry loads silently. If no current entry exists, the reader can migrate a valid legacy zpd.doc.v1 envelope containing PanelConfig v1–v4. Promotion is transactional: the migrated document is exposed only after its canonical v5 form has been written to the v2 key, while the legacy bytes are retained as rollback data.
The editor falls back to the fixed-stack demo document whenever readDoc() cannot restore safely. Corrupt or unsupported current v2 bytes are preserved and protected from automatic overwrite; corrupt legacy bytes are also preserved, but because they live at a separate key they do not block later legitimate v2 autosaves. readDoc() never throws.
The stored payload is a small versioned envelope:
interface StoredDocPayload {
version: 2; // DOC_STORAGE_VERSION
savedAt: number;
config: PanelConfig; // exact v5 serializePanelConfig(doc) output
}readDoc() validates the storage envelope and uses core's non-throwing exact config parser (see Defensive Parsing). Unsupported/future/corrupt payloads are rejected rather than silently rewritten as defaults. readDoc()/writeDoc() never throw — every localStorage access (including the property read itself, which can throw under locked-down privacy settings) is wrapped and reduced to a tagged result.
Debounced autosave
Every commit to the document schedules a write 500ms later (AUTOSAVE_DEBOUNCE_MS). The effect that owns the timer is keyed on doc, so each new commit clears the previous run's pending timer before scheduling its own — rapid edits (a drag, a fast run of undo/redo) coalesce into a single write instead of one per commit.
Two early-flush triggers bypass the debounce so a pending write is never lost when the tab goes away:
pagehide— the modern replacement forbeforeunload; it also fires on bfcache navigation and mobile tab switches.visibilitychangeturning'hidden'— mobile OSes can kill a backgrounded tab's process without ever firingpagehide.
There is deliberately no beforeunload confirmation prompt ("Leave site? Changes may not be saved") — continuous local persistence replaces the need for one.
The save-status chip
components/ renders a small, non-interactive pill (role="status") in the header, next to the app name. It reflects one of three states:
| State | Copy | Style |
|---|---|---|
unsaved | "Unsaved changes…" | neutral pill |
saved | "Saved locally HH:MM" (24-hour, local time) | neutral pill |
failed (quota) | "Save failed (document too large for local storage)" | red pill, same text as the title tooltip |
failed (other) | "Save failed" | red pill, tooltip "Save failed — local storage is unavailable" |
unsaved is derived, not stored explicitly: the hook compares the last write's target document by reference against the current doc — any new commit makes that reference stale, which alone means "unsaved," with nothing to reset by hand.
Save failures
writeDoc() tags every failure with a reason — 'quota', 'unavailable', or 'error' — and the autosave hook fires a one-per-session warning toast (toastWarning) the first time a write fails, so a repeated quota failure on every subsequent debounce doesn't spam the toast queue:
quota— aQuotaExceededError(or Firefox'sNS_ERROR_DOM_QUOTA_REACHED). zpd'simagelayers embed base64 data URLs, so a design with a few large images can realistically exceed the browser's per-originlocalStoragequota. Toast: "This panel is too large to save locally — some changes may be lost if you close the tab."unavailable—localStorageitself is inaccessible (private-browsing lockdown, third-party-storage blocking, SSR). Toast: "Local storage is unavailable in this browser — changes are not being saved."error— any other write failure (e.g. the document failed to serialize). Toast: "Could not save your changes locally."
Note
The chip's copy is deliberately conservative about what it promises: this is localStorage in this browser only — not an account, not a synced project, not a guarantee against the tab's storage being cleared.
New panel
The header's New panel button (and the command palette's File → New Panel, the same underlying newPanelAction()) starts a fresh document. It always confirms first:
Start a new panel? This replaces the current panel with the default starter panel. This cannot be undone. [Cancel] [New panel]
On confirm, it replaces the whole document with createDefaultDoc() — the same plain fixed-stack starter document (one dot-grid pattern under Copper) a script or a fresh @zpd/core consumer would get, not the richer first-visit demo document described above.
replaceDoc() (replace-doc.ts) is the shared whole-document-replacement primitive behind both New panel and JSON import (see Round-Trip → Importing) — unlike a normal ctx.commit()/ctx.replace(), it:
discards the entire undo/redo history (
ctx.reset) — a new document's edit history isn't meaningful against the old one,clears the current selection (stale layer ids from the old document must not linger), and
evicts renderer image-cache entries that no longer match the new layer set.
New panel doesn't clear localStorage directly — it just replaces the in-memory document, and the next debounced autosave overwrites the stored entry with the new starter panel, same as any other edit.