Skip to content

Durable canvas layout: stable per-cell overrides + committable sidecar#17

Merged
aaltshuler merged 3 commits into
mainfrom
durable-layout
Jul 4, 2026
Merged

Durable canvas layout: stable per-cell overrides + committable sidecar#17
aaltshuler merged 3 commits into
mainfrom
durable-layout

Conversation

@aaltshuler

@aaltshuler aaltshuler commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

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

  • notebookKeydashbook:layout:v3:<title> (fingerprint dropped). The override maps were always per-cell — only the key discarded that. Surviving cells keep their boxes; pruneOverrides drops orphans at load and before save; new cells flow in below (buildTabLayout already tolerated partial maps).
  • migrateV2 adopts an existing v2 arrangement once (newest entry, pruned, re-homed under v3, old keys removed) — nobody loses their layout on upgrade.
  • loadOverrides returns null on a miss (miss vs empty distinguishable); normalizeOverrides extracted as the shared validator for localStorage, migration, and sidecar payloads.
  • The old test asserting "key changes when the cell-id set changes" encoded the bug — rewritten to assert stability.

Git-committable sidecar via the view BFF

  • PUT /layout writes <notebook>.layout.json beside 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 /og proxy is untouched.
  • The sidecar is read fresh per page load and injected pre-paint through the existing window.__DASHBOOK__ contract (layout + canPersistLayout) — no fetch, no flash of default layout.
  • Layering: 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

  • Web tests 54 → 59 (v3 key stability, prune, normalize, v2 migration adopt/no-op/prunes-to-nothing; loadOverrides null-on-miss).
  • Live on the dev-graph view: hide a card → Save → sidecar on disk → 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 migrateV2 adoption path, and introduces a git-committable .layout.json sidecar written by a new PUT /layout BFF endpoint and injected pre-paint into each page load.

  • Stable key + pruning: notebookKey drops the djb2 cell-id fingerprint; pruneOverrides drops orphan entries at load and before each sidecar write; normalizeOverrides is extracted as a shared validator for localStorage, sidecar, and migration payloads.
  • Sidecar persistence: receiveLayout in serve.ts accepts 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 via window.__DASHBOOK__.
  • Two-layer override state: committedLayout React 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

Filename Overview
packages/web/src/layout-overrides.ts Key module refactor: key simplified to title-only (v3), normalizeOverrides extracted as shared validator, pruneOverrides added for orphan cleanup, loadOverrides returns null on miss, migrateV2 handles one-time v2→v3 adoption — all well-structured and testable.
packages/web/src/App.tsx Adds two-layer override state (localStorage personal + committed sidecar), persistLayout async PUT, committedLayout state for correct Reset baseline, and LayoutToastBridge for success/error toasts. The async persistLayout has a narrow race where drags during the in-flight PUT are silently reverted.
packages/cli/src/serve.ts Adds PUT /layout endpoint with size cap (256 KB), JSON validation, and pretty-printed sidecar write; sidecar path is fixed at startup from notebookPath so the browser cannot influence it. Sidecar contents are injected into each index.html response pre-paint.
packages/web/src/components/ui/toast.tsx Extends ToastItem and add() with optional tone: "error" for destructive border/text styling; manager remains stable via useMemo. Error toasts keep role="status" (polite) rather than role="alert" (assertive), which may delay screen-reader announcements.
packages/web/src/config.ts Adds initialLayout and canPersistLayout to AppConfig; validates and prunes the sidecar payload before exposing it, so malformed BFF-injected data never reaches the canvas.
packages/web/src/layout-overrides.test.ts Tests expanded from 54 to 59; covers v3 key stability, pruneOverrides, normalizeOverrides, loadOverrides null-on-miss, and all three migrateV2 branches (adopt, no-op, prunes-to-nothing). Reusable stubStorage helper 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)
Loading
%%{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)
Loading

Reviews (3): Last reviewed commit: "Reset falls back to the LAST-SAVED sidec..." | Re-trigger Greptile

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
Comment thread packages/web/src/App.tsx
Comment thread packages/web/src/layout-overrides.ts
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
Comment thread packages/web/src/App.tsx Outdated
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
@aaltshuler aaltshuler merged commit e7094ed into main Jul 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant