# Darkly — Agent Guidelines Darkly is a web-based, gpu-native paint program written in Rust, Svelte and Typescript, leveraging WebAssembly and WebGPU. This document exists to keep Darkly **minimal, elegant, and proper**. The best code is the code never written; nearly all the principles below are in support of this core principle. ## Architecture Darkly's Rust core (`crates/darkly/`) is platform-agnostic — document, brush engine, GPU compositor, undo, and `DarklyEngine` itself, all with zero platform dependencies. A WASM bridge (`frontend/wasm/`) wraps the engine for the browser. The frontend's `Engine` transport (`frontend/src/engine/`) issues typed requests by kind (`send`/`post`) over a single id→promise FIFO; the wasm `DarklyHandle` enqueues them — borrowing nothing — and resolves them on a scheduled drain or at frame time. Payloads cross the boundary as `serde_wasm_bindgen` values (with raw `bytes` alongside for binary responses). A process-level `DarklySession` owns one wgpu device and hands out one `DarklyHandle` per canvas (`createHandle`); the multi-tab editor runs N handles on the one shared device. State splits three ways: - **Document** — authoritative, undoable, serializable. Layer tree, modifiers (mask / selection), canvas size. Reasoning about it requires no GPU. - **Session** — transient editor state on `DarklyEngine`. Active tool, view transform, in-flight stroke, undo stack. - **Compositor** — derived realization. GPU textures, pipelines, render caches. Always rebuildable from the document on the next frame. Data flows downhill: document → compositor, session → compositor. Never upward. Bulk pixel data (layer pixels, mask pixels) is the principled exception — GPU-authoritative because it's huge and the GPU is where it's used. **Runtime stack** — pointer event to pixel: ```mermaid flowchart LR User[Pointer / keyboard] Svelte[Svelte UI
frontend/src/] Transport[Engine transport
frontend/src/engine/
id→promise request/response] Handle[DarklyHandle
frontend/wasm/
enqueue → drain / render] Core[DarklyEngine
crates/darkly/] WGPU[wgpu] Canvas[WebGPU canvas] User --> Svelte Svelte <-->|send / post requests by kind| Transport Transport <-->|enqueue + drain / render| Handle Handle --> Core Core --> WGPU WGPU --> Canvas ``` **Repo layout** — `★` marks modular subsystems (drop a new file with `pub fn register()`; `build.rs` discovers it — no central registration to touch): ``` crates/darkly/src/ document/ Authoritative model (layer tree, canvas, ...) layer_kinds/ ★ group, raster, void modifiers/ ★ mask, selection engine/ DarklyEngine — session + per-domain dispatch (painting, rendering, load/save, export, floating, flatten, merge, undo_dispatch, …) gpu/ Compositor, ping-pong blend, regions, readback blend_modes/ ★ normal, multiply, hue, color_burn, … veils/ ★ post-process effects (rainy_glass, VHS, painting, …) voids/ ★ procedural fill sources (camera, noise, …) brush/ Stroke engine + node-graph brush engine, GPU compute pipelines, WGSL compilation, brush bundles + import. Files: stroke_engine, eval, pipeline, composite_pipeline, gpu_context, wgsl_compile, bundle, library, save_points, preview_renderer, checkpoint_ring, … nodes/ ★ graph nodes — input, math, color, shape, modulation, output terminals stabilizers/ ★ stroke stabilizers (laplacian, …) import/ brush-bundle importers (krita) config/ sections/ ★ schema sections (canvas, input, ui, …) presets/ ★ bundled presets (gimp, krita, photoshop) tools/ ★ brush, fill, gradient, colorpicker, select (rect/ellipse/lasso/polygon/magic_wand), transform undo/ Per-domain undoable ops (layer, modifier, property, selection, gpu_region, compound) format/ Save/load — zip container, manifest, registry I/O nodegraph/ Generic node-graph (graph, compiler, layout) frontend/wasm/ WASM bridge (wasm-bindgen) — single API surface frontend/src/ Svelte UI shared/styles/ @darkly/styles — tokens + themes (UI + website) website/ Astro + Starlight site (splash, docs, /demo/) ``` ### Coordinate Systems **If you're touching anything with x/y coordinates, read [`docs/coordinate-systems.md`](docs/coordinate-systems.md) first.** Darkly moves a pixel through several frames (screen → plane → window-local → layer-local), and a value carried into the wrong frame is the single most recurring class of bug here — invisible until the canvas is cropped. The doc covers the frames, their authority, how to convert between them, and the pitfalls that have bitten us. ### Brush Preview & Overlays **If you're touching the brush hover preview or on-canvas overlays, read [`docs/brush-preview-and-overlays.md`](docs/brush-preview-and-overlays.md) first.** Brushes compile to two shader variants (stroke + cursor-preview), `setOverlay` is single-slot, and preview swaps stroke-only bindings for fallbacks — the model that makes hover-feedback bugs hard to see otherwise. ### Hotkey & Config Presets Darkly's settings use a three-layer resolution order: `user → overlay (krita/ps/gimp) → defaults`. Placement rule is documented in [`crates/darkly/presets/defaults.yaml`](crates/darkly/presets/defaults.yaml)'s header; host-editor reference hotkeys live in [`docs/*-default-hotkeys.md`](docs/). ## DRY Principle Don't Repeat Yourself — and interpret this broadly. If two pieces of code aren't identical but follow a similar enough pattern that they could be generalized, they should be. This applies across modules, layers (Rust, WASM bridge, JS), and systems. **Search before writing.** To ensure what you're about to write doesn't have siblings somewhere else in the codebase, grep for similar functionality. By doing this, you may discover overlap that can be extracted into a shared component (DRYify opportunity), or even better, that what you need is already written, and can simply be imported. **Place functionality where it generalizes.** Before writing logic, ask: "where does this belong so that it works for all cases, not just this one?" If a behavior applies to any tool, it belongs in the tool system's generic hooks — not inside one specific tool. If a behavior applies to any async operation, it belongs in the async completion pipeline — not special-cased at one call site. Putting the right logic in the right architectural layer eliminates the need to repeat it, and prevents future features from having to rediscover where to plug in. A good signal you've placed something wrong: it only works for one workflow, or a second caller would have to copy-paste the same pattern. **Stop-sign phrases.** If you find yourself writing "mirrors X", "bit-exact copy of X", "keep in sync with X", or "identical to X" in a comment, you are duplicating code. Pause and consider why you're doing it. If it's not easily factorable into a shared feature, stop executing and raise the issue to the user. ## Modularity Principle **Default to modular.** When you design anything with more than one variant — or that will plausibly grow one — the first question is "what's the unit, and how does the rest of the code stay ignorant of which one it's looking at?" That mindset applies at every scale: from a small enum where one method per variant beats a `match` at the call site, up to full subsystems with traits, registries, and per-variant files. The cost of designing modularly up front is almost always small; the cost of retrofitting after centralized branching has spread across the codebase is large. Hand-written dispatch should feel like an exception that needs justifying, not the default shape. This is a stronger claim than the Engineering Principle's "build a proper system for it" — that one says *don't hack*; this one says *the proper system is almost always one where new variants slot in without consumers being edited*. Module-specific code lives in the module. Module-generic infrastructure — registries, dispatchers, shared state, caches — is generic by name and by shape, never named after any single module that happens to use it today. When adding a new item to a modular system (filter, tool, brush, etc.): - **DO:** Create a single file in the appropriate directory that contains everything about that module — struct, implementation, registration function, constants, helpers. - **DO NOT:** Add match arms to a central dispatcher. Add entries to a handwritten list. Touch any file outside the module directory except the generated `mod.rs`. Mechanics: `build.rs` scans module directories and generates each `mod.rs` (never edit by hand) with a `registrations()` function. Each variant file exports `pub fn register() -> XRegistration`; the registry calls `registrations()` to populate itself. Generic infrastructure (`Trait`, `Registration`, `Registry`) is named after the kind, not after the first variant that happened to exist. See `gpu/veil.rs` + `gpu/veils/*.rs` for a worked example. The "default to modular" stance leads directly to the type-owned dispatch rule below: once a system is modular, the consumer must not re-introduce centralized branching by asking variants what they are. **Type-owned dispatch:** Anything a type knows about itself — behavior, properties, capabilities, identity — lives on the type, behind a uniform interface. Consumers call methods; they never introspect, classify, or branch on which variant they got. The diagnostic question: *would adding a new variant, or changing what an existing one knows about itself, force me to edit this code?* If yes, the knowledge is misplaced. The violation has one recurring shape — `matches!(type_id, ...)`, `if kind == X`, `fn is_foo(type_id) -> bool`, or any consumer-side helper that routes by type — code outside a type's own module asking questions the type should be answering itself. Replace it with a trait method, defaulted to the common case and overridden per variant, so new variants are purely additive. ## Ownership Principle State belongs to the thing it describes — not to a parent that manages it on its behalf. Don't let Rust's borrow checker dictate the data model. If splitting state out of a struct makes borrowing easier but scatters a logical concept across multiple locations, find a different way to satisfy the borrow checker (helper methods, borrow-splitting, restructured access) and keep the data model clean. ## Document Authority Principle The **document** is the authoritative model. The **compositor** is a derived realization. State falls into three categories: - **Document** (`crates/darkly/src/document.rs`, `src/layer.rs`): persistent, undoable, serializable. Tree structure, layer properties, mask presence, layer extents, selection regions, canvas size + `canvas_origin` (see [Coordinate Systems](#coordinate-systems) — the canvas window is a plane rect anchored at `canvas_origin`). Must be possible to reason about without a GPU. - **Session** (fields on `DarklyEngine` and tool/UI structs): transient editor state. Active tool, mask-editing target, viewport transform. Does not survive reload. - **Compositor** (`src/gpu/compositor.rs` and friends): GPU textures, bind groups, pipelines, render caches. Always derivable from document + dirty regions; rebuildable on demand. **Data flows downhill: document → compositor, session → compositor.** The compositor never feeds back upward. If a piece of state seems to want to flow up, the model is broken — fix the originating operation to lead with the document. **Bulk pixel data (layer pixels, mask pixels) is the principled exception** — GPU-authoritative because it's huge and the GPU is where it's used. The document tracks "this layer has pixels" structurally (e.g. `has_mask`); the bytes themselves live in VRAM. **Anti-patterns to recognize and refuse:** - A doc-side bool and a `HashMap` on the compositor that mirror the same fact (`has_mask` vs `mask_textures.contains_key(id)` was the canonical example). - A doc-side field and a GPU resource's metadata that must be manually re-synced after a compositor-led operation. - The same logical fact stored in two places "for ergonomics" — pick one home and expose a getter for the other side. **When in doubt:** if the value survives save/load, it's document. If it can be rebuilt from the document on the next frame, it's compositor. Otherwise it's session. ## Prior Art Principle Before deciding on an approach, research how established editors handle it. Krita and GIMP are checked out under the project root (`krita/`, `gimp/`). Read the actual source — never rely on web searches, docs, blog posts, or LLM training data for architectural claims. If a reference repo isn't checked out, clone it. Never claim "Krita does X" without pointing to a specific file and function. When delegating research to a subagent, instruct it to clone and cite specific files and line numbers — reject any claim not backed by source. We do not blindly copy prior art; we use it to inform our own decisions. Our implementation will differ in specifics (GPU pipelines, tile formats, Rust idioms), but core algorithms and architectural decisions should be informed by prior art, not invented from scratch. ## Credit Principle When an idea, algorithm, shader, or implementation comes from an external source — open source code, Shadertoy, papers, blog posts, video tutorials, etc. — credit the source and author at the top of the file (or inline next to the borrowed fragment, if it's smaller than file-scope). Include the author's name or handle and a link to the original. ## Planning and Independent Review Workflow Unless the user explicitly waives it, every bug fix and feature follows this workflow. Production code may not change before step 5. ### 1. Draft Delegate planning to a fresh, isolated agent with only the repository instructions and user request. It must investigate the code and required prior art, then write a self-contained plan to `docs/plans/.md` covering: - Problem and root cause or feature semantics - Architectural impact and implementation steps - Tests, risks, and unresolved questions - A rough LOC estimate — lines added or lines removed, not lines touched — split into production, tests, and generated/docs changes. This estimate is a primary scope and complexity signal, not optional metadata. - For bugs, a regression test that will fail before the fix The planning agent must not modify production code. If isolated agents are unavailable, ask the user to run this step in a fresh session. ### 2. Review Have a different fresh, isolated agent independently investigate the repository and review the plan. Give it only the repository instructions, plan path, and review task. The reviewer must challenge the diagnosis, scope, architecture, ownership, authority, modularity, duplication, complexity, prior-art support, and test coverage. It should seek the simplest general solution, including removing machinery or relocating behavior to its proper owner, and ensure bug tests reproduce the reported failure. Add concrete, file-referenced findings under `## Independent Review` at the top of the plan and give a verdict: `accept`, `revise`, or `rethink`. Do not modify production code. If isolated agents are unavailable, ask the user to run this step in a fresh session. ### 3. Revise The orchestrator addresses every substantive finding in the plan or records an evidence-backed reason for rejecting it. A `rethink` verdict requires re-investigation and a rewritten approach, not an incremental patch. Preserve the review. ### 4. Approve Give the user: - **First:** the estimated LOC range from the plan. Lead the approval summary with this because it is the clearest signal of implementation size and possible over-design. - The plan path and review verdict - The proposed approach, tradeoffs, and unresolved questions - Confirmation that implementation has not begun Then stop and request explicit approval. Plan changes require revision and, when material, another independent review and approval. ### 5. Implement After approval, the orchestrator implements and verifies the plan. For bugs, first demonstrate the regression test failing, then make it pass. Keep the plan synchronized with material discoveries. If the implementation's expected LOC materially exceeds the approved estimate, stop and explain why before continuing. If implementation requires a material redesign, stop and return to review, revision, and user approval. ## Testing Principle **Every feature must have a test.** Verify the feature works. The test exists; it passes. That's it. **Every bug must have a _regression_ test — one that defends against that specific bug being reintroduced.** "Regression" means "the bug we just fixed must not come back"; a test for a new feature is not a regression test, even if it follows the same pattern. Write it FIRST, confirm it FAILS against the unfixed code, then fix the bug and confirm it passes; if it doesn't fail without the fix, it doesn't count. ## No Blocking GPU Readbacks **Never use `device.poll(Wait)`, `blocking_read()`, `readback_texture()`, or any synchronous GPU→CPU readback in production code.** These deadlock on WebGPU/WASM — the browser event loop is the only mechanism for resolving GPU buffer mappings, and any form of blocking (`recv()`, spin-wait, `thread::park()`) prevents it from running. See `docs/lessons-learned/gpu-lessons-learned.md` §5 for the full stack trace of why. The correct pattern is async readback: `request_readback()` → `readbacks.submit()` → poll on the next frame via `ReadbackScheduler`. If CPU data is needed from a GPU texture that changes infrequently (e.g., the selection mask), maintain a CPU cache populated by the async readback and read from that. `test_utils::readback_texture()` and `blocking_read()` are **test-only** — they work on native (Vulkan/Metal) where `device.poll(Wait)` drives the completion queue synchronously. They must be gated behind `#[cfg(test)]` and never called from engine, compositor, or WASM bridge code. ## Engineering Principle Every system must be implemented properly. No hacks, no hardcoding, no shortcuts in Rust or the WASM bridge. If we implement one of something, we build a proper system for it. It's okay to take a step back from the current task to do things right. **Every bug is a signal that something nearby is awkward or overcomplicated.** Before patching, ask: "is this an elegant solution?" If the answer is no, the bug is telling you the code wants to be restructured — propose a refactor instead of layering a fix on top. The cleanest fix is often the one that makes the bug impossible to express, not the one that handles it. **Comments describe the code, not the plan that produced it.** Write comments about what the code does and why it's there as it stands — never about the process that got it there. Do not reference ephemeral planning artifacts: step or phase numbers, plan-list items, "TODO from the plan", "as decided in step 3", or before/after framing ("new", "now", "previously", "used to") that only makes sense relative to a change in flight. A comment that would be meaningless to someone reading the file fresh — with no knowledge of the task that introduced it — is in the wrong register; rewrite it to stand on its own, or delete it. ## No Migrations / No Backwards Compatibility (pre-release) Darkly is in pre-release / alpha. Until the first public release, breaking on-disk and on-the-wire formats is fine — do not write migrations, format-version upgrade paths, or legacy compatibility shims. Make the breaking change directly and update every producer and consumer in the same pass; existing user data can be invalidated. ## PR Descriptions Fork every feature branch off `dev` and target PRs at `dev`, never `master` (which only receives release merges from `dev`, despite being GitHub's default branch). Every PR body has **two parts**: a human-written preamble explaining *why* the work was undertaken and who it's useful to, then the AI-generated technical description below a `---` separator. When you finish implementing a plan, emit the PR description in a fenced markdown code block as part of your reply, shaped like this — leave the top as a placeholder for the human to fill in: ````markdown --- ```` The AI portion must cover the *entire* feature branch (everything since it diverged from `dev`), not just the latest change — the user pastes the whole block as the PR body. On follow-up work, re-emit the complete, updated block as a single description that wholly replaces the previous one; never emit a delta or a partial revision. ## Lint / CI Checks Run at commit time only — not during iterative debugging. Use `cargo check` for mid-iteration build sanity. All must pass: ```bash cargo fmt --all -- --check RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets --exclude darkly-wasm --features darkly/testing -- -D warnings RUSTFLAGS="-D warnings" cargo clippy -p darkly-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings # `--features darkly/testing` exposes `gpu::test_utils`, `blocking_read`, and # the engine's `test_readback_*` accessors that integration tests rely on # (compile-time gate enforcing CLAUDE.md "No Blocking GPU Readbacks"). # `--test-threads=1` is mandatory: GPU-touching integration tests (`engine.rs`, `blend_modes.rs`, etc.) share a process-wide wgpu device and SIGSEGV when run in parallel. cargo test --workspace --exclude darkly-wasm --features darkly/testing -- --test-threads=1 (cd frontend/wasm && wasm-pack build --release --target web --out-dir pkg) # `tsc --noEmit` is the TS gate for `.ts` files — but it CANNOT see inside # `.svelte` files (it doesn't parse the extension), and neither `vite build` # nor Vitest type-checks components. `svelte-check` is the only gate that # type-checks `.svelte` scripts + templates (via `svelte2tsx` + the TS API): # it catches nonexistent engine methods, wrong props, and null-safety in # components. Both are required — `tsc` alone gives false green on component bugs. (cd frontend && npx tsc --noEmit) (cd frontend && npm run check) (cd frontend && npm run build) # Vitest runs in the node environment — there is no DOM, so globals like # `KeyboardEvent` / `PointerEvent` / `window` are undefined. Test against # plain object fakes (`{ key, shiftKey } as KeyboardEvent`), and for code # that touches `window`, stub it with `vi.stubGlobal('window', …)` and a # fake node — see `src/lib/__tests__/clickOutside.test.ts`. (cd frontend && npm test) # Reclaim stale build artifacts — Cargo orphans a ~300 MB static test binary on # every fingerprint change and never GCs it, so `target/` balloons over time. # `cargo install cargo-sweep` once, then periodically: cargo sweep --time 7 ``` Never run `git commit` — make the changes and leave staging and committing to the user.