Codex stop-hook flagged: "canvas viewport is not paint-isolated".
Root cause: `CanvasViewport::paint` walked the document tree and
issued draw calls translated by `viewport_origin = rect.origin`,
but never clipped to the widget's `rect`. A document node whose
bounds extend past the canvas-widget rect (e.g. a frame at
document (40, 40)–(960, 640) painted into a 300-px-wide canvas
band) would spill onto the LayerPanel / PropertyPanel area
sitting in adjacent rects.
Fix: wrap the entire viewport paint in
save → clip_rect(rect) → bg fill → recursive node paints →
restore. The host-level clip stack catches whatever the
recursive paint emits, and the recursive code stays unaware of
the bounds (no per-node clipping needed).
Defensive guard up front: `if rect.size.x <= 0.0 || rect.size.y
<= 0.0 { return; }` so a zero-size canvas (host clamped below
MIN_RAIL_WIDTH) doesn't even open the save scope.
Test additions (41 lib tests, was 39):
- `paint_is_clip_isolated_save_clip_then_restore` extends the
RecordingBackend to capture op order via a new `Op` enum;
asserts the first three ops are Save → Clip → Fill (canvas
bg), the last op is Restore, and save/restore counts balance.
- `paint_with_zero_size_rect_skips_entirely` confirms the
defensive zero-size early return — backend.ops stays empty
when rect has zero width or height.
Verification:
- `cargo test -p openpencil-shell-core --lib` — 41 tests passing
- `cargo build -p openpencil-shell-native --example
inspector_window` — green
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 907 141
bytes gzip = 86% of 1 MiB ceiling