Repository navigation
Copy and paste, pictures on the clipboard, and corners you can choose - #36
Conversation
Copy and paste the selection, paste an image from anywhere, and give any object square corners instead of round ones. **The clipboard** goes through the `copy` and `paste` events, not `navigator.clipboard`: reading asynchronously needs a permission the browser prompts for, and a prompt in the middle of ⌘V is not paste. The event hands the data over because the keystroke is the consent — and it is the only route by which an image arrives as a file rather than as nothing. On `document`, for the reason the shortcuts are on `window`, so `isTyping` is what keeps a copy inside the board title from copying the board. A selection travels as `text/plain` holding a JSON envelope with a marker, which is what makes a card copied on one board appear on another; anything that is not ours is left to the browser. A paste lands under the pointer when there is one, and follows the rules the board already has: new ids, envelopes bring what they hold, pasted envelopes go to the back, and the whole paste is one undo entry. **An image** is an object like any other, carrying the picture as a data URL rather than a link — the document is stored whole, broadcast, and drawn to a PNG, three places a link would have to still resolve. So the import is a policy: a source is a base64 data URL of a raster type or it is not drawn at all, bytes already small are kept exactly as they came, and anything larger is redrawn through a canvas as WebP until it fits a budget set by the broadcast payload limit. A file that cannot be made into a picture says so in the board's banner, which is now a notice rather than an export error. **Corners** are a token on the object with the radius left in `canvas.css`, in their own module rather than in `card-style.js` — which is what lets the format bar offer them to a selection with no cards in it. An envelope alone now gets a bar with that one control, where before it got no bar at all. The export draws all of it: pictures are decoded before painting, because a 2D context cannot wait for a bitmap, and a source that will not decode is drawn as the empty box the DOM shows rather than left out. 742 tests, 96.1% lines. The clipboard suite drives the real system clipboard through CDP editing commands, so "the handlers are never reached" is a way this could break that the tests would notice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (4)
📝 WalkthroughWalkthroughThis PR adds image objects, rounded or square corners, system clipboard copy/paste, image import and export support, generalized formatting controls, and unified board notices. ChangesBoard objects and clipboard features
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant BoardCanvas
participant createClipboard
participant createImageImport
participant Board
User->>BoardCanvas: Copy or paste
BoardCanvas->>createClipboard: Handle clipboard event
createClipboard->>Board: Read or write board objects
createClipboard->>createImageImport: Decode image files
createImageImport-->>createClipboard: Return bounded image data
createClipboard->>Board: Insert and select pasted objects
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (8)
test/browser/renderer.test.js (1)
47-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd
imageto the corner-attribute loop.The test name says every type. The loop covers
card,envelopeandlist. test/node/corners.test.js:33 asserts thatimageis inCORNERED_TYPES, and this PR makesimagerenderable, so the one place the token is checked for every type should include it.💚 Proposed test change
- for (const type of ['card', 'envelope', 'list']) { + for (const type of ['card', 'envelope', 'list', 'image']) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/browser/renderer.test.js` around lines 47 - 54, Add image to the type list in the corner-attribute test so the existing round and square dataset.corners assertions also cover image, matching CORNERED_TYPES and the newly renderable type.src/core/clipboard.js (1)
54-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThe
structuredClonecall inwriteClipboardis redundant.
JSON.stringifyalready produces a detached string, so the caller cannot observe the objects afterwards. The clone adds a copy of every object and a new failure path:structuredClonethrowsDataCloneErroron values thatJSON.stringifysilently drops, such as a function or a symbol on an object. The test at test/node/clipboard.test.js:38-43 passes either way, because it mutates afterwriteClipboardreturns.♻️ Proposed simplification
export function writeClipboard(objects) { return JSON.stringify({ kind: CLIPBOARD_KIND, v: CLIPBOARD_VERSION, - objects: objects.map((obj) => structuredClone(obj)), + objects, }); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/core/clipboard.js` around lines 54 - 60, Remove the structuredClone call from writeClipboard and pass each object directly to JSON.stringify. Preserve the existing clipboard envelope and objects.map behavior while allowing JSON.stringify to retain its current handling of unsupported values.src/platform/clipboard.js (2)
161-164: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value
getBoundingClientRectruns on everypointermove.
pointermovefires at pointer sampling rate, andgetBoundingClientRectforces a layout read each time. The stage rect changes only on resize, scroll, or layout change. Cache the rect and refresh it on resize, or store the raw client coordinates and convert them inlanding(), which runs once per paste.🤖 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/clipboard.js` around lines 161 - 164, Update the pointermove handler near the stage listener to avoid calling stage.getBoundingClientRect() for every event. Cache the stage rectangle and refresh it when the stage layout can change, or store raw client coordinates and perform the coordinate conversion in landing(), preserving the existing paste behavior.
144-158: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winA paste of several images creates one undo entry per image.
Each
board.addCenteredOncall runs its ownstore.apply, so four pasted images need four presses of undo.Board#pasteat src/core/board.js:145-147 deliberately batches its adds into a singleapplyfor the opposite reason, and the PR objectives state that a paste creates one undo entry. The comment at Lines 155-156 already applies the "one thing that happened" rule to the selection, but not to undo.Consider decoding all files first, then inserting them through one call, for example by building the image objects and passing them to
board.paste, or by adding a board method that applies a batch of adds.Based on the PR objectives: "Pasted objects receive new IDs, preserve nested envelope contents, land under the pointer or at the view center, and create one undo entry."
🤖 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/clipboard.js` around lines 144 - 158, Update the multi-image paste flow around board.addCenteredOn so all decoded images are inserted through a single batched board operation, such as Board#paste or an equivalent batch-add method, rather than invoking a separate store.apply per image. Preserve new IDs, cascade positioning, refusal handling, and selection of all pasted images while ensuring the entire paste creates exactly one undo entry.src/core/board.js (1)
123-136: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low valueConsider rejecting entries with non-numeric geometry in
paste.Line 124 only checks
obj.type. If an entry has a missing or non-finitex,y,w, orh,bboxreturns NaN bounds and the clones land at NaN coordinates, which then enters the document throughstore.apply. The clipboard path is safe today becausereadClipboardappliesisPlaceable, butpasteis a public core method and Board is described in the comment as the place where the vocabulary question is answered.♻️ Proposed guard
- const usable = objects.filter((obj) => OBJECT_DEFAULTS[obj?.type]); + const usable = objects.filter((obj) => OBJECT_DEFAULTS[obj?.type] + && [obj.x, obj.y, obj.w, obj.h].every((n) => Number.isFinite(n)));🤖 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 123 - 136, Update Board.paste to filter out objects whose x, y, w, or h values are not finite numbers, in addition to the existing OBJECT_DEFAULTS type check. Ensure bbox and clone coordinate calculations only receive valid geometry, while preserving the current empty-result behavior when no usable objects remain.test/node/clipboard.test.js (1)
51-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse
CLIPBOARD_VERSIONin place of the literal1.Lines 51 and 52 test "no objects" and "an empty payload". If
CLIPBOARD_VERSIONis raised to 2, both payloads become unknown-version payloads. They still read as null, so both tests keep passing, but they no longer exercise the object checks at src/core/clipboard.js:88-91.♻️ Proposed change
- ['a payload with no objects', JSON.stringify({ kind: CLIPBOARD_KIND, v: 1 })], - ['an empty payload', JSON.stringify({ kind: CLIPBOARD_KIND, v: 1, objects: [] })], + ['a payload with no objects', JSON.stringify({ kind: CLIPBOARD_KIND, v: CLIPBOARD_VERSION })], + ['an empty payload', JSON.stringify({ kind: CLIPBOARD_KIND, v: CLIPBOARD_VERSION, objects: [] })],🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/node/clipboard.test.js` around lines 51 - 52, Replace the literal clipboard version 1 in the “a payload with no objects” and “an empty payload” test fixtures with CLIPBOARD_VERSION. Keep both payload shapes unchanged so these tests continue exercising the object-presence checks in clipboard parsing.test/browser/export.test.js (1)
333-341: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRename the
outsideprobe.
at(39, 29)is the bottom-right pixel inside the 40 × 30 box, not a pixel outside it. The name states the opposite of what the assertion checks. Rename it tofarCorneror similar so a later reader does not treat a passing assertion as proof that paint stopped at the box edge.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/browser/export.test.js` around lines 333 - 341, Rename the `outside` probe in the `is drawn, at the size its box says` test to `farCorner` or another name indicating the bottom-right in-bounds pixel, and update the corresponding assertion reference.src/platform/images.js (1)
42-47: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueHandle an aborted
FileReaderread.
dataUrlOfsettles onloadanderroronly. If the read is aborted, the promise never settles.readthen never returns, andplaceinsrc/platform/clipboard.jsstops without adding an object and without reporting a problem. Add anonaborthandler that resolvesnull.♻️ Proposed change
const reader = new window.FileReader(); reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null); reader.onerror = () => resolve(null); + reader.onabort = () => resolve(null); reader.readAsDataURL(blob);🤖 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/images.js` around lines 42 - 47, Update dataUrlOf to handle FileReader aborts by adding an onabort handler that resolves the promise with null, matching the existing error behavior and ensuring aborted reads settle.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/platform/clipboard.js`:
- Around line 66-78: Update onCopy to guard event.clipboardData before calling
setData, matching the existing data check in onPaste; return early when it is
null or unavailable while preserving the current typing and empty-selection
behavior.
- Around line 101-107: Update the file-drop handler’s place(files, landing())
flow to handle rejected promises from the conversion path. Attach rejection
handling that reports failures through onProblem and preserves processing of
remaining files, while keeping the event prevention and non-awaited handler
behavior intact.
In `@src/routes/BoardPage.jsx`:
- Around line 167-184: Update the export handler in BoardPage to capture the
current board generation before awaiting board.commands.exportPng(), then guard
every subsequent notice update, including success-result handling and the catch
path, so stale exports from a previous board cannot call setNotice after boardId
changes. Keep the existing messages and current-board availability behavior
unchanged.
In `@test/browser/clipboard.test.js`:
- Around line 93-116: Replace the fixed-sleep reliance in the clipboard tests
around the copy/paste assertions and the pasteText cases with a wait for the
expected app.store.order.length readiness condition, following the existing
pattern elsewhere in the file. Ensure each test waits until the pasted object
count is reached before calling objects(), selected(), or other store
assertions, while preserving the existing expected counts.
In `@test/browser/format-bar.test.js`:
- Around line 39-42: Update the absent helper’s fixed
double-requestAnimationFrame delay to wait for a state-specific DOM readiness
condition before evaluating absence. Ensure the condition reflects the relevant
React update for the provided selector, while preserving the existing app
readiness check and null-element assertion.
In `@test/node/board.test.js`:
- Around line 192-200: Update the test “nested data is copied, not shared” to
retain the original object passed through copied(), then mutate that source
object’s nested items after paste instead of applying a store patch. Assert
pasted.items remains unchanged, making the test verify that paste performs a
structured clone rather than sharing nested data.
---
Nitpick comments:
In `@src/core/board.js`:
- Around line 123-136: Update Board.paste to filter out objects whose x, y, w,
or h values are not finite numbers, in addition to the existing OBJECT_DEFAULTS
type check. Ensure bbox and clone coordinate calculations only receive valid
geometry, while preserving the current empty-result behavior when no usable
objects remain.
In `@src/core/clipboard.js`:
- Around line 54-60: Remove the structuredClone call from writeClipboard and
pass each object directly to JSON.stringify. Preserve the existing clipboard
envelope and objects.map behavior while allowing JSON.stringify to retain its
current handling of unsupported values.
In `@src/platform/clipboard.js`:
- Around line 161-164: Update the pointermove handler near the stage listener to
avoid calling stage.getBoundingClientRect() for every event. Cache the stage
rectangle and refresh it when the stage layout can change, or store raw client
coordinates and perform the coordinate conversion in landing(), preserving the
existing paste behavior.
- Around line 144-158: Update the multi-image paste flow around
board.addCenteredOn so all decoded images are inserted through a single batched
board operation, such as Board#paste or an equivalent batch-add method, rather
than invoking a separate store.apply per image. Preserve new IDs, cascade
positioning, refusal handling, and selection of all pasted images while ensuring
the entire paste creates exactly one undo entry.
In `@src/platform/images.js`:
- Around line 42-47: Update dataUrlOf to handle FileReader aborts by adding an
onabort handler that resolves the promise with null, matching the existing error
behavior and ensuring aborted reads settle.
In `@test/browser/export.test.js`:
- Around line 333-341: Rename the `outside` probe in the `is drawn, at the size
its box says` test to `farCorner` or another name indicating the bottom-right
in-bounds pixel, and update the corresponding assertion reference.
In `@test/browser/renderer.test.js`:
- Around line 47-54: Add image to the type list in the corner-attribute test so
the existing round and square dataset.corners assertions also cover image,
matching CORNERED_TYPES and the newly renderable type.
In `@test/node/clipboard.test.js`:
- Around line 51-52: Replace the literal clipboard version 1 in the “a payload
with no objects” and “an empty payload” test fixtures with CLIPBOARD_VERSION.
Keep both payload shapes unchanged so these tests continue exercising the
object-presence checks in clipboard parsing.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 0b62bdd2-a966-4bba-98c0-9620eb53ac5c
📒 Files selected for processing (25)
README.mdsrc/app.jssrc/components/BoardCanvas.jsxsrc/components/FormatBar.jsxsrc/core/bar-position.jssrc/core/board.jssrc/core/clipboard.jssrc/core/corners.jssrc/core/image.jssrc/platform/clipboard.jssrc/platform/export-png.jssrc/platform/images.jssrc/platform/renderer.jssrc/platform/views.jssrc/routes/BoardPage.jsxsrc/styles/canvas.csssrc/styles/shell.csstest/browser/clipboard.test.jstest/browser/export.test.jstest/browser/format-bar.test.jstest/browser/renderer.test.jstest/node/board.test.jstest/node/clipboard.test.jstest/node/corners.test.jstest/node/image.test.js
`nested data is copied, not shared` could not fail. It mutated `items` through `store.apply`, which clones its own patch, so the assertion never touched the array `paste` handed back — it passed whether or not anything was copied. It mutates the source object now, and I broke `structuredClone` to watch it fail. - **`onCopy` asks for `clipboardData` before reaching into it**, as `onPaste` already did. A `ClipboardEvent` the browser dispatched always carries one; a bare one from anything else on the page does not, and that would throw inside a document-level listener. - **A file that cannot be placed is one refusal, not the end of the paste.** `images.read` answers null for the failures it expects, but it is a decoder and a canvas underneath and either can raise something else. Letting that out left the remaining files unplaced, said nothing to the person who pasted them, and became an unhandled rejection, because nobody is waiting on `place`. - **An export reports on the board it exported.** `toBlob` on a large board is long enough to press back and open another one, and the banner would have turned up on a board that was never exported. Guarded by the board on screen, which is the rule the title read already follows. - **Waits instead of sleeps** where there is a condition to wait for: the pasted objects landing, the words arriving in the field. "A paragraph does nothing" has no condition of its own, so it now pastes something that *does* land afterwards and counts what is there. Left alone: `absent()` in the format-bar suite. Its two frames are the documented answer to an assertion with no positive condition, and every call site already waits for one — the bar appearing, or the selection reaching the renderer — before asking what is missing. 742 tests, 96.1% lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ
Two things: the clipboard, and corners you can choose. They arrived together because pasting a picture needed a new object type, and a new object type is the moment to notice that "how round are the corners" had never been anybody's decision.
The clipboard is the
copyandpasteeventsNot
navigator.clipboard. Reading the clipboard asynchronously needs a permission the browser prompts for, and a prompt in the middle of ⌘V is not paste. The event hands the data over with no permission at all, because the keystroke is the consent — and it is the only route by which an image on the clipboard arrives as a file rather than as nothing.The listeners are on
document, for the reason the board's shortcuts are onwindow: a canvas is a place where the thing you are acting on is selected rather than focused, so there is no element to hang them on. That means they hear the app's own chrome too, andisTypingis what keeps a copy inside the board title from copying the board instead.I probed Chrome before designing around any of this, because two of its answers could have forced a hidden contenteditable — the trick other canvas apps use — and both went the right way: a
documentlistener does fire with nothing focused and nothing selected, and what acopyhandler writes does reach the real OS clipboard. That probe is now a test.A selection travels as
text/plainA JSON envelope with a marker in it. The cost is visible and accepted — paste three cards into a text editor and you get JSON. The alternative is a custom clipboard type, which pastes into a text editor as nothing at all and is not carried between browsers or reliably between tabs; text is what makes a card copied on one board appear on another. Anything that is not ours is left to the browser rather than swallowed, so pasting a paragraph from a web page onto a board does nothing.
core/clipboard.jsdecides that a payload is ours and structurally sound.Board#pastedecides whether the vocabulary is one this build speaks — the renderer builds an element by askingviewsfor the type, so an object from a newer client would not draw wrong, it would throw and take the sheet's rendering with it.Where a paste lands
Under the pointer when the pointer is over the board, and in the middle of the view when it is not. That is what makes pasting twice into two places one gesture rather than two, and the pointer is the only thing the person can be said to have pointed at.
Everything else follows rules the board already has:
Board#duplicateandSheets#duplicateleave them.addfollows, applied to a group. Appending one where it happens to fall in the copied order would drop it over the objects already on the board and bury them.An image is an object, and it carries the picture
{ type: 'image', x, y, w, h, src }, wheresrcis a data URL rather than a link. The board is a document that is stored whole, broadcast to the other people on it and drawn to a PNG: three places a link would have to still resolve, from whatever network the reader happens to be on. Carrying the bytes means an image that is on the board is on the board.The bytes being in the document is what makes the import a policy rather than a formality —
core/image.jsdecides,platform/images.jsenforces:img. A remote URL in there is a request this browser makes to a host of somebody else's choosing, which reports the reader's address and presence to it;svg+xmlis a document rather than a bitmap. The same rule the style tokens follow. The attribute is removed rather than emptied, becausesrc=""is a request for the page itself.The budget — around 180KB — is set by the channel, not the disk. Ops are broadcast, and an
addcarrying a whole picture is the only op that can reach a payload limit. Under it, pasting an image is a live edit like any other; over it, the send fails and the picture reaches everyone else on their next load. The failure is soft, which is exactly why it is worth spending some quality to stay under.A file that cannot be made into a picture says so. The canvas layer reports a code and the shell writes the sentence, which is where every other message a person reads is written — so the board's export banner became a notice with a
kind. One banner and not two: it is a fixed strip over the canvas, so a second would be drawn on top of the first.Corners
corners: 'round' | 'square'as a token on the object, with the radius left incanvas.css— the split colour already uses. A card carryingcorners: 8is a card to find and rewrite the day the radius changes, and one carryingcorners: 400is a card nobody meant to make. The stylesheet needs one rule, because round is what every object has always been and stays the default: an object with nocornersfield renders exactly as it did before this existed.It lives in its own module rather than in
card-style.js, which is about what a card looks like and means nothing for an envelope. That is what lets the format bar offer the control to a selection with no cards in it — an envelope selected on its own now gets a bar with that one control, where before it got no bar at all. Each control is offered to whatever it means something for and to nothing else, which is the rule the bar was already following for a mixed selection.Two buttons rather than one toggle, for the reason the alignment group has three: a selection where some objects are rounded and some are not has no true answer for a pressed state, and a toggle would have to invent one.
Images are in the corner vocabulary too. You named cards, envelopes and lists — images did not exist yet — and the alternative was a control that silently skipped one of the four things on a board.
The export draws all of it
radiusOf(obj)replaces the fixed 8, in four places. Pictures are the one thing that cannot be drawn in the pass they are read in — a 2D context has no way to wait for a bitmap — so every image is decoded first, keyed by source, and a source that will not decode is drawn as the empty box the DOM shows. The rest of the board is still worth a file, and drawing nothing would look like the export lost it.What is deliberately not here
channel.sendfor a broadcast resolves before the socket has been written to, which is already documented two sections above it in the README; the picture is in the snapshot either way.Tests
742 passing, line coverage 96.1%. Every new module at 100%:
core/clipboard.js,core/corners.js,core/image.js,platform/clipboard.js,platform/images.js.test/browser/clipboard.test.jsdrives the real system clipboard:Input.dispatchKeyEventwithcommands: ['copy']and['paste']runs the browser's own copy and paste, so a card copied and pasted really did go out to the OS and come back. That is the one thing a syntheticClipboardEventcannot check — that the handlers are reached at all — and it is the way this feature could be wholly broken with every other test in the file green. The details use synthetic events, including the image paste, since nothing can put a picture on the OS clipboard from a test.Also covered: an envelope's contents travelling with it; a copy with nothing selected leaving the event alone; a copy inside a text field staying the field's; a paste landing under the pointer and in the middle of the view when there is none; foreign text doing nothing; a paste into a card being edited going in as text; images sized, cascaded and all selected; a clipboard holding both a picture and its file name taking the picture; a non-image file left to the browser; a refusal reported once; and a pasted picture surviving a reload.
test/node/board.test.jsgainedcopyableandpaste— the centring, the group keeping its shape, the new ids, the envelope depth, the unknown type dropped, and the single undo entry.test/browser/format-bar.test.jsandrenderer.test.jscover corners on all three existing types plus the guard on where a picture may come from.test/browser/export.test.jschecks square corners reach the corner pixel and round ones do not, on a card, a list and an envelope, and that a picture is drawn, clipped, and replaced by its box when it will not decode.One rename in the tests: the export banner's
[data-export-error]is now[data-board-notice], since the banner is no longer only about exporting.🤖 Generated with Claude Code
https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ
Summary by CodeRabbit