Skip to content

Add a React shell with routing and a board list - #4

Merged
boubou666 merged 7 commits into
mainfrom
react-shell
Aug 4, 2026
Merged

boubou666 merged 7 commits into
mainfrom
react-shell

Conversation

@boubou666

@boubou666 boubou666 commented Aug 4, 2026 •

Copy link
Copy Markdown
Owner

Step 2 of supporting multiple boards. React owns the shell — routing, the board list, page chrome — and nothing else.

The canvas stays imperative

Pan, zoom and drag fire at frame rate, so putting board objects into React state would mean reconciling hundreds of components per pointer move. BoardCanvas renders the stage markup once and hands the refs to createApp; destroy() is the effect cleanup:

useEffect(() => {
  const app = createApp({ document, window, boardId, repository, elements: { …refs } });
  return () => app.destroy();
}, [boardId]);

That is the entire integration, and it only works because elements was already an injected argument. createApp now requires it — the getElementById fallback had no callers left.

Routes

/ for the board list, /b/:boardId for a board, so a board is linkable. Boards created from the list start empty; only a first-ever visit gets the starter board, which is a tour rather than content anyone asked for.

Stack

React 19, Vite 8, react-router 8.

The router is v8 rather than v7's react-router-dom because that package carries a high-severity RSC CSRF advisory (GHSA-qwww-vcr4-c8h2, affecting 7.12–8.2). It is not reachable in a client-only SPA, but it is not worth keeping in the tree. npm audit --omit=dev is clean, and CI now fails on any high-severity production advisory so this cannot recur silently.

Routing is hash-based because GitHub Pages has no SPA rewrite. Swapping to BrowserRouter is one line once hosting moves.

Layout and pipeline

Sources moved public/js/** → src/**; public/ is now Vite's static asset directory and the site builds to dist/.

  • CI installs dependencies, runs the suite, builds the artifact, then audits.
  • Deploy publishes dist/ instead of public/.

⚠️ Both workflow changes run on a runner for the first time in this PR. Merging also switches the live site to the React build.

Tests

The browser suite runs against Vite's dev server, not a bundle. Unbundled modules are served at their source paths, so V8 coverage attributes to src/core/board.js rather than one minified chunk; a sourcemap remapper would be a project of its own. CI builds the production artifact separately so the shipped output is still verified.

Two harness bugs fixed along the way:

  • goto() paired Page.navigate with an immediate Page.reload, which aborted the in-flight navigation and left every page on about:blank. It now bounces through about:blank instead.
  • server.test.js hardcoded port 3999 and assumed the old public/ layout. It now uses a free port and a checked-in fixture site, so it no longer depends on a build having happened.

270 tests, 99.7% line coverage. Every React component and route is at 100%.

Known gaps

The toolbar is still imperative rather than a React component — converting it needs useSyncExternalStore over the store and viewport, which would have doubled this diff.

Summary by CodeRabbit

  • New Features
    • Added a React-based workspace with board listing, creation, renaming, deletion, and navigation.
    • Added persistent boards with autosave and seeded instructional content.
    • Added interactive canvas support for cards, envelopes, lists, selection, dragging, resizing, zooming, panning, snapping, and undo/redo.
    • Added responsive layouts, editable board titles, empty states, and error handling.
  • Documentation
    • Updated setup, routing, development, testing, and feature documentation.
  • Build & Deployment
    • Updated production builds and deployment to use the generated application artifact.

Step 2 of supporting multiple boards. React owns the shell — routing, the
board list, page chrome — and nothing else.

The canvas stays imperative. Pan, zoom and drag fire at frame rate, so
putting board objects into React state would mean reconciling hundreds of
components per pointer move. BoardCanvas renders the stage markup once and
hands the refs to createApp; destroy() is the effect cleanup. That works
only because elements were already an injected argument, so the framework
integration is three lines rather than a rewrite. createApp now requires
`elements` — the getElementById fallback had no callers left.

Routes are / for the list and /b/:boardId for a board, so a board is
linkable. Boards created from the list start empty; only a first-ever visit
gets the starter board, which is a tour rather than content anyone asked
for.

Stack: React 19, Vite 8, react-router 8. The router is v8 rather than v7's
react-router-dom because that package carries a high-severity RSC CSRF
advisory — unreachable in a client-only SPA, but not worth keeping in the
tree. `npm audit --omit=dev` is clean and CI now fails on any high-severity
production advisory.

Routing is hash-based because GitHub Pages has no SPA rewrite. Swapping to
BrowserRouter is one line once hosting moves.

Sources moved from public/js/** to src/**; public/ is now Vite's static
asset directory and the site builds to dist/. CI installs dependencies,
runs the suite, then builds the artifact; Deploy publishes dist/.

The browser suite runs against Vite's dev server rather than a bundle:
unbundled modules are served at their source paths, so V8 coverage
attributes to src/core/board.js instead of one minified chunk. The shipped
build is verified separately in CI.

Two test-harness bugs fixed along the way. goto() paired Page.navigate with
an immediate Page.reload, which aborted the in-flight navigation and left
every page on about:blank; it now bounces through about:blank instead. And
server.test.js hardcoded port 3999 and assumed the old public/ layout — it
now uses a free port and a checked-in fixture site, so it no longer depends
on a build having happened.

266 tests, 99.7% line coverage.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f6122b4c-cc32-4f64-8c04-06bb36c79357

📥 Commits

Reviewing files that changed from the base of the PR and between f8b2bbe and aa0ec40.

📒 Files selected for processing (7)
  • .coderabbit.yaml
  • .github/workflows/ci.yml
  • .github/workflows/deploy.yml
  • package.json
  • test/helpers/vite.js
  • test/node/server.test.js
  • test/run.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • package.json
  • .github/workflows/ci.yml
  • .github/workflows/deploy.yml
  • test/run.js

📝 Walkthrough

Walkthrough

The application moved from a static browser entry point to a React/Vite architecture. It adds routed board management, a React-mounted canvas, source-based core and platform modules, local persistence, browser integration tests, and Vite build and deployment workflows.

Changes

React/Vite application migration

Layer / File(s) Summary
Vite build and test infrastructure
.github/workflows/*, package.json, vite.config.js, index.html, server.js, test/*, README.md
Vite builds the application into dist. CI builds and audits production dependencies. Deployment publishes dist. Browser tests run against a Vite server and collect coverage from src.
Board domain and state foundation
src/core/*
Core modules provide board objects, geometry, IDs, scheduling, selection, store history, viewport operations, autosave, and seeded content.
Canvas rendering and interaction platform
src/platform/views.js, src/platform/renderer.js, src/platform/input.js, src/platform/toolbar.js, src/app.js, src/components/BoardCanvas.jsx
The canvas renders stored objects through DOM views. It handles pointer and keyboard interactions, editing, dragging, resizing, snapping, viewport controls, and cleanup through the React shell.
React routes and board persistence
src/main.jsx, src/App.jsx, src/routes/*, src/shell/storage.js, src/platform/storage.js, src/styles/*
Hash routes provide board listing and board pages. Boards can be created, renamed, opened, deleted, and persisted through local storage with a null fallback.
Source-based validation and server coverage
test/browser/*, test/helpers/*, test/node/*, test/fixtures/*
Tests import source modules, exercise routed shell behavior, use Vite browser startup, validate static serving from dist, and retain fixture coverage for server behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • boubou666/peerthink#3: Adds board-scoped repository and autosave changes incorporated into the new src architecture.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: adding a React shell, routing, and a board list.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch react-shell

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (6)
src/core/board.js (1)

163-182: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Note the quadratic cost of envelope expansion.

Each pass compares every id in out against every object in all, for up to MAX_NESTING passes. The worst case is 8 × n² containment tests. The method runs once per drag start, not per frame, so the current cost is acceptable for typical board sizes.

If board sizes grow, index objects by envelope or sort by area so each pass only tests plausible children. No change is required now.

🤖 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/board.js` around lines 163 - 182, No implementation change is
required for withEnvelopeChildren; retain the current envelope expansion logic
and its existing behavior.
src/platform/input.js (2)

404-416: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

focusItemSoon bypasses the injected scheduler.

createApp builds a clock from the injected scheduler and passes it to createRenderer. It gives createInput the raw window instead. focusItemSoon therefore calls window.requestAnimationFrame directly, and createApp exposes a comparable helper focusNew that uses clock.nextFrame.

Two consequences follow. A test that injects a fake scheduler cannot drive this path. The callback can also run after destroy(), because destroy() removes listeners but does not cancel the frame. The callback is safe today, since layer.querySelector returns null after React unmounts the subtree.

Pass the scheduler into createInput and use nextFrame for consistency with the renderer.

🤖 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/platform/input.js` around lines 404 - 416, Update createInput and its
caller createApp to accept the injected scheduler, then change focusItemSoon to
schedule through the scheduler’s nextFrame method instead of
window.requestAnimationFrame. Preserve the existing focus and selection behavior
while ensuring tests can control the frame callback through the injected
scheduler.

360-364: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the deprecated API usage.

Add a short comment explaining that document.execCommand('insertText') is retained for cross-browser contenteditable insertion and native undo integration. No standardized cross-browser replacement provides the same behavior.

🤖 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/platform/input.js` around lines 360 - 364, Add a concise comment
immediately before the document.execCommand('insertText') call in onPaste,
documenting that this deprecated API is intentionally retained for cross-browser
contenteditable insertion and native undo integration because no standardized
replacement provides equivalent behavior.
src/platform/toolbar.js (1)

7-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The listen helper is duplicated.

src/platform/input.js defines the same helper at lines 34-37. That copy also accepts an options argument, which this copy omits. Two near-identical copies will drift.

Extract one helper into a shared module, for example src/platform/listeners.js, and import it in both files.

♻️ Proposed extraction

New file src/platform/listeners.js:

/** Collect event listeners so a module can remove all of them at once. */
export function createListenerBag() {
  const offs = [];
  return {
    listen(target, type, fn, options) {
      target.addEventListener(type, fn, options);
      offs.push(() => target.removeEventListener(type, fn, options));
    },
    add: (off) => offs.push(off),
    removeAll() {
      for (const off of offs) off();
      offs.length = 0;
    },
  };
}

Then in this file:

