Repository navigation
Conversation
A board was one infinite surface, which is enough room until a project has three subjects — and then they either sprawl into each other or get split across boards that have nothing to do with one another. Sheets are the tabbed answer every tool with this problem arrives at: one board, several named canvases, a strip along the bottom to move between them. Anyone who can open the board gets all of them. Sheets organise a board; they do not restrict it. **Only the active sheet is a live `Store`.** The rest are documents held beside it. That is the whole design, and what it buys is that `store` is the same object for the life of the app: the renderer, the input layer, the selection and the format bar never learn that sheets exist, and an inactive sheet costs no listeners and no rendering — only correct content, which is all anyone can see of it. Switching is `checkpoint` on the way out and `restore` on the way in, so undo travels with the sheet. Undoing on the canvas you are looking at is what ctrl+Z means; walking back into edits on a canvas nobody is showing is not. **The stored document keeps a board where a board has always been.** A sheeted board writes its first sheet twice — once in `sheets`, once at the top level. `isBoard` and the database's `boards_doc_shape` check both still pass, so there is no migration and no policy to change; and a tab still running the previous build finds a real board rather than a document it reads as absent, which it would answer by seeding a starter board over the top. Insurance, not a guarantee: that client saving still writes v1 back and drops the other sheets. **Ops say which sheet they were made on.** Without that, two people on one board with different sheets open write into each other's canvas — so this ships with the addressing rather than after it. A payload with no sheet is an older client, and means the canvas it is looking at. Sheet *management* does not cross to other clients yet; that is the next change. Nothing gates who may add or remove one, deliberately: nothing else on this canvas gates editing either — a viewer can drag a card all day and the write is refused at the database. Sheets follow the document's rule rather than inventing one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe PR adds multi-sheet board documents with tabs, per-sheet objects and undo history, versioned persistence, legacy migration, camera handling, autosave integration, and sheet-aware synchronization. ChangesMulti-sheet board flow
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant SheetTabs
participant createSheets
participant Store
participant createBoardSync
participant Repository
User->>SheetTabs: select or edit a sheet
SheetTabs->>createSheets: invoke sheet command
createSheets->>Store: checkpoint or restore sheet state
Store->>createBoardSync: emit sheet operation
createBoardSync->>createBoardSync: route operation by sheet ID
createSheets->>Repository: serialize complete document
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (5)
src/core/sheets.js (1)
304-309: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid cloning the undo stacks on every save.
stateOfreturnsstore.checkpoint()for the active entry.checkpointrunsstructuredCloneoverpastandfuture.toJSONdiscards both. Autosave callstoJSONon every write, so each save deep-clones a history that holds full object copies for every recordedaddanddel.Read the active sheet through
store.toJSON()here instead.♻️ Proposed refactor
toJSON() { return writeDocument(entries.map((entry) => { - const { order, objects } = stateOf(entry); + const { order, objects } = entry.id === activeId ? store.toJSON() : entry.state; return { id: entry.id, name: entry.name, order, objects }; })); },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/core/sheets.js` around lines 304 - 309, Update toJSON() to serialize the active sheet using store.toJSON() instead of stateOf(entry), avoiding checkpoint cloning of undo history. Preserve the returned id, name, order, and objects fields while excluding past and future from the saved document.src/app.js (1)
98-128: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
cameraskeeps an entry for every removed sheet.
showingSheetwrites the outgoing camera at Line 111 before it runschange(). Whenchange()issheets.remove(id)and the removed sheet was on screen, the entry written for that id stays in the map. The id can never become active again, so the entry is never read and never replaced.Delete the camera for a sheet that is no longer in
sheetsafter the change.♻️ Proposed refactor
const result = change(); if (sheets.activeId === from) return result; + // The sheet just left may have been removed rather than switched away + // from, and a camera for a sheet that cannot come back is never read. + if (!sheets.has(from)) cameras.delete(from); selection.clear();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app.js` around lines 98 - 128, Update showingSheet so that after change() completes, it removes the camera entry for the previous active sheet when that sheet no longer exists in sheets. Preserve the existing camera restoration and viewport behavior for sheets that remain available.test/browser/sheets.test.js (1)
236-237: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winAlso assert that the duplicated objects match the first sheet.
Line 236 checks only that
stored.objectsis an array. Line 237 comparesstored.orderwithstored.sheets[0].order. The top-level compatibility copy could carry the wrong objects and still pass. Add the matching check onobjects.💚 Proposed addition
assert.ok(Array.isArray(stored.order) && Array.isArray(stored.objects), 'no board at the top level'); assert.deepEqual(stored.order, stored.sheets[0].order, 'the top level is not the first sheet'); + assert.deepEqual(stored.objects, stored.sheets[0].objects, 'the top level objects are not the first sheet\'s');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/browser/sheets.test.js` around lines 236 - 237, Extend the assertions in the relevant browser sheet persistence test to compare top-level stored.objects with stored.sheets[0].objects, alongside the existing stored.order comparison, so the duplicated compatibility data is verified against the first sheet.src/styles/canvas.css (1)
761-762: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse a theme token for the danger colour.
Every other colour in this block reads from a variable (
--panel,--line,--ink,--muted,--accent).#d83a52is hardcoded. The comment above states that deleting reads red "here as everywhere else", which suggests a token already exists. A fixed red also risks low contrast against a dark--panel.#!/bin/bash # Description: Find existing danger colour tokens and other uses of this hex. rg -n --glob '*.css' -- '`#d83a52`|--danger|--red|\.danger\b' rg -n --glob '*.css' -C 2 -- '--accent\s*:'🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/styles/canvas.css` around lines 761 - 762, Replace the hardcoded `#d83a52` values in the .sheet-menu button.danger color and hover background with the existing danger theme token, preserving the current hover opacity behavior and matching the token used elsewhere for danger styling.src/components/SheetTabs.jsx (1)
80-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAlign the tablist structure with the ARIA tabs pattern.
Keep only sheet tabs inside
role="tablist". Move.sheet-moreand.sheet-addto a separate control group, and remove the wrapperdivfrom the tablist hierarchy. Add arrow-key navigation with rovingtabindex.Expose the canvas as a
role="tabpanel"linked to the selected tab witharia-controlsandaria-labelledby.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/SheetTabs.jsx` around lines 80 - 93, Update the SheetTabs component so role="tablist" contains only sheet tab elements, moving .sheet-more and .sheet-add into a separate control group and removing the extra tablist wrapper. Implement roving tabindex with arrow-key navigation among tabs, and add matching tab/panel ARIA linkage: each tab should use aria-controls for the canvas panel, while the canvas should be role="tabpanel" with aria-labelledby referencing the selected tab.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app.js`:
- Around line 314-318: Update the saved-board initialization around
sheets.load(saved.board) so outbound synchronization remains queued until
hydration completes. Ensure createBoardSync does not broadcast the temporary
sheets.activeId, then release and stamp the queued batch with the loaded sheet
ID after sheets.load finishes; keep heldUntil limited to inbound operations.
In `@src/components/SheetTabs.jsx`:
- Around line 57-63: Update the rename flow around the ask.prompt call to trim
the returned name before calling commands.renameSheet, and only rename when the
trimmed value is non-empty. Preserve the existing null/cancel behavior and pass
the trimmed value to commands.renameSheet.
- Around line 125-129: Update the menu dismissal effect to close the open menu
on both window resize and the sheet strip’s scroll events, in addition to its
existing dismissal triggers. Reuse the strip ref and existing menu state/setter
symbols in the effect, and ensure both listeners are registered and cleaned up
so the fixed-position menu cannot retain stale coordinates.
In `@src/core/sheets.js`:
- Around line 65-72: Update the sheet-mapping logic in readDocument to track ids
already assigned while iterating through sheets, generating a new id when an id
is missing, empty, or already used; preserve unique valid ids and record every
final id before processing the next sheet. Correct the duplicate-id fixture in
the relevant sheets test so it verifies both sheets receive distinct ids.
In `@src/platform/sync.js`:
- Around line 145-151: Update the store.onOps queueing flow to capture
sheets.activeId when each local operation batch is created, rather than reading
it during the delayed flush. Queue batches with their captured sheet IDs and
have the broadcast send path use each batch’s stored sheet; add coverage for
switching sheets before a delayed flush and verify operations retain their
original sheet.
In `@src/styles/canvas.css`:
- Around line 677-682: Update the .sheet-tab rule to prevent flex shrinking by
applying the same non-shrinking flex behavior used by .sheet-add, while
preserving the existing width cap and scrolling behavior of the .sheet-tabs
container.
In `@test/browser/sheets.test.js`:
- Around line 76-80: Replace the immediate absence assertion at
test/browser/sheets.test.js:76-80 with page.waitFor waiting until
document.querySelector('[data-id="${first}"]') === null. Before reading
app.selection.size at test/browser/sheets.test.js:117-118, add page.waitFor for
the second tab’s appearance; both sites must wait on the UI condition produced
by the preceding click rather than relying on timing.
- Around line 242-250: Navigate away from the board route before seeding the
legacy v1 document in this test, matching the existing beforeEach setup. Insert
the root-route navigation before the localStorage.setItem call so pending saves
from app.store.load cannot overwrite the seeded data before the subsequent
page.goto.
---
Nitpick comments:
In `@src/app.js`:
- Around line 98-128: Update showingSheet so that after change() completes, it
removes the camera entry for the previous active sheet when that sheet no longer
exists in sheets. Preserve the existing camera restoration and viewport behavior
for sheets that remain available.
In `@src/components/SheetTabs.jsx`:
- Around line 80-93: Update the SheetTabs component so role="tablist" contains
only sheet tab elements, moving .sheet-more and .sheet-add into a separate
control group and removing the extra tablist wrapper. Implement roving tabindex
with arrow-key navigation among tabs, and add matching tab/panel ARIA linkage:
each tab should use aria-controls for the canvas panel, while the canvas should
be role="tabpanel" with aria-labelledby referencing the selected tab.
In `@src/core/sheets.js`:
- Around line 304-309: Update toJSON() to serialize the active sheet using
store.toJSON() instead of stateOf(entry), avoiding checkpoint cloning of undo
history. Preserve the returned id, name, order, and objects fields while
excluding past and future from the saved document.
In `@src/styles/canvas.css`:
- Around line 761-762: Replace the hardcoded `#d83a52` values in the .sheet-menu
button.danger color and hover background with the existing danger theme token,
preserving the current hover opacity behavior and matching the token used
elsewhere for danger styling.
In `@test/browser/sheets.test.js`:
- Around line 236-237: Extend the assertions in the relevant browser sheet
persistence test to compare top-level stored.objects with
stored.sheets[0].objects, alongside the existing stored.order comparison, so the
duplicated compatibility data is verified against the first sheet.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 2d0d0d40-92af-420f-8910-8286b9ccf645
📒 Files selected for processing (14)
src/app.jssrc/components/BoardCanvas.jsxsrc/components/SheetTabs.jsxsrc/core/autosave.jssrc/core/sheets.jssrc/core/store.jssrc/platform/sync.jssrc/styles/canvas.csstest/browser/sheets.test.jstest/node/autosave.test.jstest/node/lifecycle.test.jstest/node/sheets.test.jstest/node/sync-hydration.test.jstest/node/sync.test.js
CI caught what a machine with no Docker could not: every cross-client test failed, because each client minted its own id for the same first sheet and then addressed its ops to it. The receiver had no such sheet and dropped them. That was not the test harness being unrealistic — it was every board that exists, since the first sheet is not created by anybody. It is what a board from before sheets *becomes* when it is read, on each client separately, and two of them have to arrive at the same answer without being told. So it is a constant. Every other sheet is made by somebody, on a client, at a moment, and gets a generated id that travels with the sheet itself. Pinned three ways: the id is the same wherever a document is read, ops addressed by one client's reading land on another's, and — the one that would have caught this — the two-client suite that only CI can run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr
Three from review, two of them places where the comment claimed something the code did not do. **The sheet was read at send time.** The throttle holds ops for up to 50ms after the gesture that made them, and switching sheets in that window is one click — so a drag on one sheet could go out addressed to the sheet the person moved to, and land there on everyone else's screen. The comment said switching "is not something you can do in the middle of the gesture", which is true and beside the point: the window is after the gesture, not during it. The queue holds batches now, each stamped when its ops arrive. **Ops made before the board loaded were addressed to a sheet nobody else has.** Until the load lands, the sheet on screen is the empty one this client started with. Those sends are held until there is a board to name — the same holding inbound ops already had, for the same reason from the other end. **A repeated sheet id survived being read.** The comment said missing *or repeated* ids were replaced; the code only replaced missing ones, and the test named for the case had no duplicate in its fixture. Two sheets with one id is worse than it sounds: everything here finds a sheet by first match, so the second is unreachable, takes the first's remote ops, and is written back for next time. Also: the tab menu closes when the strip scrolls under it, tabs no longer shrink to ellipses instead of scrolling, and three test sites wait for what they assert rather than reading straight after a click. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr
The scroll half of this landed last commit; the resize half did not, and the finding named both. A fixed menu placed from a button's box floats away from it whenever anything moves the button without a pointer going down — a wheel over the strip, or the window being resized. The scroll case has a test that fails without the listener. Resize does not: driving a real window resize through CDP for a listener whose whole body is `setMenu(null)` costs more than it pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr
|
@coderabbitai review |
|
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app.js (1)
314-332: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftPreserve sheet changes made during hydration.
sheets.load(saved.board)replaces every pre-hydration sheet. Theearlybuffer contains only raw Store operations, so sheet additions, renames, duplicates, removals, and selections are lost.If a user adds or selects a sheet before loading completes, its object operations are also replayed onto the loaded first sheet because
earlydoes not retain a sheet ID.Buffer sheet lifecycle actions and operation sheet addresses until loading completes, then replay them against the loaded document. Alternatively, disable sheet-changing commands until hydration completes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app.js` around lines 314 - 332, Preserve sheet changes made before hydration completes: extend the early-action buffering around sheets.load(saved.board) to retain sheet lifecycle actions and each operation’s sheet ID, then replay them against the loaded document in order before applying remaining early operations. Ensure additions, renames, duplicates, removals, and selections target their original sheets rather than the loaded first sheet.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/core/store.js`:
- Around line 249-264: Update Store.restore to represent state restoration as a
single non-broadcast transaction routed through apply(ops), including a correct
inverse for the replaced objects, order, past, and future state. Preserve the
existing restore notification behavior and ensure restore does not trigger
persistence or broadcasting; keep load using restore with empty history.
In `@src/platform/sync.js`:
- Around line 242-254: In src/platform/sync.js lines 242-254, update release to
replay every pending local batch with sheet:null into the hydrated active sheet
through a non-outbound path before calling flush(), while preserving batch
ordering and existing stopped handling. In test/node/sync-hydration.test.js
lines 228-248, add or update coverage so the initiating store retains the early
operation after hydration and release.
---
Outside diff comments:
In `@src/app.js`:
- Around line 314-332: Preserve sheet changes made before hydration completes:
extend the early-action buffering around sheets.load(saved.board) to retain
sheet lifecycle actions and each operation’s sheet ID, then replay them against
the loaded document in order before applying remaining early operations. Ensure
additions, renames, duplicates, removals, and selections target their original
sheets rather than the loaded first sheet.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 54f9b1f6-714b-40a1-bdf7-d5b81b0ccd88
📒 Files selected for processing (14)
src/app.jssrc/components/BoardCanvas.jsxsrc/components/SheetTabs.jsxsrc/core/autosave.jssrc/core/sheets.jssrc/core/store.jssrc/platform/sync.jssrc/styles/canvas.csstest/browser/sheets.test.jstest/node/autosave.test.jstest/node/lifecycle.test.jstest/node/sheets.test.jstest/node/sync-hydration.test.jstest/node/sync.test.js
The test stopped one step short of what `hydrate()` does — it loaded the board and released the buffer, but left out the replay of the edits made while the board was in flight. Read literally it said those edits are lost, and review read it exactly that way. They are not: `app.js` replays them onto the loaded sheet as REMOTE, and the queued batch goes out addressed to that same sheet. The test now does both halves and asserts both — the sender keeps the edit, and it crosses once rather than twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/node/sync-hydration.test.js`:
- Around line 252-253: Replace await settle() in the hydration replay test with
the hydration completion promise or explicit replay-ready signal, then call
scheduler.flushTimers() only after hydration replay has completed; remove the
fixed event-loop delay.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 20f5a93c-0e03-44ed-bba9-0c3f283c5817
📒 Files selected for processing (1)
test/node/sync-hydration.test.js
Select something, open the menu on the tab you are on, and pick another sheet. The objects go there — with what their envelopes hold and the arrows between them, which is the set copying already takes — and you arrive with them, selected, where the camera is looking. Until now the only way to get work from one canvas to another was copy, switch, paste, switch back, delete. Sheets have been in the app since #33 and this is the hole in them. **A move is a copy and a delete, made in that order on two sheets.** That is not a compromise, it is the only shape that fits: a sheet's history travels with the sheet, which is what makes undo mean "undo what happened on the canvas I am looking at", so a change spanning two sheets would have to live in one stack and be invisible from the other. This way each sheet holds its own half — undo where they arrived takes them away, undo where they were puts them back — and going with them is what makes that legible rather than strange. It also means nothing new had to cross the wire. Every op is made while its own sheet is the one on screen, so they are addressed and broadcast exactly as any other edit is; writing into a document nobody is looking at would have needed a word in the sheet vocabulary and a second path through sync, for a feature that does not need one. The offer lives in the tab menu of the sheet you are on, one entry per other sheet, named — that menu is the only one a tab has, and "move selection to Later" says which sheet without asking anyone to work out what "there" meant. It is absent with nothing selected, since then it is an offer to move nothing. `deleteSelected` grew a sibling on the way: `remove(ids)` takes objects and the arrows that pointed at them, and the selection-shaped version is now one line on top of it — the move needs the same rule for a set that is not the selection. 1059 tests, 97.2% lines. Claude-Session: https://claude.ai/code/session_01PTizw7U35rC2MPquwr4Fv5 Co-authored-by: boubou666 <thomas.epitech@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
First of three. This one is the model, the stored format, the tab strip, and the op addressing that has to ship with them. Sheet management crossing live to other people on the board is PR 2; duplicating a whole board is PR 3.
A board was one infinite surface, which is enough room until a project has three subjects — and then they either sprawl into each other or get split across boards that have nothing to do with one another. Sheets are the tabbed answer every tool with this problem arrives at. Anyone who can open the board gets all of them: sheets organise a board, they do not restrict it.
Only the active sheet is a live
StoreThe rest are documents held beside it, in
src/core/sheets.js. That is the whole design, and what it buys is thatstoreis the same object for the life of the app — the renderer, the input layer, the selection and the format bar never learn that sheets exist, and an inactive sheet costs no listeners and no rendering, only correct content.Switching is
Store#checkpoint()on the way out andStore#restore()on the way in, both new and both small, so the undo stack travels with the sheet. Undoing on the canvas you are looking at is what ctrl+Z means; walking back into edits on a canvas nobody is showing is not.Ops from another client for a sheet that is not on screen are applied through a throwaway
Store— so there is exactly one implementation of what an op means,mergeOrderincluded, rather than a second one that would drift from it.The stored document keeps a board where a board has always been
{ "v": 2, "sheets": [ { "id": …, "name": "Discovery", "order": […], "objects": […] }, … ], "order": […], // a copy of sheets[0] "objects": […] } // a copy of sheets[0]The first sheet is written twice on purpose:
isBoard()inplatform/storage.jsand theboards_doc_shapeconstraint from20260804101204_boards.sqlboth still pass. "Board access is sheet access" needs no enforcement because there is nothing new to enforce.app.hydrate()answers by seeding a starter board and autosaving it over the top. An old client reading a v2 board instead finds a real one.It is insurance, not a guarantee: an old client that saves still writes a v1 document and drops the other sheets. There is no version of this that fixes a client already running, and it is worth knowing before this deploys.
Reading is total — a v1 board, or junk, is one sheet named
Sheet 1. Every board that exists upgrades on load and nothing is migrated in place.Ops name the sheet they were made on
Shipping the model without this would mean two people on one board with different sheets open writing into each other's canvas, so it is here rather than in PR 2. The payload gains
sheet; the receiver routes throughsheets.applyRemote. A payload with no sheet is an older client — or a board with one canvas — and means the sheet on screen.Verified by breaking the routing and watching exactly the two covering tests fail.
The strip
Along the bottom left, from the same tokens as the toolbar and the format bar. Click a tab to switch, double-click to rename,
+to add,⋯on the active tab for Rename / Duplicate / Delete. Delete confirms through the existing in-page dialog and is absent on the last sheet — a board with no sheets has no canvas.The menu is positioned
fixedfrom the button's box rather than hung off the tab: the strip scrolls, and a scroll container clips what its children draw outside it, which for a menu that opens upwards is all of it. That cost me a test run to find.Two calls made while building
Tests
631 passing, line coverage 96.0%.
core/sheets.jsandSheetTabs.jsxat 100%.test/node/sheets.test.js— the v1 upgrade, junk read as an empty sheet, repeated ids replaced, add/rename/duplicate/delete, the last-sheet refusal, per-sheet undo, remote ops landing on an inactive sheet without clearing its history, and the shape of what gets stored.test/node/sync-hydration.test.js— a new block for addressing: an op names its sheet going out, lands on that sheet arriving, falls back to the one on screen when unnamed, and keeps its sheet across the held-ops replay.test/browser/sheets.test.js— the strip end to end, including a reload, the stored shape, and a pre-sheets board opening as one sheet.🤖 Generated with Claude Code
https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr
Summary by CodeRabbit
New Features
Improvements