Pixel Labdeveloper docs

Design record · 2026-10-scene-canvas

Plan: A scene without a size, and keyboard block placement (from intent.md 2026-10-21)#

Files that change#

Slice 1, the program (crates/, docs/)

  • crates/engine/src/engine.rs: the scene's viewBox optional and margin added in validation and key order; one resolve_frame function; render_scene and Rendered use it.
  • crates/engine/src/starter.rs: the starter scene without a viewBox.
  • crates/engine/src/svg.rs, preview.rs, compile.rs: read the resolved box.
  • crates/engine/src/format.rs: margin in the canonical scene key order.
  • crates/engine/src/ambient.rs, motion.rs: the reach of an action or ambient loop, for the fit.
  • crates/pixellab/src/render.rs: the resolved box; an optional view window for scene.render.
  • crates/pixellab/src/scene_ops.rs: top_at (the top of a tile), stacking on place, drop.
  • crates/pixellab/src/commands/scene.rs, scene_edit.rs, check.rs, registry.rs: scene.frame (new command), scene.drop (new command), scene.place --on-top and stacking by default, frame in scene.render/show/list, the fixed-box-cuts warning.
  • crates/pixellab/src/server_mcp.rs: nothing to change by hand if tools come from the registry; checked by test.
  • Tests (new or extended): crates/engine/tests/{engine,svg_starter,preview,compile,motion_site,format}.rs, crates/pixellab/tests/{scene_commands,scene_ops,render,check_canonical,servers,help}.rs.
  • docs/cli.md (generated by npm run docs:cli), docs/document-format.md, README.md (the scene text and the shortcuts table).