+import { createListenerBag } from './listeners.js';
+
 export function createToolbar({ window, elements, store, viewport, commands }) {
   const { toolbar, zoomLabel } = elements;
-  const listeners = [];
-  const listen = (target, type, fn) => {
-    target.addEventListener(type, fn);
-    listeners.push(() => target.removeEventListener(type, fn));
-  };
+  const bag = createListenerBag();
+  const listen = bag.listen;
🤖 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/platform/toolbar.js` around lines 7 - 11, Extract the duplicated listen
helper into a shared listener-bag module, preserving support for the options
argument and centralized cleanup behavior. Update the toolbar module’s listener
setup and the corresponding helper in input.js to import and use the shared
createListenerBag implementation, removing their local copies.
src/core/viewport.js (1)

78-84: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

fit can exceed the instance scale limit.

The clamp uses the maxScale option, which defaults to 1.5. It does not consider this.maxScale. Every other scale path (zoomAt, setScaleAt) respects this.maxScale. If a caller constructs Viewport with maxScale below 1.5, fit can set a scale above that limit.

The default construction in src/app.js uses MAX_SCALE = 6, so the current behavior is correct. Bound the option to the instance limit to keep the invariant true for any construction.

♻️ Proposed change
     const scale = clamp(
       Math.min((w - padding * 2) / rect.w, (h - padding * 2) / rect.h),
       this.minScale,
-      maxScale,
+      Math.min(maxScale, this.maxScale),
     );
🤖 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/viewport.js` around lines 78 - 84, Update the scale clamp in
Viewport.fit so its upper bound cannot exceed this.maxScale: combine the
maxScale option with the instance limit before passing it to clamp. Preserve the
existing default option and ensure fit respects the same instance-wide maximum
enforced by zoomAt and setScaleAt.
test/helpers/vite.js (1)

13-14: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use fileURLToPath for filesystem paths.

If contributors run this test helper on Windows, .pathname can produce /C:/... instead of a platform filesystem path. Convert both URLs with fileURLToPath, or document Linux-only support.

Proposed fix
 import { createServer } from 'vite';
+import { fileURLToPath } from 'node:url';

 export async function startDevServer() {
   const server = await createServer({
-    configFile: new URL('../../vite.config.js', import.meta.url).pathname,
-    root: new URL('../../', import.meta.url).pathname,
+    configFile: fileURLToPath(new URL('../../vite.config.js', import.meta.url)),
+    root: fileURLToPath(new URL('../../', import.meta.url)),
🤖 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/helpers/vite.js` around lines 13 - 14, Update the Vite configuration in
the test helper to convert both URL values used by configFile and root through
fileURLToPath instead of reading .pathname directly, preserving the existing
target paths while supporting Windows filesystem path conventions.
🤖 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 `@index.html`:
- Line 5: Update the viewport meta tag in index.html to remove the maximum-scale
and user-scalable directives, leaving width=device-width and initial-scale=1
unchanged so browser page zoom remains available.

In `@README.md`:
- Around line 6-11: Update both fenced command blocks in README.md, including
the blocks near the npm command examples, to specify the sh language identifier
after the opening fence. Leave the command contents unchanged.

In `@src/App.jsx`:
- Around line 9-10: Ensure BoardPage synchronizes board-specific state when the
boardId route parameter changes, rather than retaining the previous board’s
title. Update the title input from defaultValue to controlled state or remount
BoardPage using boardId, and add coverage for navigating directly between two
board routes.

In `@src/core/ids.js`:
- Around line 5-9: Correct the explanatory comment above the 12-character ID
width to use the birthday-bound figures: 48 bits yields approximately 1.8 × 10⁻³
collision probability at one million IDs (about 1 in 560), while the
below-one-in-a-million threshold is around 24,000 IDs; preserve the existing
rationale for choosing 12 hex characters without changing implementation.

In `@src/core/store.js`:
- Around line 137-143: Update the storage validation in the load path around
store.load and the board record checks to accept only supported v values and
object entries that are valid records with usable ids; reject malformed data
before constructing the Map. Ensure invalid input is handled as a rejected load
rather than allowing load to throw while reading o.id.

In `@src/platform/input.js`:
- Around line 52-58: Update onPointerDown to end any existing gesture before
starting a new one, ensuring startPan, startDrag, and startMarquee cannot
overwrite active gesture state. Extract the shared active-gesture teardown into
a helper and reuse it from onPointerUp, preserving each gesture’s end() behavior
such as marquee cleanup and drag history recording.

In `@src/routes/BoardListPage.jsx`:
- Around line 23-26: Update create in BoardListPage so it checks the boolean
result of repository.save before navigating; on failure, show a storage error
and remain on the board list, while preserving navigation only for successful
saves.

In `@src/routes/BoardPage.jsx`:
- Around line 11-19: Update rename in BoardPage so the displayed title is
changed only after repository.rename succeeds; when rename returns false for an
unstored board, persist the current board snapshot with the trimmed title
instead. Reuse the existing onReady canvas-app state rather than removing or
altering handleReady, and report completion only after either repository write
succeeds.

In `@test/browser/shell.test.js`:
- Around line 35-40: Remove the fixed page.sleep call from clickOn and keep that
helper limited to dispatching clicks. Update each affected test to use
page.waitFor with the specific expected route, canvas readiness, or rendered
title before assertions, preserving the existing test flow while eliminating
timing-based waits.

In `@test/helpers/browser.js`:
- Around line 74-86: Update the browser helper’s goto readiness selection so the
default ready expression is derived from the target route `to`, while preserving
an explicitly supplied `readyWhen`. Ensure the initial navigation passes the
custom readiness condition explicitly, and subsequent calls such as
`goto(LIST_PATH)` wait for the readiness condition associated with that
destination rather than the initial path.

In `@test/node/server.test.js`:
- Around line 23-25: Update the listen helper to reject when server.listen emits
an error, while continuing to resolve with the localhost URL on successful
listening. Attach the error handler before starting the server and remove it
once the listening callback succeeds.
- Around line 127-129: Update the cleanup logic in the test’s finally block to
attach the child process exit listener before calling child.kill('SIGINT'),
while also resolving immediately if the process has already exited via exitCode
or signalCode. Ensure cleanup cannot hang when startup fails.
- Around line 116-124: Update the startup wait around the child.stdout listener
and banner promise to accumulate chunks until the complete expected “PeerThink →
http://localhost:${port}” banner is present before resolving. Also reject the
promise when the child process emits an error or exits before the banner
arrives, while preserving the existing timeout and assertion.
- Around line 99-100: Strengthen the production-server test around the “defaults
to serving the build output” case: ensure the dist build is available before
spawning server.js, then assert the response status is exactly 200 and verify
the expected index content rather than only checking basename(PUBLIC_DIR) or
accepting any positive status. Preserve the existing server startup flow and use
the test’s existing build/server helpers.

In `@test/run.js`:
- Line 44: Update the test setup flow around startDevServer so Chrome startup
and test execution are wrapped in a finally block that always awaits
vite.close(). Track whether Chrome started before calling chrome.kill(), and
only kill it when initialization succeeded.

---

Nitpick comments:
In `@src/core/board.js`:
- Around line 163-182: No implementation change is required for
withEnvelopeChildren; retain the current envelope expansion logic and its
existing behavior.

In `@src/core/viewport.js`:
- Around line 78-84: Update the scale clamp in Viewport.fit so its upper bound
cannot exceed this.maxScale: combine the maxScale option with the instance limit
before passing it to clamp. Preserve the existing default option and ensure fit
respects the same instance-wide maximum enforced by zoomAt and setScaleAt.

In `@src/platform/input.js`:
- Around line 404-416: Update createInput and its caller createApp to accept the
injected scheduler, then change focusItemSoon to schedule through the
scheduler’s nextFrame method instead of window.requestAnimationFrame. Preserve
the existing focus and selection behavior while ensuring tests can control the
frame callback through the injected scheduler.
- Around line 360-364: Add a concise comment immediately before the
document.execCommand('insertText') call in onPaste, documenting that this
deprecated API is intentionally retained for cross-browser contenteditable
insertion and native undo integration because no standardized replacement
provides equivalent behavior.

In `@src/platform/toolbar.js`:
- Around line 7-11: Extract the duplicated listen helper into a shared
listener-bag module, preserving support for the options argument and centralized
cleanup behavior. Update the toolbar module’s listener setup and the
corresponding helper in input.js to import and use the shared createListenerBag
implementation, removing their local copies.

In `@test/helpers/vite.js`:
- Around line 13-14: Update the Vite configuration in the test helper to convert
both URL values used by configFile and root through fileURLToPath instead of
reading .pathname directly, preserving the existing target paths while
supporting Windows filesystem path conventions.
🪄 Autofix (Beta)

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: 6d2486ec-4132-46e2-9167-07725fc963b3

📥 Commits

Reviewing files that changed from the base of the PR and between ef9c72e and 128a0a0.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (53)
  • .github/workflows/ci.yml
  • .github/workflows/deploy.yml
  • .gitignore
  • README.md
  • index.html
  • package.json
  • public/index.html
  • public/js/main.js
  • server.js
  • src/App.jsx
  • src/app.js
  • src/components/BoardCanvas.jsx
  • src/core/autosave.js
  • src/core/board.js
  • src/core/geometry.js
  • src/core/ids.js
  • src/core/scheduler.js
  • src/core/seed.js
  • src/core/selection.js
  • src/core/store.js
  • src/core/viewport.js
  • src/main.jsx
  • src/platform/input.js
  • src/platform/renderer.js
  • src/platform/storage.js
  • src/platform/toolbar.js
  • src/platform/views.js
  • src/routes/BoardListPage.jsx
  • src/routes/BoardPage.jsx
  • src/shell/storage.js
  • src/styles/canvas.css
  • src/styles/shell.css
  • test/browser/app.test.js
  • test/browser/shell.test.js
  • test/coverage.js
  • test/fixtures/site/assets/app.js
  • test/fixtures/site/index.html
  • test/fixtures/site/style.css
  • test/helpers/browser.js
  • test/helpers/vite.js
  • test/node/autosave.test.js
  • test/node/board.test.js
  • test/node/geometry.test.js
  • test/node/ids.test.js
  • test/node/scheduler.test.js
  • test/node/seed.test.js
  • test/node/selection.test.js
  • test/node/server.test.js
  • test/node/storage.test.js
  • test/node/store.test.js
  • test/node/viewport.test.js
  • test/run.js
  • vite.config.js
💤 Files with no reviewable changes (2)
  • public/js/main.js
  • public/index.html

Comment thread index.html Outdated
Comment thread README.md Outdated
Comment thread src/App.jsx
Comment thread src/routes/BoardListPage.jsx
Comment thread src/routes/BoardPage.jsx Outdated
Comment thread test/node/server.test.js Outdated
Comment thread test/node/server.test.js
Comment thread test/node/server.test.js Outdated
Comment thread test/node/server.test.js Outdated
Comment thread test/run.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 15

🧹 Nitpick comments (6)
src/core/board.js (1)

163-182: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Note the quadratic cost of envelope expansion.

Each pass compares every id in out against every object in all, for up to MAX_NESTING passes. The worst case is 8 × n² containment tests. The method runs once per drag start, not per frame, so the current cost is acceptable for typical board sizes.

If board sizes grow, index objects by envelope or sort by area so each pass only tests plausible children. No change is required now.

🤖 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/board.js` around lines 163 - 182, No implementation change is
required for withEnvelopeChildren; retain the current envelope expansion logic
and its existing behavior.
src/platform/input.js (2)

404-416: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

focusItemSoon bypasses the injected scheduler.

createApp builds a clock from the injected scheduler and passes it to createRenderer. It gives createInput the raw window instead. focusItemSoon therefore calls window.requestAnimationFrame directly, and createApp exposes a comparable helper focusNew that uses clock.nextFrame.

Two consequences follow. A test that injects a fake scheduler cannot drive this path. The callback can also run after destroy(), because destroy() removes listeners but does not cancel the frame. The callback is safe today, since layer.querySelector returns null after React unmounts the subtree.

Pass the scheduler into createInput and use nextFrame for consistency with the renderer.

🤖 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/platform/input.js` around lines 404 - 416, Update createInput and its
caller createApp to accept the injected scheduler, then change focusItemSoon to
schedule through the scheduler’s nextFrame method instead of
window.requestAnimationFrame. Preserve the existing focus and selection behavior
while ensuring tests can control the frame callback through the injected
scheduler.

360-364: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the deprecated API usage.

Add a short comment explaining that document.execCommand('insertText') is retained for cross-browser contenteditable insertion and native undo integration. No standardized cross-browser replacement provides the same behavior.

🤖 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/platform/input.js` around lines 360 - 364, Add a concise comment
immediately before the document.execCommand('insertText') call in onPaste,
documenting that this deprecated API is intentionally retained for cross-browser
contenteditable insertion and native undo integration because no standardized
replacement provides equivalent behavior.
src/platform/toolbar.js (1)

7-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The listen helper is duplicated.

src/platform/input.js defines the same helper at lines 34-37. That copy also accepts an options argument, which this copy omits. Two near-identical copies will drift.

Extract one helper into a shared module, for example src/platform/listeners.js, and import it in both files.

♻️ Proposed extraction

New file src/platform/listeners.js:

/** Collect event listeners so a module can remove all of them at once. */
export function createListenerBag() {
  const offs = [];
  return {
    listen(target, type, fn, options) {
      target.addEventListener(type, fn, options);
      offs.push(() => target.removeEventListener(type, fn, options));
    },
    add: (off) => offs.push(off),
    removeAll() {
      for (const off of offs) off();
      offs.length = 0;
    },
  };
}

Then in this file:

+import { createListenerBag } from './listeners.js';
+
 export function createToolbar({ window, elements, store, viewport, commands }) {
   const { toolbar, zoomLabel } = elements;
-  const listeners = [];
-  const listen = (target, type, fn) => {
-    target.addEventListener(type, fn);
-    listeners.push(() => target.removeEventListener(type, fn));
-  };
+  const bag = createListenerBag();
+  const listen = bag.listen;
🤖 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/platform/toolbar.js` around lines 7 - 11, Extract the duplicated listen
helper into a shared listener-bag module, preserving support for the options
argument and centralized cleanup behavior. Update the toolbar module’s listener
setup and the corresponding helper in input.js to import and use the shared
createListenerBag implementation, removing their local copies.
src/core/viewport.js (1)

78-84: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

fit can exceed the instance scale limit.

The clamp uses the maxScale option, which defaults to 1.5. It does not consider this.maxScale. Every other scale path (zoomAt, setScaleAt) respects this.maxScale. If a caller constructs Viewport with maxScale below 1.5, fit can set a scale above that limit.

The default construction in src/app.js uses MAX_SCALE = 6, so the current behavior is correct. Bound the option to the instance limit to keep the invariant true for any construction.

♻️ Proposed change
     const scale = clamp(
       Math.min((w - padding * 2) / rect.w, (h - padding * 2) / rect.h),
       this.minScale,
-      maxScale,
+      Math.min(maxScale, this.maxScale),
     );
🤖 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/viewport.js` around lines 78 - 84, Update the scale clamp in
Viewport.fit so its upper bound cannot exceed this.maxScale: combine the
maxScale option with the instance limit before passing it to clamp. Preserve the
existing default option and ensure fit respects the same instance-wide maximum
enforced by zoomAt and setScaleAt.
test/helpers/vite.js (1)

13-14: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use fileURLToPath for filesystem paths.

If contributors run this test helper on Windows, .pathname can produce /C:/... instead of a platform filesystem path. Convert both URLs with fileURLToPath, or document Linux-only support.

Proposed fix
 import { createServer } from 'vite';
+import { fileURLToPath } from 'node:url';

 export async function startDevServer() {
   const server = await createServer({
-    configFile: new URL('../../vite.config.js', import.meta.url).pathname,
-    root: new URL('../../', import.meta.url).pathname,
+    configFile: fileURLToPath(new URL('../../vite.config.js', import.meta.url)),
+    root: fileURLToPath(new URL('../../', import.meta.url)),
🤖 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/helpers/vite.js` around lines 13 - 14, Update the Vite configuration in
the test helper to convert both URL values used by configFile and root through
fileURLToPath instead of reading .pathname directly, preserving the existing
target paths while supporting Windows filesystem path conventions.
🤖 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 `@index.html`:
- Line 5: Update the viewport meta tag in index.html to remove the maximum-scale
and user-scalable directives, leaving width=device-width and initial-scale=1
unchanged so browser page zoom remains available.

In `@README.md`:
- Around line 6-11: Update both fenced command blocks in README.md, including
the blocks near the npm command examples, to specify the sh language identifier
after the opening fence. Leave the command contents unchanged.

In `@src/App.jsx`:
- Around line 9-10: Ensure BoardPage synchronizes board-specific state when the
boardId route parameter changes, rather than retaining the previous board’s
title. Update the title input from defaultValue to controlled state or remount
BoardPage using boardId, and add coverage for navigating directly between two
board routes.

In `@src/core/ids.js`:
- Around line 5-9: Correct the explanatory comment above the 12-character ID
width to use the birthday-bound figures: 48 bits yields approximately 1.8 × 10⁻³
collision probability at one million IDs (about 1 in 560), while the
below-one-in-a-million threshold is around 24,000 IDs; preserve the existing
rationale for choosing 12 hex characters without changing implementation.

In `@src/core/store.js`:
- Around line 137-143: Update the storage validation in the load path around
store.load and the board record checks to accept only supported v values and
object entries that are valid records with usable ids; reject malformed data
before constructing the Map. Ensure invalid input is handled as a rejected load
rather than allowing load to throw while reading o.id.

In `@src/platform/input.js`:
- Around line 52-58: Update onPointerDown to end any existing gesture before
starting a new one, ensuring startPan, startDrag, and startMarquee cannot
overwrite active gesture state. Extract the shared active-gesture teardown into
a helper and reuse it from onPointerUp, preserving each gesture’s end() behavior
such as marquee cleanup and drag history recording.

In `@src/routes/BoardListPage.jsx`:
- Around line 23-26: Update create in BoardListPage so it checks the boolean
result of repository.save before navigating; on failure, show a storage error
and remain on the board list, while preserving navigation only for successful
saves.

In `@src/routes/BoardPage.jsx`:
- Around line 11-19: Update rename in BoardPage so the displayed title is
changed only after repository.rename succeeds; when rename returns false for an
unstored board, persist the current board snapshot with the trimmed title
instead. Reuse the existing onReady canvas-app state rather than removing or
altering handleReady, and report completion only after either repository write
succeeds.

In `@test/browser/shell.test.js`:
- Around line 35-40: Remove the fixed page.sleep call from clickOn and keep that
helper limited to dispatching clicks. Update each affected test to use
page.waitFor with the specific expected route, canvas readiness, or rendered
title before assertions, preserving the existing test flow while eliminating
timing-based waits.

In `@test/helpers/browser.js`:
- Around line 74-86: Update the browser helper’s goto readiness selection so the
default ready expression is derived from the target route `to`, while preserving
an explicitly supplied `readyWhen`. Ensure the initial navigation passes the
custom readiness condition explicitly, and subsequent calls such as
`goto(LIST_PATH)` wait for the readiness condition associated with that
destination rather than the initial path.

In `@test/node/server.test.js`:
- Around line 23-25: Update the listen helper to reject when server.listen emits
an error, while continuing to resolve with the localhost URL on successful
listening. Attach the error handler before starting the server and remove it
once the listening callback succeeds.
- Around line 127-129: Update the cleanup logic in the test’s finally block to
attach the child process exit listener before calling child.kill('SIGINT'),
while also resolving immediately if the process has already exited via exitCode
or signalCode. Ensure cleanup cannot hang when startup fails.
- Around line 116-124: Update the startup wait around the child.stdout listener
and banner promise to accumulate chunks until the complete expected “PeerThink →
http://localhost:${port}” banner is present before resolving. Also reject the
promise when the child process emits an error or exits before the banner
arrives, while preserving the existing timeout and assertion.
- Around line 99-100: Strengthen the production-server test around the “defaults
to serving the build output” case: ensure the dist build is available before
spawning server.js, then assert the response status is exactly 200 and verify
the expected index content rather than only checking basename(PUBLIC_DIR) or
accepting any positive status. Preserve the existing server startup flow and use
the test’s existing build/server helpers.

In `@test/run.js`:
- Line 44: Update the test setup flow around startDevServer so Chrome startup
and test execution are wrapped in a finally block that always awaits
vite.close(). Track whether Chrome started before calling chrome.kill(), and
only kill it when initialization succeeded.

---

Nitpick comments:
In `@src/core/board.js`:
- Around line 163-182: No implementation change is required for
withEnvelopeChildren; retain the current envelope expansion logic and its
existing behavior.

In `@src/core/viewport.js`:
- Around line 78-84: Update the scale clamp in Viewport.fit so its upper bound
cannot exceed this.maxScale: combine the maxScale option with the instance limit
before passing it to clamp. Preserve the existing default option and ensure fit
respects the same instance-wide maximum enforced by zoomAt and setScaleAt.

In `@src/platform/input.js`:
- Around line 404-416: Update createInput and its caller createApp to accept the
injected scheduler, then change focusItemSoon to schedule through the
scheduler’s nextFrame method instead of window.requestAnimationFrame. Preserve
the existing focus and selection behavior while ensuring tests can control the
frame callback through the injected scheduler.
- Around line 360-364: Add a concise comment immediately before the
document.execCommand('insertText') call in onPaste, documenting that this
deprecated API is intentionally retained for cross-browser contenteditable
insertion and native undo integration because no standardized replacement
provides equivalent behavior.

In `@src/platform/toolbar.js`:
- Around line 7-11: Extract the duplicated listen helper into a shared
listener-bag module, preserving support for the options argument and centralized
cleanup behavior. Update the toolbar module’s listener setup and the
corresponding helper in input.js to import and use the shared createListenerBag
implementation, removing their local copies.

In `@test/helpers/vite.js`:
- Around line 13-14: Update the Vite configuration in the test helper to convert
both URL values used by configFile and root through fileURLToPath instead of
reading .pathname directly, preserving the existing target paths while
supporting Windows filesystem path conventions.
🪄 Autofix (Beta)

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: 6d2486ec-4132-46e2-9167-07725fc963b3

📥 Commits

Reviewing files that changed from the base of the PR and between ef9c72e and 128a0a0.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (53)
  • .github/workflows/ci.yml
  • .github/workflows/deploy.yml
  • .gitignore
  • README.md
  • index.html
  • package.json
  • public/index.html
  • public/js/main.js
  • server.js
  • src/App.jsx
  • src/app.js
  • src/components/BoardCanvas.jsx
  • src/core/autosave.js
  • src/core/board.js
  • src/core/geometry.js
  • src/core/ids.js
  • src/core/scheduler.js
  • src/core/seed.js
  • src/core/selection.js
  • src/core/store.js
  • src/core/viewport.js
  • src/main.jsx
  • src/platform/input.js
  • src/platform/renderer.js
  • src/platform/storage.js
  • src/platform/toolbar.js
  • src/platform/views.js
  • src/routes/BoardListPage.jsx
  • src/routes/BoardPage.jsx
  • src/shell/storage.js
  • src/styles/canvas.css
  • src/styles/shell.css
  • test/browser/app.test.js
  • test/browser/shell.test.js
  • test/coverage.js
  • test/fixtures/site/assets/app.js
  • test/fixtures/site/index.html
  • test/fixtures/site/style.css
  • test/helpers/browser.js
  • test/helpers/vite.js
  • test/node/autosave.test.js
  • test/node/board.test.js
  • test/node/geometry.test.js
  • test/node/ids.test.js
  • test/node/scheduler.test.js
  • test/node/seed.test.js
  • test/node/selection.test.js
  • test/node/server.test.js
  • test/node/storage.test.js
  • test/node/store.test.js
  • test/node/viewport.test.js
  • test/run.js
  • vite.config.js
💤 Files with no reviewable changes (2)
  • public/js/main.js
  • public/index.html
🛑 Comments failed to post (3)
src/core/ids.js (1)

5-9: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the collision-probability claim.

The comment states that 48 bits keeps a collision "below one in a million out past a million objects". The birthday bound gives a different result. For n ids and 2^48 values, the collision probability is approximately n² / 2^49. At n = 1,000,000 that is about 1.8 × 10⁻³, or roughly 1 in 560. A probability below one in a million is reached at about 24,000 ids.

The 12-character width can still be the right choice. Update the justification so the stated margin matches the arithmetic.

📝 Proposed comment correction
- * 12 hex characters is 48 bits. Ids are minted independently by every client —
- * there is no coordinator to reject a duplicate — so the width has to survive
- * the birthday bound rather than the object count: 48 bits keeps a collision
- * below one in a million out past a million objects, where 32 bits would start
- * colliding in the tens of thousands.
+ * 12 hex characters is 48 bits. Ids are minted independently by every client —
+ * there is no coordinator to reject a duplicate — so the width has to survive
+ * the birthday bound rather than the object count. At 48 bits the collision
+ * probability is about n² / 2^49: under one in a million up to ~24k ids, and
+ * still under 0.2% at a million. 32 bits would pass 1% before 10k ids.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

 * 12 hex characters is 48 bits. Ids are minted independently by every client —
 * there is no coordinator to reject a duplicate — so the width has to survive
 * the birthday bound rather than the object count. At 48 bits the collision
 * probability is about n² / 2^49: under one in a million up to ~24k ids, and
 * still under 0.2% at a million. 32 bits would pass 1% before 10k ids.
🤖 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/ids.js` around lines 5 - 9, Correct the explanatory comment above
the 12-character ID width to use the birthday-bound figures: 48 bits yields
approximately 1.8 × 10⁻³ collision probability at one million IDs (about 1 in
560), while the below-one-in-a-million threshold is around 24,000 IDs; preserve
the existing rationale for choosing 12 hex characters without changing
implementation.
src/core/store.js (1)

137-143: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the storage repository for board-payload validation before store.load.
set -euo pipefail

fd -t f 'storage.js' src | while IFS= read -r f; do
  echo "===== $f ====="
  cat -n "$f"
done

echo "===== load callers ====="
rg -nP -C 4 '\bstore\.load\s*\(|\bload\s*\(\s*boardId' --glob '*.js' --glob '*.jsx'

Repository: boubou666/peerthink

Length of output: 7241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== candidate app and store files ====="
fd -t f -e js -e jsx src | sort | while IFS= read -r f; do
  if rg -q 'saved\.board|store\.load|repository\.load|function createApp|BoardCanvas|class .*Store|load\(data\)' "$f"; then
    echo "===== $f ====="
    rg -n -C 8 'saved\.board|store\.load|repository\.load|function createApp|BoardCanvas|class .*Store|load\(data\)' "$f"
  fi
done

echo "===== all repository load and store load references ====="
rg -n -C 5 '\.(load|list)\s*\(|\bload\s*\(\s*data\s*\)' src --glob '*.js' --glob '*.jsx'

echo "===== tests and package metadata ====="
fd -t f . | rg '(^|/)(package\.json|.*test.*|.*spec.*|.*store.*|.*storage.*)$' | sort

Repository: boubou666/peerthink

Length of output: 7332


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== src/app.js ====="
cat -n src/app.js | sed -n '1,125p'

echo "===== src/platform/storage.js validation and version references ====="
rg -n -C 6 'RECORD_VERSION|isBoard|JSON\.parse|return read|function createLocalStorageRepository|load\(id\)' src/platform/storage.js

echo "===== storage tests ====="
cat -n test/node/storage.test.js | sed -n '1,260p'

echo "===== version and board-shape references ====="
rg -n -C 3 '\b(v|RECORD_VERSION|objects|order)\b' src test --glob '*.js' --glob '*.jsx' | head -n 260

echo "===== standalone validation probe ====="
node - <<'JS'
const isBoard = (board) => Array.isArray(board?.objects) && Array.isArray(board?.order);
const records = [
  null,
  {},
  { board: {} },
  { board: { objects: [], order: [] } },
  { v: 0, board: { objects: [], order: [] } },
  { v: 1, board: { objects: [], order: [] } },
  { v: 2, board: { objects: [], order: [] } },
];
for (const record of records) {
  console.log(JSON.stringify(record), '=>', isBoard(record?.board) ? 'accepted by read' : 'rejected by read');
}
JS

Repository: boubou666/peerthink

Length of output: 32662


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'JS'
const isBoard = (board) => Array.isArray(board?.objects) && Array.isArray(board?.order);
const load = (data) => {
  const objects = new Map(data.objects.map((o) => [o.id, structuredClone(o)]));
  return data.order.filter((id) => objects.has(id));
};

const records = [
  { label: 'missing version', record: { board: { objects: [], order: [] } } },
  { label: 'newer version', record: { v: 2, board: { objects: [], order: [] } } },
  { label: 'null object', record: { v: 1, board: { objects: [null], order: [] } } },
  { label: 'primitive object', record: { v: 1, board: { objects: [7], order: [] } } },
  { label: 'valid object', record: { v: 1, board: { objects: [{ id: 'a' }], order: ['a'] } } },
];

for (const { label, record } of records) {
  const accepted = isBoard(record.board);
  let result = 'not passed to load';
  if (accepted) {
    try {
      result = `load completed with ${load(record.board).length} ordered object(s)`;
    } catch (error) {
      result = `${error.name}: ${error.message}`;
    }
  }
  console.log(`${label}: ${accepted ? 'accepted by repository predicate' : 'rejected'}; ${result}`);
}
JS

Repository: boubou666/peerthink

Length of output: 621


Reject malformed object entries before store.load. src/platform/storage.js checks only that board.objects and board.order are arrays. A record with objects: [null] passes this check, then load throws when it reads o.id. The repository also accepts unsupported v values. Validate the record version and object entries, or make load reject invalid data without throwing.

🤖 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/store.js` around lines 137 - 143, Update the storage validation in
the load path around store.load and the board record checks to accept only
supported v values and object entries that are valid records with usable ids;
reject malformed data before constructing the Map. Ensure invalid input is
handled as a rejected load rather than allowing load to throw while reading
o.id.
src/platform/input.js (1)

52-58: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A second pointerdown replaces the active gesture without ending it.

startPan, startDrag, and startMarquee each assign gesture directly. onPointerDown does not check whether a gesture is already active. A second button press during an active gesture discards the first gesture object without calling its end().

The marquee case leaves visible state behind. startMarquee appends #marquee to overlay and removes it only in end(). Reproduce it as follows:

  1. Press the left button on empty space. The marquee starts.
  2. Press the right button without releasing the left button. onPointerDown calls startPan, which overwrites gesture.
  3. Release. onPointerUp runs the pan end(), which is empty.

The #marquee element stays in the overlay until the next marquee gesture appends a second one. The drag case is less visible but also drops the pushHistory call in its end(), so the move is not undoable.

Multi-button presses and multi-touch both reach this path. End the active gesture before starting a new one.

🐛 Proposed fix

Add a helper that ends any active gesture, then call it at the top of onPointerDown.

   function onPointerDown(e) {
+    endGesture(e); // a second press must not orphan the active gesture
     if (e.button === 1 || e.button === 2 || spaceDown) {
       e.preventDefault();
       startPan(e);
       return;
     }

Extract the shared teardown so onPointerUp reuses it:

+  function endGesture(e) {
+    if (!gesture) return;
+    const active = gesture;
+    gesture = null;
+    active.end(e);
+    stage.classList.remove('panning');
+  }
+
   function onPointerUp(e) {
-    if (!gesture) return;
-    const active = gesture;
-    gesture = null;
-    active.end(e);
-    stage.classList.remove('panning');
+    endGesture(e);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  function endGesture(e) {
    if (!gesture) return;
    const active = gesture;
    gesture = null;
    active.end(e);
    stage.classList.remove('panning');
  }

  function onPointerDown(e) {
    endGesture(e); // a second press must not orphan the active gesture
    if (e.button === 1 || e.button === 2 || spaceDown) {
      e.preventDefault();
      startPan(e);
      return;
    }
    if (e.button !== 0) return;
  }

  function onPointerUp(e) {
    endGesture(e);
  }
🤖 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/platform/input.js` around lines 52 - 58, Update onPointerDown to end any
existing gesture before starting a new one, ensuring startPan, startDrag, and
startMarquee cannot overwrite active gesture state. Extract the shared
active-gesture teardown into a helper and reuse it from onPointerUp, preserving
each gesture’s end() behavior such as marquee cleanup and drag history
recording.

Janacek added 2 commits August 4, 2026 02:46
BoardPage never resynced when only :boardId changed. The router reuses the
component across that navigation, so a title read once at mount and pinned
through defaultValue kept showing the previous board's name over the new
board's canvas. The field is now controlled, with a draft/committed split,
and both follow the parameter — an uncontrolled input ignores defaultValue
after mount, which is why the first attempt at this fix did not work.

BoardListPage navigated after a save that returned false. That opens a route
with no stored record, which createApp reads as a first visit and seeds — so
a failed creation handed back a board full of starter content and then
autosaved it. It now reports the failure and stays on the list.

Renaming a board that had never been stored lost the name. On a first visit
the seeded board lives only in memory until autosave fires, so rename() has
no record to touch and returned false while the UI updated anyway; the next
autosave then wrote the default title back. A failed rename now writes the
record itself.

goto() picked its readiness condition from the path the session was opened
with rather than the path it was navigating to, so a canvas session moving
to the list would have waited for window.app on a route that deliberately
destroys it. Readiness now comes from the target.

Also: browser zoom is no longer disabled — the canvas has its own zoom, the
board list does not, and pinch on the stage is already handled by
touch-action. Remaining fixed sleeps in the shell tests are gone, and the
title-field tests drive real key input, which a controlled field requires.

273 tests, 99.6% line coverage.
The spawn test resolved on the first stdout chunk and then asserted the
whole banner matched it. A data event is not a line boundary, so a server
that started perfectly well could fail the assertion on a split write. It
now buffers until the expected text appears, and rejects on early exit or
spawn error rather than waiting out the timeout.

Cleanup killed the child before attaching the exit listener, so an
already-dead child would never fire it and the finally block would hang. The
listener is attached first, and resolves immediately when exitCode or
signalCode is already set.

The same test only checked that PUBLIC_DIR ends in dist and that the status
was positive — which a 404 satisfies. It now builds dist when it is missing,
since npm test runs before npm run build in CI, and asserts a real 200 whose
body contains the application root.

listen() resolved on success but never rejected, so a bind failure hung the
setup instead of failing it.

test/run.js had no try/finally around the run, so a throw leaked Chromium
and its profile lock — which surfaces on the *next* run as a message about
the debugging port that says nothing about the real cause.

Verified with dist/ deleted first, so the build-if-missing path runs rather
than being assumed. 273 tests, 99.6% line coverage.
@boubou666

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

♻️ Duplicate comments (1)
test/run.js (1)

44-44: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Put resource acquisition inside the cleanup scope.

startDevServer() runs at Line 44, before the try at Line 71. A failure during Chromium startup or other setup can skip both cleanup paths. The finally block also assumes that chrome exists. Acquire Vite and Chromium inside an outer try/finally, initialize their handles to null, and close only initialized resources. This is the same unresolved cleanup issue reported in the previous review.

Also applies to: 67-83

🤖 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/run.js` at line 44, Move the startDevServer() acquisition and Chromium
setup into an outer try/finally cleanup scope, initialize vite, appBase, and
chrome handles to null, and close each resource only when it was successfully
initialized. Ensure setup failures still reach cleanup while preserving the
existing test execution flow.
🧹 Nitpick comments (6)
test/node/server.test.js (1)

8-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep Vite out of the test runtime.

Line 8 imports an npm package from a test/** file. Lines 155-160 use it to prepare test input. Build dist/ in a pre-test script or CI step, then keep this suite on Node built-ins. If that is not possible, document an approved exception.

As per path instructions, “test/**: Zero-dependency suite: node:test plus raw CDP over Node's built-in WebSocket. Flag added npm dependencies.”

Also applies to: 155-160

🤖 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/node/server.test.js` at line 8, Remove the Vite import and the
corresponding build usage in the test setup around the affected preparation
logic, keeping test/node/server.test.js dependent only on Node built-ins. Move
dist/ generation into the existing pre-test script or CI workflow; if that
cannot be done, document the approved exception instead.

Source: Path instructions

src/core/autosave.js (1)

9-9: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider surfacing a failed autosave.

repository.save returns false when storage is full or unavailable. flush drops that result, so the user gets no signal that edits stopped persisting. BoardListPage already reports a failed board creation, so the shell has a place to show this.

An onError callback on createAutosave would let BoardCanvas or BoardPage render the same warning.

🤖 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/autosave.js` at line 9, Update createAutosave and its flush function
to inspect the boolean result from repository.save and invoke an optional
onError callback when saving fails. Thread this callback through the BoardCanvas
or BoardPage integration so the shell displays the existing failed-operation
warning consistently with BoardListPage.
src/platform/storage.js (1)

95-106: 🗄️ Data Integrity & Integration | 🔵 Trivial

Consider how two tabs on one board interact.

save reads the existing record, then writes a complete replacement. Web Storage offers no transaction, so two tabs open on the same board each write their own full snapshot, and the later write discards the other tab's edits. Autosave makes this automatic rather than user-driven.

The storage event is the usual mitigation: a tab listens for changes to its own key and either reloads the board or warns the user. That work belongs in the shell rather than in this file, so treat this as a follow-up for the persistence design instead of a change to this diff.

🤖 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/platform/storage.js` around lines 95 - 106, Keep save in
src/platform/storage.js unchanged; treat cross-tab concurrent writes as a
follow-up persistence concern. Implement any storage-event listener and reload
or warning behavior in the shell layer, outside save and this storage module.
src/core/board.js (1)

72-81: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Regenerate list item ids in duplicate.

structuredClone(src) copies the items array of a list, including each item id. The clone receives a new object id, but its rows keep the source rows' ids. Two list objects then hold rows with identical ids.

No current caller breaks, because toggleItem, onInput, and focusItemSoon all scope the row lookup by object id first. The duplicate rows still violate the uniqueness that newItem establishes, so any later code that indexes rows across objects would mix them up.

♻️ Proposed fix to mint new row ids
   duplicate(offset = 24) {
     if (!this.selection.size) return [];
     const clones = this.selection.list().map((id) => {
       const src = this.store.get(id);
-      return { ...structuredClone(src), id: this.newId(), x: src.x + offset, y: src.y + offset };
+      const clone = { ...structuredClone(src), id: this.newId(), x: src.x + offset, y: src.y + offset };
+      if (Array.isArray(clone.items)) {
+        clone.items = clone.items.map((item) => ({ ...item, id: this.newId() }));
+      }
+      return clone;
     });
🤖 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/board.js` around lines 72 - 81, Update Board.duplicate to regenerate
ids for every item in a cloned list, rather than retaining the ids copied by
structuredClone(src). Preserve each item’s other fields while assigning a fresh
id through the same uniqueness mechanism used by newItem, and ensure the cloned
object’s items contain only the newly minted row ids.
src/platform/input.js (1)

359-364: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the execCommand('insertText') compatibility assumption.

execCommand is deprecated, but insertText remains supported in current Chrome, Firefox, and Safari and preserves the native undo stack. Add a short comment explaining why this call remains. The repository has no declared browser matrix, and CI tests only Chromium; document the tested browser target or define the supported browsers.

🤖 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/platform/input.js` around lines 359 - 364, Add a concise comment
immediately before the execCommand('insertText') call in onPaste explaining that
insertText is retained for current Chrome, Firefox, and Safari compatibility and
native undo-stack preservation, and note the tested Chromium target or
repository-supported browser scope. Leave the paste behavior unchanged.
src/app.js (1)

20-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Validate elements at the createApp boundary. Check elements and its six required refs before constructing the renderer, input, and toolbar. The only source caller and all test callers pass elements; remaining getElementById calls are unrelated DOM queries, not fallback usage.

🤖 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 20 - 26, Update createApp to validate elements and
all six required refs immediately at its boundary, before constructing the
renderer, input, or toolbar. Reject missing or invalid elements explicitly,
while preserving the existing caller-provided refs and unrelated getElementById
queries.
🤖 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 @.github/workflows/deploy.yml:
- Around line 3-4: Update the deployment comment near the workflow
trigger/condition to document both automatic publishing after successful CI and
manual publishing via workflow_dispatch, unless the intended policy requires CI
success for every deployment, in which case remove workflow_dispatch from the
condition.
- Around line 42-49: Update the actions/checkout@v4 step in the deployment
workflow to set its ref to `${{ github.event.workflow_run.head_sha || github.sha
}}`. Keep the existing build and Pages artifact steps unchanged so workflow_run
deployments build the commit validated by CI.

In `@README.md`:
- Line 160: Update the React Router entry in the README dependency table to
remove the broad “v7 carries a CSRF advisory” rationale or replace it with a
precise reference to GHSA-qwww-vcr4-c8h2, its unstable RSC APIs, affected
version ranges, and patched versions 7.18.2 and 8.3.0.

In `@src/core/autosave.js`:
- Around line 8-13: Update the debounce flow used by createAutosave so
scheduler.debounce exposes a cancel handle for its pending timer, and have the
returned stop function remove the store listener and cancel that timer. Preserve
the existing flush behavior and return shape in createAutosave, while ensuring
stopped autosaves cannot execute a previously scheduled save.

In `@src/platform/input.js`:
- Around line 100-106: Update the pointer event wiring near the existing
pointerdown, pointermove, and pointerup registrations to also register
pointercancel with onPointerUp, reusing that handler’s gesture cleanup so
cancelled gestures clear gesture, end the active interaction, and remove the
panning state and marquee overlay.
- Around line 420-457: Update onKeyDown to return before board shortcut handling
when e.target is a form control, including input, textarea, select, or a
content-editable element. Preserve the existing Space handling only for non-form
targets, while retaining shortcuts for canvas and other non-editable targets.

In `@src/platform/renderer.js`:
- Around line 29-52: Guard the views[obj.type] lookup in createElement and
updateElement, and make createElement return null for unknown types instead of
throwing. Update sync to skip objects whose createElement result is null, while
preserving normal creation and update behavior for recognized types.

In `@src/platform/toolbar.js`:
- Around line 27-32: Update the keydown handler around the existing
e.target.isContentEditable guard to also ignore standard form controls,
including input elements such as the board title, before evaluating the ! and )
shortcuts. Preserve shortcut handling for canvas and other non-form targets.

In `@src/routes/BoardPage.jsx`:
- Around line 58-63: Update the title input’s onKeyDown handler so the Escape
branch marks the edit as cancelled before calling e.currentTarget.blur(),
allowing the nested onBlur handler to skip persisting the cancelled title;
preserve the existing draft reset and Enter behavior.

In `@test/browser/shell.test.js`:
- Around line 47-49: Update the browser test teardown wait condition around
onList and newBoard so it waits until window.app is absent and `#stage` no longer
exists before asserting unmount. Keep the existing .shell presence check only
where needed to confirm the list view, and ensure the unmount assertions run
after canvas teardown completes.

In `@test/coverage.js`:
- Around line 15-16: Update the coverage path handling around isMeasured so
paths from relative() are normalized by replacing backslashes with forward
slashes before filtering. Preserve the existing server.js and src JavaScript/JSX
matching rules after normalization.

In `@test/helpers/browser.js`:
- Around line 81-94: Update the goto method’s about:blank navigation flow to
wait until the blank document is committed, using the Page.frameNavigated event
or an equivalent committed-document condition, before issuing the appBase + to
navigation. Keep the existing readiness polling and timeout behavior unchanged.

In `@test/helpers/vite.js`:
- Around line 13-14: Update the Vite configuration options in the test helper to
convert both new URL values through fileURLToPath() instead of reading .pathname
directly. Apply this to configFile and root, preserving their existing URL
targets while producing native paths on Windows and decoding percent-encoded
characters.
- Line 1: Document Vite as an explicit orchestration exception for the test
suite, covering the dependencies in test/helpers/vite.js, test/run.js, and
test/node/server.test.js; alternatively, move the Vite-dependent code outside
test/** while preserving the existing test behavior.
- Around line 19-23: Update the Vite server setup flow after server.listen so a
missing server.resolvedUrls?.local?.[0] closes the server before throwing the
existing error. Preserve the successful return path in the helper that returns {
server, base }.

In `@test/node/server.test.js`:
- Around line 46-74: Update waitForOutput so child-process errors use a
dedicated handler that calls cleanup() before rejecting, rather than passing
reject directly to child.on('error'). Ensure teardown also resolves or completes
when the child emits error or close, preventing the timeout and pending teardown
from remaining active after a spawn failure.

In `@test/run.js`:
- Around line 72-79: Update the Promise wrapping child-process creation to
accept both resolve and reject, and register a one-time error handler on proc
that calls reject when spawn fails. Preserve the existing exit resolution while
ensuring the surrounding finally cleanup runs for rejected spawn attempts.

---

Duplicate comments:
In `@test/run.js`:
- Line 44: Move the startDevServer() acquisition and Chromium setup into an
outer try/finally cleanup scope, initialize vite, appBase, and chrome handles to
null, and close each resource only when it was successfully initialized. Ensure
setup failures still reach cleanup while preserving the existing test execution
flow.

---

Nitpick comments:
In `@src/app.js`:
- Around line 20-26: Update createApp to validate elements and all six required
refs immediately at its boundary, before constructing the renderer, input, or
toolbar. Reject missing or invalid elements explicitly, while preserving the
existing caller-provided refs and unrelated getElementById queries.

In `@src/core/autosave.js`:
- Line 9: Update createAutosave and its flush function to inspect the boolean
result from repository.save and invoke an optional onError callback when saving
fails. Thread this callback through the BoardCanvas or BoardPage integration so
the shell displays the existing failed-operation warning consistently with
BoardListPage.

In `@src/core/board.js`:
- Around line 72-81: Update Board.duplicate to regenerate ids for every item in
a cloned list, rather than retaining the ids copied by structuredClone(src).
Preserve each item’s other fields while assigning a fresh id through the same
uniqueness mechanism used by newItem, and ensure the cloned object’s items
contain only the newly minted row ids.

In `@src/platform/input.js`:
- Around line 359-364: Add a concise comment immediately before the
execCommand('insertText') call in onPaste explaining that insertText is retained
for current Chrome, Firefox, and Safari compatibility and native undo-stack
preservation, and note the tested Chromium target or repository-supported
browser scope. Leave the paste behavior unchanged.

In `@src/platform/storage.js`:
- Around line 95-106: Keep save in src/platform/storage.js unchanged; treat
cross-tab concurrent writes as a follow-up persistence concern. Implement any
storage-event listener and reload or warning behavior in the shell layer,
outside save and this storage module.

In `@test/node/server.test.js`:
- Line 8: Remove the Vite import and the corresponding build usage in the test
setup around the affected preparation logic, keeping test/node/server.test.js
dependent only on Node built-ins. Move dist/ generation into the existing
pre-test script or CI workflow; if that cannot be done, document the approved
exception instead.
🪄 Autofix (Beta)

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: 49940a2d-4368-47cf-9a8f-967686c48951

📥 Commits

Reviewing files that changed from the base of the PR and between ef9c72e and 6bf3fd8.

⛔ Files ignored due to path filters (5)
  • dist/assets/index-CGKfOYFM.js is excluded by !**/dist/**
  • dist/assets/index-CGKfOYFM.js.map is excluded by !**/dist/**, !**/*.map
  • dist/assets/index-DUTNQzWv.css is excluded by !**/dist/**
  • dist/index.html is excluded by !**/dist/**
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (53)
  • .github/workflows/ci.yml
  • .github/workflows/deploy.yml
  • .gitignore
  • README.md
  • index.html
  • package.json
  • public/index.html
  • public/js/main.js
  • server.js
  • src/App.jsx
  • src/app.js
  • src/components/BoardCanvas.jsx
  • src/core/autosave.js
  • src/core/board.js
  • src/core/geometry.js
  • src/core/ids.js
  • src/core/scheduler.js
  • src/core/seed.js
  • src/core/selection.js
  • src/core/store.js
  • src/core/viewport.js
  • src/main.jsx
  • src/platform/input.js
  • src/platform/renderer.js
  • src/platform/storage.js
  • src/platform/toolbar.js
  • src/platform/views.js
  • src/routes/BoardListPage.jsx
  • src/routes/BoardPage.jsx
  • src/shell/storage.js
  • src/styles/canvas.css
  • src/styles/shell.css
  • test/browser/app.test.js
  • test/browser/shell.test.js
  • test/coverage.js
  • test/fixtures/site/assets/app.js
  • test/fixtures/site/index.html
  • test/fixtures/site/style.css
  • test/helpers/browser.js
  • test/helpers/vite.js
  • test/node/autosave.test.js
  • test/node/board.test.js
  • test/node/geometry.test.js
  • test/node/ids.test.js
  • test/node/scheduler.test.js
  • test/node/seed.test.js
  • test/node/selection.test.js
  • test/node/server.test.js
  • test/node/storage.test.js
  • test/node/store.test.js
  • test/node/viewport.test.js
  • test/run.js
  • vite.config.js
💤 Files with no reviewable changes (2)
  • public/index.html
  • public/js/main.js

Comment thread .github/workflows/deploy.yml Outdated
Comment thread .github/workflows/deploy.yml
Comment thread README.md Outdated
Comment thread src/routes/BoardPage.jsx
Comment thread test/browser/shell.test.js
Comment thread test/helpers/vite.js Outdated
Comment thread test/helpers/vite.js Outdated
Comment thread test/helpers/vite.js
Comment thread test/node/server.test.js
Comment thread test/run.js Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 17

♻️ Duplicate comments (1)
test/run.js (1)

44-44: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Put resource acquisition inside the cleanup scope.

startDevServer() runs at Line 44, before the try at Line 71. A failure during Chromium startup or other setup can skip both cleanup paths. The finally block also assumes that chrome exists. Acquire Vite and Chromium inside an outer try/finally, initialize their handles to null, and close only initialized resources. This is the same unresolved cleanup issue reported in the previous review.

Also applies to: 67-83

🤖 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/run.js` at line 44, Move the startDevServer() acquisition and Chromium
setup into an outer try/finally cleanup scope, initialize vite, appBase, and
chrome handles to null, and close each resource only when it was successfully
initialized. Ensure setup failures still reach cleanup while preserving the
existing test execution flow.
🧹 Nitpick comments (6)
test/node/server.test.js (1)

8-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep Vite out of the test runtime.

Line 8 imports an npm package from a test/** file. Lines 155-160 use it to prepare test input. Build dist/ in a pre-test script or CI step, then keep this suite on Node built-ins. If that is not possible, document an approved exception.

As per path instructions, “test/**: Zero-dependency suite: node:test plus raw CDP over Node's built-in WebSocket. Flag added npm dependencies.”

Also applies to: 155-160

🤖 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/node/server.test.js` at line 8, Remove the Vite import and the
corresponding build usage in the test setup around the affected preparation
logic, keeping test/node/server.test.js dependent only on Node built-ins. Move
dist/ generation into the existing pre-test script or CI workflow; if that
cannot be done, document the approved exception instead.

Source: Path instructions

src/core/autosave.js (1)

9-9: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider surfacing a failed autosave.

repository.save returns false when storage is full or unavailable. flush drops that result, so the user gets no signal that edits stopped persisting. BoardListPage already reports a failed board creation, so the shell has a place to show this.

An onError callback on createAutosave would let BoardCanvas or BoardPage render the same warning.

🤖 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/autosave.js` at line 9, Update createAutosave and its flush function
to inspect the boolean result from repository.save and invoke an optional
onError callback when saving fails. Thread this callback through the BoardCanvas
or BoardPage integration so the shell displays the existing failed-operation
warning consistently with BoardListPage.
src/platform/storage.js (1)

95-106: 🗄️ Data Integrity & Integration | 🔵 Trivial

Consider how two tabs on one board interact.

save reads the existing record, then writes a complete replacement. Web Storage offers no transaction, so two tabs open on the same board each write their own full snapshot, and the later write discards the other tab's edits. Autosave makes this automatic rather than user-driven.

The storage event is the usual mitigation: a tab listens for changes to its own key and either reloads the board or warns the user. That work belongs in the shell rather than in this file, so treat this as a follow-up for the persistence design instead of a change to this diff.

🤖 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/platform/storage.js` around lines 95 - 106, Keep save in
src/platform/storage.js unchanged; treat cross-tab concurrent writes as a
follow-up persistence concern. Implement any storage-event listener and reload
or warning behavior in the shell layer, outside save and this storage module.
src/core/board.js (1)

72-81: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Regenerate list item ids in duplicate.

structuredClone(src) copies the items array of a list, including each item id. The clone receives a new object id, but its rows keep the source rows' ids. Two list objects then hold rows with identical ids.

No current caller breaks, because toggleItem, onInput, and focusItemSoon all scope the row lookup by object id first. The duplicate rows still violate the uniqueness that newItem establishes, so any later code that indexes rows across objects would mix them up.

♻️ Proposed fix to mint new row ids
   duplicate(offset = 24) {
     if (!this.selection.size) return [];
     const clones = this.selection.list().map((id) => {
       const src = this.store.get(id);
-      return { ...structuredClone(src), id: this.newId(), x: src.x + offset, y: src.y + offset };
+      const clone = { ...structuredClone(src), id: this.newId(), x: src.x + offset, y: src.y + offset };
+      if (Array.isArray(clone.items)) {
+        clone.items = clone.items.map((item) => ({ ...item, id: this.newId() }));
+      }
+      return clone;
     });
🤖 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/board.js` around lines 72 - 81, Update Board.duplicate to regenerate
ids for every item in a cloned list, rather than retaining the ids copied by
structuredClone(src). Preserve each item’s other fields while assigning a fresh
id through the same uniqueness mechanism used by newItem, and ensure the cloned
object’s items contain only the newly minted row ids.
src/platform/input.js (1)

359-364: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the execCommand('insertText') compatibility assumption.

execCommand is deprecated, but insertText remains supported in current Chrome, Firefox, and Safari and preserves the native undo stack. Add a short comment explaining why this call remains. The repository has no declared browser matrix, and CI tests only Chromium; document the tested browser target or define the supported browsers.

🤖 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/platform/input.js` around lines 359 - 364, Add a concise comment
immediately before the execCommand('insertText') call in onPaste explaining that
insertText is retained for current Chrome, Firefox, and Safari compatibility and
native undo-stack preservation, and note the tested Chromium target or
repository-supported browser scope. Leave the paste behavior unchanged.
src/app.js (1)

20-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Validate elements at the createApp boundary. Check elements and its six required refs before constructing the renderer, input, and toolbar. The only source caller and all test callers pass elements; remaining getElementById calls are unrelated DOM queries, not fallback usage.

🤖 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 20 - 26, Update createApp to validate elements and
all six required refs immediately at its boundary, before constructing the
renderer, input, or toolbar. Reject missing or invalid elements explicitly,
while preserving the existing caller-provided refs and unrelated getElementById
queries.
🤖 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 @.github/workflows/deploy.yml:
- Around line 3-4: Update the deployment comment near the workflow
trigger/condition to document both automatic publishing after successful CI and
manual publishing via workflow_dispatch, unless the intended policy requires CI
success for every deployment, in which case remove workflow_dispatch from the
condition.
- Around line 42-49: Update the actions/checkout@v4 step in the deployment
workflow to set its ref to `${{ github.event.workflow_run.head_sha || github.sha
}}`. Keep the existing build and Pages artifact steps unchanged so workflow_run
deployments build the commit validated by CI.

In `@README.md`:
- Line 160: Update the React Router entry in the README dependency table to
remove the broad “v7 carries a CSRF advisory” rationale or replace it with a
precise reference to GHSA-qwww-vcr4-c8h2, its unstable RSC APIs, affected
version ranges, and patched versions 7.18.2 and 8.3.0.

In `@src/core/autosave.js`:
- Around line 8-13: Update the debounce flow used by createAutosave so
scheduler.debounce exposes a cancel handle for its pending timer, and have the
returned stop function remove the store listener and cancel that timer. Preserve
the existing flush behavior and return shape in createAutosave, while ensuring
stopped autosaves cannot execute a previously scheduled save.

In `@src/platform/input.js`:
- Around line 100-106: Update the pointer event wiring near the existing
pointerdown, pointermove, and pointerup registrations to also register
pointercancel with onPointerUp, reusing that handler’s gesture cleanup so
cancelled gestures clear gesture, end the active interaction, and remove the
panning state and marquee overlay.
- Around line 420-457: Update onKeyDown to return before board shortcut handling
when e.target is a form control, including input, textarea, select, or a
content-editable element. Preserve the existing Space handling only for non-form
targets, while retaining shortcuts for canvas and other non-editable targets.

In `@src/platform/renderer.js`:
- Around line 29-52: Guard the views[obj.type] lookup in createElement and
updateElement, and make createElement return null for unknown types instead of
throwing. Update sync to skip objects whose createElement result is null, while
preserving normal creation and update behavior for recognized types.

In `@src/platform/toolbar.js`:
- Around line 27-32: Update the keydown handler around the existing
e.target.isContentEditable guard to also ignore standard form controls,
including input elements such as the board title, before evaluating the ! and )
shortcuts. Preserve shortcut handling for canvas and other non-form targets.

In `@src/routes/BoardPage.jsx`:
- Around line 58-63: Update the title input’s onKeyDown handler so the Escape
branch marks the edit as cancelled before calling e.currentTarget.blur(),
allowing the nested onBlur handler to skip persisting the cancelled title;
preserve the existing draft reset and Enter behavior.

In `@test/browser/shell.test.js`:
- Around line 47-49: Update the browser test teardown wait condition around
onList and newBoard so it waits until window.app is absent and `#stage` no longer
exists before asserting unmount. Keep the existing .shell presence check only
where needed to confirm the list view, and ensure the unmount assertions run
after canvas teardown completes.

In `@test/coverage.js`:
- Around line 15-16: Update the coverage path handling around isMeasured so
paths from relative() are normalized by replacing backslashes with forward
slashes before filtering. Preserve the existing server.js and src JavaScript/JSX
matching rules after normalization.

In `@test/helpers/browser.js`:
- Around line 81-94: Update the goto method’s about:blank navigation flow to
wait until the blank document is committed, using the Page.frameNavigated event
or an equivalent committed-document condition, before issuing the appBase + to
navigation. Keep the existing readiness polling and timeout behavior unchanged.

In `@test/helpers/vite.js`:
- Around line 13-14: Update the Vite configuration options in the test helper to
convert both new URL values through fileURLToPath() instead of reading .pathname
directly. Apply this to configFile and root, preserving their existing URL
targets while producing native paths on Windows and decoding percent-encoded
characters.
- Line 1: Document Vite as an explicit orchestration exception for the test
suite, covering the dependencies in test/helpers/vite.js, test/run.js, and
test/node/server.test.js; alternatively, move the Vite-dependent code outside
test/** while preserving the existing test behavior.
- Around line 19-23: Update the Vite server setup flow after server.listen so a
missing server.resolvedUrls?.local?.[0] closes the server before throwing the
existing error. Preserve the successful return path in the helper that returns {
server, base }.

In `@test/node/server.test.js`:
- Around line 46-74: Update waitForOutput so child-process errors use a
dedicated handler that calls cleanup() before rejecting, rather than passing
reject directly to child.on('error'). Ensure teardown also resolves or completes
when the child emits error or close, preventing the timeout and pending teardown
from remaining active after a spawn failure.

In `@test/run.js`:
- Around line 72-79: Update the Promise wrapping child-process creation to
accept both resolve and reject, and register a one-time error handler on proc
that calls reject when spawn fails. Preserve the existing exit resolution while
ensuring the surrounding finally cleanup runs for rejected spawn attempts.

---

Duplicate comments:
In `@test/run.js`:
- Line 44: Move the startDevServer() acquisition and Chromium setup into an
outer try/finally cleanup scope, initialize vite, appBase, and chrome handles to
null, and close each resource only when it was successfully initialized. Ensure
setup failures still reach cleanup while preserving the existing test execution
flow.

---

Nitpick comments:
In `@src/app.js`:
- Around line 20-26: Update createApp to validate elements and all six required
refs immediately at its boundary, before constructing the renderer, input, or
toolbar. Reject missing or invalid elements explicitly, while preserving the
existing caller-provided refs and unrelated getElementById queries.

In `@src/core/autosave.js`:
- Line 9: Update createAutosave and its flush function to inspect the boolean
result from repository.save and invoke an optional onError callback when saving
fails. Thread this callback through the BoardCanvas or BoardPage integration so
the shell displays the existing failed-operation warning consistently with
BoardListPage.

In `@src/core/board.js`:
- Around line 72-81: Update Board.duplicate to regenerate ids for every item in
a cloned list, rather than retaining the ids copied by structuredClone(src).
Preserve each item’s other fields while assigning a fresh id through the same
uniqueness mechanism used by newItem, and ensure the cloned object’s items
contain only the newly minted row ids.

In `@src/platform/input.js`:
- Around line 359-364: Add a concise comment immediately before the
execCommand('insertText') call in onPaste explaining that insertText is retained
for current Chrome, Firefox, and Safari compatibility and native undo-stack
preservation, and note the tested Chromium target or repository-supported
browser scope. Leave the paste behavior unchanged.

In `@src/platform/storage.js`:
- Around line 95-106: Keep save in src/platform/storage.js unchanged; treat
cross-tab concurrent writes as a follow-up persistence concern. Implement any
storage-event listener and reload or warning behavior in the shell layer,
outside save and this storage module.

In `@test/node/server.test.js`:
- Line 8: Remove the Vite import and the corresponding build usage in the test
setup around the affected preparation logic, keeping test/node/server.test.js
dependent only on Node built-ins. Move dist/ generation into the existing
pre-test script or CI workflow; if that cannot be done, document the approved
exception instead.
🪄 Autofix (Beta)

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: 49940a2d-4368-47cf-9a8f-967686c48951

📥 Commits

Reviewing files that changed from the base of the PR and between ef9c72e and 6bf3fd8.

⛔ Files ignored due to path filters (5)
  • dist/assets/index-CGKfOYFM.js is excluded by !**/dist/**
  • dist/assets/index-CGKfOYFM.js.map is excluded by !**/dist/**, !**/*.map
  • dist/assets/index-DUTNQzWv.css is excluded by !**/dist/**
  • dist/index.html is excluded by !**/dist/**
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (53)
  • .github/workflows/ci.yml
  • .github/workflows/deploy.yml
  • .gitignore
  • README.md
  • index.html
  • package.json
  • public/index.html
  • public/js/main.js
  • server.js
  • src/App.jsx
  • src/app.js
  • src/components/BoardCanvas.jsx
  • src/core/autosave.js
  • src/core/board.js
  • src/core/geometry.js
  • src/core/ids.js
  • src/core/scheduler.js
  • src/core/seed.js
  • src/core/selection.js
  • src/core/store.js
  • src/core/viewport.js
  • src/main.jsx
  • src/platform/input.js
  • src/platform/renderer.js
  • src/platform/storage.js
  • src/platform/toolbar.js
  • src/platform/views.js
  • src/routes/BoardListPage.jsx
  • src/routes/BoardPage.jsx
  • src/shell/storage.js
  • src/styles/canvas.css
  • src/styles/shell.css
  • test/browser/app.test.js
  • test/browser/shell.test.js
  • test/coverage.js
  • test/fixtures/site/assets/app.js
  • test/fixtures/site/index.html
  • test/fixtures/site/style.css
  • test/helpers/browser.js
  • test/helpers/vite.js
  • test/node/autosave.test.js
  • test/node/board.test.js
  • test/node/geometry.test.js
  • test/node/ids.test.js
  • test/node/scheduler.test.js
  • test/node/seed.test.js
  • test/node/selection.test.js
  • test/node/server.test.js
  • test/node/storage.test.js
  • test/node/store.test.js
  • test/node/viewport.test.js
  • test/run.js
  • vite.config.js
💤 Files with no reviewable changes (2)
  • public/index.html
  • public/js/main.js
🛑 Comments failed to post (5)
src/core/autosave.js (1)

8-13: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

stop() leaves a pending debounced save armed.

stop only removes the store listener. The timer that scheduler.debounce created stays live, so a save scheduled in the last delay milliseconds still runs after createApp().destroy().

BoardCanvas destroys the app on every route change, so this fires on normal navigation. The captured boardId and store keep the write itself correct, but the ordering is not safe: if the user returns to the same board and edits inside the window, the stale timer can write its older snapshot over the newer record.

Give debounce a cancel handle, then cancel or flush it in stop.

🐛 Proposed fix to cancel the pending save

In src/core/scheduler.js, expose a cancel function:

     debounce(fn, ms) {
       let timer = null;
-      return (...a) => {
+      const run = (...a) => {
         clearTimeout(timer);
         timer = setTimeout(() => fn(...a), ms);
       };
+      run.cancel = () => {
+        clearTimeout(timer);
+        timer = null;
+      };
+      return run;
     },

Then use it here:

 export function createAutosave({ store, repository, boardId, scheduler, delay = 400 }) {
   const flush = () => repository.save(boardId, store.toJSON());
   const save = scheduler.debounce(flush, delay);
-  const stop = store.on(save);
-  return { stop, flush, boardId };
+  const off = store.on(save);
+  const stop = () => {
+    off();
+    save.cancel?.();
+    flush(); // persist the last edits instead of dropping them
+  };
+  return { stop, flush, boardId };
 }
🤖 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/autosave.js` around lines 8 - 13, Update the debounce flow used by
createAutosave so scheduler.debounce exposes a cancel handle for its pending
timer, and have the returned stop function remove the store listener and cancel
that timer. Preserve the existing flush behavior and return shape in
createAutosave, while ensuring stopped autosaves cannot execute a previously
scheduled save.
src/platform/input.js (2)

100-106: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle pointercancel so a gesture cannot get stuck.

onPointerUp is the only path that clears gesture. The wiring at Line 466 registers pointerdown, pointermove, and pointerup, but not pointercancel.

The browser fires pointercancel instead of pointerup when it takes over the pointer, which happens on touch devices during a scroll or a gesture interruption. gesture then stays non-null, so later pointermove events keep dragging, resizing, or extending the marquee with no button pressed. A marquee element also stays in the overlay.

Route pointercancel through the same cleanup.

🐛 Proposed fix to end the gesture on `pointercancel`
   listen(window, 'pointerup', onPointerUp);
+  listen(window, 'pointercancel', onPointerUp);
🤖 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/platform/input.js` around lines 100 - 106, Update the pointer event
wiring near the existing pointerdown, pointermove, and pointerup registrations
to also register pointercancel with onPointerUp, reusing that handler’s gesture
cleanup so cancelled gestures clear gesture, end the active interaction, and
remove the panning state and marquee overlay.

420-457: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Board shortcuts fire while the user types in the React title input.

onKeyDown is registered on window at Line 479. The only guards are editingId and e.target.isContentEditable. editingId is set by focusin on layer, so it stays null for any element outside the canvas. A plain <input> is not contentEditable.

src/routes/BoardPage.jsx renders <input className="board-bar-title" aria-label="Board title"> outside layer. With focus in that field:

  • Line 424 calls preventDefault() for Space, so the user cannot type a space in a board title.
  • Line 454 creates a card, envelope, or list when the user types c, e, or l.
  • Line 440 deletes the current selection on Delete or Backspace.
  • Line 446 blocks caret movement with the arrow keys and moves the selected objects instead.
  • Line 434 and Line 437 hijack Ctrl+A and Ctrl+D.

src/routes/BoardListPage.jsx uses window.prompt, which is not affected, but any future React form field has the same problem.

Ignore key events that target a form control.

🐛 Proposed fix to skip editable form controls
+  /** True for anything the browser is already routing text into. */
+  const isTextTarget = (target) =>
+    target?.isContentEditable ||
+    /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '');
+
   function onKeyDown(e) {
-    if (e.code === 'Space' && !spaceDown && !editingId) {
+    if (isTextTarget(e.target)) return;
+
+    if (e.code === 'Space' && !spaceDown && !editingId) {
       spaceDown = true;
       stage.classList.add('space');
       e.preventDefault();
     }
-    if (editingId || e.target.isContentEditable) return;
+    if (editingId) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  /** True for anything the browser is already routing text into. */
  const isTextTarget = (target) =>
    target?.isContentEditable ||
    /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '');

  function onKeyDown(e) {
    if (isTextTarget(e.target)) return;

    if (e.code === 'Space' && !spaceDown && !editingId) {
      spaceDown = true;
      stage.classList.add('space');
      e.preventDefault();
    }
    if (editingId) return;

    const mod = e.metaKey || e.ctrlKey;
    const key = e.key.toLowerCase();

    if (mod && key === 'z') {
      e.preventDefault();
      e.shiftKey ? store.redo() : store.undo();
    } else if (mod && key === 'a') {
      e.preventDefault();
      selection.set(store.order);
    } else if (mod && key === 'd') {
      e.preventDefault();
      board.duplicate();
    } else if (e.key === 'Delete' || e.key === 'Backspace') {
      if (!selection.size) return;
      e.preventDefault();
      board.deleteSelected();
    } else if (e.key === 'Escape') {
      selection.clear();
    } else if (e.key.startsWith('Arrow')) {
      if (!selection.size) return;
      e.preventDefault();
      const step = e.shiftKey ? 20 : 1;
      board.nudgeSelection(
        ((e.key === 'ArrowRight') - (e.key === 'ArrowLeft')) * step,
        ((e.key === 'ArrowDown') - (e.key === 'ArrowUp')) * step,
      );
    } else if (!mod && e.key.length === 1 && 'cel'.includes(key)) {
      commands.addAtCenter({ c: 'card', e: 'envelope', l: 'list' }[key]);
    }
  }
🤖 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/platform/input.js` around lines 420 - 457, Update onKeyDown to return
before board shortcut handling when e.target is a form control, including input,
textarea, select, or a content-editable element. Preserve the existing Space
handling only for non-form targets, while retaining shortcuts for canvas and
other non-editable targets.
src/platform/renderer.js (1)

29-52: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the views[obj.type] lookup against an unknown object type.

createElement reads views[obj.type].create() and updateElement reads views[obj.type].update(...). Neither checks that the type exists. An object with an unrecognized type throws a TypeError inside sync, and the whole board fails to render.

That input is reachable. read() in src/platform/storage.js accepts any record whose board.objects and board.order are arrays; it does not validate each object's type. A record written by an older version, or edited by hand, therefore reaches this code. The storage layer states that it tolerates junk from an older version, so the render path should not turn that record into a hard failure.

Skip unknown types in sync so one bad object cannot take down the board.

🐛 Proposed fix to skip unknown object types
   function createElement(obj) {
+    if (!views[obj.type]) return null;
     const el = views[obj.type].create();
     el.dataset.id = obj.id;
     el.dataset.type = obj.type;

Then handle the null in sync:

       let el = els.get(id);
       if (!el) {
         el = createElement(obj);
+        if (!el) continue;
         els.set(id, el);
         ids?.add(id);
       }
🤖 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/platform/renderer.js` around lines 29 - 52, Guard the views[obj.type]
lookup in createElement and updateElement, and make createElement return null
for unknown types instead of throwing. Update sync to skip objects whose
createElement result is null, while preserving normal creation and update
behavior for recognized types.
src/platform/toolbar.js (1)

27-32: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Ignore canvas shortcuts in standard form controls.

Line 28 excludes only contenteditable targets. The board title is an <input>. Typing ! or ) in that input also runs commands.fit() or commands.resetZoom().

Proposed fix
   listen(window, 'keydown', (e) => {
-    if (e.target.isContentEditable) return;
+    const target = e.target;
+    if (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
     // "!" and ")" are shift+1 / shift+0 on a US layout; the code covers the rest
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  listen(window, 'keydown', (e) => {
    const target = e.target;
    if (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
    // "!" and ")" are shift+1 / shift+0 on a US layout; the code covers the rest
    if (e.key === '!' || (e.shiftKey && e.code === 'Digit1')) commands.fit();
    else if (e.key === ')' || (e.shiftKey && e.code === 'Digit0')) commands.resetZoom();
  });
🤖 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/platform/toolbar.js` around lines 27 - 32, Update the keydown handler
around the existing e.target.isContentEditable guard to also ignore standard
form controls, including input elements such as the board title, before
evaluating the ! and ) shortcuts. Preserve shortcut handling for canvas and
other non-form targets.

Twelve findings from the second review pass.

The one a user could hit: Escape in the title field restored the draft and
called blur() — but blur() runs onBlur synchronously, before React re-renders,
so commit() still read the typed-and-abandoned value and persisted exactly what
had just been rejected. A ref flags the intent so commit() can bail. The test
fails without the fix; the neighbouring Enter path had no coverage at all, so
it gets a test too.

CI and deploy:

- Checkout now pins `github.event.workflow_run.head_sha`. A workflow_run event
  checks out the default branch, which need not be the commit CI just passed —
  the deploy could ship something that was never tested.
- The header describes both entry points rather than implying CI gates every
  deployment; workflow_dispatch is deliberately ungated as the escape hatch.
- README no longer claims v7 broadly "carries a CSRF advisory". GHSA-qwww-vcr4-c8h2
  is scoped to the unstable RSC APIs, affects >=7.12.0 <7.18.2 and >=8.0.0 <8.3.0,
  and both lines are patched. The ^8.3.0 pin is on the patched side either way.

Process and navigation lifecycles — the paths the last round's fixes left open:

- test/run.js: a spawn failure emits 'error' and never 'exit', so the promise
  never settled and the finally that tears down Chromium never ran.
- server.test.js: waitForOutput passed `reject` straight to 'error', skipping
  cleanup and leaving the timer live; teardown now settles on 'close' or
  'error', since a child that failed to spawn has no exit to wait for.
- helpers/vite.js: close the dev server when listen() succeeds but no local URL
  comes back, or the caller gets an exception instead of a handle to clean up.
- helpers/browser.js: Page.navigate resolving means the request was accepted,
  not that the document committed. goto() now waits for the main frame to land
  on about:blank before navigating on, so the second navigation cannot replace
  an uncommitted blank and leave the old app state in place. CDPSession grew
  off()/once() for it, and #dispatch iterates a copy so a one-shot handler can
  unsubscribe mid-dispatch.

Windows paths, which were wrong in two places and would have failed quietly:

- coverage.js normalises relative() output to forward slashes; backslashes make
  isMeasured() reject every src/ file, emptying the report rather than failing.
- fileURLToPath instead of URL.pathname in helpers/vite.js and helpers/browser.js
  — pathname keeps percent-encoding and yields /C:/... paths.

Also: onList waited only for `.shell`, so the unmount assertions raced a
teardown still in flight; it now waits for the canvas to actually be gone.
Vite's role as the one framework dependency test/** may import is documented
where it is imported.

275 tests, 99.6% line coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKSLjnqN7rKPVNYjbg5J6H

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/node/server.test.js (1)

4-14: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the Vite API dependency from this test.

npm test runs before npm run build in CI, so this test calls build() from vite when dist/index.html is absent. Build dist before npm test, and keep test/node/server.test.js limited to Node built-ins.

🤖 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/node/server.test.js` around lines 4 - 14, Remove the Vite build import
and any build() usage from test/node/server.test.js, keeping the test dependent
only on Node built-ins and the server exports. Update the test/build workflow so
dist is generated before npm test runs, preserving the existing server test
behavior without an in-test build fallback.

Source: Path instructions

🤖 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 @.github/workflows/deploy.yml:
- Around line 37-40: Update the checkout step in the deployment workflow to set
persist-credentials to false, and constrain workflow_dispatch deployments to
trusted refs while preserving the workflow_run head_sha selection. Use the
existing trigger and checkout configuration rather than changing unrelated
deployment behavior.

---

Outside diff comments:
In `@test/node/server.test.js`:
- Around line 4-14: Remove the Vite build import and any build() usage from
test/node/server.test.js, keeping the test dependent only on Node built-ins and
the server exports. Update the test/build workflow so dist is generated before
npm test runs, preserving the existing server test behavior without an in-test
build fallback.
🪄 Autofix (Beta)

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: 06be2d2a-c363-4ff5-867a-7e11b81579b3

📥 Commits

Reviewing files that changed from the base of the PR and between 6bf3fd8 and f8b2bbe.

📒 Files selected for processing (10)
  • .github/workflows/deploy.yml
  • README.md
  • src/routes/BoardPage.jsx
  • test/browser/shell.test.js
  • test/coverage.js
  • test/helpers/browser.js
  • test/helpers/cdp.js
  • test/helpers/vite.js
  • test/node/server.test.js
  • test/run.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • test/helpers/vite.js
  • test/run.js
  • test/coverage.js
  • README.md
  • test/helpers/browser.js

Comment thread .github/workflows/deploy.yml
Janacek and others added 2 commits August 4, 2026 11:22
actions/checkout persists GITHUB_TOKEN into .git/config by default. Both jobs
then run `npm ci` and `npm run build`, which execute code from the checked-out
ref — lifecycle scripts, the vite config — with that token sitting in the
workspace. Deploy is the one that matters, since it holds pages:write and
id-token:write, but CI runs fork code on pull_request, so both get it.

Neither job runs an authenticated git command, so this costs nothing.

Not done: restricting workflow_dispatch to an allowlist of refs. Dispatching
already requires write access, and the ref picker only offers refs in this
repo — anyone who can choose a ref can push one to main instead. An allowlist
would remove the escape hatch without closing a path that is open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKSLjnqN7rKPVNYjbg5J6H
The one outside-diff finding: test/node/server.test.js imported `build` from
vite to create dist/ when it was missing. The repo's own test/** instruction
says node:test plus raw CDP, and a test file reaching for a bundler to
manufacture its own fixture is exactly what that rule is about.

Building dist/ moves to test/helpers/vite.js — already the documented Vite
exception — and test/run.js calls it before spawning the suite. Vite is now
imported in exactly one file under test/. The production-server test skips,
with a message saying what to run, only on a bare `npm run test:node` against a
never-built tree; under `npm test` dist/ always exists, so it never skips.

Two things found on the way:

- .coderabbit.yaml still pointed its layering rules at public/js/core/** and
  public/js/platform/**, which this PR moved to src/. Those two instructions —
  the ones the config calls the thing worth policing — have been matching
  nothing since the move. Repointed.
- `npm run test:node` was broken before this branch: Node 24 will not take a
  bare directory for --test, so it died resolving `test/node` as a module
  without running anything. Now a quoted glob.

The test/** instruction itself said "zero-dependency suite", which stopped
being true the moment this PR adopted Vite. It now states the actual rule —
Vite only in test/helpers/vite.js, a test file importing it is a finding — so
it is enforceable instead of merely aspirational.

275 tests, 99.6% line coverage. Verified with dist/ removed, for both runners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKSLjnqN7rKPVNYjbg5J6H
@boubou666

Copy link
Copy Markdown
Owner Author

Re: the outside-diff comment on test/node/server.test.js (4-14) — fixed in aa0ec40.

You were right, and the rule you were applying is the repo's own. Building dist/ moved to test/helpers/vite.js, which is already the documented Vite exception, and test/run.js calls it before spawning the suite. Vite is now imported in exactly one file under test/. The production-server test skips — with a message saying what to run — only on a bare npm run test:node against a never-built tree; under npm test, dist/ always exists, so it never skips and the assertion keeps its teeth.

Verified with dist/ removed rather than assumed, for both entry points: npm test builds it and reports 275 passed / 0 skipped, and the direct invocation reports 153 passed with the one skip.

Two things surfaced while doing it, both worth a look:

.coderabbit.yaml is pointing at files that no longer exist. The public/js/core/** and public/js/platform/** instructions were not updated when this PR moved those trees to src/, so the two rules the config describes as "the layering is the thing worth policing here" have been matching nothing. Repointed to src/core/** and src/platform/** in aa0ec40 — worth noting that neither of the last two review passes could have flagged any layering violation in this PR.

npm run test:node was already broken. Node 24 will not accept a bare directory for --test, so it died resolving test/node as a module without running a test. It reproduces at the PR head, so it predates this branch. Now a quoted glob.

I also rewrote the test/** instruction itself. It opened with "Zero-dependency suite", which stopped being true when this PR adopted Vite — left as-is it would regenerate this same finding on every future review. It now states the actual boundary: Vite only in test/helpers/vite.js, and a test file importing it is a finding.

dist/ is in .gitignore, but dist/index.html was tracked anyway, and none of the
assets it references were. A fresh checkout therefore got an index.html asking
for ./assets/index-BUMevTdn.js and ./assets/index-C3A6Oezp.css, neither of
which existed in the repository.

That is not cosmetic. ensureBuild() skips when dist/index.html is present, so
CI never built, and the production-server test — the one hardened last round to
assert a real 200 containing the application root rather than `status > 0` —
passed against that stale index. Verified against a clean `git archive` of the
previous commit: the test goes green while / returns 200 and both assets it
references return 404. The suite was proving the server could serve a file, not
that it could serve the application.

With dist/ untracked, a checkout has no dist/ at all, ensureBuild() builds it,
and the assertion is made against an artifact that was actually produced from
the source in the tree. Deploy already builds from source, so nothing there
changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKSLjnqN7rKPVNYjbg5J6H
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.

2 participants