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'sviewBoxoptional andmarginadded in validation and key order; oneresolve_framefunction;render_sceneandRendereduse it.crates/engine/src/starter.rs: the starter scene without aviewBox.crates/engine/src/svg.rs,preview.rs,compile.rs: read the resolved box.crates/engine/src/format.rs:marginin 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 optionalviewwindow forscene.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-topand stacking by default,frameinscene.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 bynpm 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:frameandviewin the render types,scene.frameandscene.dropcalls.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 taband theexpectlines named in the spec.app/Tests/PixelLabTests/SceneCanvasTests.swift(new),StageGeometryand key-handling tests.scripts/acceptance/(a newcanvas.txt, andscripts/acceptance.shruns 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.
- Format and starter:
viewBoxoptional,marginadded, canonical order, the starter without aviewBox; testsR1,R12, and the validation messages. 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 byrender_scene,svg,previewandcompile's images; testsR2,R3,R5,R6,R7, andR4:examples/and the engine fixtures compile to the same bytes as before.render.rsand the read commands use the resolved box;scene.rendertakesviewand returnsframe;scene.listandscene.showprint "fits" or "fixed";scene.placewith no position starts at the content's middle ([0, 0, 0]when empty); testsR7.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); testsR8,R11.- Stacking:
top_at, place stacks when no level is given and takes--on-top,scene.drop; one undo step each; testsR16,R17(a given level wins) and over MCP (R19). npm run docs:cli;docs/document-format.mdandREADME.mdupdated;pixellab helptext.
PR 2: the app. Each step is one commit.
- Protocol, models and
StageGeometry: render a window,framein 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. - 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.
- 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. - Script hook lines, Swift tests,
scripts/acceptance/canvas.txt(a scene made withscene 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#
| Step | Who | Branch | Then |
|---|---|---|---|
| Plan | main session (Opus) | docs/scene-canvas-spec → the docs PR | the owner approves it by merging |
Cut feat/scene-canvas | main session | from the docs branch | — |
| PR 1, steps 1–6 | one subagent (Sonnet) | feat/scene-canvas-program | the main session merges it into feat/scene-canvas once cargo fmt --check, cargo test and npm test pass |
| PR 2, steps 7–10 | one subagent (Sonnet), starts after PR 1 merges | feat/scene-canvas-app | owner review (below), then the main session merges it |
| Final PR | main session | feat/scene-canvas → main | the owner merges |
- Merge rules. The main session merges slice PRs into
feat/scene-canvaswithgh pr merge --squash --delete-branch. Whenmainmoves, the main session merges it intofeat/scene-canvasbefore PR 2 starts. No conflict between slices is expected: PR 2 only reads what PR 1 changed. - File ownership. PR 1 owns
crates/,docs/andREADME.md; PR 2 ownsapp/andscripts/.docs/cli.mdis generated, only in PR 1. PR 2 stops and reports if it needs a change incrates/. - 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 thetownexample: 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-changeon 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_frameruns in the path every scene compiles through. If a scene that has aviewBoxchanged at all, the site's art would shift. Mitigation: a scene with aviewBoxnever calls the fit;R4compares the SVGs and images ofexamples/and the engine fixtures to their previous bytes, andscripts/acceptance.shpins the hero island. If any differs, stop. - The fit missing painted pixels. Ambient loops and actions move layers by CSS, outside the tree. Mitigation:
R3tests 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.jsonstays0.0.0-dev). - Stage performance. A window render per scrubbed frame on a large scene. Mitigation: step 7 measures the
townexample first; if it is slow, the window is capped to the visible area plus one viewport. - Proposals. A proposal's
baseHashcovers the scene file; removing or adding aviewBoxchanges 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.dropand the rendervieware registry commands, so the CLI and MCP get them;servers.rsandhelp.rstests check that the tool list and help include them. - Existing keys. Changing Return's behavior touches the Place tool. Mitigation:
R14tests the existing keys first, in step 9, before the new ones are added.
Proof#
- Program:
cargo fmt --check,cargo testwith no failures,npm testwith# fail 0,npm run docs:clileavinggit statusclean apart from the plan's edits. - App:
swift build --package-path appwith no errors,scripts/test-app.shwith every test passing. - End to end:
scripts/build-app.sh, thenscripts/acceptance.shexits 0, includingcanvas.txtand 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 levelandexpect atfor 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:sitebuilds with no broken links. - Each test is named for its requirement (
R2: …), soverify-changecan trace them.
Departures#
Recorded while building PR 1 (the program slice).
- Only blocks are a floor. The spec's
top_atcounts "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 --attakescol,row[,level]. Without a level the item stacks; with one it is used as given, and--on-topstacks 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'sviewBoxis the window asked for withview, else the resolved box, so item bounds and the PNG's pixels always agree with it. The result carriesframe("fit"or"fixed") as the spec says. Aviewis refused above 100 million pixels.check's new warnings have codescropped(R11) andempty(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, socheckon 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) andcrates/pixellab/tests/scene_canvas.rs(R7 to R8, R11, R16, R17), with R19 inservers.rs. R4 compares the SVG and PNG hashes of every scene of theexamples/and the fixture withcrates/engine/tests/data/frame-goldens.txt, taken before this change;PIXELLAB_WRITE_GOLDENS=1rewrites 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.rendertakes noview(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 needviewonproposal.render, a change incrates/. - 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.frameis 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 sendat: [col, row](stack) or[col, row, level]. - Script hook.
frame fit|box x,y,w,h|margin n|outline on|off(an object'sframe next|addstill works),drop,key tab,key cmd+down,place <col> <row>(stacks), andexpect frame|size|outline|window|cursor|movemenu,expect visible cursor|selected true|false.expect levelandexpect atwere already there. - Acceptance.
canvas.txtruns on a temp copy of the harbor example (not the site), with ameadowmade byscene newand given anoutputbyacceptance.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 bylayers.txtas 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.buildnow returns 19 outputs (6 SVGs and 13 PNGs) and the app'sArtOutputfailed to decode a PNG (nogzip,overorreport); it decodes them now, andexpect outputsinscene-object.txtanddocument.txtsays 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 YESso the owner's saved settings don't decide them. - Found, not fixed (in
crates/). A publish from the app writes the manifest'spublish.imagesfolder relative to the program's working directory, so an acceptance run leavesstatic/public/thumbnails/in the repo root (removed by hand, never committed).proposal.rendertakes noview.