Durable canvas layout: stable per-cell overrides + committable sidecar#17
Merged
Conversation
Layout (drag/resize boxes, hidden cells, card tints) previously lived in one browser's localStorage under a title+cell-fingerprint key: ANY notebook edit changed the key and silently reset the whole arranged canvas, and the arrangement never left the browser. Stable key + per-cell continuity - notebookKey is now v3, keyed by title alone. Overrides were always per-cell maps — only the storage key discarded that. Surviving cells keep their boxes; pruneOverrides drops orphans at load/save; new cells flow in below (buildTabLayout already handled partial maps). - migrateV2 adopts an existing v2 arrangement once (newest entry, pruned, re-homed under v3; old keys removed). - loadOverrides returns null on a key miss (miss vs empty now distinguishable); normalizeOverrides extracted as the shared validator for localStorage, migration, and sidecar payloads. Committable sidecar via the view BFF - PUT /layout on the view server writes <notebook>.layout.json beside the YAML (pretty-printed; size-capped; 400 on non-JSON, 405 on other methods). Read fresh per page load and injected pre-paint through window.__DASHBOOK__ (layout + canPersistLayout) — no fetch, no flash. - Layering: personal localStorage ?? committed sidecar ?? YAML defaults (wholesale, so hide/unhide never half-merges). "Save layout" (edit mode, BFF only) PUTs the pruned effective set, clears the personal layer, and toasts; Reset drops only personal changes. Verified live on the dev-graph view: hide → save → sidecar on disk → localStorage.clear() + reload → arrangement restored from the sidecar alone; GET /layout 405, garbage 400. Web tests 54 → 59. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
Greptile: (1) the save-failure path was shoehorned through SuccessToastBridge as kind:"success" — failures rendered identically to successes. The toast stack now supports tone:"error" (destructive border + text) and Save-layout outcomes flow through a dedicated LayoutNotice bridge that models both outcomes. (2) migrateV2's doc claimed "newest v2 entry" — v2 keys embed no timestamp, so the pick among several is arbitrary; the doc now says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
Greptile: config.initialLayout is captured once at page load, so after "Save layout" a Reset reverted to the stale (possibly empty) page-load baseline instead of the arrangement just written to disk — blanking the canvas until a reload. The committed baseline is now React state, seeded from the injected sidecar and advanced on every successful save; Reset uses it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Canvas layout previously lived in one browser's localStorage under a
title + djb2(cell-id set)key — so any notebook edit changed the key and silently reset the whole arranged canvas, and an arrangement could never leave the browser. Two fixes, one slice:Stable key + per-cell continuity
notebookKey→dashbook:layout:v3:<title>(fingerprint dropped). The override maps were always per-cell — only the key discarded that. Surviving cells keep their boxes;pruneOverridesdrops orphans at load and before save; new cells flow in below (buildTabLayoutalready tolerated partial maps).migrateV2adopts an existing v2 arrangement once (newest entry, pruned, re-homed under v3, old keys removed) — nobody loses their layout on upgrade.loadOverridesreturnsnullon a miss (miss vs empty distinguishable);normalizeOverridesextracted as the shared validator for localStorage, migration, and sidecar payloads.Git-committable sidecar via the
viewBFFPUT /layoutwrites<notebook>.layout.jsonbeside the YAML (pretty-printed for clean diffs; 256 KB cap → 413; non-JSON → 400; other methods → 405). Local file I/O only — the auth-stripping/ogproxy is untouched.window.__DASHBOOK__contract (layout+canPersistLayout) — no fetch, no flash of default layout.personal localStorage ?? committed sidecar ?? YAML defaults— wholesale per layer, so hide/unhide never half-merges. Save layout (edit mode, BFF-served only) PUTs the pruned effective set, clears the personal layer, and toasts through the existing bridge; Reset drops only personal changes. Vite dev / no-BFF keeps pure-localStorage behavior.Verification
localStorage.clear()+ reload → arrangement restored from the sidecar alone;GET /layout→ 405, garbage body → 400.🤖 Generated with Claude Code
https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
Greptile Summary
This PR replaces the fingerprint-based v2 localStorage key with a title-only v3 key so notebook edits no longer reset the canvas, adds a one-time
migrateV2adoption path, and introduces a git-committable.layout.jsonsidecar written by a newPUT /layoutBFF endpoint and injected pre-paint into each page load.notebookKeydrops the djb2 cell-id fingerprint;pruneOverridesdrops orphan entries at load and before each sidecar write;normalizeOverridesis extracted as a shared validator for localStorage, sidecar, and migration payloads.receiveLayoutinserve.tsaccepts a size-capped, validated JSON body and writes it pretty-printed beside the notebook YAML; the server reads it fresh on every SPA fallback request and injects it viawindow.__DASHBOOK__.committedLayoutReact state seeds from the injected sidecar and advances on every successful save, so Reset correctly falls back to the latest saved state rather than the page-load snapshot.Confidence Score: 5/5
Safe to merge — the sidecar path is fixed at startup from the operator-controlled notebook path, all untrusted payloads are validated before use, and the migration path is covered by tests.
All three previously flagged issues (error-bridged-as-success, arbitrary v2 pick, Reset falling back to page-load snapshot) are resolved. The remaining observations are about a narrow concurrent-drag window during a localhost save, and an accessibility nuance on error toast role — neither affects core correctness.
No files require special attention; the layout-overrides module and serve.ts changes are straightforward and well-tested.
Important Files Changed
normalizeOverridesextracted as shared validator,pruneOverridesadded for orphan cleanup,loadOverridesreturns null on miss,migrateV2handles one-time v2→v3 adoption — all well-structured and testable.persistLayoutasync PUT,committedLayoutstate for correct Reset baseline, andLayoutToastBridgefor success/error toasts. The asyncpersistLayouthas a narrow race where drags during the in-flight PUT are silently reverted.PUT /layoutendpoint with size cap (256 KB), JSON validation, and pretty-printed sidecar write; sidecar path is fixed at startup fromnotebookPathso the browser cannot influence it. Sidecar contents are injected into eachindex.htmlresponse pre-paint.add()with optionaltone: "error"for destructive border/text styling;managerremains stable viauseMemo. Error toasts keeprole="status"(polite) rather thanrole="alert"(assertive), which may delay screen-reader announcements.initialLayoutandcanPersistLayouttoAppConfig; validates and prunes the sidecar payload before exposing it, so malformed BFF-injected data never reaches the canvas.pruneOverrides,normalizeOverrides,loadOverridesnull-on-miss, and all threemigrateV2branches (adopt, no-op, prunes-to-nothing). ReusablestubStoragehelper removes repeated boilerplate.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Browser as Browser (React) participant LS as localStorage participant BFF as CLI BFF (serve.ts) participant Disk as Disk (.layout.json) note over Browser: Page load BFF->>Disk: readFileSync(sidecar) [per request] BFF-->>Browser: "index.html + window.__DASHBOOK__ {layout, canPersistLayout}" Browser->>LS: loadOverrides(v3 key) alt v3 miss Browser->>LS: migrateV2(title, liveIds) — adopt + prune v2 keys, write v3 end note over Browser: Effective = local ?? sidecar ?? EMPTY note over Browser: Edit layout mode Browser->>Browser: drag/resize card Browser->>LS: saveOverrides(v3 key, overrides) note over Browser: Save layout click Browser->>BFF: PUT /layout (pruned JSON, ≤256 KB) BFF->>BFF: JSON.parse body BFF->>Disk: writeFileSync(sidecar, pretty-JSON) BFF-->>Browser: 204 Browser->>LS: clearOverrides(v3 key) Browser->>Browser: setCommittedLayout(effective) note over Browser: Reset click Browser->>LS: clearOverrides(v3 key) Browser->>Browser: setOverrides(committedLayout ?? EMPTY)%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Browser as Browser (React) participant LS as localStorage participant BFF as CLI BFF (serve.ts) participant Disk as Disk (.layout.json) note over Browser: Page load BFF->>Disk: readFileSync(sidecar) [per request] BFF-->>Browser: "index.html + window.__DASHBOOK__ {layout, canPersistLayout}" Browser->>LS: loadOverrides(v3 key) alt v3 miss Browser->>LS: migrateV2(title, liveIds) — adopt + prune v2 keys, write v3 end note over Browser: Effective = local ?? sidecar ?? EMPTY note over Browser: Edit layout mode Browser->>Browser: drag/resize card Browser->>LS: saveOverrides(v3 key, overrides) note over Browser: Save layout click Browser->>BFF: PUT /layout (pruned JSON, ≤256 KB) BFF->>BFF: JSON.parse body BFF->>Disk: writeFileSync(sidecar, pretty-JSON) BFF-->>Browser: 204 Browser->>LS: clearOverrides(v3 key) Browser->>Browser: setCommittedLayout(effective) note over Browser: Reset click Browser->>LS: clearOverrides(v3 key) Browser->>Browser: setOverrides(committedLayout ?? EMPTY)Reviews (3): Last reviewed commit: "Reset falls back to the LAST-SAVED sidec..." | Re-trigger Greptile