Spec: The engine in Rust, and a site that only takes its output (from intent.md 2026-10-21)#
Status: approved.
This spec turns the accepted intent, as amended on 2026-10-21 with the owner's decisions about the hop, the budgets and the art tests, into requirements and a design. It supersedes parts of ../2026-10-pixellab-document/spec.md: R6's "the modules are exported from the package so the site's tests can import them", R15's git-installed package, and R7's over-budget failure. Everything else in that change (the document as a folder bundle, resolution, publish folders, Save As) stands, now on the Rust program and JSON sources.
What the site uses today was read from jlawcordova.github.io at main on 2026-10-21, read-only:
- Library imports: seven test files and
scripts/blog-thumbnails.mjsimportpixellab/*. - Animation CSS:
src/styles/pixel-art.cssholds the frame loops (wf/ff/hf), the clouds' drift (pcloud,pc1,pc2), the character's bob (cbob) and its hop (chop, on.cbob.is-hopping). - Triggers:
Range.astrotogglesis-hopping, andHero.astroandRange.astroplay the "build itself" entrances from.entergroups with the Motion library. - The document: 204 objects, 28 scenes and a palette. All of it is literal data: no imports, functions or spreads. Its only comments, apart from the generated header lines, are in
palette.mjs. - Frame loops: every one in the document already has the frame count
pixel-art.cssplays (ff4,wf5,hf6).
Requirements#
The program#
R1. One Rust program replaces the Node CLI.
pixellabis a single native executable with the same commands, params, JSON results, human output, exit codes and errors as the Node CLI it replaces: every command indocs/cli.md, the Agent chat'schat.askandchat.contextincluded. It keeps all three doors: the CLI,serve --stdio(the app's JSON-RPC, same methods and notifications) andserve --mcp(the same tools, instructions and left-out commands, with saves still becoming proposals).R2. It is the only engine. Validating, resolving, rendering, depth order, motion, compiling, the lossless check, previews, edits and serializing are all in Rust. When the change is complete, no JavaScript engine is left in this repo, the npm package stops exporting
pixellab/*, and no site imports one.R3. Parity before cutover. Until the Node CLI is removed, a parity check runs both programs over a corpus: the test fixture, both examples, and, when a site checkout is present, a temp copy of the owner's document. The Node CLI reads each document as it is, in format 1. The Rust program reads a temp copy migrated to format 2 (R8). Their outputs must agree in four ways:
- every scene's SVG is byte-identical;
- every preview PNG is pixel-identical (dimensions and decoded RGBA);
- every read command's JSON result is equal (ignoring
ms, ids and times); - validation problems are the same text, apart from the file extension.
R3 holds for the outputs as they are today, before R11–R15 change them.
R4. Over budget warns, never fails. A scene over its raw or gzip budget is still written, and its result is
ok: truewith awarningslist. This applies everywhere:art buildover the CLI prints the warning and exits 0;- the app shows it in the publish report;
- over MCP, the tool result carries it;
- the site's build in CI prints it.
Problems in the sources (validation errors) still stop a build, and nothing is written.
R5. Dependencies. The program uses only the three crates the owner approved for this change:
serde,serde_json(withindexmap) andminiz_oxide(D2). Any other crate needs the owner's approval, as npm and Swift packages do today.
The document#
R6. Format 2: JSON sources. A format-2 document holds:
manifest.jsonwith"format": 2;palette.json;objects/<name>.json;scenes/<name>.json.
Each source file holds what the
.mjsfile's default export holds today, as JSON, with the same keys and the same meaning. Every file equals the canonical serialization of itself (D4), so hand edits and saves give small, comparable diffs.R7. Notes. The palette keeps its comments:
notes, a list of lines, holds the header text;colorNotesmaps a color's name to its note, for example"soil-3": "--color-earth".
An object or scene may have
notestoo. The generated header line ("Pixel-art object. How to edit it: …") is dropped.R8. Migration.
pixellab document migrate [--to <path>]converts a format-1 document (.mjssources, or the legacysource/folder) to format 2:- It converts in place by default, or writes a copy with
--to. - It carries every comment into notes.
- Before writing, it checks that the migrated document builds byte-identical SVGs to the original, and refuses if it doesn't.
- It works over all three doors, and over MCP it is left out, like
document.copy. - It is written in Rust, so it outlives the Node CLI.
- It converts in place by default, or writes a copy with
R9. Format 1 is migrated, not edited. After cutover, opening a format-1 document gives the problem "<name> uses the old format. Migrate it with
pixellab document migrate, or in the app." The app's setup screen offers Migrate…, which asks first and then migrates in place.R10. The examples and the fixture are format 2.
examples/harbor.pixellab,examples/town.pixellabandcli/test/fixtures/sample.pixellabare migrated.npm run examplesregenerates them with the program, andnpm testchecks they match.
Self-contained art#
R11. Frame loops in the SVG. An SVG whose art has a frame loop (
wf,ff,hf, or any other loop the engine plays) carries that loop's keyframes, its per-frame timing and its reduced-motion rule in its own<style>. It plays the waypixel-art.cssplays it today: the same cycle, the same frames, and frame 0 under reduced motion.R12. Ambient loops. A scene item and an object's map layer may carry an ambient loop: a repeating move of its own, with its own duration and offset, that steps a whole pixel at a time between keys. It plays in the SVG on its own, and holds still at rest under reduced motion. This replaces the site's
pcloudandcbobclasses, whose motion it can express exactly.R13. Actions. A scene item and an object's map layer may carry named actions: one-shot moves, stepped like ambient loops, that play only when asked. The SVG carries them, and the site plays them through a two-way contract:
- To play an action, the page adds the action's name to a
data-playattribute on the<svg>, or on any group that contains the item. The value is a space-separated list, for exampledata-play="hop". While it plays, the action replaces the element's ambient loop. - To restart it, the page removes the name and adds it back.
- When it ends, the browser fires its standard
animationendevent, which bubbles. ItsanimationNameis the action's documented name (D7). - Under reduced motion, an action doesn't play and no event fires.
This carries the Range character's hop.
- To play an action, the page adds the action's name to a
R14. Crisp edges in the SVG. Every compiled SVG says
shape-rendering="crispEdges"itself, so it renders crisply without the site's.pixel-artrule.R15. No clashes on a page. Every class and keyframe name the compiler generates is scoped to its scene, so two SVGs inlined on one page never clash. The classes and
data-*attributes a document gives its groups and items pass through unchanged (R19).R16. The art moves the same. For every scene in the owner's document, the new SVG on a bare page and the old SVG with
pixel-art.cssshow the same pixels:- at time 0;
- at sampled times across every loop;
- under reduced motion;
- for the hop, through one play.
Timings converted from the site's CSS may differ by no more than 4 ms. A visual check in Chromium proves this (D9).
The site takes only output#
R17. Two builds per release. Each GitHub Release publishes
pixellab-darwin-arm64.tar.gzandpixellab-linux-x64.tar.gz, each holding the one executable, alongside aSHA256SUMSfile and the app's.dmgand.zip. The asset names stay the same from release to release, so the site can pin a version and verify the download.R18. One build makes everything the site uses.
art buildwrites every compiled SVG, and every PNG image a scene declares (D5), into the folders the manifest names. This takes over from the site's thumbnails script. The site's mapping of blog posts to thumbnails stays in the site, as content.R19. A documented contract for what the site reaches into.
docs/site-contract.mdsays what a site may rely on in a compiled SVG:- the root's
data-pixellabattribute; - the classes and
data-*attributes the document's groups and items carry, such asdata-class,enter,data-enter,data-rowanddata-x; - actions, played through
data-playand ended byanimationend; - what reduced motion does.
Nothing else in the SVG is a promise.
- the root's
R20. The site's art tests leave the site. The site's six art suites are sorted three ways:
- Engine behavior comes here, on fixtures. The tests of the engine itself are ported to this repo with their names, and run against the fixture document, not the owner's art. These are
pixel-art-motion's tests: motion validation, stepping,loopFrame, the motion CSS, serializing, and animating, switching or stopping a frame loop. - Generic document rules are the engine's job. Loading and validating, the palette's caps and names, depth order and budgets are already enforced by
art buildandcheck, which the site's CI runs on every build. Three that aren't are added here:- compiling twice gives identical bytes (an engine test on fixtures);
- a compiled SVG has no script, link, text or external reference (an engine test on fixtures);
- a source file that isn't in canonical form is a
checkwarning.
- Rules about the owner's particular art are dropped, not ported. These are the rest of
pixel-art-island,pixel-art-sources,pixel-art-roundtrip,range-artandachievement-icons: the trucks keep to the road, the crane is two stacked objects, each Range floor is three ground rows, and so on. So are the tests that compare the art with the site's own files:range-classes(againstsrc/data/home.ts), the palette ↔variables.csscheck, and the thumbnails' size and staleness checks. For this change, the visual check (R16) proves the migrated art looks and moves the same.
Nothing in this repo's tests or CI needs a site checkout. A document declaring checks of its own is a possible follow-up.
- Engine behavior comes here, on fixtures. The tests of the engine itself are ported to this repo with their names, and run against the fixture document, not the owner's art. These are
The app#
R21. Nothing to install first. The app bundles the program. It never looks for Node, and starting it needs nothing installed. The setup screen asks only for a document. Settings loses its Node.js field, and the stored setting is ignored.
R22. The app runs the bundled program everywhere. That covers:
- its own server (
serve --stdio); - one-off runs (the script hook's
cliaction); - the Agent chat's MCP config, whose
commandbecomes the bundled program.
The
claude mcp addcommand shown in the Agent window, printed byinstall.shand given in the README, points at the bundled program.- its own server (
R23. The app shows and plays what the SVG plays. The stage plays ambient loops on the timeline, as it plays scene motion. An item or layer with actions shows them in the Inspector, each with a Play button that previews it once on the stage. Play has a VoiceOver label and visible focus, and under Reduce Motion it shows the rest frame. Editing ambient loops and actions is done by command in this change (R24). An editing UI is a follow-up.
R24. Every new action is a command. Each is available over all three doors. Over MCP, the edits become proposals.
- setting, changing and clearing an ambient loop on a scene item or an object layer;
- adding, changing and deleting an action;
- rendering a scene or object at a time, with an action at a time into its play.
R25. Clean slate for proposals. Proposals written before format 2 are no longer valid. On first start, the program moves them to
<state dir>/retired-proposals/, where they're no longer listed, approved or rejected. A chat card for one reads "Settled elsewhere", as for any proposal that left the list. Activity, chats and proposal numbers carry on.R26. File names follow the format. Every place the app or the program names a source file says
.json. That includes watching, problems ("scenes/x.json: items[3]: …"), chat notes ("wrote scenes/harbor.json") and the acceptance scripts.
Release, CI and docs#
R27. Release.
.github/workflows/release.yml:- builds the darwin-arm64 program on macOS and the linux-x64 program on Linux;
- runs
cargo teston both; - bundles the macOS program in the app;
- smoke-tests
serve --mcpwith the bundled program; - publishes everything in R17.
The tag is still the version (
PIXELLAB_VERSION). The program reports it inpixellab --versionand inserve --mcp's server info.R28. Checks on every PR. A new workflow runs
cargo testandnpm test(the repo tooling's suites, and the parity check over the fixture and the examples while the Node CLI exists), on Linux. It needs no site checkout.R29. Node stays only as repo tooling. Node is still used by the docs site builder, the fake
claude, the parity and visual checks, and the scripts that drive the program. The app, the CLI, agents and the site never need it. Repo scripts that import the Node engine (make-examples.mjs,make-icon.mjs,docs-cli.mjs) run the program instead.R30. Docs.
CLAUDE.md: its commands, architecture and rules become "the engine lives here in Rust; the site takes only output".README.md: install, the MCP setup and no Node.docs/cli.md: generated by the program.docs/architecture.mdanddocs/testing.md.- The new
docs/site-contract.mdanddocs/document-format.md. - The
2026-10-mac-apprecord and thepixellab-documentspec get a pointer to this change, not a rewrite.
Design#
D0. The migration at a glance#
The diagram shows the whole change, across both repos. The diamonds are gates the requirements set: parity before cutover (R3), the art moving the same before the site switches (R16), and the release before the site's change (concern 8). The order of work between the gates is the plan's.
flowchart TB
subgraph today["Today"]
direction LR
nodecli["Node CLI and engine<br/>cli/, the pixellab npm package"]
doc1[("Document, format 1<br/>palette.mjs, objects/*.mjs, scenes/*.mjs")]
site1["Site imports the library,<br/>commits its SVGs and thumbnails,<br/>pixel-art.css animates the art"]
nodecli -->|reads and writes| doc1
nodecli -.->|"npm pin v0.5.0"| site1
end
subgraph lab["This repo: this change"]
rust["Rust engine and program<br/>crates/engine, crates/pixellab<br/>R1, R2, D1, D2"]
fmt["Format 2 and document migrate<br/>JSON, notes, colorNotes<br/>R6–R10, D3, D4"]
parity{"Parity: Node on format 1<br/>vs Rust on format 2<br/>R3"}
anim["Self-contained SVGs<br/>frame loops, ambient loops,<br/>actions, crispEdges, scoped names<br/>R11–R15, D6, D7"]
visual{"Visual check in Chromium<br/>on a temp copy, site swap rehearsed<br/>R16, D9"}
app["App bundles the program<br/>no Node, Migrate…, Play,<br/>proposals retired<br/>R21–R26, D10, D11"]
cut["Cutover: cli/ and the npm<br/>exports removed<br/>R2, R29"]
rel["Release: the app, pixellab-darwin-arm64,<br/>pixellab-linux-x64, SHA256SUMS<br/>R17, R27"]
rust --> fmt --> parity
parity -->|passes| anim --> visual
visual -->|passes| app --> cut --> rel
end
subgraph sitechange["Site repo: its own change, after the release (D13)"]
mig["pixellab document migrate"]
doc2[("Document, format 2<br/>palette.json, objects/*.json, scenes/*.json")]
swap["pcloud and cbob become ambient loops,<br/>the hop becomes an action,<br/>thumb scenes declare images"]
drop["Drop the library, the art tests,<br/>pixel-art.css keyframes,<br/>committed SVGs and thumbnails"]
ci["CI: download pixellab-linux-x64,<br/>check SHA256SUMS,<br/>pixellab art build, then astro build"]
pub["Published site: each SVG carries its animation,<br/>the A button sets data-play to hop"]
mig --> doc2 --> swap --> drop --> ci --> pub
end
nodecli -.->|"replaced by, after parity"| rust
doc1 -->|in place| mig
rel -->|"pinned version"| ci
owner(["The owner in the app,<br/>agents over MCP"]) -->|"saves and approved proposals"| doc2
D1. Layout#
Cargo.toml workspace: crates/engine, crates/pixellab
crates/engine/ the library: no I/O beyond what callers pass in
src/iso.rs depth.rs motion.rs ports of cli/lib/engine/*.mjs, one module per file
engine.rs edit.rs svg.rs
compile.rs preview.rs
serialize.rs starter.rs
src/ambient.rs ambient loops and actions → CSS (R12, R13)
src/format.rs format 2 read/write, canonical JSON (D4)
src/migrate.rs format 1 → 2: the JS-literal reader (D3)
src/jsnum.rs JS number semantics (D2, "Byte parity")
src/deflate.rs png.rs sha256.rs gzip/PNG/crc32/sha-256 around miniz_oxide (D2)
tests/ ported node:test suites and the site's engine tests, names kept (R1, R20)
crates/pixellab/ the executable
src/main.rs cli.rs argv.rs help.rs
src/registry.rs commands/*.rs one file per cli/lib/commands/*.mjs
src/session.rs document.rs state.rs watch.rs merge.rs diff.rs
src/server_stdio.rs server_mcp.rs jsonrpc.rs chat_context.rs
tests/ servers, commands, help, state, sync
scripts/parity.mjs R3: runs both programs over the corpus (removed at cutover)
scripts/visual-parity.mjs R16: Chromium screenshots, old vs new (D9)
docs/site-contract.md R19
docs/document-format.md R6, R7: the format, for people and agents
The Node CLI in cli/ stays, unchanged in behavior, until the cutover. Then cli/ is removed, along with package.json's bin and exports. package.json stays for the repo tooling.
D2. The Rust program#
Toolchain: stable Rust, edition 2021.
rust-toolchain.tomlpins the version so the cloud container, the owner's Mac and CI agree.Crates (approved by the owner):
serdeandserde_json, withpreserve_order, which brings inindexmap. JSON in and out, in the order the Node CLI writes keys.miniz_oxide, for deflate and inflate, pure Rust with no C toolchain.
Everything else is the standard library or a small module in
crates/engine, each tested against published test vectors:- the gzip wrapper, CRC-32, PNG encode and decode, and SHA-256 (for
baseHash); - JSON-RPC, argument parsing and help;
- ISO timestamps and random ids (
/dev/urandom).
Watching: the stdio server polls the document's source files and the state dir every 250 ms, comparing size and mtime, and keeps the Node CLI's 150 ms debounce for bursts. That avoids a file-watching crate. A document of a few hundred files costs well under a millisecond per poll.
Git:
art build's "against HEAD" reports rungitas a subprocess, ascompile.mjsdoes today.Byte parity: these are the places where Rust and JavaScript differ, each with a module and tests drawn from the corpus:
jsnum::roundisMath.round, which rounds halves up (−2.5 → −2, wheref64::roundgives −3). It's used byiso.rsand the previews.jsnum::to_fixedisNumber.prototype.toFixed, which picks the larger neighbor on a tie. It's used bypct,kband the motion stops.jsnum::to_stringis JS shortest round-trip number printing, including JS's exponent rules. It's used everywhere a number becomes text.- Key order: JSON objects keep insertion order (
preserve_order). Where the Node CLI relied on JS putting integer-like keys first, the port sorts them explicitly.serialize.mjs'sprop()shows where that happens. - Sorting: Rust's
sort_byis stable, likeArray.prototype.sort. Comparators are ported as written (byCodePoint).
Gzip sizes:
miniz_oxidedoesn't produce zlib's exact bytes, so reported gzip sizes can differ from the Node CLI's by a few bytes. The owner accepted this. Budgets only warn now (R4), so R3 compares raw sizes exactly and gzip sizes within 1%. PNGs are compared decoded for the same reason.Errors: the same codes and messages as the Node CLI (
CommandError,NotFoundError, …), checked by the ported tests.
D3. Migration (R8, R9)#
crates/engine/src/migrate.rs reads the subset of JavaScript that serialize.mjs writes and the owner's palette uses:
//comments;export defaultfollowed by one object literal;- unquoted identifier keys, or single- and double-quoted keys;
- single-quoted strings with
\\and\'escapes; - numbers,
true,false, arrays and nested objects, with trailing commas.
Anything else (an import, a function, a spread, a template string, an expression) is refused with its file, line and column. Comments go to notes:
- palette header lines →
notes; - a comment after a palette entry →
colorNotes[name]; - the generated header line → dropped;
- any other comment → the file's
notes, prefixed with the path it sat at (items[3]: …).
document migrate:
- reads every source;
- converts it;
- builds both versions in memory and compares every SVG;
- writes the format-2 files and removes the
.mjsfiles in place (or writes the copy with--to); - rewrites
manifest.jsonwith"format": 2.
A legacy source/ folder becomes <site>/src/assets/pixel-art/<name>.pixellab/, as the pixellab-document spec's resolution expects. The result is { ok, root, files, notes, removed }. The command refuses when the document has unsaved edits open in a session.
D4. Format 2 (R6, R7)#
Canonical JSON:
- two-space indentation;
- keys in the order
serialize.mjsuses today (one table per kind, informat.rs); - a map's rows one string per line, and number arrays (
size,at.tile,move) on one line; - no trailing commas and a final newline.
format::serialize(parse(text)) == textfor every committed file, andnpm testchecks the examples and the fixture.Palette:
{ "notes": [ "The shared pixel-art palette …" ], "world": { "grass-1": "#B5C79C" }, "outfit": {}, "colorNotes": { "soil-3": "--color-earth" } }A name in
colorNotesmust be a color inworldoroutfit. Validation names any stray one.Objects and scenes: today's keys, plus an optional
notes(a list of strings) first, plus the newambientandactions(D6).Manifest:
formatmust be2. A1gives R9's problem.publishgainsimages, a folder relative to the document, for D5's PNGs. Everything else is unchanged.
D5. Images from scenes (R18)#
A scene may declare "image": { "file": "rest.png", "scale": 5 }. art build renders the scene's viewBox on the manifest's preview card color at scale, and writes it to <publish.images>/<file>. That's exactly what blog-thumbnails.mjs's renderThumbnail does today with renderPreview and encodePng. Validation rejects these:
- a scene with
imageand nopublish.imagesin the manifest, at build time, with the same "no publish folder" problemart buildgives today; - a
filethat isn't a plainname.png; - a
scalethat isn't 1–16.
The site's thumbnail scenes (thumb-*) gain image in the site's change. The build result lists images with the outputs:
{ file, scene, kind: "svg" | "png", raw, gzip?, warnings }
D6. Ambient loops and actions in the document (R12, R13)#
On a scene item, or on an object map layer (beside its class and map):
"ambient": { "duration": 16000, "offset": 0, "keys": [{ "t": 0, "move": [0, 0] }, { "t": 8000, "move": [12, 0] }] },
"actions": {
"hop": { "duration": 480, "keys": [{ "t": 0, "move": [0, 0] }, { "t": 44, "move": [0, -2] }] }
}
- Keys are scene motion's keys, with only
tandmove.tis in ms, from 0 up toduration, strictly rising.moveis pixels from where the art is drawn. Between keys the element steps a whole pixel at a time, by the rule scene motion uses (stepsFor). The first key must be att: 0. - Ambient loops: after the last key, the loop steps back to the first key over the rest of
duration.offsetstarts the loop that many ms in (−7000 in the site's CSS isoffset: 7000of a 20000 ms loop). This expressespcloud's 16 s, 20 s and 13 s drifts andcbob's 1 s bob exactly. - Actions: the element holds the last key until
duration, then returns to rest. Action names follow the engine's name rule (lowercase words joined by hyphens) and are unique per element. - Validation:
- keys in order;
moveas two integers;duration1–600000 ms;- at most 16 actions per element;
- an item can't have both scene motion and an ambient loop. Their transforms would fight, so validation names the item.
- Engine time:
sceneAt(time)places ambient loops attime(with no actions playing), so renders, previews and the app's timeline see them. Renders take an optionalplay: { action, at }.
D7. The SVG's <style> (R11–R15)#
The root: the root
<svg>gainsdata-pixellab="<scene>"andshape-rendering="crispEdges". It never gains aclassattribute, because the site adds its own with a string replace (Range.astro).Scoping: every generated name is
<scene>--…, and every rule's selector starts with[data-pixellab="<scene>"]:- scene motion:
mv-<id>becomes<scene>--mv-<id>; - frame loops:
<scene>--wf; - ambient loops:
<scene>--am-<n>; - actions:
<scene>--<action>.
- scene motion:
Frame loops: the elements keep their
wf w0… classes. Rules like these are written once per loop the scene uses:[data-pixellab="hero-island"] .wf{opacity:0;animation:hero-island--wf 1.0s step-end infinite} [data-pixellab="hero-island"] .wf.w1{animation-delay:-0.800s}They copy
pixel-art.css's values today: the cycles fromLOOP_CYCLES, the frame counts fromSITE_LOOPS. Any other loop uses the editor's 200 ms per frame.Ambient loops: an element with one gets a generated class and a keyframes rule built like scene motion's
transform:translatesteps.offsetbecomes a negativeanimation-delay.Actions: each element that has an action gets a generated class. The rule
[data-pixellab="s"] [data-play~="hop"] .s--a3,[data-pixellab="s"][data-play~="hop"] .s--a3{animation:s--hop 480ms step-end 1}plays it. This overrides the ambient animation on the same element by specificity, the way.cbob.is-hoppingoverrides.cbobtoday.animationendfires withanimationNames--hop. That<scene>--<action>is the documented name (R13, R19).Reduced motion: one rule turns everything off:
@media (prefers-reduced-motion:reduce){[data-pixellab="s"] [class*="s--"]{animation:none}}, plus frame 0 shown for frame loops, aspixel-art.cssdoes today.Size: the rules are short, and a scene without animation gets no
<style>at all. Budgets apply to the output with its style, as they do today.
D8. Commands (R4, R8, R18, R24)#
| Command | Notes |
|---|---|
document migrate [--to <path>] | R8, D3. Left out of MCP. |
scene ambient <scene> <item> --duration <ms> [--offset <ms>] --keys <json> / --clear | Sets or clears an item's ambient loop. A tool over MCP, and saving it is a proposal. |
scene action <scene> <item> <name> --duration <ms> --keys <json> / --delete | Adds, changes or deletes an action. A tool. |
object ambient <object> <layer> … / object action <object> <layer> <name> … | The same, for an object's map layer. Tools. |
scene render, object render | Gain --play <action>@<ms>, and render ambient loops at --time. |
art build | Result outputs[] gains kind, warnings. ok is false only for problems (R4). Human output: hero-island.svg: 780.1 KB / 98.1 KB gzip → … lossless — over its 750 KB budget (warning). |
art sizes, check | Over budget is a warning line, not an error. check also warns about a source file that isn't in canonical form (R20). |
Undo, redo, proposals, ghosts and diffs treat the new fields like any other edit. docs/cli.md is regenerated by pixellab help --markdown through npm run docs:cli.
D9. Proving the art moves the same (R16)#
scripts/visual-parity.mjs (Node, no new dependencies) uses the pre-installed Chromium in headless mode. For each scene of a temp copy of the owner's document, it compares two pages:
- old: the format-1 build, inlined with the site's
pixel-art.css; - new: the format-2 build, with the site's change applied (classes swapped for ambient loops and actions), inlined bare.
Each page pauses every animation at a time t through document.getAnimations() and takes a screenshot at 1×. The pages must match pixel for pixel at:
t = 0;- 24 times across each loop's cycle;
- every 40 ms through the hop, started at the same instant on both pages;
--force-prefers-reduced-motion.
The check writes a report and the mismatched frames to a scratch folder. It runs in the cloud container and on the Mac.
D10. The app (R21–R23, R25, R26)#
Locator:
findNodeandrunCli(node:cli:)go.Locator.findProgram()returnsContents/MacOS/pixellabin the bundle, with an override throughPIXELLAB_PROGRAMfor tests and a development fallback oftarget/release/pixellabin the checkout.ServerLaunch, the script hook'scliaction andClaudeTurn.mcpConfigtake the program's URL.documentProblemchecks forpalette.json, and gives R9's problem, with Migrate…, for apalette.mjs.The setup screen and Settings:
- The Node.js row, Choose…, "Find Node Automatically" and
SettingsKey.nodego. - The setup copy becomes "Pixel Lab edits pixel art in a .pixellab document."
- The startup phase "Finding Node.js…" goes.
- Migrate… opens an alert: "Migrate <name> to the new format? Its sources become JSON files. The art doesn't change. You can undo this with git." It has Migrate and Cancel buttons.
Colors come from the board 03 tokens, and focus, VoiceOver labels and hit targets follow the existing setup screen.
- The Node.js row, Choose…, "Find Node Automatically" and
The chat's PATH: today the child's
PATHputsnode's folder first, because an npm-installedclaudeis a Node script. Now the app uses the login shell'sPATH, resolved once withzsh -lc 'echo $PATH'(5 s limit, as today's lookups), afterclaude's folder. This keeps an npm-installedclaudeworking without Pixel Lab looking for Node.Ambient loops on the stage: the timeline's length becomes the longest of scene motion and the ambient loops, capped at 60 s for scrubbing. Playing renders through the server's
time, as scene motion does now.Actions in the Inspector: a section, ACTIONS (Silkscreen label), with one row per action: its name, its duration, and a Play button (
play.fill, labelled "Play hop"). Play renders the action's frames from the server and shows them once. Under Reduce Motion it shows the rest frame and announces "hop: shown at rest".Text:
.jsoninProtocol.swift'sfile,MainWindow's problem trimming,AppModel's watch handling and the chat's notes.The script hook gains
play <action> [<item>]andexpect ambient|actions <v>. Itscliaction runs the program.
D11. Proposals, clean slate (R25)#
A proposal file gains "format": 2. On start, state.rs moves any proposal file without it to retired-proposals/<id>.json, and logs one activity line: "retired 3 proposals from before the format change". proposal-numbers.log is untouched, so numbers keep counting. A proposal's source holds the format-2 JSON text.
D12. Tests (R1, R3, R16, R20, R28)#
- Ported suites: every
cli/test/*.test.mjssuite becomes a Rust test file in the crate that owns the code, with its test names kept (R7: the site's art compiles byte-identicalstays readable incargo testoutput).helpers.mjs'smakeDocument()andmakeSite()becometests/commonhelpers that copy into a temp dir, and never the real checkout. - The site's engine tests (R20):
pixel-art-motion's tests land incrates/engine/tests/motion_site.rs, rewritten against the fixture (given an animated layer and a motion scene where it lacks them), names kept. The two new engine tests (deterministic output; no script, link, text or external reference) run over the fixture and both examples. - The servers:
servers.test.mjs's JSON-RPC and MCP transcripts are replayed against the program, unchanged apart from.jsonfile names. - The app:
scripts/test-app.shand the acceptance pass run against the bundled program. Changed scripts:document.txtandscene-object.txt, for.jsonnames;- new
migrate.txt: opening a format-1 temp document, Migrate…, the expects; - new
actions.txt:play hopand the expects, on a temp copy of an example given an ambient loop and an action (scripts/make-examples.mjsadds them).
- The PR workflow (R28):
ubuntu-latest. It installs the pinned toolchain, runscargo test, and runsnpm test(repo tooling and the parity check). The parity check and the visual check over the owner's document (R3, R16) run in the main session, against a temp copy from a site checkout, and their output goes in the PR.
D13. What the site's change does (external, 2026-10-… in jlawcordova.github.io)#
For the site's own intent, which depends on this one:
- Migrate its document with
pixellab document migrate. - Replace the
pcloud/pc0–pc2classes with ambient loops. Replacecbobwith an ambient loop plus ahopaction on the character's layer. Give thethumb-*scenesimageand the manifestpublish.images. - Remove
pixel-art.css's keyframes.img.pixel-art'simage-renderingand anything for the HTML wrappers (floaty) stay. - Make the Range's A button set
data-play="hop"on the current class's group, and clear it onanimationend. - Drop the
pixellabdevDependency, the art tests,range-classesand the thumbnails script. Gitignore the compiled SVGs and thumbnails. - In CI, download the pinned
pixellab-linux-x64release, check it againstSHA256SUMS, and runpixellab art buildbeforeastro build. Locally, publishing from the app fills the same folders. - Update
pixel-first-look.test.mjs's dependency check, and the site's skills andpixel-artistagent, for JSON and the CLI.
Areas of concern#
Resolved by the owner on 2026-10-21:
- The entrances stay on the site. The hero island and the Range "build themselves" (
Hero.astro,Range.astro): the site's JavaScript and the Motion library animate the document's 274.entergroups with eased, staggered timing worked out fromdata-enter,data-rowanddata-x. That's visitor-triggered like the hop, but smooth rather than pixel-stepped, and its schedule is tuned to the site's layout. In this change they keep working through the documented group attributes (R19). Entrances as actions (with easing and stagger) is a follow-up. - Ambient loops and actions are edited by command. They are the exception the intent makes for self-contained animation (R12, R13). The app shows and plays them (R23), and editing them is by command over all three doors (R24). An editing UI is a follow-up.
- Crates:
serde,serde_json(withindexmap) andminiz_oxideare approved (R5, D2). The trade-off: polling instead of a watcher crate, and hand-written SHA-256, CRC-32 and PNG code. - Gzip numbers move slightly, by a few bytes, with
miniz_oxide(D2). Budgets only warn, so nothing breaks. Accepted. - The site's art tests leave the site. The engine tests come here on fixtures, three generic rules become engine tests and a
checkwarning, and the tests about the owner's particular art are dropped (R20). This repo's CI needs no site checkout. The trade-off: rules like "no truck is on the crossing while the train is" are no longer checked automatically after this change. The visual check (R16) covers the migration, and later art changes are reviewed as proposals in the app.
Still open, for information or for the site's change:
- Generated names change (R15). Scoping renames scene motion's
mv-…classes in the hero island's SVG. Nothing in the site references them (checked), but any future page code must usedata-playand the contract, not generated names. - Outputs not committed.
astro devneeds the SVGs to exist. After the site's change, a fresh site clone shows broken imports until someone runspixellab art build, or publishes from the app. Whether the site's dev script runs the pinned program first is the site's call, and that change must make it. - External dependency and order. The site's change (D13) depends on this one's release, which includes R17's binaries. Until then, the site keeps its pin (
v0.5.0) and keeps working on the Node package. They pin a tag, so a later release can't break them. - A large port. About 12,000 lines of Node source and 9,000 lines of tests are ported. The parity check (R3) and the ported tests are the safety net. The plan should port engine-first and keep the Node CLI shippable until cutover.
Open questions#
From the intent, all answered by the owner:
- The Range hop: carried by the art as an action, played with
data-playand ended byanimationend(R13, D7). - Comments: the meaningful ones are kept, as
notesandcolorNotes(R7). - Tests that check the art against the site's pages and styles: dropped, along with the tests of the owner's particular art. Only the engine's tests come here (R20).
- Over budget: a warning everywhere, never a failure (R4).
From the Areas of concern, all answered by the owner: the entrances stay on the site for now, ambient loops and actions are edited by command for now, the three crates are approved, and the gzip drift is accepted.
Follow-ups this change leaves:
- entrances as actions;
- an editing UI for ambient loops and actions;
- checks a document declares for itself.