Vibecode Pixlr
track this build4 phases, 12 steps, beginner friendly0%The visible browser photo editor loop is buildable, but a credible replacement needs more than the first screen. Pixlr earns its keep through editor polish, assets, algorithms, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Pixlr. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Pixlr · indie build A layered editor whose hard problems are keeping pixel data out of the canvas so repeated edits do not degrade it, holding large images in bounded memory, and exporting files that still carry the orientation and colour information they arrived with. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with the file dialogs and decode on the Rust side | the browser drops metadata that the image libraries keep | | Interface | React and TypeScript, canvas used only as a display surface | reading pixels back out of the display is the mistake this build avoids | | Pixel store | Fixed-size tiles of unpremultiplied RGBA in typed arrays | bounded memory and cheap partial redraws | | History | An operation log with inverse operations, not bitmap snapshots | a snapshot per stroke on a large image exhausts memory in a minute | | Codecs | Decode and encode in Rust, carrying orientation and any colour profile | the metadata is the difference between an export that matches and one that does not | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Builds the interface half of the application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A Rust toolchain and the Tauri prerequisites** · free - Why: Decoding and encoding happen here, which is the only place orientation and colour profiles are visible. - Get it: rustup.rs, then the platform packages listed in the Tauri prerequisites. - Verify: cargo --version prints a version and a bare Tauri app runs - [ ] **Photographs covering all eight orientation values** · free - Why: Orientation handling is the bug that ships, and it is only caught by a fixture per value rather than by the one photograph on your desk. - Get it: Generate them from one image with an exif tool, or use a published orientation test set. - Verify: All eight fixtures import upright in the suite - [ ] **One genuinely large photograph** · free - Why: Memory behaviour is invisible until the document is bigger than the ceiling, and a sample image from a tutorial never is. - Get it: A full resolution photograph from a recent camera or phone, at least twenty megapixels. - Verify: It opens, edits and exports inside the stated memory ceiling - [ ] **Your memory ceiling and history cap** · free - Why: Both are asserted by tests, and picking them after watching the app fall over means choosing whatever number happens to pass. - Get it: Write down the tile memory ceiling, the history byte cap and the autosave interval before Phase 2. - Verify: All three are in the configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Generative fill and background removal. - Reading or writing commercial layered formats. - A colour transform engine. - Cloud documents and collaboration. - cloud collaboration and mobile apps - high-fidelity color, format, and export handling - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases If one of those is essential to you, that is the reason to keep paying for Pixlr, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Pixlr The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local layered image editor that keeps the pixels in buffers you own to replace Pixlr. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one decides the architecture. The canvas is a screen, not a document. A 2D rendering context's output bitmap must use premultiplied alpha, and the HTML specification says outright that converting to and from premultiplied alpha is lossy for any colour that is not fully opaque, so pixels just written with putImageData can come back from an equivalent getImageData as different values. An editor that stores its layers in canvas elements and reads them back to apply the next operation therefore bleeds a little colour out of every semi-transparent pixel every time, and after twenty strokes a soft edge has drifted somewhere nobody can explain. Keep every layer in unpremultiplied buffers you allocate yourself, composite to the canvas for display, and never read the display surface back into the document. Second, the file that was imported is not yours to write to. Copy it in, hash it, keep it untouched, and put exports somewhere else. An editor that saves over the original has deleted the only copy of a photograph, and no amount of undo history brings that back. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with the file dialogs and decode on the Rust side · the browser drops metadata that the image libraries keep - Interface: React and TypeScript, canvas used only as a display surface · reading pixels back out of the display is the mistake this build avoids - Pixel store: Fixed-size tiles of unpremultiplied RGBA in typed arrays · bounded memory and cheap partial redraws - History: An operation log with inverse operations, not bitmap snapshots · a snapshot per stroke on a large image exhausts memory in a minute - Codecs: Decode and encode in Rust, carrying orientation and any colour profile · the metadata is the difference between an export that matches and one that does not ### Data model (create this before Phase 1) - documents(id, name, width, height, colour_space, format_version, created_at, updated_at) - layers(id, document_id, ordinal, name, kind, opacity, blend_mode, visible, offset_x, offset_y) - tiles(layer_id, tx, ty, bytes) · fixed size unpremultiplied RGBA, the only place pixel data lives - operations(id, document_id, seq, kind, params_json, layer_id, at) · the undo history is this table - originals(document_id, path, sha256, orientation, icc_profile, exif_json) · the imported file, kept byte for byte - exports(id, document_id, at, format, settings_json, path, flattened, profile_written) - Nothing here is a canvas element. The canvas is redrawn from tiles and is never a source of truth. ### Phase 1 · Pixels in buffers the browser cannot round Build: Define the tile store: fixed-size tiles of unpremultiplied RGBA, allocated and freed by you, with a layer as an ordered set of tiles and an offset. Composite visible layers into the canvas for display. Write a test that applies the same semi-transparent stroke fifty times and asserts the stored pixel values are exactly what the compositor computed. Trap: Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. Done when: Fifty repeated operations on a semi-transparent region produce pixel values identical to the compositor's own arithmetic, tested directly. No code path anywhere calls getImageData on a display canvas, verified by a grep in the test suite. Memory for a document is tiles times bytes and is reported in the interface. A layer offset beyond the document bounds does not allocate tiles for empty space. Do not build yet: No tools, no undo, no import or export beyond a raw test fixture. ### Phase 2 · Undo that does not cost a copy of the image Build: Represent each edit as an operation with parameters and the tiles it touched. Undo restores only the affected tiles from a per-operation patch. Cap history by total bytes rather than by count, and drop the oldest patches when the cap is reached, telling the user it happened. Trap: Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. Done when: Undo and redo restore only the tiles an operation touched, asserted by counting tile writes. History memory is capped in bytes and the cap is shown, and reaching it drops the oldest step with a visible notice rather than crashing. A hundred brush strokes on a large document stay inside the stated ceiling, measured. Undo after a layer reorder, a delete and a resize each round trip exactly. Do not build yet: No selections, masks, import or export yet. ### Phase 3 · Selections, masks and a small set of tools that are correct Build: Build rectangular and freehand selections with a feathered edge stored as an alpha mask, a brush and eraser, fill, crop, resize and one adjustment such as levels. Composite with the source-over operator on unpremultiplied values and convert only at the display boundary. Trap: Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. Done when: A feathered cut-out placed on a transparent background shows no dark fringe, tested against reference values. Every tool writes through the same compositor rather than touching tiles directly, verified by review. A selection survives an undo of the operation applied through it. Crop and resize update layer offsets so nothing moves relative to the canvas, tested with an off-centre layer. Do not build yet: No file import or export yet, and no filters beyond the one adjustment. ### Phase 4 · Files that arrive and leave intact Build: Decode on the Rust side so orientation and any embedded colour profile are read rather than discarded, apply the orientation once at import and record that you did, and keep the original file untouched with its hash. On export state plainly what the chosen format drops: alpha for JPEG, colour depth for indexed PNG, everything for a screen-resolution copy. Trap: Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. Done when: A photograph carrying each of the eight orientation values imports upright, tested with a fixture per value. The original file is byte identical after a full edit and export cycle, verified by hash. Exporting to a format without alpha shows what will be flattened and onto what colour before it writes. An exported file opens at the original pixel dimensions unless a resize was chosen explicitly. Do not build yet: No project format, autosave or crash recovery yet. ### Phase 5 · Large documents, autosave and a project file that opens next year Build: Add a memory ceiling with tiles evicted to a scratch file and reloaded on demand, autosave of the operation log at a stated interval, and crash recovery that replays it. Version the project format from the first release and write an importer for version one before you ever write version two. Export a flattened image and the layers as separate files. Trap: Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one. Done when: A document larger than the memory ceiling stays editable with tiles evicted and reloaded, tested with a deliberately small ceiling. Killing the process mid-edit loses at most one autosave interval and the recovered document opens, tested by killing it during a write. A project written by the first format version opens after the format changes. Layer export produces one file per layer with position recorded alongside. Do not build yet: Nothing after this. ### Out of scope (and why) - Generative fill and background removal, which are model work rather than editor work and belong behind a clearly optional switch if at all. - Reading and writing the layered formats of commercial editors, which are large reverse engineering projects with moving targets. - Colour management beyond carrying the profile through, because doing it properly means a transform engine and a calibrated display. - Cloud documents and collaborative editing, which change every assumption in the tile store. ### README must contain - That layers live in unpremultiplied buffers and the canvas is a display surface that is never read back - That the imported original is kept byte for byte and exports are written elsewhere - The memory ceiling, how tiles are evicted, and what happens when a document exceeds it - What each export format drops, and that the warning appears before the write - The project format version and where the importer for older versions lives ===== AGENTS.md ===== # Agent instructions · Pixlr indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with the file dialogs and decode on the Rust side, React and TypeScript, canvas used only as a display surface, Fixed-size tiles of unpremultiplied RGBA in typed arrays, An operation log with inverse operations, not bitmap snapshots, Decode and encode in Rust, carrying orientation and any colour profile. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. ===== BUILD_PLAN.md ===== # Build plan · Pixlr A layered editor whose hard problems are keeping pixel data out of the canvas so repeated edits do not degrade it, holding large images in bounded memory, and exporting files that still carry the orientation and colour information they arrived with. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pixels in buffers the browser cannot round A document is tiles of unpremultiplied RGBA and the canvas only ever receives them. ### Steps 1. Allocate tiles yourself and keep them unpremultiplied This single decision is what keeps repeated edits from degrading the image. Files: `src/pixels/tiles.ts`, `src/pixels/tiles.test.ts` 2. Composite to the canvas in one direction only Tiles go to the screen. Nothing comes back. Files: `src/render/compositor.ts` 3. Ban reading the display surface in the suite A grep test is crude and it will catch the regression a year from now. Files: `tests/no-canvas-readback.test.ts` ### Done when - [ ] Repeated semi-transparent edits match the compositor exactly - [ ] No call site reads pixels back from a display canvas - [ ] Document memory is reported and bounded by tiles - [ ] Empty regions allocate no tiles ### Watch out - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. ## Phase 2 · Undo that does not cost a copy of the image Every edit is a record with an inverse, and history has a memory ceiling. ### Steps 1. Patch tiles, never the document The cost of an undo step should scale with what changed, not with the canvas size. Files: `src/history/operations.ts`, `src/history/patch.ts` 2. Cap history in bytes and say so A count based cap means a hundred tiny steps and one enormous one look the same. Files: `src/history/limits.ts` 3. Round trip the awkward operations Reorder, delete and resize are where inverse operations are usually wrong. Files: `src/history/history.test.ts` ### Done when - [ ] Undo touches only the affected tiles - [ ] History is capped in bytes and the cap is visible - [ ] A hundred strokes stay inside the ceiling - [ ] Reorder, delete and resize round trip ### Watch out - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. ## Phase 3 · Selections, masks and a small set of tools that are correct Fewer tools than the original, each one composited properly. ### Steps 1. Store selections as alpha masks A path is a description; a mask is what the compositor can use. Files: `src/tools/selection.ts` 2. Route every tool through one compositor One place to get premultiplication right, not one per tool. Files: `src/render/compositor.ts`, `src/tools/brush.ts` 3. Test the transparent background case Dark fringes on cut-outs are invisible on white and obvious everywhere else. Files: `src/render/compositor.test.ts` ### Done when - [ ] A feathered cut-out has no dark fringe - [ ] Every tool writes through the compositor - [ ] Selections survive undo of their own operation - [ ] Crop and resize keep layer offsets correct ### Watch out - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. ## Phase 4 · Files that arrive and leave intact Import keeps orientation and profile, and export says what it is about to lose. ### Steps 1. Decode where the metadata is visible Orientation and profile are lost the moment the browser hands you a drawn image. Files: `src-tauri/src/decode.rs`, `src/import/import.ts` 2. Keep the original and prove it Hash on import, hash again after export, assert they match. Files: `tests/original-untouched.test.ts` 3. Say what the export format will lose Before the write, not in the README. Files: `src/export/warnings.ts` ### Done when - [ ] All eight orientation values import upright - [ ] The original file is byte identical afterwards - [ ] Flattening is shown before a write - [ ] Exports keep the original dimensions by default ### Watch out - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. ## Not in this build - Generative fill and background removal. - Reading or writing commercial layered formats. - A colour transform engine. - Cloud documents and collaboration. ## After v1, if you want it - A GPU compositor, once the reference tests exist to prove it matches - Adjustment layers, which are operations that never write tiles at all - A batch mode that applies a saved operation log to a folder of images ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Project files. Versioned from the first release. DOCUMENTS_DIR=~/.local/share/pixeleditor/documents # Required. Imported files, kept byte for byte and never written to. ORIGINALS_DIR=~/.local/share/pixeleditor/originals # Required. Evicted tiles. Safe to delete when the app is not running. SCRATCH_DIR=~/.cache/pixeleditor/tiles # Required. The ceiling above which tiles are evicted to scratch. TILE_MEMORY_MB=512 # Required. Undo history cap in bytes, not in steps. HISTORY_MAX_MB=256 # Required. The most work a crash can cost. Written to a temporary file first. AUTOSAVE_SECONDS=30
You are building a lean indie version of Pixlr. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Pixlr · indie build A layered editor whose hard problems are keeping pixel data out of the canvas so repeated edits do not degrade it, holding large images in bounded memory, and exporting files that still carry the orientation and colour information they arrived with. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with the file dialogs and decode on the Rust side | the browser drops metadata that the image libraries keep | | Interface | React and TypeScript, canvas used only as a display surface | reading pixels back out of the display is the mistake this build avoids | | Pixel store | Fixed-size tiles of unpremultiplied RGBA in typed arrays | bounded memory and cheap partial redraws | | History | An operation log with inverse operations, not bitmap snapshots | a snapshot per stroke on a large image exhausts memory in a minute | | Codecs | Decode and encode in Rust, carrying orientation and any colour profile | the metadata is the difference between an export that matches and one that does not | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Builds the interface half of the application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A Rust toolchain and the Tauri prerequisites** · free - Why: Decoding and encoding happen here, which is the only place orientation and colour profiles are visible. - Get it: rustup.rs, then the platform packages listed in the Tauri prerequisites. - Verify: cargo --version prints a version and a bare Tauri app runs - [ ] **Photographs covering all eight orientation values** · free - Why: Orientation handling is the bug that ships, and it is only caught by a fixture per value rather than by the one photograph on your desk. - Get it: Generate them from one image with an exif tool, or use a published orientation test set. - Verify: All eight fixtures import upright in the suite - [ ] **One genuinely large photograph** · free - Why: Memory behaviour is invisible until the document is bigger than the ceiling, and a sample image from a tutorial never is. - Get it: A full resolution photograph from a recent camera or phone, at least twenty megapixels. - Verify: It opens, edits and exports inside the stated memory ceiling - [ ] **Your memory ceiling and history cap** · free - Why: Both are asserted by tests, and picking them after watching the app fall over means choosing whatever number happens to pass. - Get it: Write down the tile memory ceiling, the history byte cap and the autosave interval before Phase 2. - Verify: All three are in the configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Generative fill and background removal. - Reading or writing commercial layered formats. - A colour transform engine. - Cloud documents and collaboration. - cloud collaboration and mobile apps - high-fidelity color, format, and export handling - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases If one of those is essential to you, that is the reason to keep paying for Pixlr, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Pixlr The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local layered image editor that keeps the pixels in buffers you own to replace Pixlr. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one decides the architecture. The canvas is a screen, not a document. A 2D rendering context's output bitmap must use premultiplied alpha, and the HTML specification says outright that converting to and from premultiplied alpha is lossy for any colour that is not fully opaque, so pixels just written with putImageData can come back from an equivalent getImageData as different values. An editor that stores its layers in canvas elements and reads them back to apply the next operation therefore bleeds a little colour out of every semi-transparent pixel every time, and after twenty strokes a soft edge has drifted somewhere nobody can explain. Keep every layer in unpremultiplied buffers you allocate yourself, composite to the canvas for display, and never read the display surface back into the document. Second, the file that was imported is not yours to write to. Copy it in, hash it, keep it untouched, and put exports somewhere else. An editor that saves over the original has deleted the only copy of a photograph, and no amount of undo history brings that back. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with the file dialogs and decode on the Rust side · the browser drops metadata that the image libraries keep - Interface: React and TypeScript, canvas used only as a display surface · reading pixels back out of the display is the mistake this build avoids - Pixel store: Fixed-size tiles of unpremultiplied RGBA in typed arrays · bounded memory and cheap partial redraws - History: An operation log with inverse operations, not bitmap snapshots · a snapshot per stroke on a large image exhausts memory in a minute - Codecs: Decode and encode in Rust, carrying orientation and any colour profile · the metadata is the difference between an export that matches and one that does not ### Data model (create this before Phase 1) - documents(id, name, width, height, colour_space, format_version, created_at, updated_at) - layers(id, document_id, ordinal, name, kind, opacity, blend_mode, visible, offset_x, offset_y) - tiles(layer_id, tx, ty, bytes) · fixed size unpremultiplied RGBA, the only place pixel data lives - operations(id, document_id, seq, kind, params_json, layer_id, at) · the undo history is this table - originals(document_id, path, sha256, orientation, icc_profile, exif_json) · the imported file, kept byte for byte - exports(id, document_id, at, format, settings_json, path, flattened, profile_written) - Nothing here is a canvas element. The canvas is redrawn from tiles and is never a source of truth. ### Phase 1 · Pixels in buffers the browser cannot round Build: Define the tile store: fixed-size tiles of unpremultiplied RGBA, allocated and freed by you, with a layer as an ordered set of tiles and an offset. Composite visible layers into the canvas for display. Write a test that applies the same semi-transparent stroke fifty times and asserts the stored pixel values are exactly what the compositor computed. Trap: Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. Done when: Fifty repeated operations on a semi-transparent region produce pixel values identical to the compositor's own arithmetic, tested directly. No code path anywhere calls getImageData on a display canvas, verified by a grep in the test suite. Memory for a document is tiles times bytes and is reported in the interface. A layer offset beyond the document bounds does not allocate tiles for empty space. Do not build yet: No tools, no undo, no import or export beyond a raw test fixture. ### Phase 2 · Undo that does not cost a copy of the image Build: Represent each edit as an operation with parameters and the tiles it touched. Undo restores only the affected tiles from a per-operation patch. Cap history by total bytes rather than by count, and drop the oldest patches when the cap is reached, telling the user it happened. Trap: Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. Done when: Undo and redo restore only the tiles an operation touched, asserted by counting tile writes. History memory is capped in bytes and the cap is shown, and reaching it drops the oldest step with a visible notice rather than crashing. A hundred brush strokes on a large document stay inside the stated ceiling, measured. Undo after a layer reorder, a delete and a resize each round trip exactly. Do not build yet: No selections, masks, import or export yet. ### Phase 3 · Selections, masks and a small set of tools that are correct Build: Build rectangular and freehand selections with a feathered edge stored as an alpha mask, a brush and eraser, fill, crop, resize and one adjustment such as levels. Composite with the source-over operator on unpremultiplied values and convert only at the display boundary. Trap: Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. Done when: A feathered cut-out placed on a transparent background shows no dark fringe, tested against reference values. Every tool writes through the same compositor rather than touching tiles directly, verified by review. A selection survives an undo of the operation applied through it. Crop and resize update layer offsets so nothing moves relative to the canvas, tested with an off-centre layer. Do not build yet: No file import or export yet, and no filters beyond the one adjustment. ### Phase 4 · Files that arrive and leave intact Build: Decode on the Rust side so orientation and any embedded colour profile are read rather than discarded, apply the orientation once at import and record that you did, and keep the original file untouched with its hash. On export state plainly what the chosen format drops: alpha for JPEG, colour depth for indexed PNG, everything for a screen-resolution copy. Trap: Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. Done when: A photograph carrying each of the eight orientation values imports upright, tested with a fixture per value. The original file is byte identical after a full edit and export cycle, verified by hash. Exporting to a format without alpha shows what will be flattened and onto what colour before it writes. An exported file opens at the original pixel dimensions unless a resize was chosen explicitly. Do not build yet: No project format, autosave or crash recovery yet. ### Phase 5 · Large documents, autosave and a project file that opens next year Build: Add a memory ceiling with tiles evicted to a scratch file and reloaded on demand, autosave of the operation log at a stated interval, and crash recovery that replays it. Version the project format from the first release and write an importer for version one before you ever write version two. Export a flattened image and the layers as separate files. Trap: Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one. Done when: A document larger than the memory ceiling stays editable with tiles evicted and reloaded, tested with a deliberately small ceiling. Killing the process mid-edit loses at most one autosave interval and the recovered document opens, tested by killing it during a write. A project written by the first format version opens after the format changes. Layer export produces one file per layer with position recorded alongside. Do not build yet: Nothing after this. ### Out of scope (and why) - Generative fill and background removal, which are model work rather than editor work and belong behind a clearly optional switch if at all. - Reading and writing the layered formats of commercial editors, which are large reverse engineering projects with moving targets. - Colour management beyond carrying the profile through, because doing it properly means a transform engine and a calibrated display. - Cloud documents and collaborative editing, which change every assumption in the tile store. ### README must contain - That layers live in unpremultiplied buffers and the canvas is a display surface that is never read back - That the imported original is kept byte for byte and exports are written elsewhere - The memory ceiling, how tiles are evicted, and what happens when a document exceeds it - What each export format drops, and that the warning appears before the write - The project format version and where the importer for older versions lives ===== AGENTS.md ===== # Agent instructions · Pixlr indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with the file dialogs and decode on the Rust side, React and TypeScript, canvas used only as a display surface, Fixed-size tiles of unpremultiplied RGBA in typed arrays, An operation log with inverse operations, not bitmap snapshots, Decode and encode in Rust, carrying orientation and any colour profile. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. ===== BUILD_PLAN.md ===== # Build plan · Pixlr A layered editor whose hard problems are keeping pixel data out of the canvas so repeated edits do not degrade it, holding large images in bounded memory, and exporting files that still carry the orientation and colour information they arrived with. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pixels in buffers the browser cannot round A document is tiles of unpremultiplied RGBA and the canvas only ever receives them. ### Steps 1. Allocate tiles yourself and keep them unpremultiplied This single decision is what keeps repeated edits from degrading the image. Files: `src/pixels/tiles.ts`, `src/pixels/tiles.test.ts` 2. Composite to the canvas in one direction only Tiles go to the screen. Nothing comes back. Files: `src/render/compositor.ts` 3. Ban reading the display surface in the suite A grep test is crude and it will catch the regression a year from now. Files: `tests/no-canvas-readback.test.ts` ### Done when - [ ] Repeated semi-transparent edits match the compositor exactly - [ ] No call site reads pixels back from a display canvas - [ ] Document memory is reported and bounded by tiles - [ ] Empty regions allocate no tiles ### Watch out - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. ## Phase 2 · Undo that does not cost a copy of the image Every edit is a record with an inverse, and history has a memory ceiling. ### Steps 1. Patch tiles, never the document The cost of an undo step should scale with what changed, not with the canvas size. Files: `src/history/operations.ts`, `src/history/patch.ts` 2. Cap history in bytes and say so A count based cap means a hundred tiny steps and one enormous one look the same. Files: `src/history/limits.ts` 3. Round trip the awkward operations Reorder, delete and resize are where inverse operations are usually wrong. Files: `src/history/history.test.ts` ### Done when - [ ] Undo touches only the affected tiles - [ ] History is capped in bytes and the cap is visible - [ ] A hundred strokes stay inside the ceiling - [ ] Reorder, delete and resize round trip ### Watch out - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. ## Phase 3 · Selections, masks and a small set of tools that are correct Fewer tools than the original, each one composited properly. ### Steps 1. Store selections as alpha masks A path is a description; a mask is what the compositor can use. Files: `src/tools/selection.ts` 2. Route every tool through one compositor One place to get premultiplication right, not one per tool. Files: `src/render/compositor.ts`, `src/tools/brush.ts` 3. Test the transparent background case Dark fringes on cut-outs are invisible on white and obvious everywhere else. Files: `src/render/compositor.test.ts` ### Done when - [ ] A feathered cut-out has no dark fringe - [ ] Every tool writes through the compositor - [ ] Selections survive undo of their own operation - [ ] Crop and resize keep layer offsets correct ### Watch out - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. ## Phase 4 · Files that arrive and leave intact Import keeps orientation and profile, and export says what it is about to lose. ### Steps 1. Decode where the metadata is visible Orientation and profile are lost the moment the browser hands you a drawn image. Files: `src-tauri/src/decode.rs`, `src/import/import.ts` 2. Keep the original and prove it Hash on import, hash again after export, assert they match. Files: `tests/original-untouched.test.ts` 3. Say what the export format will lose Before the write, not in the README. Files: `src/export/warnings.ts` ### Done when - [ ] All eight orientation values import upright - [ ] The original file is byte identical afterwards - [ ] Flattening is shown before a write - [ ] Exports keep the original dimensions by default ### Watch out - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. ## Not in this build - Generative fill and background removal. - Reading or writing commercial layered formats. - A colour transform engine. - Cloud documents and collaboration. ## After v1, if you want it - A GPU compositor, once the reference tests exist to prove it matches - Adjustment layers, which are operations that never write tiles at all - A batch mode that applies a saved operation log to a folder of images ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Project files. Versioned from the first release. DOCUMENTS_DIR=~/.local/share/pixeleditor/documents # Required. Imported files, kept byte for byte and never written to. ORIGINALS_DIR=~/.local/share/pixeleditor/originals # Required. Evicted tiles. Safe to delete when the app is not running. SCRATCH_DIR=~/.cache/pixeleditor/tiles # Required. The ceiling above which tiles are evicted to scratch. TILE_MEMORY_MB=512 # Required. Undo history cap in bytes, not in steps. HISTORY_MAX_MB=256 # Required. The most work a crash can cost. Written to a temporary file first. AUTOSAVE_SECONDS=30
You are building a production product version of Pixlr. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Pixlr · product brief ## Problem The visible browser photo editor loop is buildable, but a credible replacement needs more than the first screen. Pixlr earns its keep through editor polish, assets, algorithms, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome An editor where a soft edge looks the same after two hundred operations as it did after one, a twenty megapixel photograph stays editable, the imported file is never touched, and every export states what it is about to discard. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - Node.js 22 and Rust toolchain - sample user-owned assets ## Explicit non-goals for v1 - Generative fill and background removal. - Reading or writing commercial layered formats. - A colour transform engine. - Cloud documents and collaboration. - cloud collaboration and mobile apps - high-fidelity color, format, and export handling - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases ## Success criteria - Repeated semi-transparent edits are confirmed lossless against the compositor - No display canvas readback exists anywhere in the code - All eight orientation fixtures are confirmed to import upright - The original file is confirmed byte identical after an edit and export cycle - A kill mid-edit is confirmed to cost at most one autosave interval ===== BRIEF.md ===== # Build brief · Pixlr The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local layered image editor that keeps the pixels in buffers you own to replace Pixlr. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one decides the architecture. The canvas is a screen, not a document. A 2D rendering context's output bitmap must use premultiplied alpha, and the HTML specification says outright that converting to and from premultiplied alpha is lossy for any colour that is not fully opaque, so pixels just written with putImageData can come back from an equivalent getImageData as different values. An editor that stores its layers in canvas elements and reads them back to apply the next operation therefore bleeds a little colour out of every semi-transparent pixel every time, and after twenty strokes a soft edge has drifted somewhere nobody can explain. Keep every layer in unpremultiplied buffers you allocate yourself, composite to the canvas for display, and never read the display surface back into the document. Second, the file that was imported is not yours to write to. Copy it in, hash it, keep it untouched, and put exports somewhere else. An editor that saves over the original has deleted the only copy of a photograph, and no amount of undo history brings that back. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with the file dialogs and decode on the Rust side · the browser drops metadata that the image libraries keep - Interface: React and TypeScript, canvas used only as a display surface · reading pixels back out of the display is the mistake this build avoids - Pixel store: Fixed-size tiles of unpremultiplied RGBA in typed arrays · bounded memory and cheap partial redraws - History: An operation log with inverse operations, not bitmap snapshots · a snapshot per stroke on a large image exhausts memory in a minute - Codecs: Decode and encode in Rust, carrying orientation and any colour profile · the metadata is the difference between an export that matches and one that does not ### Data model (create this before Phase 1) - documents(id, name, width, height, colour_space, format_version, created_at, updated_at) - layers(id, document_id, ordinal, name, kind, opacity, blend_mode, visible, offset_x, offset_y) - tiles(layer_id, tx, ty, bytes) · fixed size unpremultiplied RGBA, the only place pixel data lives - operations(id, document_id, seq, kind, params_json, layer_id, at) · the undo history is this table - originals(document_id, path, sha256, orientation, icc_profile, exif_json) · the imported file, kept byte for byte - exports(id, document_id, at, format, settings_json, path, flattened, profile_written) - Nothing here is a canvas element. The canvas is redrawn from tiles and is never a source of truth. ### Phase 1 · Pixels in buffers the browser cannot round Build: Define the tile store: fixed-size tiles of unpremultiplied RGBA, allocated and freed by you, with a layer as an ordered set of tiles and an offset. Composite visible layers into the canvas for display. Write a test that applies the same semi-transparent stroke fifty times and asserts the stored pixel values are exactly what the compositor computed. Trap: Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. Done when: Fifty repeated operations on a semi-transparent region produce pixel values identical to the compositor's own arithmetic, tested directly. No code path anywhere calls getImageData on a display canvas, verified by a grep in the test suite. Memory for a document is tiles times bytes and is reported in the interface. A layer offset beyond the document bounds does not allocate tiles for empty space. Do not build yet: No tools, no undo, no import or export beyond a raw test fixture. ### Phase 2 · Undo that does not cost a copy of the image Build: Represent each edit as an operation with parameters and the tiles it touched. Undo restores only the affected tiles from a per-operation patch. Cap history by total bytes rather than by count, and drop the oldest patches when the cap is reached, telling the user it happened. Trap: Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. Done when: Undo and redo restore only the tiles an operation touched, asserted by counting tile writes. History memory is capped in bytes and the cap is shown, and reaching it drops the oldest step with a visible notice rather than crashing. A hundred brush strokes on a large document stay inside the stated ceiling, measured. Undo after a layer reorder, a delete and a resize each round trip exactly. Do not build yet: No selections, masks, import or export yet. ### Phase 3 · Selections, masks and a small set of tools that are correct Build: Build rectangular and freehand selections with a feathered edge stored as an alpha mask, a brush and eraser, fill, crop, resize and one adjustment such as levels. Composite with the source-over operator on unpremultiplied values and convert only at the display boundary. Trap: Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. Done when: A feathered cut-out placed on a transparent background shows no dark fringe, tested against reference values. Every tool writes through the same compositor rather than touching tiles directly, verified by review. A selection survives an undo of the operation applied through it. Crop and resize update layer offsets so nothing moves relative to the canvas, tested with an off-centre layer. Do not build yet: No file import or export yet, and no filters beyond the one adjustment. ### Phase 4 · Files that arrive and leave intact Build: Decode on the Rust side so orientation and any embedded colour profile are read rather than discarded, apply the orientation once at import and record that you did, and keep the original file untouched with its hash. On export state plainly what the chosen format drops: alpha for JPEG, colour depth for indexed PNG, everything for a screen-resolution copy. Trap: Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. Done when: A photograph carrying each of the eight orientation values imports upright, tested with a fixture per value. The original file is byte identical after a full edit and export cycle, verified by hash. Exporting to a format without alpha shows what will be flattened and onto what colour before it writes. An exported file opens at the original pixel dimensions unless a resize was chosen explicitly. Do not build yet: No project format, autosave or crash recovery yet. ### Phase 5 · Large documents, autosave and a project file that opens next year Build: Add a memory ceiling with tiles evicted to a scratch file and reloaded on demand, autosave of the operation log at a stated interval, and crash recovery that replays it. Version the project format from the first release and write an importer for version one before you ever write version two. Export a flattened image and the layers as separate files. Trap: Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one. Done when: A document larger than the memory ceiling stays editable with tiles evicted and reloaded, tested with a deliberately small ceiling. Killing the process mid-edit loses at most one autosave interval and the recovered document opens, tested by killing it during a write. A project written by the first format version opens after the format changes. Layer export produces one file per layer with position recorded alongside. Do not build yet: Nothing after this. ### Out of scope (and why) - Generative fill and background removal, which are model work rather than editor work and belong behind a clearly optional switch if at all. - Reading and writing the layered formats of commercial editors, which are large reverse engineering projects with moving targets. - Colour management beyond carrying the profile through, because doing it properly means a transform engine and a calibrated display. - Cloud documents and collaborative editing, which change every assumption in the tile store. ### README must contain - That layers live in unpremultiplied buffers and the canvas is a display surface that is never read back - That the imported original is kept byte for byte and exports are written elsewhere - The memory ceiling, how tiles are evicted, and what happens when a document exceeds it - What each export format drops, and that the warning appears before the write - The project format version and where the importer for older versions lives ===== ARCHITECTURE.md ===== # Architecture · Pixlr ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with the file dialogs and decode on the Rust side | the browser drops metadata that the image libraries keep | | Interface | React and TypeScript, canvas used only as a display surface | reading pixels back out of the display is the mistake this build avoids | | Pixel store | Fixed-size tiles of unpremultiplied RGBA in typed arrays | bounded memory and cheap partial redraws | | History | An operation log with inverse operations, not bitmap snapshots | a snapshot per stroke on a large image exhausts memory in a minute | | Codecs | Decode and encode in Rust, carrying orientation and any colour profile | the metadata is the difference between an export that matches and one that does not | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Tile store | Unpremultiplied RGBA in bounded memory. | Any allocator or tile size, provided the values stay unpremultiplied. | | Compositor | Blending and the one conversion to display. | A GPU implementation, provided it matches the reference values in the tests. | | History | Operations, inverses and the byte cap. | Not swappable for document snapshots, which is how memory dies. | | Codecs | Decode and encode with metadata. | Any image library that exposes orientation and profile. | | Persistence | Versioned project files and autosave. | Any format, provided an importer for every earlier version exists. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DOCUMENTS_DIR` · required · Project files. Versioned from the first release. - `ORIGINALS_DIR` · required · Imported files, kept byte for byte and never written to. - `SCRATCH_DIR` · required · Evicted tiles. Safe to delete when the app is not running. - `TILE_MEMORY_MB` · required · The ceiling above which tiles are evicted to scratch. - `HISTORY_MAX_MB` · required · Undo history cap in bytes, not in steps. - `AUTOSAVE_SECONDS` · required · The most work a crash can cost. Written to a temporary file first. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Pixlr product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with the file dialogs and decode on the Rust side, React and TypeScript, canvas used only as a display surface, Fixed-size tiles of unpremultiplied RGBA in typed arrays, An operation log with inverse operations, not bitmap snapshots, Decode and encode in Rust, carrying orientation and any colour profile. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. - Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one. ===== MILESTONES.md ===== # Delivery milestones · Pixlr Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Pixels in buffers the browser cannot round A document is tiles of unpremultiplied RGBA and the canvas only ever receives them. ### Steps 1. Allocate tiles yourself and keep them unpremultiplied This single decision is what keeps repeated edits from degrading the image. Files: `src/pixels/tiles.ts`, `src/pixels/tiles.test.ts` 2. Composite to the canvas in one direction only Tiles go to the screen. Nothing comes back. Files: `src/render/compositor.ts` 3. Ban reading the display surface in the suite A grep test is crude and it will catch the regression a year from now. Files: `tests/no-canvas-readback.test.ts` ### Done when - [ ] Repeated semi-transparent edits match the compositor exactly - [ ] No call site reads pixels back from a display canvas - [ ] Document memory is reported and bounded by tiles - [ ] Empty regions allocate no tiles ### Watch out - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. ## M2 · Undo that does not cost a copy of the image Every edit is a record with an inverse, and history has a memory ceiling. ### Steps 1. Patch tiles, never the document The cost of an undo step should scale with what changed, not with the canvas size. Files: `src/history/operations.ts`, `src/history/patch.ts` 2. Cap history in bytes and say so A count based cap means a hundred tiny steps and one enormous one look the same. Files: `src/history/limits.ts` 3. Round trip the awkward operations Reorder, delete and resize are where inverse operations are usually wrong. Files: `src/history/history.test.ts` ### Done when - [ ] Undo touches only the affected tiles - [ ] History is capped in bytes and the cap is visible - [ ] A hundred strokes stay inside the ceiling - [ ] Reorder, delete and resize round trip ### Watch out - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. ## M3 · Selections, masks and a small set of tools that are correct Fewer tools than the original, each one composited properly. ### Steps 1. Store selections as alpha masks A path is a description; a mask is what the compositor can use. Files: `src/tools/selection.ts` 2. Route every tool through one compositor One place to get premultiplication right, not one per tool. Files: `src/render/compositor.ts`, `src/tools/brush.ts` 3. Test the transparent background case Dark fringes on cut-outs are invisible on white and obvious everywhere else. Files: `src/render/compositor.test.ts` ### Done when - [ ] A feathered cut-out has no dark fringe - [ ] Every tool writes through the compositor - [ ] Selections survive undo of their own operation - [ ] Crop and resize keep layer offsets correct ### Watch out - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. ## M4 · Files that arrive and leave intact Import keeps orientation and profile, and export says what it is about to lose. ### Steps 1. Decode where the metadata is visible Orientation and profile are lost the moment the browser hands you a drawn image. Files: `src-tauri/src/decode.rs`, `src/import/import.ts` 2. Keep the original and prove it Hash on import, hash again after export, assert they match. Files: `tests/original-untouched.test.ts` 3. Say what the export format will lose Before the write, not in the README. Files: `src/export/warnings.ts` ### Done when - [ ] All eight orientation values import upright - [ ] The original file is byte identical afterwards - [ ] Flattening is shown before a write - [ ] Exports keep the original dimensions by default ### Watch out - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. ## M5 · Large documents, autosave and a project file that opens next year (production only) Real photographs do not exhaust memory, and an unexpected quit costs one autosave interval. ### Steps 1. Evict tiles under a stated ceiling Real photographs are larger than the memory you want to use. Files: `src/pixels/eviction.ts` 2. Append the log, replace the project atomically A rewrite in place turns a crash into data loss. Files: `src/persist/autosave.ts`, `tests/crash-recovery.test.ts` 3. Write the version one importer now Before there is a version two and no way back. Files: `src/persist/format.ts` ### Done when - [ ] Oversized documents stay editable under eviction - [ ] A kill mid-edit loses at most one interval - [ ] An old format version still opens - [ ] Layer export records position per layer ### Watch out - Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one. ===== OPERATIONS.md ===== # Operations · Pixlr ## Backup Projects, originals and the operation logs together. The originals directory is the irreplaceable one, since a project can be rebuilt from a log and an original but not from a log alone. ## Restore Restore and open a project written under an earlier format version, confirming the importer runs and the referenced original is found by hash rather than by path. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a desktop application this lives in the suite and in the status bar: tile memory against the ceiling, history bytes against the cap, and the time of the last successful autosave. ## Incident checklist A document that will not open is the incident. Recovery order is the autosaved operation log, then the previous project generation, then the untouched original, which is why the original is never written to. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Repeated semi-transparent edits are confirmed lossless against the compositor - [ ] No display canvas readback exists anywhere in the code - [ ] All eight orientation fixtures are confirmed to import upright - [ ] The original file is confirmed byte identical after an edit and export cycle - [ ] A kill mid-edit is confirmed to cost at most one autosave interval ## Launch constraint Do not market omitted Pixlr capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Project files. Versioned from the first release. DOCUMENTS_DIR=~/.local/share/pixeleditor/documents # Required. Imported files, kept byte for byte and never written to. ORIGINALS_DIR=~/.local/share/pixeleditor/originals # Required. Evicted tiles. Safe to delete when the app is not running. SCRATCH_DIR=~/.cache/pixeleditor/tiles # Required. The ceiling above which tiles are evicted to scratch. TILE_MEMORY_MB=512 # Required. Undo history cap in bytes, not in steps. HISTORY_MAX_MB=256 # Required. The most work a crash can cost. Written to a temporary file first. AUTOSAVE_SECONDS=30
# Pixlr · indie build A layered editor whose hard problems are keeping pixel data out of the canvas so repeated edits do not degrade it, holding large images in bounded memory, and exporting files that still carry the orientation and colour information they arrived with. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with the file dialogs and decode on the Rust side | the browser drops metadata that the image libraries keep | | Interface | React and TypeScript, canvas used only as a display surface | reading pixels back out of the display is the mistake this build avoids | | Pixel store | Fixed-size tiles of unpremultiplied RGBA in typed arrays | bounded memory and cheap partial redraws | | History | An operation log with inverse operations, not bitmap snapshots | a snapshot per stroke on a large image exhausts memory in a minute | | Codecs | Decode and encode in Rust, carrying orientation and any colour profile | the metadata is the difference between an export that matches and one that does not | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Builds the interface half of the application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A Rust toolchain and the Tauri prerequisites** · free - Why: Decoding and encoding happen here, which is the only place orientation and colour profiles are visible. - Get it: rustup.rs, then the platform packages listed in the Tauri prerequisites. - Verify: cargo --version prints a version and a bare Tauri app runs - [ ] **Photographs covering all eight orientation values** · free - Why: Orientation handling is the bug that ships, and it is only caught by a fixture per value rather than by the one photograph on your desk. - Get it: Generate them from one image with an exif tool, or use a published orientation test set. - Verify: All eight fixtures import upright in the suite - [ ] **One genuinely large photograph** · free - Why: Memory behaviour is invisible until the document is bigger than the ceiling, and a sample image from a tutorial never is. - Get it: A full resolution photograph from a recent camera or phone, at least twenty megapixels. - Verify: It opens, edits and exports inside the stated memory ceiling - [ ] **Your memory ceiling and history cap** · free - Why: Both are asserted by tests, and picking them after watching the app fall over means choosing whatever number happens to pass. - Get it: Write down the tile memory ceiling, the history byte cap and the autosave interval before Phase 2. - Verify: All three are in the configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Generative fill and background removal. - Reading or writing commercial layered formats. - A colour transform engine. - Cloud documents and collaboration. - cloud collaboration and mobile apps - high-fidelity color, format, and export handling - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases If one of those is essential to you, that is the reason to keep paying for Pixlr, and the README should say so rather than pretend.
# Build brief · Pixlr The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local layered image editor that keeps the pixels in buffers you own to replace Pixlr. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one decides the architecture. The canvas is a screen, not a document. A 2D rendering context's output bitmap must use premultiplied alpha, and the HTML specification says outright that converting to and from premultiplied alpha is lossy for any colour that is not fully opaque, so pixels just written with putImageData can come back from an equivalent getImageData as different values. An editor that stores its layers in canvas elements and reads them back to apply the next operation therefore bleeds a little colour out of every semi-transparent pixel every time, and after twenty strokes a soft edge has drifted somewhere nobody can explain. Keep every layer in unpremultiplied buffers you allocate yourself, composite to the canvas for display, and never read the display surface back into the document. Second, the file that was imported is not yours to write to. Copy it in, hash it, keep it untouched, and put exports somewhere else. An editor that saves over the original has deleted the only copy of a photograph, and no amount of undo history brings that back. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with the file dialogs and decode on the Rust side · the browser drops metadata that the image libraries keep - Interface: React and TypeScript, canvas used only as a display surface · reading pixels back out of the display is the mistake this build avoids - Pixel store: Fixed-size tiles of unpremultiplied RGBA in typed arrays · bounded memory and cheap partial redraws - History: An operation log with inverse operations, not bitmap snapshots · a snapshot per stroke on a large image exhausts memory in a minute - Codecs: Decode and encode in Rust, carrying orientation and any colour profile · the metadata is the difference between an export that matches and one that does not ### Data model (create this before Phase 1) - documents(id, name, width, height, colour_space, format_version, created_at, updated_at) - layers(id, document_id, ordinal, name, kind, opacity, blend_mode, visible, offset_x, offset_y) - tiles(layer_id, tx, ty, bytes) · fixed size unpremultiplied RGBA, the only place pixel data lives - operations(id, document_id, seq, kind, params_json, layer_id, at) · the undo history is this table - originals(document_id, path, sha256, orientation, icc_profile, exif_json) · the imported file, kept byte for byte - exports(id, document_id, at, format, settings_json, path, flattened, profile_written) - Nothing here is a canvas element. The canvas is redrawn from tiles and is never a source of truth. ### Phase 1 · Pixels in buffers the browser cannot round Build: Define the tile store: fixed-size tiles of unpremultiplied RGBA, allocated and freed by you, with a layer as an ordered set of tiles and an offset. Composite visible layers into the canvas for display. Write a test that applies the same semi-transparent stroke fifty times and asserts the stored pixel values are exactly what the compositor computed. Trap: Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. Done when: Fifty repeated operations on a semi-transparent region produce pixel values identical to the compositor's own arithmetic, tested directly. No code path anywhere calls getImageData on a display canvas, verified by a grep in the test suite. Memory for a document is tiles times bytes and is reported in the interface. A layer offset beyond the document bounds does not allocate tiles for empty space. Do not build yet: No tools, no undo, no import or export beyond a raw test fixture. ### Phase 2 · Undo that does not cost a copy of the image Build: Represent each edit as an operation with parameters and the tiles it touched. Undo restores only the affected tiles from a per-operation patch. Cap history by total bytes rather than by count, and drop the oldest patches when the cap is reached, telling the user it happened. Trap: Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. Done when: Undo and redo restore only the tiles an operation touched, asserted by counting tile writes. History memory is capped in bytes and the cap is shown, and reaching it drops the oldest step with a visible notice rather than crashing. A hundred brush strokes on a large document stay inside the stated ceiling, measured. Undo after a layer reorder, a delete and a resize each round trip exactly. Do not build yet: No selections, masks, import or export yet. ### Phase 3 · Selections, masks and a small set of tools that are correct Build: Build rectangular and freehand selections with a feathered edge stored as an alpha mask, a brush and eraser, fill, crop, resize and one adjustment such as levels. Composite with the source-over operator on unpremultiplied values and convert only at the display boundary. Trap: Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. Done when: A feathered cut-out placed on a transparent background shows no dark fringe, tested against reference values. Every tool writes through the same compositor rather than touching tiles directly, verified by review. A selection survives an undo of the operation applied through it. Crop and resize update layer offsets so nothing moves relative to the canvas, tested with an off-centre layer. Do not build yet: No file import or export yet, and no filters beyond the one adjustment. ### Phase 4 · Files that arrive and leave intact Build: Decode on the Rust side so orientation and any embedded colour profile are read rather than discarded, apply the orientation once at import and record that you did, and keep the original file untouched with its hash. On export state plainly what the chosen format drops: alpha for JPEG, colour depth for indexed PNG, everything for a screen-resolution copy. Trap: Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. Done when: A photograph carrying each of the eight orientation values imports upright, tested with a fixture per value. The original file is byte identical after a full edit and export cycle, verified by hash. Exporting to a format without alpha shows what will be flattened and onto what colour before it writes. An exported file opens at the original pixel dimensions unless a resize was chosen explicitly. Do not build yet: No project format, autosave or crash recovery yet. ### Phase 5 · Large documents, autosave and a project file that opens next year Build: Add a memory ceiling with tiles evicted to a scratch file and reloaded on demand, autosave of the operation log at a stated interval, and crash recovery that replays it. Version the project format from the first release and write an importer for version one before you ever write version two. Export a flattened image and the layers as separate files. Trap: Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one. Done when: A document larger than the memory ceiling stays editable with tiles evicted and reloaded, tested with a deliberately small ceiling. Killing the process mid-edit loses at most one autosave interval and the recovered document opens, tested by killing it during a write. A project written by the first format version opens after the format changes. Layer export produces one file per layer with position recorded alongside. Do not build yet: Nothing after this. ### Out of scope (and why) - Generative fill and background removal, which are model work rather than editor work and belong behind a clearly optional switch if at all. - Reading and writing the layered formats of commercial editors, which are large reverse engineering projects with moving targets. - Colour management beyond carrying the profile through, because doing it properly means a transform engine and a calibrated display. - Cloud documents and collaborative editing, which change every assumption in the tile store. ### README must contain - That layers live in unpremultiplied buffers and the canvas is a display surface that is never read back - That the imported original is kept byte for byte and exports are written elsewhere - The memory ceiling, how tiles are evicted, and what happens when a document exceeds it - What each export format drops, and that the warning appears before the write - The project format version and where the importer for older versions lives
# Agent instructions · Pixlr indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with the file dialogs and decode on the Rust side, React and TypeScript, canvas used only as a display surface, Fixed-size tiles of unpremultiplied RGBA in typed arrays, An operation log with inverse operations, not bitmap snapshots, Decode and encode in Rust, carrying orientation and any colour profile. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied.
# Build plan · Pixlr A layered editor whose hard problems are keeping pixel data out of the canvas so repeated edits do not degrade it, holding large images in bounded memory, and exporting files that still carry the orientation and colour information they arrived with. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pixels in buffers the browser cannot round A document is tiles of unpremultiplied RGBA and the canvas only ever receives them. ### Steps 1. Allocate tiles yourself and keep them unpremultiplied This single decision is what keeps repeated edits from degrading the image. Files: `src/pixels/tiles.ts`, `src/pixels/tiles.test.ts` 2. Composite to the canvas in one direction only Tiles go to the screen. Nothing comes back. Files: `src/render/compositor.ts` 3. Ban reading the display surface in the suite A grep test is crude and it will catch the regression a year from now. Files: `tests/no-canvas-readback.test.ts` ### Done when - [ ] Repeated semi-transparent edits match the compositor exactly - [ ] No call site reads pixels back from a display canvas - [ ] Document memory is reported and bounded by tiles - [ ] Empty regions allocate no tiles ### Watch out - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. ## Phase 2 · Undo that does not cost a copy of the image Every edit is a record with an inverse, and history has a memory ceiling. ### Steps 1. Patch tiles, never the document The cost of an undo step should scale with what changed, not with the canvas size. Files: `src/history/operations.ts`, `src/history/patch.ts` 2. Cap history in bytes and say so A count based cap means a hundred tiny steps and one enormous one look the same. Files: `src/history/limits.ts` 3. Round trip the awkward operations Reorder, delete and resize are where inverse operations are usually wrong. Files: `src/history/history.test.ts` ### Done when - [ ] Undo touches only the affected tiles - [ ] History is capped in bytes and the cap is visible - [ ] A hundred strokes stay inside the ceiling - [ ] Reorder, delete and resize round trip ### Watch out - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. ## Phase 3 · Selections, masks and a small set of tools that are correct Fewer tools than the original, each one composited properly. ### Steps 1. Store selections as alpha masks A path is a description; a mask is what the compositor can use. Files: `src/tools/selection.ts` 2. Route every tool through one compositor One place to get premultiplication right, not one per tool. Files: `src/render/compositor.ts`, `src/tools/brush.ts` 3. Test the transparent background case Dark fringes on cut-outs are invisible on white and obvious everywhere else. Files: `src/render/compositor.test.ts` ### Done when - [ ] A feathered cut-out has no dark fringe - [ ] Every tool writes through the compositor - [ ] Selections survive undo of their own operation - [ ] Crop and resize keep layer offsets correct ### Watch out - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. ## Phase 4 · Files that arrive and leave intact Import keeps orientation and profile, and export says what it is about to lose. ### Steps 1. Decode where the metadata is visible Orientation and profile are lost the moment the browser hands you a drawn image. Files: `src-tauri/src/decode.rs`, `src/import/import.ts` 2. Keep the original and prove it Hash on import, hash again after export, assert they match. Files: `tests/original-untouched.test.ts` 3. Say what the export format will lose Before the write, not in the README. Files: `src/export/warnings.ts` ### Done when - [ ] All eight orientation values import upright - [ ] The original file is byte identical afterwards - [ ] Flattening is shown before a write - [ ] Exports keep the original dimensions by default ### Watch out - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. ## Not in this build - Generative fill and background removal. - Reading or writing commercial layered formats. - A colour transform engine. - Cloud documents and collaboration. ## After v1, if you want it - A GPU compositor, once the reference tests exist to prove it matches - Adjustment layers, which are operations that never write tiles at all - A batch mode that applies a saved operation log to a folder of images
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Project files. Versioned from the first release. DOCUMENTS_DIR=~/.local/share/pixeleditor/documents # Required. Imported files, kept byte for byte and never written to. ORIGINALS_DIR=~/.local/share/pixeleditor/originals # Required. Evicted tiles. Safe to delete when the app is not running. SCRATCH_DIR=~/.cache/pixeleditor/tiles # Required. The ceiling above which tiles are evicted to scratch. TILE_MEMORY_MB=512 # Required. Undo history cap in bytes, not in steps. HISTORY_MAX_MB=256 # Required. The most work a crash can cost. Written to a temporary file first. AUTOSAVE_SECONDS=30
# Pixlr · product brief ## Problem The visible browser photo editor loop is buildable, but a credible replacement needs more than the first screen. Pixlr earns its keep through editor polish, assets, algorithms, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome An editor where a soft edge looks the same after two hundred operations as it did after one, a twenty megapixel photograph stays editable, the imported file is never touched, and every export states what it is about to discard. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - Node.js 22 and Rust toolchain - sample user-owned assets ## Explicit non-goals for v1 - Generative fill and background removal. - Reading or writing commercial layered formats. - A colour transform engine. - Cloud documents and collaboration. - cloud collaboration and mobile apps - high-fidelity color, format, and export handling - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases ## Success criteria - Repeated semi-transparent edits are confirmed lossless against the compositor - No display canvas readback exists anywhere in the code - All eight orientation fixtures are confirmed to import upright - The original file is confirmed byte identical after an edit and export cycle - A kill mid-edit is confirmed to cost at most one autosave interval
# Build brief · Pixlr The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local layered image editor that keeps the pixels in buffers you own to replace Pixlr. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one decides the architecture. The canvas is a screen, not a document. A 2D rendering context's output bitmap must use premultiplied alpha, and the HTML specification says outright that converting to and from premultiplied alpha is lossy for any colour that is not fully opaque, so pixels just written with putImageData can come back from an equivalent getImageData as different values. An editor that stores its layers in canvas elements and reads them back to apply the next operation therefore bleeds a little colour out of every semi-transparent pixel every time, and after twenty strokes a soft edge has drifted somewhere nobody can explain. Keep every layer in unpremultiplied buffers you allocate yourself, composite to the canvas for display, and never read the display surface back into the document. Second, the file that was imported is not yours to write to. Copy it in, hash it, keep it untouched, and put exports somewhere else. An editor that saves over the original has deleted the only copy of a photograph, and no amount of undo history brings that back. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with the file dialogs and decode on the Rust side · the browser drops metadata that the image libraries keep - Interface: React and TypeScript, canvas used only as a display surface · reading pixels back out of the display is the mistake this build avoids - Pixel store: Fixed-size tiles of unpremultiplied RGBA in typed arrays · bounded memory and cheap partial redraws - History: An operation log with inverse operations, not bitmap snapshots · a snapshot per stroke on a large image exhausts memory in a minute - Codecs: Decode and encode in Rust, carrying orientation and any colour profile · the metadata is the difference between an export that matches and one that does not ### Data model (create this before Phase 1) - documents(id, name, width, height, colour_space, format_version, created_at, updated_at) - layers(id, document_id, ordinal, name, kind, opacity, blend_mode, visible, offset_x, offset_y) - tiles(layer_id, tx, ty, bytes) · fixed size unpremultiplied RGBA, the only place pixel data lives - operations(id, document_id, seq, kind, params_json, layer_id, at) · the undo history is this table - originals(document_id, path, sha256, orientation, icc_profile, exif_json) · the imported file, kept byte for byte - exports(id, document_id, at, format, settings_json, path, flattened, profile_written) - Nothing here is a canvas element. The canvas is redrawn from tiles and is never a source of truth. ### Phase 1 · Pixels in buffers the browser cannot round Build: Define the tile store: fixed-size tiles of unpremultiplied RGBA, allocated and freed by you, with a layer as an ordered set of tiles and an offset. Composite visible layers into the canvas for display. Write a test that applies the same semi-transparent stroke fifty times and asserts the stored pixel values are exactly what the compositor computed. Trap: Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. Done when: Fifty repeated operations on a semi-transparent region produce pixel values identical to the compositor's own arithmetic, tested directly. No code path anywhere calls getImageData on a display canvas, verified by a grep in the test suite. Memory for a document is tiles times bytes and is reported in the interface. A layer offset beyond the document bounds does not allocate tiles for empty space. Do not build yet: No tools, no undo, no import or export beyond a raw test fixture. ### Phase 2 · Undo that does not cost a copy of the image Build: Represent each edit as an operation with parameters and the tiles it touched. Undo restores only the affected tiles from a per-operation patch. Cap history by total bytes rather than by count, and drop the oldest patches when the cap is reached, telling the user it happened. Trap: Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. Done when: Undo and redo restore only the tiles an operation touched, asserted by counting tile writes. History memory is capped in bytes and the cap is shown, and reaching it drops the oldest step with a visible notice rather than crashing. A hundred brush strokes on a large document stay inside the stated ceiling, measured. Undo after a layer reorder, a delete and a resize each round trip exactly. Do not build yet: No selections, masks, import or export yet. ### Phase 3 · Selections, masks and a small set of tools that are correct Build: Build rectangular and freehand selections with a feathered edge stored as an alpha mask, a brush and eraser, fill, crop, resize and one adjustment such as levels. Composite with the source-over operator on unpremultiplied values and convert only at the display boundary. Trap: Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. Done when: A feathered cut-out placed on a transparent background shows no dark fringe, tested against reference values. Every tool writes through the same compositor rather than touching tiles directly, verified by review. A selection survives an undo of the operation applied through it. Crop and resize update layer offsets so nothing moves relative to the canvas, tested with an off-centre layer. Do not build yet: No file import or export yet, and no filters beyond the one adjustment. ### Phase 4 · Files that arrive and leave intact Build: Decode on the Rust side so orientation and any embedded colour profile are read rather than discarded, apply the orientation once at import and record that you did, and keep the original file untouched with its hash. On export state plainly what the chosen format drops: alpha for JPEG, colour depth for indexed PNG, everything for a screen-resolution copy. Trap: Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. Done when: A photograph carrying each of the eight orientation values imports upright, tested with a fixture per value. The original file is byte identical after a full edit and export cycle, verified by hash. Exporting to a format without alpha shows what will be flattened and onto what colour before it writes. An exported file opens at the original pixel dimensions unless a resize was chosen explicitly. Do not build yet: No project format, autosave or crash recovery yet. ### Phase 5 · Large documents, autosave and a project file that opens next year Build: Add a memory ceiling with tiles evicted to a scratch file and reloaded on demand, autosave of the operation log at a stated interval, and crash recovery that replays it. Version the project format from the first release and write an importer for version one before you ever write version two. Export a flattened image and the layers as separate files. Trap: Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one. Done when: A document larger than the memory ceiling stays editable with tiles evicted and reloaded, tested with a deliberately small ceiling. Killing the process mid-edit loses at most one autosave interval and the recovered document opens, tested by killing it during a write. A project written by the first format version opens after the format changes. Layer export produces one file per layer with position recorded alongside. Do not build yet: Nothing after this. ### Out of scope (and why) - Generative fill and background removal, which are model work rather than editor work and belong behind a clearly optional switch if at all. - Reading and writing the layered formats of commercial editors, which are large reverse engineering projects with moving targets. - Colour management beyond carrying the profile through, because doing it properly means a transform engine and a calibrated display. - Cloud documents and collaborative editing, which change every assumption in the tile store. ### README must contain - That layers live in unpremultiplied buffers and the canvas is a display surface that is never read back - That the imported original is kept byte for byte and exports are written elsewhere - The memory ceiling, how tiles are evicted, and what happens when a document exceeds it - What each export format drops, and that the warning appears before the write - The project format version and where the importer for older versions lives
# Architecture · Pixlr ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with the file dialogs and decode on the Rust side | the browser drops metadata that the image libraries keep | | Interface | React and TypeScript, canvas used only as a display surface | reading pixels back out of the display is the mistake this build avoids | | Pixel store | Fixed-size tiles of unpremultiplied RGBA in typed arrays | bounded memory and cheap partial redraws | | History | An operation log with inverse operations, not bitmap snapshots | a snapshot per stroke on a large image exhausts memory in a minute | | Codecs | Decode and encode in Rust, carrying orientation and any colour profile | the metadata is the difference between an export that matches and one that does not | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Tile store | Unpremultiplied RGBA in bounded memory. | Any allocator or tile size, provided the values stay unpremultiplied. | | Compositor | Blending and the one conversion to display. | A GPU implementation, provided it matches the reference values in the tests. | | History | Operations, inverses and the byte cap. | Not swappable for document snapshots, which is how memory dies. | | Codecs | Decode and encode with metadata. | Any image library that exposes orientation and profile. | | Persistence | Versioned project files and autosave. | Any format, provided an importer for every earlier version exists. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DOCUMENTS_DIR` · required · Project files. Versioned from the first release. - `ORIGINALS_DIR` · required · Imported files, kept byte for byte and never written to. - `SCRATCH_DIR` · required · Evicted tiles. Safe to delete when the app is not running. - `TILE_MEMORY_MB` · required · The ceiling above which tiles are evicted to scratch. - `HISTORY_MAX_MB` · required · Undo history cap in bytes, not in steps. - `AUTOSAVE_SECONDS` · required · The most work a crash can cost. Written to a temporary file first. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Pixlr product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with the file dialogs and decode on the Rust side, React and TypeScript, canvas used only as a display surface, Fixed-size tiles of unpremultiplied RGBA in typed arrays, An operation log with inverse operations, not bitmap snapshots, Decode and encode in Rust, carrying orientation and any colour profile. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. - Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one.
# Delivery milestones · Pixlr Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Pixels in buffers the browser cannot round A document is tiles of unpremultiplied RGBA and the canvas only ever receives them. ### Steps 1. Allocate tiles yourself and keep them unpremultiplied This single decision is what keeps repeated edits from degrading the image. Files: `src/pixels/tiles.ts`, `src/pixels/tiles.test.ts` 2. Composite to the canvas in one direction only Tiles go to the screen. Nothing comes back. Files: `src/render/compositor.ts` 3. Ban reading the display surface in the suite A grep test is crude and it will catch the regression a year from now. Files: `tests/no-canvas-readback.test.ts` ### Done when - [ ] Repeated semi-transparent edits match the compositor exactly - [ ] No call site reads pixels back from a display canvas - [ ] Document memory is reported and bounded by tiles - [ ] Empty regions allocate no tiles ### Watch out - Holding each layer in its own canvas and calling getImageData before every operation loses accuracy on every pixel that is not fully opaque, because the output bitmap is premultiplied and the round trip divides and multiplies in finite precision, so soft brush edges and feathered masks drift grey after a few dozen edits. Keep the pixels in your own buffers and treat the canvas as write only. ## M2 · Undo that does not cost a copy of the image Every edit is a record with an inverse, and history has a memory ceiling. ### Steps 1. Patch tiles, never the document The cost of an undo step should scale with what changed, not with the canvas size. Files: `src/history/operations.ts`, `src/history/patch.ts` 2. Cap history in bytes and say so A count based cap means a hundred tiny steps and one enormous one look the same. Files: `src/history/limits.ts` 3. Round trip the awkward operations Reorder, delete and resize are where inverse operations are usually wrong. Files: `src/history/history.test.ts` ### Done when - [ ] Undo touches only the affected tiles - [ ] History is capped in bytes and the cap is visible - [ ] A hundred strokes stay inside the ceiling - [ ] Reorder, delete and resize round trip ### Watch out - Snapshotting the whole document before each stroke looks fine on a small test image and then eats a hundred and ninety two megabytes per undo step on a six by four thousand pixel photograph, so the app dies on the fifth brush stroke of real work. Store only the tiles an operation touched, and bound the history in bytes. ## M3 · Selections, masks and a small set of tools that are correct Fewer tools than the original, each one composited properly. ### Steps 1. Store selections as alpha masks A path is a description; a mask is what the compositor can use. Files: `src/tools/selection.ts` 2. Route every tool through one compositor One place to get premultiplication right, not one per tool. Files: `src/render/compositor.ts`, `src/tools/brush.ts` 3. Test the transparent background case Dark fringes on cut-outs are invisible on white and obvious everywhere else. Files: `src/render/compositor.test.ts` ### Done when - [ ] A feathered cut-out has no dark fringe - [ ] Every tool writes through the compositor - [ ] Selections survive undo of their own operation - [ ] Crop and resize keep layer offsets correct ### Watch out - Compositing a feathered mask by multiplying colour channels that are already premultiplied darkens every soft edge towards black, and the error only shows on transparent backgrounds, which is where cut-outs are always used. Decide once that everything in the store is unpremultiplied, and do the multiply inside the compositor rather than in each tool. ## M4 · Files that arrive and leave intact Import keeps orientation and profile, and export says what it is about to lose. ### Steps 1. Decode where the metadata is visible Orientation and profile are lost the moment the browser hands you a drawn image. Files: `src-tauri/src/decode.rs`, `src/import/import.ts` 2. Keep the original and prove it Hash on import, hash again after export, assert they match. Files: `tests/original-untouched.test.ts` 3. Say what the export format will lose Before the write, not in the README. Files: `src/export/warnings.ts` ### Done when - [ ] All eight orientation values import upright - [ ] The original file is byte identical afterwards - [ ] Flattening is shown before a write - [ ] Exports keep the original dimensions by default ### Watch out - Loading a phone photograph through an image element and drawing it to a canvas gives you whatever the browser decided about rotation, and exporting from the canvas writes a file with no profile and no orientation tag, so the result looks right in your app and sideways or washed out everywhere else. Decode where you can see the metadata, normalise once, and record what you applied. ## M5 · Large documents, autosave and a project file that opens next year (production only) Real photographs do not exhaust memory, and an unexpected quit costs one autosave interval. ### Steps 1. Evict tiles under a stated ceiling Real photographs are larger than the memory you want to use. Files: `src/pixels/eviction.ts` 2. Append the log, replace the project atomically A rewrite in place turns a crash into data loss. Files: `src/persist/autosave.ts`, `tests/crash-recovery.test.ts` 3. Write the version one importer now Before there is a version two and no way back. Files: `src/persist/format.ts` ### Done when - [ ] Oversized documents stay editable under eviction - [ ] A kill mid-edit loses at most one interval - [ ] An old format version still opens - [ ] Layer export records position per layer ### Watch out - Autosaving by rewriting the whole project file over itself every thirty seconds turns a crash during the write into a lost manuscript of edits, which is the exact moment autosave was supposed to help. Append the operation log, write the project to a temporary file and replace it only once it reads back, and keep the previous one.
# Operations · Pixlr ## Backup Projects, originals and the operation logs together. The originals directory is the irreplaceable one, since a project can be rebuilt from a log and an original but not from a log alone. ## Restore Restore and open a project written under an earlier format version, confirming the importer runs and the referenced original is found by hash rather than by path. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a desktop application this lives in the suite and in the status bar: tile memory against the ceiling, history bytes against the cap, and the time of the last successful autosave. ## Incident checklist A document that will not open is the incident. Recovery order is the autosaved operation log, then the previous project generation, then the untouched original, which is why the original is never written to. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Repeated semi-transparent edits are confirmed lossless against the compositor - [ ] No display canvas readback exists anywhere in the code - [ ] All eight orientation fixtures are confirmed to import upright - [ ] The original file is confirmed byte identical after an edit and export cycle - [ ] A kill mid-edit is confirmed to cost at most one autosave interval ## Launch constraint Do not market omitted Pixlr capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Project files. Versioned from the first release. DOCUMENTS_DIR=~/.local/share/pixeleditor/documents # Required. Imported files, kept byte for byte and never written to. ORIGINALS_DIR=~/.local/share/pixeleditor/originals # Required. Evicted tiles. Safe to delete when the app is not running. SCRATCH_DIR=~/.cache/pixeleditor/tiles # Required. The ceiling above which tiles are evicted to scratch. TILE_MEMORY_MB=512 # Required. Undo history cap in bytes, not in steps. HISTORY_MAX_MB=256 # Required. The most work a crash can cost. Written to a temporary file first. AUTOSAVE_SECONDS=30
$ choose a build depth, inspect the files, then open the complete pack in your agent
Pixlr: Creative users pay for speed, output fidelity, nondestructive editing, and years of edge-case work hidden behind direct manipulation.
xcloud collaboration and mobile apps
xhigh-fidelity color, format, and export handling
xpixel-perfect professional tooling
xlarge template and asset libraries
xcolor-management edge cases
Don't feel like building it? These folks already made it free.
all 5 free alternatives to Pixlr →· no votes, no pay-to-list · just what's real
Pixlr pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0 | $0 | Free editor access; the official page does not publish a numeric recurring AI-credit allowance. |
| plus | $2.49 | $1.99 | 80 AI credits/month or 960/year; 1 concurrent generation. |
| premium | $9.99 | $7.99 | 1,000 AI credits/month or 12,000/year; 4 concurrent generations. |
| ultra | $24.99 | $19.99 | 5,000 AI credits/month plus fair-use unlimited fast generations; 8 concurrent generations. |
| ultra max | $49.99 | $39.99 | 10,000 AI credits/month or 120,000/year; 8 concurrent generations. |
free tierFree editor access; the recurring AI-credit amount is not published.
billingmonthly + annual (-20%)
hidden costsIncluded subscription credits reset and expire; canceling can reset the subscription balance to zero. Separate credit packs do not expire. 'Unlimited' generation is subject to fair-use and demand controls.
verified 2026-08-14 · source ↗
Vibecode Pixlr
Kinda. The core of Pixlr is buildable in a weekend with the prompt on this page, but there are real gaps: cloud collaboration and mobile apps, high-fidelity color, format, and export handling. Read the honest list above before committing.
How much does Pixlr cost?
Pixlr's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Pixlr?
Honestly: cloud collaboration and mobile apps; high-fidelity color, format, and export handling; pixel-perfect professional tooling; large template and asset libraries; color-management edge cases. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Pixlr?
Yes: Adobe Express Free (Better for templates, quick effects, generative credits, and social assets; weaker for deep layer-by-layer image surgery.) Canva Free (A broad free template and asset library with lightweight photo editing and limited AI; not a serious PSD workstation.) Desygner Free (Templates, photo tools, stock assets, and monthly AI credits in a durable free plan; the editor feels less slick.) All 5 curated free alternatives are at vibecodeit.com/pixlr/alternatives. The prompt is for when you want it exactly your way.