Slice 2, the app (app/, scripts/)

  • app/Sources/PixelLab/Server/Protocol.swift: frame and view in the render types, scene.frame and scene.drop calls.
  • app/Sources/PixelLab/Views/StageGeometry.swift: the workspace for a scene without a fixed box.
  • app/Sources/PixelLab/Model/{AppModel,DocumentModel,TimelineModel,ThumbnailStore,HitTesting,SceneEditor,ProposalGhost,AgentModel}.swift: render a window, the window in the timeline cache key, placing and selecting in empty space, Tab, ⌘↓, stacking on Return, scroll into view, the ghost's shared window.
  • app/Sources/PixelLab/Views/ (the stage view, InspectorView.swift, the app's menu commands): the published-frame outline, View › Show Published Frame, the inspector's size line and Fit to content, Edit › Move.
  • app/Sources/PixelLab/Support/ScriptRunner.swift: frame …, drop, key tab and the expect lines named in the spec.
  • app/Tests/PixelLabTests/SceneCanvasTests.swift (new), StageGeometry and key-handling tests.
  • scripts/acceptance/ (a new canvas.txt, and scripts/acceptance.sh runs it), examples/ is only read.

Order of work#

main ── feat/scene-canvas (plan, then both slices) ───────── final PR (owner merges) ──▶ main
          ├─ PR 1  program  ◀─ feat/scene-canvas-program
          └─ PR 2  app      ◀─ feat/scene-canvas-app     (starts when PR 1 is merged)

Two slices, one after the other, because the app calls the commands the program slice adds and the protocol they return. Splitting the work into parallel slices would put two writers on ScriptRunner.swift and AppModel.swift, so PR 2 stays one slice. The intent, spec and this plan travel in the same docs PR as the code: feat/scene-canvas is cut from docs/scene-canvas-intent once the spec PR is merged into it, so the final PR into main holds all of it, and PR #39 is closed in its favor. (If the owner would rather merge the docs first, feat/scene-canvas is cut from main after that; nothing else changes.)

PR 1: the program. Each step is one commit.

  1. Format and starter: viewBox optional, margin added, canonical order, the starter without a viewBox; tests R1, R12, and the validation messages.
  2. resolve_frame: painted bounds of the rendered tree (so every place a moving item takes is in), the reach of ambient loops and actions, the margin, the empty fallback [-16, -8, 32, 16]; used by render_scene, svg, preview and compile's images; tests R2, R3, R5, R6, R7, and R4: examples/ and the engine fixtures compile to the same bytes as before.
  3. render.rs and the read commands use the resolved box; scene.render takes view and returns frame; scene.list and scene.show print "fits" or "fixed"; scene.place with no position starts at the content's middle ([0, 0, 0] when empty); tests R7.
  4. scene.frame (--box, --fit, --margin, none to report), as a command in the registry, so the CLI, stdio and MCP all have it; check's warning for a fixed box that cuts painted pixels (R11); tests R8, R11.
  5. Stacking: top_at, place stacks when no level is given and takes --on-top, scene.drop; one undo step each; tests R16, R17 (a given level wins) and over MCP (R19).
  6. npm run docs:cli; docs/document-format.md and README.md updated; pixellab help text.

PR 2: the app. Each step is one commit.

  1. Protocol, models and StageGeometry: render a window, frame in the render types, the workspace for a scene without a fixed box (grows toward the edge on a place or move, never shrinks under a drag), the window in the timeline and thumbnail caches; hit-testing and placing in empty space.
  2. The published frame: the outline (accent token), View › Show Published Frame (remembered per user, not in the document), the inspector's "Publishes at w×h · Fits the content / Fixed", Fit to content and the box field; the proposal ghost's shared window; VoiceOver labels.
  3. Keyboard: Tab / ⇧Tab, ⌘↓ drop, Return stacks, scroll into view, the Edit › Move menu listing every key, announcements ("on top of pine", "on the ground"); tests R14–R19.
  4. Script hook lines, Swift tests, scripts/acceptance/canvas.txt (a scene made with scene new, filled past where the old default would have cut, saved, published; the hero island's SVG byte-identical), README shortcut table, snapshots in dark and light.

Workflow and reviews#

StepWhoBranchThen
Planmain session (Opus)docs/scene-canvas-spec → the docs PRthe owner approves it by merging
Cut feat/scene-canvasmain sessionfrom the docs branch—
PR 1, steps 1–6one subagent (Sonnet)feat/scene-canvas-programthe main session merges it into feat/scene-canvas once cargo fmt --check, cargo test and npm test pass
PR 2, steps 7–10one subagent (Sonnet), starts after PR 1 mergesfeat/scene-canvas-appowner review (below), then the main session merges it
Final PRmain sessionfeat/scene-canvas → mainthe owner merges
  • Merge rules. The main session merges slice PRs into feat/scene-canvas with gh pr merge --squash --delete-branch. When main moves, the main session merges it into feat/scene-canvas before PR 2 starts. No conflict between slices is expected: PR 2 only reads what PR 1 changed.
  • File ownership. PR 1 owns crates/, docs/ and README.md; PR 2 owns app/ and scripts/. docs/cli.md is generated, only in PR 1. PR 2 stops and reports if it needs a change in crates/.
  • Owner review (after PR 2's snapshots, before it merges). The owner looks at the dark and light snapshots of the stage with the published frame, and tries an installed build (scripts/install.sh --dest <temp> by the main session) on a temp copy of the town example: pan past the art, place in empty space, the keys (Tab, ⌘↓, Return onto a block, ⇧↑ and Page Up). The feel of the infinite stage and of the keys is the owner's call. Feedback comes back as changes to PR 2.
  • Independent review. verify-change on the final PR, in a fresh session (Opus), because the change alters the scene format that is written to the site, adds MCP tools, and its final PR is large. The implementer fixes any blocking finding before the owner merges.

Risks#

  • Byte-identical output (the riskiest part). resolve_frame runs in the path every scene compiles through. If a scene that has a viewBox changed at all, the site's art would shift. Mitigation: a scene with a viewBox never calls the fit; R4 compares the SVGs and images of examples/ and the engine fixtures to their previous bytes, and scripts/acceptance.sh pins the hero island. If any differs, stop.
  • The fit missing painted pixels. Ambient loops and actions move layers by CSS, outside the tree. Mitigation: R3 tests on a moving item, an ambient loop and an action, each checking that no pixel at any sampled time lies outside the box; if the engine can't give an exact reach for a case, the fit uses a safe larger bound and the plan's PR says which case.
  • An older program reading the new format. The site's pinned release rejects a scene without a viewBox. Mitigation: only new scenes lack one; the plan does not change the site. The owner bumps the site's pin after the release, before its document gets such a scene. No version is bumped in this change (package.json stays 0.0.0-dev).
  • Stage performance. A window render per scrubbed frame on a large scene. Mitigation: step 7 measures the town example first; if it is slow, the window is capped to the visible area plus one viewport.
  • Proposals. A proposal's baseHash covers the scene file; removing or adding a viewBox changes it like any edit, and the ghost uses the shared window. Covered by a ghost test in step 8.
  • The other two doors. scene.frame, scene.drop and the render view are registry commands, so the CLI and MCP get them; servers.rs and help.rs tests check that the tool list and help include them.
  • Existing keys. Changing Return's behavior touches the Place tool. Mitigation: R14 tests the existing keys first, in step 9, before the new ones are added.

Proof#

  • Program: cargo fmt --check, cargo test with no failures, npm test with # fail 0, npm run docs:cli leaving git status clean apart from the plan's edits.
  • App: swift build --package-path app with no errors, scripts/test-app.sh with every test passing.
  • End to end: scripts/build-app.sh, then scripts/acceptance.sh exits 0, including canvas.txt and the hero island's SVG byte-identical.
  • Script hook runs end "all steps passed", with expect size, expect frame fit|fixed, expect outline on|off, expect level and expect at for the new behavior, on temp copies only (pixellab document copy --to).
  • Snapshots of the stage with the published frame, in dark and light (scripts/snapshot.sh), looked at before reporting.
  • Docs: npm run docs:site builds with no broken links.
  • Each test is named for its requirement (R2: …), so verify-change can trace them.

Departures#

Recorded while building PR 1 (the program slice).

  • Only blocks are a floor. The spec's top_at counts "any item whose block size or footprint covers that tile". Built, only a block holds something up: a sprite standing on a tile (a pine, a pebble) is not a floor, because stacking one pine on another by default floated it a level above the first and changed the Place tool's result on every tile that holds a prop. A sprite or block being placed or dropped still covers the tiles its own footprint or block size covers. A block of height h placed on a block rests its bottom on the top (its level is the top plus h), as the town example builds it; with nothing under it, a block stays at level 0 as it has always been placed.
  • scene.place --at takes col,row[,level]. Without a level the item stacks; with one it is used as given, and --on-top stacks whatever the position says (also for --px). The spec named only --on-top; the two-number form is how the keyboard and mouse say "no level".
  • scene.render's viewBox is the window asked for with view, else the resolved box, so item bounds and the PNG's pixels always agree with it. The result carries frame ("fit" or "fixed") as the spec says. A view is refused above 100 million pixels.
  • check's new warnings have codes cropped (R11) and empty (R6), and their text starts with the scene's file like the other warnings (scenes/hero-island.json: 14 pixels outside its viewBox …), not the bare scene name. The sample fixture's pebble stands partly outside its viewBox, so check on the fixture now says so; the tests that pinned its warnings were updated.
  • Where the tests live. crates/engine/tests/frame.rs (R1 to R7, R12 and R4) and crates/pixellab/tests/scene_canvas.rs (R7 to R8, R11, R16, R17), with R19 in servers.rs. R4 compares the SVG and PNG hashes of every scene of the examples/ and the fixture with crates/engine/tests/data/frame-goldens.txt, taken before this change; PIXELLAB_WRITE_GOLDENS=1 rewrites it and must never be run to make a failing R4 pass.

Recorded while building PR 2 (the app slice).

  • Every scene gets the workspace, not only those without a fixed box. The spec has the workspace for "a scene without a fixed box". Built, a fixed scene uses it too: its stage has no edge either (R9), pixels outside a fixed box show while editing, and the published frame outline marks where the box is. Fit zoom uses the published box for every scene.
  • The ghost does not share a render window. proposal.render takes no view (the program slice did not add one), so the ghost is rendered at the proposal's own resolved box and drawn at its own place. The stage's workspace holds that box (AppModel.neededRect), so a proposal that grows the scene is not clipped. A shared window would need view on proposal.render, a change in crates/.
  • Workspace size. The window is the box (and where the cursor has been) plus a viewport's worth of room per side at the zoom, at least 128 and at most 1024 scene pixels, in multiples of 32. Measured on the town example: a 3500×3300 window renders in 0.15 s against 0.06 s for its box, so the cap is a safe one. A scene bigger than the cap is still rendered whole. The window grows and never shrinks until the scene is opened again; the stage scrolls so the scene pixel at the middle of the view stays there while it grows.
  • The timeline's cache is checked against the window (a frame cached for another window is a miss); it needs no new key. scene.frame is asked again once per change of the document (the timeline's generation).
  • Menu keys. Edit › Move lists every key. Only ⌘↓ is a menu shortcut; Tab, ⇧Tab, the arrows, ⇧↑/⇧↓, Page Up/Down and Return are stage keys, shown in the item titles ("Select Next Item (Tab)"), because a menu shortcut without a modifier would take them from text fields. ⌘↓ in a text field still moves the caret to the end.
  • "On purpose". A level counts as set on purpose when the Place level stepper or ⇧↑/⇧↓/Page Up/Page Down on the cursor changes it (SceneEditor.levelOnPurpose); selecting an item clears it. Return and a click then send at: [col, row] (stack) or [col, row, level].
  • Script hook. frame fit|box x,y,w,h|margin n|outline on|off (an object's frame next|add still works), drop, key tab, key cmd+down, place <col> <row> (stacks), and expect frame|size|outline|window|cursor|movemenu, expect visible cursor|selected true|false. expect level and expect at were already there.
  • Acceptance. canvas.txt runs on a temp copy of the harbor example (not the site), with a meadow made by scene new and given an output by acceptance.sh; the harbor SVGs are compared before and after, and the meadow's published width is checked to be more than the old 128-pixel default. The hero island's SVG stays pinned by layers.txt as before.
  • Not verified by script. Tab and ⌘↓ as real key presses (the hook calls the same handlers; the menu shortcut and the AppKit key path were not driven by a person), and scrolling while the workspace grows under a held key.
  • Acceptance against today's site. The site has since added PNG preview images, so art.build now returns 19 outputs (6 SVGs and 13 PNGs) and the app's ArtOutput failed to decode a PNG (no gzip, over or report); it decodes them now, and expect outputs in scene-object.txt and document.txt says 19. This is the "site's art changed" case in CLAUDE.md, plus one app bug. The chat, migrate and canvas runs pass -rightTab inspector / -showFrame YES so the owner's saved settings don't decide them.
  • Found, not fixed (in crates/). A publish from the app writes the manifest's publish.images folder relative to the program's working directory, so an acceptance run leaves static/public/thumbnails/ in the repo root (removed by hand, never committed). proposal.render takes no view.