Pixel Labdeveloper docs

Design record · 2026-10-object-libraries

Plan: Object libraries and palette files (from intent.md 2026-10-23)#

This plan builds spec.md, R1–R30, as approved with its Areas of concern settled.

Files that change#

Slice 1: the program (feat/object-libraries-program)

  • crates/engine/src/engine.rs:
    • category joins OBJECT_PROPS and BLOCK_PROPS.
    • A validator checks R2 (category_problems).
  • crates/engine/src/serialize.rs: category comes after notes in the object and block key tables.
  • crates/engine/tests/engine.rs: R1–R3 validation and output tests.
  • crates/pixellab/src/library.rs (new):
    • Reads, validates and writes .pixellib packages and .pixelpal files (D3).
    • The import and palette plans (renames, same, character swap, differing, missing, nearest, caps), as pure functions over Sources.
  • crates/pixellab/src/commands/library.rs (new): library.info, library.category, library.export and library.import (D2, D4, D5).
  • crates/pixellab/src/commands/palette.rs (new): palette.export and palette.import (D4, D6). palette.get stays in document.rs.
  • crates/pixellab/src/commands/object.rs: object.list gets a category filter and a category field. object.show gets category.
  • crates/pixellab/src/commands/object_edit.rs: object.category.
  • crates/pixellab/src/commands/check.rs: --library.
  • crates/pixellab/src/commands/mod.rs, crates/pixellab/src/registry.rs: the new modules.
  • crates/pixellab/src/lib.rs: mod library.
  • crates/pixellab/src/server_mcp.rs: the instructions mention categories, libraries, palette files, and chat_ask before accepting differences (R28).
  • crates/pixellab/src/help.rs: the two file kinds in pixellab help (R30).
  • crates/pixellab/tests/categories.rs (new) and crates/pixellab/tests/libraries.rs (new): R1–R23 through the CLI and serve --stdio, with MCP tool listing in servers.rs.
  • crates/pixellab/tests/common/: a make_library() helper, if the tests need one.
  • docs/cli.md: generated.
  • scripts/make-examples.mjs, examples/harbor.pixellab/objects/*, examples/town.pixellab/objects/*:
    • Categories for the example objects.
    • examples/coast.pixellib/ (new) and examples/coast.pixelpal (new), made with the program's own library export and palette export (R27).
  • scripts/test/examples.test.mjs: or whichever test checks the examples match; it covers the two new files too.

Slice 2: the app (feat/object-libraries-app)

  • app/Sources/PixelLab/Server/Protocol.swift: ObjectSummary.category, and the import plan and result types.
  • app/Sources/PixelLab/Model/AppModel.swift: libraryTab per document, librarySections, the export and import flows, Undo Import (see Departures), and opening a .pixellib (R18).
  • app/Sources/PixelLab/Model/LibraryCategories.swift (new): tabs, sections and flattened cursor order as pure functions. The tests use it.
  • app/Sources/PixelLab/Model/ImportModel.swift (new): the import sheets' state (renames, differences, conversions, keep/theirs, over-cap picks).
  • app/Sources/PixelLab/Views/LibraryPopover.swift: the tab row, headings and keys (D7).
  • app/Sources/PixelLab/Views/ObjectPanels.swift: the Category row in ObjectFactsSection (D8).
  • app/Sources/PixelLab/Views/ImportSheets.swift (new): the library and palette import sheets (D5, D6).
  • app/Sources/PixelLab/Views/MainWindow.swift: presents the sheets, and accepts a dropped .pixellib or .pixelpal.
  • app/Sources/PixelLab/PixelLabApp.swift: the File menu's items (D10), and the Edit menu's Undo Import.
  • app/Sources/PixelLab/Support/ScriptRunner.swift: D9's actions and expects.
  • app/Tests/PixelLabTests/LibraryTests.swift (new): R5–R8 sections and keys, the R4 path parsing, and the import model.
  • scripts/build-app.sh: UT types and document types for pixellib and pixelpal (D3).
  • scripts/acceptance/libraries.txt (new) and scripts/acceptance.sh: R29 on temp copies of examples/.

Integration (the main session, on feat/object-libraries)

  • scripts/acceptance/site-categories.txt (new), scripts/acceptance/categories/site.json (new), scripts/acceptance/categories/explainer.json (new) and scripts/acceptance.sh: R24–R26.
    • The script runs on a temp copy of the site's document and a git archive copy of the explainer document, read-only from the site checkout.
    • The JSON is made from the spec's appendices by scripts/acceptance/categories/from-spec.mjs (new), so the two never drift.
  • README.md: the tour's Library, the File menu, and importing.
  • docs/architecture.md: the two file kinds and the library commands.
  • docs/libraries.md (new): the docs-site page "Libraries and palette files", linked as docs/contributing.md says.
  • docs/intents/2026-10-object-libraries/plan.md: departures, as they happen.

Order of work#

main ── (PR #53: intent, spec and plan) ── feat/object-libraries ───── PR 4 (owner merges) ──▶ main
                                                          ├─ PR 1  program  ◀─ feat/object-libraries-program
                                                          ├─ PR 2  app      ◀─ feat/object-libraries-app   (after PR 1 merges)
                                                          └─ integration commits (docs, site categories) ─ then PR 4
  • The intent, spec and plan go to main in PR #53. Once the owner merges it, feat/object-libraries is cut from main, and the slices start.
  • There are two slices, because the app is built on the program's commands: the program is the foundation, merged and checked before the app slice starts.
  • The docs and the site-categories pass are small and need both slices, so the main session commits them straight onto feat/object-libraries.

PR 1: the program (steps 1–6)

  1. Categories in the engine.
    • category is a valid object and block property, with R2's checks in validate, placed after notes in the key tables.
    • Tests: "R1: …", "R2: …" and "R3: categories never change a render or a compiled SVG", on the starter and the harbor example.
  2. The category commands.
    • object.category (one or more names, set or clear, one session edit per object, labels "Set pine's category to Nature › Trees").
    • The object.list filter and category field, and object.show's category.
    • Tests "R9: …".
  3. The library package and plans (library.rs):
    • Read, validate (check --library) and write, canonical.
    • The import plan: renames, same, the character swap, differing, missing with the nearest color, wrong tier.
    • The palette plan: new, differing, other-tier, over the cap.
    • Unit tests for each plan case.
  4. Library commands:
    • library.export (all, category or names, with bases, replace, a temp folder then a rename).
    • library.import, which writes like history.restore's put_files: mark, create the objects, save each, mark. That makes one version, and its number is in the result.
    • library.info and library.category.
    • Tests "R10: …" to "R17: …", including the differs and missing_colors refusals, dryRun, a two-level extends rename, and the site-shaped character case.
  5. Palette commands:
    • palette.export.
    • palette.import: one edit on the palette doc, saved, so one undo step and one version.
    • Tests "R19: …" to "R23: …".
  6. Doors, docs and examples.
    • The MCP instructions and the help text.
    • npm run docs:cli.
    • scripts/make-examples.mjs gives harbor and town categories, then writes coast.pixellib and coast.pixelpal by running the built program on a temp copy of harbor. npm run examples makes them, and npm test checks they match (R27).
    • Tests "R28: every new command is an MCP tool".

PR 2: the app (steps 7–11)

  1. The protocol and model:
    • ObjectSummary.category.
    • LibraryCategories: tabs, sections and the flattened order, in pure functions.
    • AppModel.libraryTab per document.
    • Swift tests "R5…R7".
  2. The Library popover (D7):
    • The tab row, its quiet scroller and accent underline.
    • Headings as section headers, and search within the tab.
    • The keys from R8, and the VoiceOver labels.
    • Script actions librarytab, and expects tabs, libtab, headings.
  3. The inspector's Category row (D8): path parsing (› or /), the menu of categories, and R2 errors shown in place. Script actions category, and expect category.
  4. Import and export (D5, D6, D10, R18):
    • The File menu items, the save and open panels, and dropping a file on the window.
    • The library and palette import sheets, filled from dryRun, and Undo Import.
    • Opening a .pixellib offers Import.
    • UT and document types in build-app.sh.
    • Script actions import, importsheet and export, and expect importsheet, libraryobjects.
  5. Acceptance on the examples (R29):
    • scripts/acceptance/libraries.txt, added to acceptance.sh: categories and tabs on harbor, export a category, import coast.pixellib into town (a rename, a conversion), a palette import with Keep Mine, and Undo Import.
    • Snapshots of the popover with tabs and the import sheet, in dark and light.

Integration on feat/object-libraries (steps 12–14)

  1. The site's categories (R24–R26):
    • categories/from-spec.mjs writes site.json and explainer.json from the spec's appendices.
    • site-categories.txt runs R26's four steps through cli lines and expects on temp copies.
    • acceptance.sh runs it.
  2. Docs: README, docs/architecture.md, docs/libraries.md and its docs-site link. npm run docs:site builds with no broken links.
  3. The final run:
    • Every check in Proof, on the merged branch.
    • Build the app and open it on temp copies of the examples (scripts/try-example.sh), with coast.pixellib and coast.pixelpal in a temp folder, for the owner to try.
    • Open PR 4.

Workflow and reviews#

StepWhoWhereThen
Planmain sessionfeat/object-librariescommit, push
PR 1, steps 1–6subagent (Sonnet), in its own worktreefeat/object-libraries-programopens PR 1 into feat/object-libraries. The main session reads the diff, reruns the program checks, and merges it
PR 2, steps 7–11subagent (Sonnet), in its own worktree, after PR 1 mergesfeat/object-libraries-appopens PR 2. The main session reads the diff, looks at the snapshots, reruns the app checks, and merges it
Steps 12–14main sessionfeat/object-librariesPR 4 into main, and the app running for the owner
Reviewsubagent (Opus), with only "Use the verify-change skill on PR #<PR 4>."read-onlyPR comment. The main session fixes blocking findings on feat/object-libraries before handing over

Merging and ownership

  • The main session merges PRs 1 and 2 into feat/object-libraries with gh pr merge --squash --delete-branch, once their checks pass. Only the owner merges PR 4 into main.
  • When main moves, the main session merges it into feat/object-libraries before the next slice starts.
  • The slices run one after the other, so no two runners own the same files.
  • Slice 1 doesn't edit app/.
  • Slice 2 doesn't edit crates/. If it needs a command change, it stops and reports, and the main session makes the change on feat/object-libraries first.

Reviews

  • Owner: the owner reviews only at the end: PR 4, with the app running on example copies to try, and the Settled concerns in the spec to confirm. The owner asked for the change to be built to completion, so nothing pauses before then.
  • Independent: one verify-change run on PR 4. The change adds a file format other documents will depend on, a new MCP surface, and import paths that must never change existing art.

Risks#

  • Undo for an import that spans many docs.
    • The session's undo is per doc, so "one undo step" (R12) can't be one doc.undo.
    • Handled: the import is one version (put_files's pattern). The app's Edit › Undo Import runs history.undo on that version while no edit has followed, and Versions offers the same thing.
    • Recorded under Departures.
  • Categories reaching output (R3). This is the riskiest rule, because it touches what the site publishes.
    • Step 1's test compiles the harbor and town examples with and without categories and compares bytes.
    • Step 12 does the same on a copy of the site's document.
    • Stop if either differs.
  • An older engine refusing category. The site's pin must move first (spec, Areas of concern 1). Nothing in this change writes to the site repo. Step 12 reads it only through git archive and temp copies.
  • The extends rewrite on rename. A missed reference leaves an imported object extending the document's own same-named object, and silently changes its look. The tests cover a two-level chain, and a rename whose base is same.
  • The character swap. It must count as a difference and never happen silently. A test imports a library with a differing character and expects differs.
  • The popover's height and keys. The tab row takes room from the grid, and the cursor must cross headings. Swift tests cover the flattened order. The snapshots check the fixed height.
  • Long-running checks. scripts/acceptance.sh takes minutes. Run it in the background.

Proof#

  • Program:
    • cargo fmt --check
    • cargo test, with no failures, including the new "R…" tests
    • npm test, showing # fail 0, which includes the examples matching
    • npm run docs:cli, leaving git status clean apart from the change's own files
  • App:
    • swift build --package-path app, with no errors
    • scripts/test-app.sh, with every test passing
    • scripts/build-app.sh
  • End to end:
    • scripts/acceptance.sh, exiting 0, with libraries.txt and site-categories.txt passing
    • The site's published SVGs byte-identical before and after categorizing and importing
  • UI: snapshots in dark and light of:
    • the Library popover with tabs and headings (harbor, categorized)
    • the inspector's Category row
    • the library import sheet with a rename and a conversion
    • the palette import sheet Each is looked at before it's reported, and they're attached to PR 4.
  • Docs: npm run docs:site, building with no broken links.
  • For the owner: the built app open on a temp copy of an example, ready to try.

Departures#

  • R12 and D5, "one undo step": the session's undo is per doc. A library import is instead one version, which Edit › Undo Import (while nothing has followed it) and Versions' Undo These Changes take back whole.
  • R9, "several objects' at once, in one edit": object.category on several objects is one command and one version, with one undo step per object.
  • Built in PR 1 (the program) differently from the plan:
    • put_files and the version's label. history.rs put_files gets a once flag, so an import's label is carried by its first file only and the version lists "Imported 12 objects from X.pixellib" once, not twelve times; History::record skips empty labels. The version's author is whoever asked (you, an agent or the CLI): unlike a restore or an undo, which put_files credits as "a restore (you)", an import is its requester's own edit (changed after the verify-change run on #57). An undo of any version now lists its label once too. Objects are created bases first, so each saves after the object it extends.
    • The palette is a session doc. palette.import is one edit on palette: (DocKind::Palette, which Session now opens, saves and merges), and doc.save, doc.undo and the rest take palette: or palette.json. The CLI and the MCP server save it after the call, so it is one undo step and one version.
    • --dry-run is dryRun. A command with its own dryRun param (library.import, palette.import) takes the global --dry-run flag as that param.
    • check --library needs no document. Command.no_document_with names the param (library) that makes a command run without opening one, and a failed library check exits 1.
    • Result shapes are in the PR description: the import plan has objects (each with a fate of new, renamed, same or character), renames, differing, character, missing (with nearest), needsAcceptDifferences and ready; a refusal's details is the same plan.
    • The examples differ a little. The starter palette is full (32 world, 16 outfit), so no color can be added to the harbor. Instead the town lacks gold-4, has a bluer water-3 and an unplaced rock of its own, so importing coast.pixellib into it plans a rename, a conversion and a difference, and coast.pixelpal adds gold-4 and asks about water-3. None of it shows in the town's scene, whose SVG is unchanged. The harbor's barrel is left without a category so the Library has an Others tab.
  • Built in PR 2 (the app) differently from the plan:
    • The tab row's room. The row is 24 pt and has an 8 pt gap above it, so the grid gives up 32 pt, not 28, and the popover keeps its height.
    • No Theme.warning. Board 03 has no such token (gold is "a fill only, never text on light"), so an R2 reason under the Category field, and an error in a sheet, are in Theme.ink beside a gold triangle.
    • Cells read "pine, Nature › Trees" for every cell with a category, and just the name for one without (it was "Place pine"); the hint says it places.
    • Edit › Undo Import is history.undo on the import's version while that is the newest version (ImportModel.canUndo), and doc.undo on palette: for a palette import while no other doc has an unsaved edit and the palette doc's undo label is unchanged. Either way, any later edit hands ⌘Z back to the ordinary undo.
    • The palette sheet always shows (when the file has anything new, differing or in another tier); the library sheet shows only when there is a rename, a difference or a missing color, and otherwise the import goes ahead at once and announces its summary.
    • Lists refresh after an object's undo or redo, after a category is set, and when an agent edits an object that isn't open, so the tabs never lag the data.
    • Opening a file. Open Document… offers a .pixellib it is given (a package), and Finder's double-click offers both kinds; a .pixelpal is a plain file the panel can't choose, so it comes in through Import Palette…, a drop or Finder.
    • The hook has a few more words than D9 lists: libraryquery (the popover's search), openfile, dropfile and answer offer import|cancel (R18 and the window drop, which a script can't do with a real drag), importsheet add|remove (the over-cap picks), and the expects libraryorder, categorydraft, categoryerror, objectcategory, palettecolor, importundo, offer and importsheet with sheet|nearest|new|over|count|button|canimport|error.
    • The export panel has an Objects menu (accessory view) listing every category and each path above it, and a replace is passed only after the panel's own "Replace?" question.
  • Step 12, the site's categories, is one Node stage. scripts/acceptance/site-categories.mjs replaces the planned site-categories.txt, categories/site.json, categories/explainer.json and categories/from-spec.mjs: the four steps of R26 are all CLI calls with no app window, so they run as a Node script through the bundled program (like mcp-agent.mjs), and it reads Appendices A and B straight from spec.md, so the mapping can't drift from the spec and no generated JSON is kept. It's the site-categories stage of scripts/acceptance.sh.
  • Files that changed beyond the list above (found by the verify-change run on #57):
    • The program: crates/engine/tests/categories.rs (step 1's tests, rather than crates/engine/tests/engine.rs); crates/pixellab/src/cli.rs, commands/common.rs and tests/params.rs (the global --dry-run as dryRun, and no_document_with); commands/history.rs, history.rs and session.rs (put_files, the label, and the palette as a session doc); tests/common/mod.rs and tests/servers.rs.
    • The app: Model/ObjectEditor.swift and Views/SidebarView.swift (the object's category, and the lists refreshing).
    • Docs: CLAUDE.md (the hook's new words), docs/README.md, docs/document-format.md and scripts/docs-site/build.mjs (the new page).