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:categoryjoinsOBJECT_PROPSandBLOCK_PROPS.- A validator checks R2 (
category_problems).
crates/engine/src/serialize.rs:categorycomes afternotesin 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
.pixellibpackages and.pixelpalfiles (D3). - The import and palette plans (renames,
same,characterswap, differing, missing, nearest, caps), as pure functions overSources.
- Reads, validates and writes
crates/pixellab/src/commands/library.rs(new):library.info,library.category,library.exportandlibrary.import(D2, D4, D5).crates/pixellab/src/commands/palette.rs(new):palette.exportandpalette.import(D4, D6).palette.getstays indocument.rs.crates/pixellab/src/commands/object.rs:object.listgets acategoryfilter and acategoryfield.object.showgetscategory.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, andchat_askbefore accepting differences (R28).crates/pixellab/src/help.rs: the two file kinds inpixellab help(R30).crates/pixellab/tests/categories.rs(new) andcrates/pixellab/tests/libraries.rs(new): R1–R23 through the CLI andserve --stdio, with MCP tool listing inservers.rs.crates/pixellab/tests/common/: amake_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) andexamples/coast.pixelpal(new), made with the program's ownlibrary exportandpalette 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:libraryTabper 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 inObjectFactsSection(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.pixellibor.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 forpixellibandpixelpal(D3).scripts/acceptance/libraries.txt(new) andscripts/acceptance.sh: R29 on temp copies ofexamples/.
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) andscripts/acceptance.sh: R24–R26.- The script runs on a temp copy of the site's document and a
git archivecopy 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.
- The script runs on a temp copy of the site's document and a
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 asdocs/contributing.mdsays.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
mainin PR #53. Once the owner merges it,feat/object-librariesis cut frommain, 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)
- Categories in the engine.
categoryis a valid object and block property, with R2's checks invalidate, placed afternotesin the key tables.- Tests: "R1: …", "R2: …" and "R3: categories never change a render or a compiled SVG", on the starter and the harbor example.
- 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.listfilter andcategoryfield, andobject.show'scategory. - Tests "R9: …".
- The library package and plans (
library.rs):- Read, validate (
check --library) and write, canonical. - The import plan: renames,
same, thecharacterswap, differing, missing with the nearest color, wrong tier. - The palette plan: new, differing, other-tier, over the cap.
- Unit tests for each plan case.
- Read, validate (
- Library commands:
library.export(all, category or names, with bases,replace, a temp folder then a rename).library.import, which writes likehistory.restore'sput_files: mark, create the objects, save each, mark. That makes one version, and its number is in the result.library.infoandlibrary.category.- Tests "R10: …" to "R17: …", including the
differsandmissing_colorsrefusals,dryRun, a two-levelextendsrename, and the site-shapedcharactercase.
- Palette commands:
palette.export.palette.import: one edit on the palette doc, saved, so one undo step and one version.- Tests "R19: …" to "R23: …".
- Doors, docs and examples.
- The MCP instructions and the help text.
npm run docs:cli.scripts/make-examples.mjsgives harbor and town categories, then writescoast.pixellibandcoast.pixelpalby running the built program on a temp copy of harbor.npm run examplesmakes them, andnpm testchecks they match (R27).- Tests "R28: every new command is an MCP tool".
PR 2: the app (steps 7–11)
- The protocol and model:
ObjectSummary.category.LibraryCategories: tabs, sections and the flattened order, in pure functions.AppModel.libraryTabper document.- Swift tests "R5…R7".
- 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 expectstabs,libtab,headings.
- The inspector's Category row (D8): path parsing (› or /), the menu of categories, and R2 errors shown in place. Script actions
category, and expectcategory. - 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
.pixelliboffers Import. - UT and document types in
build-app.sh. - Script actions
import,importsheetandexport, and expectimportsheet,libraryobjects.
- Acceptance on the examples (R29):
scripts/acceptance/libraries.txt, added toacceptance.sh: categories and tabs on harbor, export a category, importcoast.pixellibinto 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)
- The site's categories (R24–R26):
categories/from-spec.mjswritessite.jsonandexplainer.jsonfrom the spec's appendices.site-categories.txtruns R26's four steps throughclilines and expects on temp copies.acceptance.shruns it.
- Docs: README,
docs/architecture.md,docs/libraries.mdand its docs-site link.npm run docs:sitebuilds with no broken links. - 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), withcoast.pixellibandcoast.pixelpalin a temp folder, for the owner to try. - Open PR 4.
Workflow and reviews#
| Step | Who | Where | Then |
|---|---|---|---|
| Plan | main session | feat/object-libraries | commit, push |
| PR 1, steps 1–6 | subagent (Sonnet), in its own worktree | feat/object-libraries-program | opens PR 1 into feat/object-libraries. The main session reads the diff, reruns the program checks, and merges it |
| PR 2, steps 7–11 | subagent (Sonnet), in its own worktree, after PR 1 merges | feat/object-libraries-app | opens PR 2. The main session reads the diff, looks at the snapshots, reruns the app checks, and merges it |
| Steps 12–14 | main session | feat/object-libraries | PR 4 into main, and the app running for the owner |
| Review | subagent (Opus), with only "Use the verify-change skill on PR #<PR 4>." | read-only | PR 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-librarieswithgh pr merge --squash --delete-branch, once their checks pass. Only the owner merges PR 4 intomain. - When
mainmoves, the main session merges it intofeat/object-librariesbefore 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 onfeat/object-librariesfirst.
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-changerun 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 runshistory.undoon that version while no edit has followed, and Versions offers the same thing. - Recorded under Departures.
- The session's undo is per doc, so "one undo step" (R12) can't be one
- 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 throughgit archiveand temp copies. - The
extendsrewrite 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 issame. - The
characterswap. It must count as a difference and never happen silently. A test imports a library with a differingcharacterand expectsdiffers. - 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.shtakes minutes. Run it in the background.
Proof#
- Program:
cargo fmt --checkcargo test, with no failures, including the new "R…" testsnpm test, showing# fail 0, which includes the examples matchingnpm run docs:cli, leavinggit statusclean apart from the change's own files
- App:
swift build --package-path app, with no errorsscripts/test-app.sh, with every test passingscripts/build-app.sh
- End to end:
scripts/acceptance.sh, exiting 0, withlibraries.txtandsite-categories.txtpassing- 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.categoryon 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_filesand the version's label.history.rsput_filesgets aonceflag, 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::recordskips empty labels. The version's author is whoever asked (you, an agent or the CLI): unlike a restore or an undo, whichput_filescredits as "a restore (you)", an import is its requester's own edit (changed after theverify-changerun 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.importis one edit onpalette:(DocKind::Palette, whichSessionnow opens, saves and merges), anddoc.save,doc.undoand the rest takepalette:orpalette.json. The CLI and the MCP server save it after the call, so it is one undo step and one version. --dry-runisdryRun. A command with its owndryRunparam (library.import,palette.import) takes the global--dry-runflag as that param.check --libraryneeds no document.Command.no_document_withnames 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 afateofnew,renamed,sameorcharacter),renames,differing,character,missing(withnearest),needsAcceptDifferencesandready; a refusal'sdetailsis 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 bluerwater-3and an unplacedrockof its own, so importingcoast.pixellibinto it plans a rename, a conversion and a difference, andcoast.pixelpaladdsgold-4and asks aboutwater-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 inTheme.inkbeside 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.undoon the import's version while that is the newest version (ImportModel.canUndo), anddoc.undoonpalette: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
.pixellibit is given (a package), and Finder's double-click offers both kinds; a.pixelpalis 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,dropfileandanswer 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 expectslibraryorder,categorydraft,categoryerror,objectcategory,palettecolor,importundo,offerandimportsheetwithsheet|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.mjsreplaces the plannedsite-categories.txt,categories/site.json,categories/explainer.jsonandcategories/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 (likemcp-agent.mjs), and it reads Appendices A and B straight fromspec.md, so the mapping can't drift from the spec and no generated JSON is kept. It's thesite-categoriesstage ofscripts/acceptance.sh. - Files that changed beyond the list above (found by the
verify-changerun on #57):- The program:
crates/engine/tests/categories.rs(step 1's tests, rather thancrates/engine/tests/engine.rs);crates/pixellab/src/cli.rs,commands/common.rsandtests/params.rs(the global--dry-runasdryRun, andno_document_with);commands/history.rs,history.rsandsession.rs(put_files, the label, and the palette as a session doc);tests/common/mod.rsandtests/servers.rs. - The app:
Model/ObjectEditor.swiftandViews/SidebarView.swift(the object's category, and the lists refreshing). - Docs:
CLAUDE.md(the hook's new words),docs/README.md,docs/document-format.mdandscripts/docs-site/build.mjs(the new page).
- The program: