Plan: A canvas-first window, with a floating library (from intent.md 2026-10-21)#
The intent, spec and plan merge to main together in PR #38 (intent/floating-library). The code then starts on feat/floating-library, cut from main after that merge.
The change is to the Mac app (app/), its test hook and acceptance scripts, and the README. There's one exception: scene.render's hide also takes item paths, so single items can be hidden (spec R19, D12). That's a small change in crates/pixellab and docs/cli.md. The document format, the engine and the published art don't change.
Files that change#
The app
app/Sources/PixelLab/Views/MainWindow.swiftDocumentToolbarandAgentToolbarButtondeleted (the badge moves toRightColumn).- No sidebar toggle, the compact title bar, and the inspector always presented (D1).
app/Sources/PixelLab/PixelLabApp.swift- The window toolbar style.
- Menus:
- Added: Zoom In (⌘=), Zoom Out (⌘−), Edit › Edit Object (⌘↩), View › Back to <scene> (⌘[) (D9).
- Removed: Show/Hide Inspector (⌥⌘I).
app/Sources/PixelLab/Model/AppModel.swift- Added:
stepZoom(by:anchor:),zoomReadout,libraryOpen,returnScene,editObject(_:)andbackToScene(). - Removed:
showInspector. switchMode(to:)is kept for the test hook only.
- Added:
app/Sources/PixelLab/Model/SceneEditor.swiftStageToolbecomesselect, pan, with Pan titled Hand.- Placing is the state
placeObject != nil, andeffectiveToolfollows it. - The
Akey goes, andsidebarTabis removed.
app/Sources/PixelLab/Model/ObjectEditor.swiftsidebarTabremoved.- The Hand button's held pan (
handHeld), which acts like Space held.
app/Sources/PixelLab/Model/LayersModel.swift: the twosidebarTabwrites becomelibraryOpenand placing (D6).app/Sources/PixelLab/Views/StageInput.swiftmagnify(with:), andscrollWheel(with:)with ⌘, step the zoom anchored under the pointer (D2).- The Hand tool drags the view in objects too.
app/Sources/PixelLab/Views/StageView.swift- Overlays:
FloatingBar,LibraryPopover,ZoomReadoutandBackPill. - The scroll view's scrollers are small and autohide, keeping the system's style (D3).
- The scroll position is kept after a zoom step.
- Overlays:
app/Sources/PixelLab/Views/FloatingBar.swift(new):FloatingBarandFloatingBarButton(D4).app/Sources/PixelLab/Views/LibraryPopover.swift(new):LibraryPopover, with its search, grid and keyboard handling (D5).app/Sources/PixelLab/Views/StageChrome.swift(new):ZoomReadout,BackPill, and the smallNSScrollViewconfigurator (D2, D3, D7).app/Sources/PixelLab/Views/SidebarView.swift- The segmented pickers go: Scenes then Layers, or Scenes then the Palette.
LibraryCellgains itscompactstyle, the context menu renamed to Place / Edit Object, and the library grid moves out toLibraryPopover(D5, D6).PlaceIntoChipis deleted, and its rule moves toLayersModel.placeIntoName.
app/Sources/PixelLab/Views/StageOverlay.swift: the placing ghost's tag reads "<object> → <layer>" (R9).app/Sources/PixelLab/Views/StageMenu.swift: the layer menu gains Edit Object “<name>” for the item under the pointer (D7).app/Sources/PixelLab/Views/RightColumn.swift: the tab row, Inspector | ✦ Agent ⟨n⟩ … Publish · Save (D8).app/Sources/PixelLab/Views/ObjectPanels.swift: the palette shown without the Palette | Library switch, if the switch lives there (D6).
Layers: hide and lock single items (R19)
crates/pixellab/src/render.rs:hidematches item paths as well as layer ids and names (a layer wins).crates/pixellab/src/commands/scene.rs: thehideschema and the command description mention item paths.crates/pixellab/tests/render.rs:R19: hide takes an item pathandR19: a layer name wins over a path.docs/cli.md(generated):npm run docs:cli.app/Sources/PixelLab/Model/LayersModel.swift:LayerViewStategainshiddenItemsandlockedItems(path and object), with the toggles.- Entries follow
reorderedand are dropped on open or reload when the path's object differs. renderParamssends the hidden items.
app/Sources/PixelLab/Model/LayerTree.swift: the pickability check skips hidden and locked items.app/Sources/PixelLab/Views/LayersPanel.swift: the item row loses its position text and gains the group row's lock and eye buttons.app/Tests/PixelLabTests/LayerTests.swift: the R19 tests in step 6.
Tests and the test hook
app/Sources/PixelLab/Support/ScriptRunner.swift:- The steps and expects in D10, plus aliases for
mode,sidebarandtool place. hide|show|lock|unlocktake item paths, andexpect hiddenitems|lockeditemschecks them (D12).
- The steps and expects in D10, plus aliases for
app/Tests/PixelLabTests/CanvasTests.swift(new): Swift Testing suites named by requirement (R1: …).app/Tests/PixelLabTests/StageTests.swift: tests that used the Place tool orsidebarTabupdated.scripts/acceptance/window.txt(new;canvas.txtis the scene-canvas stage): the end-to-end run of the new window on the temp site copy.scripts/acceptance/layers.txt:expect tab libraryandsidebar layersbecomeexpect library openandlibrary close.scripts/acceptance.sh: runs thewindowstage beforelayers, on the same temp site, and its header comment mentions it.
Docs
README.md: the scene, object, publish and Agent paragraphs of the tour, and the shortcuts table (D11).
Order of work#
There's one slice: one branch and one PR into main. The steps form one chain. The window chrome, the bar, the Library, Back and the item rows all touch StageView, AppModel, LayersModel and ScriptRunner, so parallel runners would fight over the same files. The program change in step 5 is small enough to stay in the same slice.
main ── PR #38 (intent, spec, plan; owner merges)
└── feat/floating-library ── steps 1–12 ── PR "A canvas-first window" (owner merges) ──▶ main
PR: A canvas-first window, with a floating library (feat/floating-library → main). Each step is one commit, and swift build --package-path app and scripts/test-app.sh pass after each one.
- Zoom (R1–R3).
AppModel.stepZoom(by:anchor:)steps throughZoom.levels, starting from Fit's effective level.- The magnify and ⌘-scroll accumulators in
StageInput. - The scroll position is kept so the anchor stays under the pointer.
- Menus: Zoom In (⌘=) and Zoom Out (⌘−).
ZoomReadout, with its VoiceOver announcement and no fade under Reduce Motion.- Tests:
R1: a pinch steps one level and keeps the anchor,R1: never past the kind's levels,R2: ⌘= and ⌘− step from Fit,R3: the readout clears.
- Scroll bars (R5).
- The scroll-view configurator: small scrollers, autohide, and
scrollerStyleleft to the system. - It's applied to the stage now, and to the Library grid in step 4.
- Test:
R5: the stage keeps the system's scroller style.
- The scroll-view configurator: small scrollers, autohide, and
- Select, Hand and the floating bar (R4, R6, R7).
StageToolloses.place, and placing becomesplaceObject != nil. The A key goes.LayersModelandSceneEditor.choosePlaceObjectstart placing without a tool switch.FloatingBarin scene and object views, and the object Hand button (handHeld).- Tests:
R7: picking a Library object starts placing; Esc and V stop it,R6: the bar's tools per doc kind.
- The Library popover and the sidebar (R8–R10).
LibraryPopover, the compactLibraryCell,libraryOpen, and L and Esc. Drag and drop reuseLibraryDrag.- The context menu becomes Place / Edit Object.
- The ghost tag names the target layer.
- The sidebar's pickers and both
sidebarTabs are removed, andPlaceIntoChipis deleted. - Tests:
R8: L opens and Esc closes, placing first,R9: the ghost tag names the layer placing goes into(the cases from../2026-10-scene-layers/spec.mdD13),R10: the sidebar has no Library.
- Item paths in
scene.render'shide(R19, program).render.rsmatches item paths after layer ids and names.- The schema and description are updated, and
docs/cli.mdis regenerated. - Tests in
crates/pixellab/tests/render.rs:R19: hide takes an item pathandR19: a layer name wins over a path. cargo fmt --check,cargo testandnpm testpass.
- Hide and lock single items (R19, app).
LayerViewState.hiddenItemsandlockedItems, with their toggles, followingreorderedand dropped on a mismatch.renderParamssends the hidden items, andLayerTreeskips hidden and locked items when picking.- The
LayersPanelitem row gets the lock and eye buttons in place of its position. - Tests:
R19: a hidden item isn't drawn or picked, and leaves the selectionR19: a locked item can't be picked on the stage but selects from the treeR19: a hide follows a depth sort's reordered pathR19: a stale entry is dropped on reload
- Edit Object and Back (R11).
editObjectandbackToScenewithreturnScene.BackPill.- Edit Object in
StageMenuand the Library menu. - The menu items Edit › Edit Object (⌘↩) and View › Back to <scene> (⌘[).
- The object view's ⌘] and ⌘[ stop stepping layers; plain ] and [ still do. Test:
R11: ⌘[ goes back and [ still steps the layer. - Tests:
R11: Edit Object from a scene, then Back, returns to it,R11: Back with no scene to return to opens the last scene.
- The window's chrome and the right panel (R12–R14).
DocumentToolbar,AgentToolbarButtonand the sidebar toggle are deleted, and the compact title bar is set.- The inspector is always presented, and
showInspectorand its menu item go. RightColumn's row gets the Agent badge, Publish and Save.- Tests:
R14: Save is disabled when clean and its label says so,R14: the Agent tab shows the waiting count.
- The test hook (R17).
- The D10 steps and expects, and the
mode,sidebarandtool placealiases. layers.txtupdated.window.txtwritten and added toacceptance.sh, which runs on the temp site copy:- pinch in, out and keys, with
expect zoomandexpect readout; expect toolbar none,expect inspector shownandexpect floatingbar select,pan,library;library open,librarydrop pine 4 -1 1,expect at 4,-1,1;editobject house,expect floatingbar pencil,eraser,fill,picker,pan, thenbackandexpect returnscene hero-island;hideandlockon an item path,expect hiddenitems, a click that passes through the locked item, thenshowandunlock;- save, and the hero island's SVG byte-identical after publishing.
- pinch in, out and keys, with
- The D10 steps and expects, and the
- Docs (R18). The README tour and shortcuts table, then
npm run docs:site. - Snapshots (R16). In dark and light: the scene stage with the bar, the open Library, the object view with the Back pill, the right panel's row, and Layers with a hidden and a locked item. Each is taken with
snapshot <png>from a script on a temp document copy, then checked against the prototype boards. - Full verification, every check in Proof, pasted into the PR. Then open the PR and hand over.
Workflow and reviews#
| Step | Who | Where | Then |
|---|---|---|---|
| Merge PR #38 | The owner | intent/floating-library → main | The main session cuts feat/floating-library from main |
| 1–12 | The main session, back to back | feat/floating-library, in its own worktree (other sessions share the main checkout) | Opens the PR into main |
| Review | The owner | The PR: snapshots, the pasted checks, and an installed build (scripts/install.sh) to try pinch and drag on the trackpad | Feedback becomes commits on the same branch. The owner merges |
- Runners and merging:
- No subagents. One runner keeps
StageView,AppModelandScriptRunnercoherent. - If
mainmoves, the main session merges it intofeat/floating-librarybefore the next step. - Only the owner merges into
main.
- No subagents. One runner keeps
- Owner review: one, at the PR. How pinch and drag feel on a real trackpad can't be checked by a script. The look is set by the approved prototype.
- Independent review: none.
- The change doesn't touch what's written to the site, proposals or approval.
- Its one change to the MCP surface is a read-only render parameter that writes nothing (step 5), covered by its tests and
docs/cli.md. - The acceptance pass checks that the published art is byte-identical.
Risks#
- ⌘[ already had a job in objects.
- ] and [ step layers in an object, and ⌘] and ⌘[ did too.
- The owner chose (2026-10-22) that Back takes ⌘[ and layers keep plain ] and [.
- Step 7 drops the ⌘ variants, step 10 updates the README row, and the R11 test pins both.
- The riskiest step is pinch zoom anchored under the pointer (step 1).
- The stage's content size changes with the zoom. Its
ScrollViewis SwiftUI on top ofNSScrollView, so the clip view's origin has to be set after layout. Otherwise the art jumps. - Handled by setting it on the next layout pass from the anchor's scene point, which
StageGeometrymaps both ways. The R1 test checks the anchor stays within a pixel. - If it can't be made reliable, the step falls back to centering on the stage, and the plan is updated.
- The stage's content size changes with the zoom. Its
- Magnify events and the art at whole zoom. The stage never scales the image mid-pinch (the spec says no smooth zoom), so there's no blurry frame. The accumulators reset at the gesture's end (
event.phase), so one pinch can't run away across many levels. - Space and the Hand in objects. The Hand button's held pan has to stop when another tool is picked, or the pencil stops painting. Covered by a test in step 3.
- Esc has several jobs. On the stage it already clears the selection and leaves a layer, and it stops placing. With the popover open, it closes the popover only after the stage has nothing else to undo. The order is tested in step 4.
- Drag from the popover. The popover is an in-window overlay, not an
NSPopover, so the existingonDragand the stage's drop keep working (D5). Nothing new in the drag path. - The test hook and acceptance. The aliases keep the existing scripts running.
layers.txtis the only one whose expects change. A failingexpectabout the art means the code is wrong, not the script (CLAUDE.md), unless the site's art changed. - The Agent window's ⌘↩ (Approve) and Edit › Edit Object (⌘↩) live in different windows. The menu item is enabled only when the main window is key and a scene item is selected. Checked in step 7 with
agentkey cmd+returnin an existing chat script. - The Command Line Tools build. No
@State: all new state lives onAppModelor the editors. - Hidden items are keyed by path (D12).
- A path moves when an edit or depth sort reorders items. The app's own edits report that in
reordered, and the entry follows it. - Outside changes can't be followed, so an entry whose path no longer holds its object is dropped.
- Tested in step 6. The rare case of an outside edit that swaps two items of the same object is accepted (spec, Areas of concern).
- A path moves when an edit or depth sort reorders items. The app's own edits report that in
- A layer and an item path with the same text in
hide. The layer wins, and the test in step 5 pins it. Layer names are words; item paths are numbers with dots. - Options not chosen:
- An
NSPopoverfor the Library: it closes on a drag out, and doesn't take board 03's look. - Smooth zoom: it breaks whole-zoom pixel art.
- Splitting the work into app slices: they'd share
StageView,AppModelandScriptRunner.
- An
Proof#
Run from the feat/floating-library worktree, with the output pasted into the PR:
swift build --package-path appwith no errors, andscripts/test-app.shwith every test passing, including the newR1–R14tests inCanvasTests.swiftand theR19tests inLayerTests.swift.cargo fmt --check,cargo testwith no failures (including the R19 render tests), andnpm testwith# fail 0.scripts/build-app.sh, thenscripts/acceptance.sh --only window,scene-object,canvas,layers,document,agent,mcp-agent,stale,stale-agent,chat,chat-harbor,examples-harbor,examples-townexits 0 (the stages this change touches, and the ones whose script changed), withwindow.txtin the log ending "all steps passed" and the hero island's SVG byte-identical.npm run docs:sitebuilds with no broken links, andnpm run docs:clileavesgit statusclean apart from the committedscene.renderchange.- Snapshots in dark and light of the four views in step 9, compared with the prototype boards
Groups.dc.html,Object.dc.htmlandAgentChat.dc.html, and attached to the PR.
Departures#
What was built differently from the plan above (the order of the steps held; one commit each, apart from the docs commit landing before the test hook's):
- The acceptance stage is
window, notcanvas:scripts/acceptance/canvas.txtis the scene-canvas change's stage. Verification followsCLAUDE.mdonmain:scripts/acceptance.sh --onlywith the stages in Proof. - Pinch and the scene workspace (step 1). The anchor reuses the scene canvas's
keepScroll: a zoom step records the scene point under the pointer (or the middle of the clip) before the zoom changes, andkeepScrollputs it back after layout. A workspace that grows later keeps the visible middle, as it did. Nothing fell back to centering.pinch,zoomkeyandexpect readout|anchorlanded in step 1, not 9, so the step could be checked. - The zoom is seamless (owner, 2026-10-22, after trying the build). R1 and R3 said a pinch steps through whole levels and art is always drawn at a whole zoom; the owner found that off and asked for a seamless zoom.
Zoomgains.scale(Double), and a pinch (and a trackpad's ⌘-scroll) multiplies the zoom by each event's magnification, within the kind's first and last level (1×–4× scenes, 4×–16× objects: the range is unchanged). Between levels the art is nearest-neighbor at a fractional scale, so pixels are uneven widths; ⌘= and ⌘− go to the next whole level, ⌘0–⌘4 are as they were. VoiceOver hears the zoom once, when the gesture ends. The spec's R1, R2 and D2 are superseded by this. - The pinch is the scroll view's own, and there's no readout (owner, same day). The first seamless version re-laid the stage out on every magnify event and shook. The pinch is now
NSScrollView's native magnification (allowsMagnification, limited to the kind's range): the stage scales on the GPU about the fingers, with nearest-neighbor layers, and when they liftcommitMagnificationturns the magnification into the zoom, lays the stage out crisply and keeps the top-left of what was visible. ⌘-scroll on a trackpad uses the same magnification, committed when the scrolling pauses. The zoom readout (R3) is removed.pinchby <factor>drives the native path in the script hook. tool placefails with a pointer tolibrary <name>(spec D10), rather than being an alias;scripts/acceptance/layers.txtchanged in step 4, wheresidebarTabwent (expect tab library→expect library open,sidebar layers→library close, and theexpect tool placeline was dropped).- A drop does not start placing. Placing is
placeObject != nil, and a drop used to leave its object chosen; it now places one and leaves the state alone. - Esc order: a drag, the layer menu, the selection, the layer you're inside, then placing, then the Library.
- Search finds objects. With the Library gone from the sidebar, a sidebar search lists matching objects under Scenes, and a row opens one, so "opening an object from Search" (R11) still works.
editObjectandreturnScenecame in step 4 (the popover's double-click needed them); step 7 added Back, its pill and the menus.returnSceneis cleared when any scene opens.- Edit › Edit Object (⌘↩) is enabled only while the main window is key, so the Agent window's Approve (⌘↩) keeps its key; the
agentstage'sagentkey cmd+returnchecks it. The Show/Hide Inspector menu item went with step 7's menu edits, not step 8's. - ⌘] and ⌘[ never stepped layers in code (the object editor ignores ⌘ keys); only the README said so. Back takes ⌘[ as a menu shortcut and the README row is corrected.
- The sidebar toggle is removed with
.toolbar(removing: .sidebarToggle)on the sidebar column; on the split view it left the button in place. - Snapshots are in
snapshots/beside this plan, made bysnapshots/snapshots.txton a temp copy of the harbor example. The Agent tab isn't among them: showing it starts the chat, and the fake Claude Code is for the acceptance stages. - A zoom keeps the view where it was. The stage's scroll view centered itself again whenever its content changed size (
defaultScrollAnchor(.center)also applies to size changes). It now centers only when a doc opens (for: .initialOffset), and the scroll is put back three times after a zoom (at once, 50 ms and 200 ms later), since SwiftUI settles a resized scroll view on its own schedule. - A zoom never grows the workspace. A zoom out could still flash the stage to the middle: the scene's workspace was padded by a viewport's worth at the current zoom, so zooming out far (4× to 1× in one pinch) needed a bigger window, and until that render landed the stage was laid out with the old one, smaller than the viewport and so centered. The pad is now a viewport's worth at the kind's lowest zoom (
AppModel.workspacePad), so the workspace holds the stage at every zoom a pinch can reach (it is capped at 1024 scene pixels a side, as before) and only the box, the cursor and the viewport grow it. The script hook'sexpect anchor steadywatches every frame drawn for half a second after a zoom and fails if the anchor strayed more than a point on the way (window.txtpinches 4× to 1×); before the fix that pinch strayed 803 points.