18 KiB
AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in Rust only (no JavaScript or TypeScript). No Tauri, Electron or webview shells: the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written PhotoCraft ({Function}Craft in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (photocraft-*), binaries, file names, ids (ai.storyteller.photocraft). Standards and learnings shared across the crafting apps live in ../craftrules (read its README.md; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in storytold/craft-libs and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then docs/.
1. Orientation (5 minutes)
| Read | Why |
|---|---|
docs/architecture.md |
Crate map, dependency layers, the engine/UI seam, document model |
docs/development.md |
Build, test, run, drive the app programmatically, debug tricks |
docs/contributing.md |
Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
docs/control-protocol.md |
JSON control channel: how agents drive and screenshot the running app |
docs/ui-design.md |
Design tokens, themes, widgets, and how to match Photoshop's look |
ROADMAP.md |
One-page summary: stage, the two parity numbers, every dimension, languages, what's next |
docs/target-app-parity.md |
The authoritative parity assessment against Photoshop 2026: numbers, weights, evidence, hours |
docs/gaps.md |
Every known shortfall, ranked: the work list to pick from |
docs/roadmap.md |
Milestones, current focus, what's next with estimates |
docs/ui-parity.md, docs/brush-parity.md, docs/context-menu-parity.md, docs/file-format-parity.md, docs/hardware-parity.md, docs/localization-parity.md |
Per-dimension parity checklists |
docs/parity-checklist.md |
Generated list of every Photoshop menu item, live or missing |
docs/scorecard.md |
Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
crates/<name>/README.md (where present) |
Public API of that crate |
photocraft-corpus + docs/development.md › Test corpora |
Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into corpus/ by cargo xtask corpus --all at the pins in xtask/src/corpus_pins.rs |
2. Workspace map
crates/
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
psd codecs L0 standalone format crates (no workspace deps; publishable)
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
io plugins L4 document <-> PSD / flat formats; sandboxed WebAssembly plug-ins
engine L5 Session + command registry (every action is a command)
ui-egui automation L6 egui shell (thin: all actions go through the engine); MCP server
testkit test helpers
apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
Layering is enforced by cargo xtask layers. A crate may depend only on lower layers. psd, codecs and cms depend on nothing in the workspace. Nothing below ui-egui may use egui, eframe, winit or rfd. A new crate must be registered in xtask/src/layers.rs.
3. Golden rules
Never crash (outranks feature work)
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is ../craftrules/standards/never-crash.md.
- Non-test code never panics. No
unwrap(),expect(),panic!,unreachable!,todo!orunimplemented!. Return the crate's error type and propagate with?; useok_or(..)?,let .. else { return Err(..) },if let, orunwrap_or*where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal:#[allow(clippy::expect_used)]plus.expect("why it can't fail"). - No
unsafe. The workspace setsunsafe_code = "forbid". The one exception is the isolated helper cratephotocraft-tablet(crates/tablet): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only itssrc/macos.rsallowsunsafe(unsafe_code = "deny"crate-wide, every block has aSAFETY:comment, tested against realNSEvents); its X11 path and all mapping code are safe. Don't addunsafeanywhere else. - Input-derived numbers are hostile. Use
get()rather than[i]/[a..b]for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; usechecked_*/saturating_*for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input. - Bound recursion with depth limits or seen-sets (documents can be deep or cyclic).
- Don't cascade. Handle lock poisoning (
lock().unwrap_or_else(PoisonError::into_inner)) and treat thread joins asResults. - Last-resort guard. The app shell must catch an escaped panic around command dispatch and file import/export, reports it as an error and keeps the document. It's a safety net, not a licence to panic. Keep
panic = "unwind". - Prove it. Every crash fix comes with a small synthetic regression test that panicked before the fix.
- Enforced by clippy.
clippy.tomlallowsunwrap/expect/panic/indexing in tests only. Clean crates carry#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]; new crates start with it.
-
Everything is a command. New user-visible behaviour = a command in the engine (
crates/engine/src/*_cmds.rs, registered incommands.rs) with id, label, menu path, shortcut, params doc,enabledandrun, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the exact id fromcrates/ui-egui/src/menu_catalog.rsand the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (menus.rsUI_COMMANDS). -
No format or colour assumptions. Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a
u8-only pixel path in public APIs. Never assume sRGB: colour conversions go throughphotocraft-cms(Transform,transform::cached). Test at several depths. -
Clean-room. We studied Photoshop and other proprietary editors for behaviour and look only. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in
ATTRIBUTION.md(path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos indocs/brand/are not open source (docs/brand/LICENSE-brand.txt). Never commit font files: fonts live in storytold/craft-fonts (rules:../craftrules/standards/fonts.md). PhotoCraft uses it through the optional build inputCRAFT_FONTS_DIR=<craft-fonts checkout>(read bycrates/text/build.rs, exposed asphotocraft_text::CRAFT_FONTS; empty without it, and everything must still work). Build with it:CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build; details indocs/development.md› Fonts. -
Tests are the gate. Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (
crates/ui-egui/src/parity.rs) from regressing. -
The UI is thin and data-driven. UI state lives in
ui-egui/src/state.rs(serde), so the control channel can read and drive it. Colours and radii come fromtheme::Tokens, never hard-coded. -
Verify UI changes visually. Render offscreen with
cargo run -p photocraft-ui-egui --example snapshot(no window, no focus stealing), or launch with--controland takeui.screenshot. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a genericPhotocraft-devUser-Agent. -
Never break wasm. L0–L6 must
cargo check --target wasm32-unknown-unknown(runcargo xtask wasm). File-system code iscfg(not(target_arch = "wasm32"))or goes through the platform services. -
Performance is a feature. Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
-
Never panic on input (see Never crash above). A command's
runclosure and anything it calls must returnErr, never panic, for any params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. Thepanic_huntintegration test fuzzes every command with adversarial params and must stay green.
4. Picking work
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
- Read
ROADMAP.md, thendocs/gaps.md.docs/target-app-parity.mdsays, dimension by dimension, where PhotoCraft is lacking;docs/gaps.mdranks the work.docs/parity-checklist.md(menu wiring) is not a measure of behaviour. When your work moves a number or closes a gap, update the parity doc and gap entry it belongs to, with the date, and bump their status line and revision history (craftrulesstandards/progress-docs.md). - Check
docs/scorecard.mdbefore picking work: each area'smissingandpartialrows, the performance scenarios that are over budget or not measurable yet, and the count of settings that do nothing. Its numbers are measured; prefer them to estimates. docs/roadmap.md→ Current focus.cargo xtask parity→docs/parity-checklist.mdlists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists inalgo,paint,vectorortext.log/devlog.md→ the "Still open" bullets of recent entries.
When parity rises, raise FLOOR in crates/ui-egui/src/parity.rs (never lower it).
5. Before you finish a task
cargo test -p <crates you touched>
cargo clippy -p <crates> --all-targets -- -D warnings
cargo xtask layers
cargo xtask wasm # if you touched L0–L6
cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
While iterating, run less and keep the full commands above as the final gate: cargo test -p <crate> --lib --tests skips the examples and doctests plain cargo test builds every time (ui-egui has 11 examples), and cargo test -p <crate> --test <file> runs one integration-test file. cargo nextest run runs tests in parallel across binaries; see docs/development.md › Faster test loops.
Test corpora. Real-file corpora live in corpus/ (gitignored, never committed), fetched at pinned commits and sha256-verified by cargo xtask corpus --all: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: xtask/src/corpus_pins.rs. The corpus tests are opt-in (cargo feature corpus): plain cargo test skips them, and with the feature on a missing corpus fails ("run cargo xtask corpus --all"). cargo xtask test-corpus fetches and runs them all. CI always runs them (the corpus job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its AGENTS.md), then a pin bump here. Details: docs/development.md › Test corpora.
Commands must never panic on bad input (Rule 9): every run closure and the code it calls returns Err, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → Err, not a crash).
Then append a terse entry to log/devlog.md (what landed, numbers, what's still open). Sessions can end abruptly (crashes, context limits), so the dev log plus a green tree is how the next agent picks up. Keep the tree building at every step.
6. Parallel agents
- Use your own target dir (
CARGO_TARGET_DIR=target/agent-<name>) to avoid the Cargo build lock, and edit only the files you own. Shared files (engine/src/lib.rs, thev.extend(...)list inengine/src/commands.rs,ui-egui/src/menus.rs,state.rs) get small, surgical edits; re-read before editing. - Put new commands in a new module (
engine/src/<area>_cmds.rswith aspecs()function) rather than growing a shared file. - If someone else's in-progress edit breaks the build, wait and retry; don't fix their files.
- Keep every
Cargo.tomlvalid at all times: thecrates/*glob means one broken manifest breaks everyone's build. Create or rewrite manifests atomically: write to a temp file outsidecrates/, thenmvit into place. - Disk space: each target dir is about 10 GB. Delete
target/agent-*dirs of finished agents.
7. Where things are tracked
docs/roadmap.md: milestones M0–M12, status and the current focus.ROADMAP.md,docs/target-app-parity.md,docs/gaps.mdand thedocs/*-parity.mdchecklists: where we stand against Photoshop.docs/parity-checklist.md: generated Photoshop menu coverage.docs/scorecard.md: generated scorecard (sources:scorecard/*.toml,perf/budgets.toml,perf/baseline.json, corpus floors, prefs audit).docs/releasing.md: cutting a release (cargo xtask version, thereleasebranch), signing secrets, packaging scripts inpackaging/.../craftrules/release/playbook.md: how every storytold app builds signed release binaries (the canonical recipe;docs/release-playbook.mdjust points there);docs/releasing.mdis PhotoCraft's specifics.plan/(local, gitignored): research, parity plan, execution plan, estimates.log/(local, gitignored): the dev log.
8. Keeping the native format complete
photocraft-format deliberately fails to compile when a photocraft-doc struct gains a field, so
nothing is silently dropped from .pcraft saves. When you add a doc field, add it to
crates/format/src/manifest.rs and convert.rs with #[serde(default)] so older files still load.
If the field has a PSD equivalent, map it in crates/io too, and keep unknown PSD blocks verbatim.
Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from
contributors/contributors.json(commit stats; generated, never hand-edit) andcontributors/people.toml(names people chose for themselves). Seedocs/contributors.md. - Agents working for a contributor: when you prepare a PR, check whether your human's GitHub
username has a
[people.<username>]entry incontributors/people.toml. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (sync_github_name = true). If yes, add only their own entry (copy the template at the top of the file, or runpython3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name) and include it in their PR, committed as them. If no, change nothing: they are credited as@usernameanyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit
contributors.json. - Maintainers refresh the stats with
python3 ../../craftrules/scripts/contributors.py .(it also re-verifies who wrote eachpeople.tomlentry;--checkonly verifies).