Vibecode Stencil
track this build4 phases, 12 steps, beginner friendly0%Stencil's solo core is compact: build a focused local social graphic design editor with import, a small set of transformations, undo, and standards-based export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on editor polish, assets, algorithms.
You are building a lean indie version of Stencil. 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 ===== # Stencil · indie build A local graphic editor for social images whose hard problems are one renderer shared by preview and export, text that lays out identically at every scale, and an export that fails loudly rather than handing back a blank canvas. Estimated effort: **weekend**. 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 a React and TypeScript interface | a desktop app with real file access and no upload step | | Rendering | One pure render function over a document, drawing to a 2D canvas | preview and export cannot diverge if there is only one of them | | Document | A versioned JSON document with assets stored beside it | the file is the product, so it has to open next year | | Text | Fonts loaded and awaited before any render, with the resolved family recorded | an export that raced the font load is the classic silent wrong output | | Export | Offscreen render at an explicit scale, then read the bytes back and check them | a canvas past its size limit draws nothing and raises no error | ## 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 and runs the test suite. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain for Tauri 2** · free - Why: Tauri's shell is compiled, and the platform build tools have to be present before anything runs. - Get it: rustup.rs, then follow the Tauri prerequisites for your operating system. - Verify: cargo --version prints a version and a Tauri dev build launches a window - [ ] **Two or three fonts you are allowed to use** · free - Why: Text is most of a social graphic, and the licence that lets you render with a font is not always the licence that lets you redistribute the file. - Get it: Open licence families, or fonts already installed on your machine, with the licence noted for each. - Verify: Each font renders and its licence is recorded in the project - [ ] **A rotated photograph and a very large image** · free - Why: Orientation metadata and the canvas size limit are the two export bugs that only appear with awkward inputs. - Get it: A phone photograph taken sideways, and an image of at least six thousand pixels on its long edge. - Verify: Both import and export correctly - [ ] **The preset sizes you start with and where they came from** · free - Why: They are shipped as editable data with a checked date, so the source and the date have to exist before the first release. - Get it: Write down each size, the platform it is for, and the date you read it from that platform's own guidance. - Verify: Every preset row has a source note and a date ## 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: - A stock and template library. - Cloud sync or collaboration. - Direct publishing to social networks. - Print colour management. - cloud collaboration and mobile apps - direct publishing reliability as platform APIs change - 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 Stencil, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Stencil 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 social graphic editor that exports exactly the pixels you saw to replace Stencil. 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 One rule: the preview and the export are the same renderer. The quick way to build this is a canvas you draw to while editing plus a second code path that redraws bigger for export. That second path is where every 'it looked different in the app' bug lives: a font that had not finished loading, a shadow scaled by the wrong factor, a text box that wrapped at a different width. Write one pure function from document plus scale to pixels. The preview calls it at the display scale, the export calls it at the export scale, and the only difference between them is that one number. The corollary is that the document, not the canvas, is the truth. Undo, autosave and the file format all act on the document. The canvas is a view that can be thrown away and redrawn at any time without losing anything, which is also what makes a crash survivable. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript interface · a desktop app with real file access and no upload step - Rendering: One pure render function over a document, drawing to a 2D canvas · preview and export cannot diverge if there is only one of them - Document: A versioned JSON document with assets stored beside it · the file is the product, so it has to open next year - Text: Fonts loaded and awaited before any render, with the resolved family recorded · an export that raced the font load is the classic silent wrong output - Export: Offscreen render at an explicit scale, then read the bytes back and check them · a canvas past its size limit draws nothing and raises no error ### Data model (create this before Phase 1) - documents(id, format_version, name, width, height, background, created_at, updated_at) - layers(id, document_id, ordinal, kind, x, y, w, h, rotation, opacity, blend, locked, hidden) · kind is text, image, shape or group - text_layers(layer_id, content, font_family, font_file_ref, size, line_height, letter_spacing, align, colour, wrap_width) - image_layers(layer_id, asset_id, crop_x, crop_y, crop_w, crop_h, fit) - assets(id, path, sha256, mime, width, height, bytes, imported_at) · the original file, never the resized copy - presets(id, name, width, height, source_note, edited_at) · sizes are data the user can edit, not constants in code - exports(id, document_id, preset_id, scale, path, pixel_w, pixel_h, bytes, renderer_version, at) - history(document_id, ordinal, patch_json, at) · undo is a stack of document patches, so the canvas can be rebuilt from nothing ### Phase 1 · A document and one renderer Build: Define the document schema with a format version. Write render(document, scale, target) and call it for the preview. Build selection, move, resize and z-order as patches applied to the document, with undo and redo replaying patches rather than touching the canvas. Trap: Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. Done when: Redrawing from the document after any edit produces the same image as the incremental draw, tested by comparing two renders pixel for pixel. Undo and redo restore the document exactly, tested over a scripted sequence of twenty edits. The format version is written into every saved file. No drawing code exists outside the render function, verified by review. Do not build yet: No text layout, image import, export or presets. ### Phase 2 · Text that wraps the same at every scale Build: Load every font through the font loading interface and await it before the first render that uses it. Measure and wrap in document units, then scale, never the other way round. Record the resolved family per text layer so a missing font is visible rather than silent. Trap: Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. Done when: Line breaks are identical at scale one and scale four, tested by comparing break positions rather than pixels. A render requested before its font has loaded either waits or fails, and never silently substitutes, tested by delaying the load. A layer whose font is unavailable is shown as unavailable in the interface. Wrapping is computed in document units, verified by a test at three scales. Do not build yet: No image import, export or presets yet. ### Phase 3 · Images that keep their original Build: Copy the chosen file into the asset folder, hash it, read its real dimensions and any orientation metadata, and store the original untouched. Generate a display-sized copy for the preview and keep the original for export. Crop and fit are document values, not edits to the file. Trap: Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. Done when: The imported original is byte identical to the source file, tested by comparing hashes. A photograph carrying orientation metadata appears the same way in preview and export, tested with a rotated sample. Export renders from the original, not the preview copy, asserted in the suite. Deleting a document offers to delete only assets no other document references. Do not build yet: No export pipeline or presets yet. ### Phase 4 · An export that cannot fail quietly Build: Compute the target pixel size from the preset and the scale, and compare it against a maximum probed at startup by attempting a draw and reading a pixel back. Render offscreen, read the bytes, check the decoded dimensions and non-zero size, then write. Offer lossless and lossy formats with the quality recorded in the export row. Trap: A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. Done when: An export request above the probed maximum is refused with the numbers shown, or rendered in tiles, and never writes a blank file, tested by forcing a small maximum. Every written file is decoded back and its dimensions and byte count checked before success is reported. The export row records preset, scale, format, quality and pixel size. A failed export leaves no partial file behind. Do not build yet: No presets management, autosave or project bundles yet. ### Phase 5 · Files, presets and getting out Build: Save a document as a folder or single bundle containing the JSON and its assets. Autosave on a timer and on blur, writing to a temporary file and replacing atomically, with recovery offered on the next launch. Ship the platform sizes as editable preset rows carrying the date they were checked, and let the user add their own. Trap: Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them. Done when: A document saved by an older format version opens, tested with a fixture file committed to the repository. Killing the app mid-edit leaves the last autosave intact and recovery is offered on launch, tested by killing the process. Presets are editable rows showing when they were last checked. Export produces standard image files with no application-specific metadata required to reopen them. Do not build yet: Nothing after this. ### Out of scope (and why) - A stock photograph and template library, which is a licensing operation rather than a feature and is the main thing the subscription actually buys. - Cloud sync and collaborative editing, which turn a local document into a distributed system with conflicts to resolve. - Direct publishing to social networks, because the posting APIs change and a broken publish button is worse than a copied file. - Colour-managed print output, which needs profile handling well beyond drawing to a screen canvas. ### README must contain - That one render function serves both preview and export, and that this is the reason they agree - That fonts are awaited before rendering, and what happens when a font is unavailable - That imported images are kept as untouched originals and exports render from them - That the canvas maximum is probed at startup and oversized exports are refused or tiled - That presets are editable rows with a checked date, not constants in the code ===== AGENTS.md ===== # Agent instructions · Stencil indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with a React and TypeScript interface, One pure render function over a document, drawing to a 2D canvas, A versioned JSON document with assets stored beside it, Fonts loaded and awaited before any render, with the resolved family recorded, Offscreen render at an explicit scale, then read the bytes back and check them. 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 - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. ===== BUILD_PLAN.md ===== # Build plan · Stencil A local graphic editor for social images whose hard problems are one renderer shared by preview and export, text that lays out identically at every scale, and an export that fails loudly rather than handing back a blank canvas. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A document and one renderer Every pixel on screen comes from a pure function of the document. ### Steps 1. Write the render function first, then the editor around it One function from document and scale to pixels, with no hidden state. Files: `src/render/render.ts`, `src/model/document.ts` 2. Make every edit a patch Undo is then a list, not a pile of special cases. Files: `src/model/patch.ts`, `src/model/history.ts` 3. Assert the two draw paths agree An incremental draw and a full redraw must produce identical pixels. Files: `tests/render-parity.test.ts` ### Done when - [ ] A full redraw matches the incremental draw pixel for pixel - [ ] Undo and redo restore the document over a long sequence - [ ] Every saved file records its format version - [ ] No drawing happens outside the render function ### Watch out - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. ## Phase 2 · Text that wraps the same at every scale A headline exported at four times the preview scale breaks lines in the same places. ### Steps 1. Await the font before rendering The font loading interface tells you when the face is really there. Files: `src/render/fonts.ts`, `tests/font-race.test.ts` 2. Measure and wrap in document units Scaling a wrapped line is fine, wrapping a scaled line is not. Files: `src/render/text.ts` 3. Record the resolved family per layer So a missing font is a visible state, not a different picture. Files: `src/model/document.ts`, `src/ui/LayerPanel.tsx` ### Done when - [ ] Line breaks match at scale one and scale four - [ ] A render before font load waits or fails, never substitutes - [ ] Missing fonts are shown as missing - [ ] Wrapping is computed in document units ### Watch out - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. ## Phase 3 · Images that keep their original Importing a photograph never damages the file the user chose. ### Steps 1. Copy in, hash, never modify The asset folder holds originals. Derived copies live elsewhere. Files: `src/assets/import.ts` 2. Honour orientation metadata once, in the renderer Applying it twice is how a photograph ends up sideways in the export only. Files: `src/render/image.ts`, `tests/orientation.test.ts` 3. Render exports from the original The preview copy exists for speed and nothing else. Files: `src/render/render.ts` ### Done when - [ ] Imported originals hash equal to the source - [ ] A rotated photograph matches in preview and export - [ ] Export reads the original asset - [ ] Asset deletion checks for other references ### Watch out - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. ## Phase 4 · An export that cannot fail quietly Either a correct file lands on disk or the user is told why not. ### Steps 1. Probe the canvas maximum at startup Draw, read a pixel back, and store what actually worked. Files: `src/export/limits.ts` 2. Verify the written file by decoding it Bytes on disk that no decoder accepts are not an export. Files: `src/export/write.ts`, `tests/export-verify.test.ts` 3. Write atomically and clean up on failure A half-written image in the output folder will be sent to someone. Files: `src/export/write.ts` ### Done when - [ ] An oversized export is refused or tiled, never blank - [ ] Written files are decoded and checked before success - [ ] Export rows record scale, format and pixel size - [ ] A failed export leaves no partial file ### Watch out - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. ## Not in this build - A stock and template library. - Cloud sync or collaboration. - Direct publishing to social networks. - Print colour management. ## After v1, if you want it - A background removal step, once you have decided which model licence you are willing to ship under - Brand kits that are just saved colour and font sets applied to a document - Batch export of one document across every preset, which reuses the export verification you already wrote ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where bundles are saved. Back this up like any other document folder. DOCUMENTS_DIR=~/Documents/Stencil # Required. Imported originals, addressed by hash. Never written to after import. ASSETS_DIR=~/Documents/Stencil/.assets # Required. Also runs on window blur. Writes to a temporary file and replaces atomically. AUTOSAVE_SECONDS=20 # Required. Upper bound offered in the interface, further limited by the probed canvas maximum. MAX_EXPORT_SCALE=4 # Required. Where finished images land. Failed exports leave nothing here. EXPORT_DIR=~/Pictures/Stencil
You are building a lean indie version of Stencil. 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 ===== # Stencil · indie build A local graphic editor for social images whose hard problems are one renderer shared by preview and export, text that lays out identically at every scale, and an export that fails loudly rather than handing back a blank canvas. Estimated effort: **weekend**. 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 a React and TypeScript interface | a desktop app with real file access and no upload step | | Rendering | One pure render function over a document, drawing to a 2D canvas | preview and export cannot diverge if there is only one of them | | Document | A versioned JSON document with assets stored beside it | the file is the product, so it has to open next year | | Text | Fonts loaded and awaited before any render, with the resolved family recorded | an export that raced the font load is the classic silent wrong output | | Export | Offscreen render at an explicit scale, then read the bytes back and check them | a canvas past its size limit draws nothing and raises no error | ## 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 and runs the test suite. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain for Tauri 2** · free - Why: Tauri's shell is compiled, and the platform build tools have to be present before anything runs. - Get it: rustup.rs, then follow the Tauri prerequisites for your operating system. - Verify: cargo --version prints a version and a Tauri dev build launches a window - [ ] **Two or three fonts you are allowed to use** · free - Why: Text is most of a social graphic, and the licence that lets you render with a font is not always the licence that lets you redistribute the file. - Get it: Open licence families, or fonts already installed on your machine, with the licence noted for each. - Verify: Each font renders and its licence is recorded in the project - [ ] **A rotated photograph and a very large image** · free - Why: Orientation metadata and the canvas size limit are the two export bugs that only appear with awkward inputs. - Get it: A phone photograph taken sideways, and an image of at least six thousand pixels on its long edge. - Verify: Both import and export correctly - [ ] **The preset sizes you start with and where they came from** · free - Why: They are shipped as editable data with a checked date, so the source and the date have to exist before the first release. - Get it: Write down each size, the platform it is for, and the date you read it from that platform's own guidance. - Verify: Every preset row has a source note and a date ## 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: - A stock and template library. - Cloud sync or collaboration. - Direct publishing to social networks. - Print colour management. - cloud collaboration and mobile apps - direct publishing reliability as platform APIs change - 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 Stencil, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Stencil 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 social graphic editor that exports exactly the pixels you saw to replace Stencil. 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 One rule: the preview and the export are the same renderer. The quick way to build this is a canvas you draw to while editing plus a second code path that redraws bigger for export. That second path is where every 'it looked different in the app' bug lives: a font that had not finished loading, a shadow scaled by the wrong factor, a text box that wrapped at a different width. Write one pure function from document plus scale to pixels. The preview calls it at the display scale, the export calls it at the export scale, and the only difference between them is that one number. The corollary is that the document, not the canvas, is the truth. Undo, autosave and the file format all act on the document. The canvas is a view that can be thrown away and redrawn at any time without losing anything, which is also what makes a crash survivable. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript interface · a desktop app with real file access and no upload step - Rendering: One pure render function over a document, drawing to a 2D canvas · preview and export cannot diverge if there is only one of them - Document: A versioned JSON document with assets stored beside it · the file is the product, so it has to open next year - Text: Fonts loaded and awaited before any render, with the resolved family recorded · an export that raced the font load is the classic silent wrong output - Export: Offscreen render at an explicit scale, then read the bytes back and check them · a canvas past its size limit draws nothing and raises no error ### Data model (create this before Phase 1) - documents(id, format_version, name, width, height, background, created_at, updated_at) - layers(id, document_id, ordinal, kind, x, y, w, h, rotation, opacity, blend, locked, hidden) · kind is text, image, shape or group - text_layers(layer_id, content, font_family, font_file_ref, size, line_height, letter_spacing, align, colour, wrap_width) - image_layers(layer_id, asset_id, crop_x, crop_y, crop_w, crop_h, fit) - assets(id, path, sha256, mime, width, height, bytes, imported_at) · the original file, never the resized copy - presets(id, name, width, height, source_note, edited_at) · sizes are data the user can edit, not constants in code - exports(id, document_id, preset_id, scale, path, pixel_w, pixel_h, bytes, renderer_version, at) - history(document_id, ordinal, patch_json, at) · undo is a stack of document patches, so the canvas can be rebuilt from nothing ### Phase 1 · A document and one renderer Build: Define the document schema with a format version. Write render(document, scale, target) and call it for the preview. Build selection, move, resize and z-order as patches applied to the document, with undo and redo replaying patches rather than touching the canvas. Trap: Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. Done when: Redrawing from the document after any edit produces the same image as the incremental draw, tested by comparing two renders pixel for pixel. Undo and redo restore the document exactly, tested over a scripted sequence of twenty edits. The format version is written into every saved file. No drawing code exists outside the render function, verified by review. Do not build yet: No text layout, image import, export or presets. ### Phase 2 · Text that wraps the same at every scale Build: Load every font through the font loading interface and await it before the first render that uses it. Measure and wrap in document units, then scale, never the other way round. Record the resolved family per text layer so a missing font is visible rather than silent. Trap: Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. Done when: Line breaks are identical at scale one and scale four, tested by comparing break positions rather than pixels. A render requested before its font has loaded either waits or fails, and never silently substitutes, tested by delaying the load. A layer whose font is unavailable is shown as unavailable in the interface. Wrapping is computed in document units, verified by a test at three scales. Do not build yet: No image import, export or presets yet. ### Phase 3 · Images that keep their original Build: Copy the chosen file into the asset folder, hash it, read its real dimensions and any orientation metadata, and store the original untouched. Generate a display-sized copy for the preview and keep the original for export. Crop and fit are document values, not edits to the file. Trap: Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. Done when: The imported original is byte identical to the source file, tested by comparing hashes. A photograph carrying orientation metadata appears the same way in preview and export, tested with a rotated sample. Export renders from the original, not the preview copy, asserted in the suite. Deleting a document offers to delete only assets no other document references. Do not build yet: No export pipeline or presets yet. ### Phase 4 · An export that cannot fail quietly Build: Compute the target pixel size from the preset and the scale, and compare it against a maximum probed at startup by attempting a draw and reading a pixel back. Render offscreen, read the bytes, check the decoded dimensions and non-zero size, then write. Offer lossless and lossy formats with the quality recorded in the export row. Trap: A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. Done when: An export request above the probed maximum is refused with the numbers shown, or rendered in tiles, and never writes a blank file, tested by forcing a small maximum. Every written file is decoded back and its dimensions and byte count checked before success is reported. The export row records preset, scale, format, quality and pixel size. A failed export leaves no partial file behind. Do not build yet: No presets management, autosave or project bundles yet. ### Phase 5 · Files, presets and getting out Build: Save a document as a folder or single bundle containing the JSON and its assets. Autosave on a timer and on blur, writing to a temporary file and replacing atomically, with recovery offered on the next launch. Ship the platform sizes as editable preset rows carrying the date they were checked, and let the user add their own. Trap: Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them. Done when: A document saved by an older format version opens, tested with a fixture file committed to the repository. Killing the app mid-edit leaves the last autosave intact and recovery is offered on launch, tested by killing the process. Presets are editable rows showing when they were last checked. Export produces standard image files with no application-specific metadata required to reopen them. Do not build yet: Nothing after this. ### Out of scope (and why) - A stock photograph and template library, which is a licensing operation rather than a feature and is the main thing the subscription actually buys. - Cloud sync and collaborative editing, which turn a local document into a distributed system with conflicts to resolve. - Direct publishing to social networks, because the posting APIs change and a broken publish button is worse than a copied file. - Colour-managed print output, which needs profile handling well beyond drawing to a screen canvas. ### README must contain - That one render function serves both preview and export, and that this is the reason they agree - That fonts are awaited before rendering, and what happens when a font is unavailable - That imported images are kept as untouched originals and exports render from them - That the canvas maximum is probed at startup and oversized exports are refused or tiled - That presets are editable rows with a checked date, not constants in the code ===== AGENTS.md ===== # Agent instructions · Stencil indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with a React and TypeScript interface, One pure render function over a document, drawing to a 2D canvas, A versioned JSON document with assets stored beside it, Fonts loaded and awaited before any render, with the resolved family recorded, Offscreen render at an explicit scale, then read the bytes back and check them. 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 - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. ===== BUILD_PLAN.md ===== # Build plan · Stencil A local graphic editor for social images whose hard problems are one renderer shared by preview and export, text that lays out identically at every scale, and an export that fails loudly rather than handing back a blank canvas. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A document and one renderer Every pixel on screen comes from a pure function of the document. ### Steps 1. Write the render function first, then the editor around it One function from document and scale to pixels, with no hidden state. Files: `src/render/render.ts`, `src/model/document.ts` 2. Make every edit a patch Undo is then a list, not a pile of special cases. Files: `src/model/patch.ts`, `src/model/history.ts` 3. Assert the two draw paths agree An incremental draw and a full redraw must produce identical pixels. Files: `tests/render-parity.test.ts` ### Done when - [ ] A full redraw matches the incremental draw pixel for pixel - [ ] Undo and redo restore the document over a long sequence - [ ] Every saved file records its format version - [ ] No drawing happens outside the render function ### Watch out - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. ## Phase 2 · Text that wraps the same at every scale A headline exported at four times the preview scale breaks lines in the same places. ### Steps 1. Await the font before rendering The font loading interface tells you when the face is really there. Files: `src/render/fonts.ts`, `tests/font-race.test.ts` 2. Measure and wrap in document units Scaling a wrapped line is fine, wrapping a scaled line is not. Files: `src/render/text.ts` 3. Record the resolved family per layer So a missing font is a visible state, not a different picture. Files: `src/model/document.ts`, `src/ui/LayerPanel.tsx` ### Done when - [ ] Line breaks match at scale one and scale four - [ ] A render before font load waits or fails, never substitutes - [ ] Missing fonts are shown as missing - [ ] Wrapping is computed in document units ### Watch out - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. ## Phase 3 · Images that keep their original Importing a photograph never damages the file the user chose. ### Steps 1. Copy in, hash, never modify The asset folder holds originals. Derived copies live elsewhere. Files: `src/assets/import.ts` 2. Honour orientation metadata once, in the renderer Applying it twice is how a photograph ends up sideways in the export only. Files: `src/render/image.ts`, `tests/orientation.test.ts` 3. Render exports from the original The preview copy exists for speed and nothing else. Files: `src/render/render.ts` ### Done when - [ ] Imported originals hash equal to the source - [ ] A rotated photograph matches in preview and export - [ ] Export reads the original asset - [ ] Asset deletion checks for other references ### Watch out - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. ## Phase 4 · An export that cannot fail quietly Either a correct file lands on disk or the user is told why not. ### Steps 1. Probe the canvas maximum at startup Draw, read a pixel back, and store what actually worked. Files: `src/export/limits.ts` 2. Verify the written file by decoding it Bytes on disk that no decoder accepts are not an export. Files: `src/export/write.ts`, `tests/export-verify.test.ts` 3. Write atomically and clean up on failure A half-written image in the output folder will be sent to someone. Files: `src/export/write.ts` ### Done when - [ ] An oversized export is refused or tiled, never blank - [ ] Written files are decoded and checked before success - [ ] Export rows record scale, format and pixel size - [ ] A failed export leaves no partial file ### Watch out - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. ## Not in this build - A stock and template library. - Cloud sync or collaboration. - Direct publishing to social networks. - Print colour management. ## After v1, if you want it - A background removal step, once you have decided which model licence you are willing to ship under - Brand kits that are just saved colour and font sets applied to a document - Batch export of one document across every preset, which reuses the export verification you already wrote ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where bundles are saved. Back this up like any other document folder. DOCUMENTS_DIR=~/Documents/Stencil # Required. Imported originals, addressed by hash. Never written to after import. ASSETS_DIR=~/Documents/Stencil/.assets # Required. Also runs on window blur. Writes to a temporary file and replaces atomically. AUTOSAVE_SECONDS=20 # Required. Upper bound offered in the interface, further limited by the probed canvas maximum. MAX_EXPORT_SCALE=4 # Required. Where finished images land. Failed exports leave nothing here. EXPORT_DIR=~/Pictures/Stencil
You are building a production product version of Stencil. 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 ===== # Stencil · product brief ## Problem Stencil's solo core is compact: build a focused local social graphic design editor with import, a small set of transformations, undo, and standards-based export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on editor polish, assets, algorithms. ## Product outcome A desktop editor where what you see is what you get because there is only one renderer, where an oversized export is refused instead of blank, and where the document file still opens after the app has changed. ## 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 - A stock and template library. - Cloud sync or collaboration. - Direct publishing to social networks. - Print colour management. - cloud collaboration and mobile apps - direct publishing reliability as platform APIs change - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases ## Success criteria - Full redraw and incremental draw are confirmed identical - A pre-font-load render is confirmed to wait or fail - An oversized export is confirmed refused or tiled - Written exports are confirmed decoded before success - An old-format fixture document is confirmed to open ===== BRIEF.md ===== # Build brief · Stencil 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 social graphic editor that exports exactly the pixels you saw to replace Stencil. 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 One rule: the preview and the export are the same renderer. The quick way to build this is a canvas you draw to while editing plus a second code path that redraws bigger for export. That second path is where every 'it looked different in the app' bug lives: a font that had not finished loading, a shadow scaled by the wrong factor, a text box that wrapped at a different width. Write one pure function from document plus scale to pixels. The preview calls it at the display scale, the export calls it at the export scale, and the only difference between them is that one number. The corollary is that the document, not the canvas, is the truth. Undo, autosave and the file format all act on the document. The canvas is a view that can be thrown away and redrawn at any time without losing anything, which is also what makes a crash survivable. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript interface · a desktop app with real file access and no upload step - Rendering: One pure render function over a document, drawing to a 2D canvas · preview and export cannot diverge if there is only one of them - Document: A versioned JSON document with assets stored beside it · the file is the product, so it has to open next year - Text: Fonts loaded and awaited before any render, with the resolved family recorded · an export that raced the font load is the classic silent wrong output - Export: Offscreen render at an explicit scale, then read the bytes back and check them · a canvas past its size limit draws nothing and raises no error ### Data model (create this before Phase 1) - documents(id, format_version, name, width, height, background, created_at, updated_at) - layers(id, document_id, ordinal, kind, x, y, w, h, rotation, opacity, blend, locked, hidden) · kind is text, image, shape or group - text_layers(layer_id, content, font_family, font_file_ref, size, line_height, letter_spacing, align, colour, wrap_width) - image_layers(layer_id, asset_id, crop_x, crop_y, crop_w, crop_h, fit) - assets(id, path, sha256, mime, width, height, bytes, imported_at) · the original file, never the resized copy - presets(id, name, width, height, source_note, edited_at) · sizes are data the user can edit, not constants in code - exports(id, document_id, preset_id, scale, path, pixel_w, pixel_h, bytes, renderer_version, at) - history(document_id, ordinal, patch_json, at) · undo is a stack of document patches, so the canvas can be rebuilt from nothing ### Phase 1 · A document and one renderer Build: Define the document schema with a format version. Write render(document, scale, target) and call it for the preview. Build selection, move, resize and z-order as patches applied to the document, with undo and redo replaying patches rather than touching the canvas. Trap: Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. Done when: Redrawing from the document after any edit produces the same image as the incremental draw, tested by comparing two renders pixel for pixel. Undo and redo restore the document exactly, tested over a scripted sequence of twenty edits. The format version is written into every saved file. No drawing code exists outside the render function, verified by review. Do not build yet: No text layout, image import, export or presets. ### Phase 2 · Text that wraps the same at every scale Build: Load every font through the font loading interface and await it before the first render that uses it. Measure and wrap in document units, then scale, never the other way round. Record the resolved family per text layer so a missing font is visible rather than silent. Trap: Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. Done when: Line breaks are identical at scale one and scale four, tested by comparing break positions rather than pixels. A render requested before its font has loaded either waits or fails, and never silently substitutes, tested by delaying the load. A layer whose font is unavailable is shown as unavailable in the interface. Wrapping is computed in document units, verified by a test at three scales. Do not build yet: No image import, export or presets yet. ### Phase 3 · Images that keep their original Build: Copy the chosen file into the asset folder, hash it, read its real dimensions and any orientation metadata, and store the original untouched. Generate a display-sized copy for the preview and keep the original for export. Crop and fit are document values, not edits to the file. Trap: Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. Done when: The imported original is byte identical to the source file, tested by comparing hashes. A photograph carrying orientation metadata appears the same way in preview and export, tested with a rotated sample. Export renders from the original, not the preview copy, asserted in the suite. Deleting a document offers to delete only assets no other document references. Do not build yet: No export pipeline or presets yet. ### Phase 4 · An export that cannot fail quietly Build: Compute the target pixel size from the preset and the scale, and compare it against a maximum probed at startup by attempting a draw and reading a pixel back. Render offscreen, read the bytes, check the decoded dimensions and non-zero size, then write. Offer lossless and lossy formats with the quality recorded in the export row. Trap: A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. Done when: An export request above the probed maximum is refused with the numbers shown, or rendered in tiles, and never writes a blank file, tested by forcing a small maximum. Every written file is decoded back and its dimensions and byte count checked before success is reported. The export row records preset, scale, format, quality and pixel size. A failed export leaves no partial file behind. Do not build yet: No presets management, autosave or project bundles yet. ### Phase 5 · Files, presets and getting out Build: Save a document as a folder or single bundle containing the JSON and its assets. Autosave on a timer and on blur, writing to a temporary file and replacing atomically, with recovery offered on the next launch. Ship the platform sizes as editable preset rows carrying the date they were checked, and let the user add their own. Trap: Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them. Done when: A document saved by an older format version opens, tested with a fixture file committed to the repository. Killing the app mid-edit leaves the last autosave intact and recovery is offered on launch, tested by killing the process. Presets are editable rows showing when they were last checked. Export produces standard image files with no application-specific metadata required to reopen them. Do not build yet: Nothing after this. ### Out of scope (and why) - A stock photograph and template library, which is a licensing operation rather than a feature and is the main thing the subscription actually buys. - Cloud sync and collaborative editing, which turn a local document into a distributed system with conflicts to resolve. - Direct publishing to social networks, because the posting APIs change and a broken publish button is worse than a copied file. - Colour-managed print output, which needs profile handling well beyond drawing to a screen canvas. ### README must contain - That one render function serves both preview and export, and that this is the reason they agree - That fonts are awaited before rendering, and what happens when a font is unavailable - That imported images are kept as untouched originals and exports render from them - That the canvas maximum is probed at startup and oversized exports are refused or tiled - That presets are editable rows with a checked date, not constants in the code ===== ARCHITECTURE.md ===== # Architecture · Stencil ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with a React and TypeScript interface | a desktop app with real file access and no upload step | | Rendering | One pure render function over a document, drawing to a 2D canvas | preview and export cannot diverge if there is only one of them | | Document | A versioned JSON document with assets stored beside it | the file is the product, so it has to open next year | | Text | Fonts loaded and awaited before any render, with the resolved family recorded | an export that raced the font load is the classic silent wrong output | | Export | Offscreen render at an explicit scale, then read the bytes back and check them | a canvas past its size limit draws nothing and raises no error | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Document | Schema, patches, undo and the format version. | Any storage, provided the format version travels with the file. | | Renderer | Document plus scale to pixels, and nothing else. | Any drawing backend, provided preview and export keep calling the same function. | | Assets | Untouched originals addressed by hash. | Any store, provided originals are never rewritten. | | Export | Limit probing, offscreen render and verification. | Any encoder, provided the written file is decoded and checked. | | Presets | Editable sizes with source and date. | Not swappable for constants in code. | ## 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 · Where bundles are saved. Back this up like any other document folder. - `ASSETS_DIR` · required · Imported originals, addressed by hash. Never written to after import. - `AUTOSAVE_SECONDS` · required · Also runs on window blur. Writes to a temporary file and replaces atomically. - `MAX_EXPORT_SCALE` · required · Upper bound offered in the interface, further limited by the probed canvas maximum. - `EXPORT_DIR` · required · Where finished images land. Failed exports leave nothing here. ## 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 · Stencil product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with a React and TypeScript interface, One pure render function over a document, drawing to a 2D canvas, A versioned JSON document with assets stored beside it, Fonts loaded and awaited before any render, with the resolved family recorded, Offscreen render at an explicit scale, then read the bytes back and check them. - 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 - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. - Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them. ===== MILESTONES.md ===== # Delivery milestones · Stencil Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A document and one renderer Every pixel on screen comes from a pure function of the document. ### Steps 1. Write the render function first, then the editor around it One function from document and scale to pixels, with no hidden state. Files: `src/render/render.ts`, `src/model/document.ts` 2. Make every edit a patch Undo is then a list, not a pile of special cases. Files: `src/model/patch.ts`, `src/model/history.ts` 3. Assert the two draw paths agree An incremental draw and a full redraw must produce identical pixels. Files: `tests/render-parity.test.ts` ### Done when - [ ] A full redraw matches the incremental draw pixel for pixel - [ ] Undo and redo restore the document over a long sequence - [ ] Every saved file records its format version - [ ] No drawing happens outside the render function ### Watch out - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. ## M2 · Text that wraps the same at every scale A headline exported at four times the preview scale breaks lines in the same places. ### Steps 1. Await the font before rendering The font loading interface tells you when the face is really there. Files: `src/render/fonts.ts`, `tests/font-race.test.ts` 2. Measure and wrap in document units Scaling a wrapped line is fine, wrapping a scaled line is not. Files: `src/render/text.ts` 3. Record the resolved family per layer So a missing font is a visible state, not a different picture. Files: `src/model/document.ts`, `src/ui/LayerPanel.tsx` ### Done when - [ ] Line breaks match at scale one and scale four - [ ] A render before font load waits or fails, never substitutes - [ ] Missing fonts are shown as missing - [ ] Wrapping is computed in document units ### Watch out - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. ## M3 · Images that keep their original Importing a photograph never damages the file the user chose. ### Steps 1. Copy in, hash, never modify The asset folder holds originals. Derived copies live elsewhere. Files: `src/assets/import.ts` 2. Honour orientation metadata once, in the renderer Applying it twice is how a photograph ends up sideways in the export only. Files: `src/render/image.ts`, `tests/orientation.test.ts` 3. Render exports from the original The preview copy exists for speed and nothing else. Files: `src/render/render.ts` ### Done when - [ ] Imported originals hash equal to the source - [ ] A rotated photograph matches in preview and export - [ ] Export reads the original asset - [ ] Asset deletion checks for other references ### Watch out - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. ## M4 · An export that cannot fail quietly Either a correct file lands on disk or the user is told why not. ### Steps 1. Probe the canvas maximum at startup Draw, read a pixel back, and store what actually worked. Files: `src/export/limits.ts` 2. Verify the written file by decoding it Bytes on disk that no decoder accepts are not an export. Files: `src/export/write.ts`, `tests/export-verify.test.ts` 3. Write atomically and clean up on failure A half-written image in the output folder will be sent to someone. Files: `src/export/write.ts` ### Done when - [ ] An oversized export is refused or tiled, never blank - [ ] Written files are decoded and checked before success - [ ] Export rows record scale, format and pixel size - [ ] A failed export leaves no partial file ### Watch out - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. ## M5 · Files, presets and getting out (production only) A document opens next year, presets are editable data, and nothing is trapped in the app. ### Steps 1. Bundle the document with its assets A file that references images you no longer have is not a file. Files: `src/file/bundle.ts` 2. Autosave atomically and offer recovery Temporary file, flush, replace, then recover on next launch. Files: `src/file/autosave.ts`, `tests/recovery.test.ts` 3. Keep presets as data with a checked date Platform sizes change and a constant in code cannot be corrected by the user. Files: `src/presets/presets.ts`, `src/ui/PresetEditor.tsx` ### Done when - [ ] An old-version fixture document opens - [ ] A kill mid-edit is recoverable on next launch - [ ] Presets are editable and show a checked date - [ ] Exports are standard image files ### Watch out - Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them. ===== OPERATIONS.md ===== # Operations · Stencil ## Backup The documents folder and the asset folder together, since a bundle without its assets is a file that cannot be rendered. Hash-addressed assets make a backup cheap to deduplicate. ## Restore Restore both folders, open the oldest document you have, and export it. If that export matches the one taken before the restore, the pipeline is intact. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a local app the checks live in the suite: render parity between draw paths, font race behaviour, export verification, and an old-format fixture that still opens. ## Incident checklist A wrong export is the incident, and it is nearly always one of three things: a font that had not loaded, an orientation applied twice, or a canvas past its maximum. The export row records scale and pixel size, which tells you which one immediately. 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 - [ ] Full redraw and incremental draw are confirmed identical - [ ] A pre-font-load render is confirmed to wait or fail - [ ] An oversized export is confirmed refused or tiled - [ ] Written exports are confirmed decoded before success - [ ] An old-format fixture document is confirmed to open ## Launch constraint Do not market omitted Stencil 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. Where bundles are saved. Back this up like any other document folder. DOCUMENTS_DIR=~/Documents/Stencil # Required. Imported originals, addressed by hash. Never written to after import. ASSETS_DIR=~/Documents/Stencil/.assets # Required. Also runs on window blur. Writes to a temporary file and replaces atomically. AUTOSAVE_SECONDS=20 # Required. Upper bound offered in the interface, further limited by the probed canvas maximum. MAX_EXPORT_SCALE=4 # Required. Where finished images land. Failed exports leave nothing here. EXPORT_DIR=~/Pictures/Stencil
# Stencil · indie build A local graphic editor for social images whose hard problems are one renderer shared by preview and export, text that lays out identically at every scale, and an export that fails loudly rather than handing back a blank canvas. Estimated effort: **weekend**. 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 a React and TypeScript interface | a desktop app with real file access and no upload step | | Rendering | One pure render function over a document, drawing to a 2D canvas | preview and export cannot diverge if there is only one of them | | Document | A versioned JSON document with assets stored beside it | the file is the product, so it has to open next year | | Text | Fonts loaded and awaited before any render, with the resolved family recorded | an export that raced the font load is the classic silent wrong output | | Export | Offscreen render at an explicit scale, then read the bytes back and check them | a canvas past its size limit draws nothing and raises no error | ## 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 and runs the test suite. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain for Tauri 2** · free - Why: Tauri's shell is compiled, and the platform build tools have to be present before anything runs. - Get it: rustup.rs, then follow the Tauri prerequisites for your operating system. - Verify: cargo --version prints a version and a Tauri dev build launches a window - [ ] **Two or three fonts you are allowed to use** · free - Why: Text is most of a social graphic, and the licence that lets you render with a font is not always the licence that lets you redistribute the file. - Get it: Open licence families, or fonts already installed on your machine, with the licence noted for each. - Verify: Each font renders and its licence is recorded in the project - [ ] **A rotated photograph and a very large image** · free - Why: Orientation metadata and the canvas size limit are the two export bugs that only appear with awkward inputs. - Get it: A phone photograph taken sideways, and an image of at least six thousand pixels on its long edge. - Verify: Both import and export correctly - [ ] **The preset sizes you start with and where they came from** · free - Why: They are shipped as editable data with a checked date, so the source and the date have to exist before the first release. - Get it: Write down each size, the platform it is for, and the date you read it from that platform's own guidance. - Verify: Every preset row has a source note and a date ## 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: - A stock and template library. - Cloud sync or collaboration. - Direct publishing to social networks. - Print colour management. - cloud collaboration and mobile apps - direct publishing reliability as platform APIs change - 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 Stencil, and the README should say so rather than pretend.
# Build brief · Stencil 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 social graphic editor that exports exactly the pixels you saw to replace Stencil. 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 One rule: the preview and the export are the same renderer. The quick way to build this is a canvas you draw to while editing plus a second code path that redraws bigger for export. That second path is where every 'it looked different in the app' bug lives: a font that had not finished loading, a shadow scaled by the wrong factor, a text box that wrapped at a different width. Write one pure function from document plus scale to pixels. The preview calls it at the display scale, the export calls it at the export scale, and the only difference between them is that one number. The corollary is that the document, not the canvas, is the truth. Undo, autosave and the file format all act on the document. The canvas is a view that can be thrown away and redrawn at any time without losing anything, which is also what makes a crash survivable. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript interface · a desktop app with real file access and no upload step - Rendering: One pure render function over a document, drawing to a 2D canvas · preview and export cannot diverge if there is only one of them - Document: A versioned JSON document with assets stored beside it · the file is the product, so it has to open next year - Text: Fonts loaded and awaited before any render, with the resolved family recorded · an export that raced the font load is the classic silent wrong output - Export: Offscreen render at an explicit scale, then read the bytes back and check them · a canvas past its size limit draws nothing and raises no error ### Data model (create this before Phase 1) - documents(id, format_version, name, width, height, background, created_at, updated_at) - layers(id, document_id, ordinal, kind, x, y, w, h, rotation, opacity, blend, locked, hidden) · kind is text, image, shape or group - text_layers(layer_id, content, font_family, font_file_ref, size, line_height, letter_spacing, align, colour, wrap_width) - image_layers(layer_id, asset_id, crop_x, crop_y, crop_w, crop_h, fit) - assets(id, path, sha256, mime, width, height, bytes, imported_at) · the original file, never the resized copy - presets(id, name, width, height, source_note, edited_at) · sizes are data the user can edit, not constants in code - exports(id, document_id, preset_id, scale, path, pixel_w, pixel_h, bytes, renderer_version, at) - history(document_id, ordinal, patch_json, at) · undo is a stack of document patches, so the canvas can be rebuilt from nothing ### Phase 1 · A document and one renderer Build: Define the document schema with a format version. Write render(document, scale, target) and call it for the preview. Build selection, move, resize and z-order as patches applied to the document, with undo and redo replaying patches rather than touching the canvas. Trap: Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. Done when: Redrawing from the document after any edit produces the same image as the incremental draw, tested by comparing two renders pixel for pixel. Undo and redo restore the document exactly, tested over a scripted sequence of twenty edits. The format version is written into every saved file. No drawing code exists outside the render function, verified by review. Do not build yet: No text layout, image import, export or presets. ### Phase 2 · Text that wraps the same at every scale Build: Load every font through the font loading interface and await it before the first render that uses it. Measure and wrap in document units, then scale, never the other way round. Record the resolved family per text layer so a missing font is visible rather than silent. Trap: Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. Done when: Line breaks are identical at scale one and scale four, tested by comparing break positions rather than pixels. A render requested before its font has loaded either waits or fails, and never silently substitutes, tested by delaying the load. A layer whose font is unavailable is shown as unavailable in the interface. Wrapping is computed in document units, verified by a test at three scales. Do not build yet: No image import, export or presets yet. ### Phase 3 · Images that keep their original Build: Copy the chosen file into the asset folder, hash it, read its real dimensions and any orientation metadata, and store the original untouched. Generate a display-sized copy for the preview and keep the original for export. Crop and fit are document values, not edits to the file. Trap: Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. Done when: The imported original is byte identical to the source file, tested by comparing hashes. A photograph carrying orientation metadata appears the same way in preview and export, tested with a rotated sample. Export renders from the original, not the preview copy, asserted in the suite. Deleting a document offers to delete only assets no other document references. Do not build yet: No export pipeline or presets yet. ### Phase 4 · An export that cannot fail quietly Build: Compute the target pixel size from the preset and the scale, and compare it against a maximum probed at startup by attempting a draw and reading a pixel back. Render offscreen, read the bytes, check the decoded dimensions and non-zero size, then write. Offer lossless and lossy formats with the quality recorded in the export row. Trap: A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. Done when: An export request above the probed maximum is refused with the numbers shown, or rendered in tiles, and never writes a blank file, tested by forcing a small maximum. Every written file is decoded back and its dimensions and byte count checked before success is reported. The export row records preset, scale, format, quality and pixel size. A failed export leaves no partial file behind. Do not build yet: No presets management, autosave or project bundles yet. ### Phase 5 · Files, presets and getting out Build: Save a document as a folder or single bundle containing the JSON and its assets. Autosave on a timer and on blur, writing to a temporary file and replacing atomically, with recovery offered on the next launch. Ship the platform sizes as editable preset rows carrying the date they were checked, and let the user add their own. Trap: Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them. Done when: A document saved by an older format version opens, tested with a fixture file committed to the repository. Killing the app mid-edit leaves the last autosave intact and recovery is offered on launch, tested by killing the process. Presets are editable rows showing when they were last checked. Export produces standard image files with no application-specific metadata required to reopen them. Do not build yet: Nothing after this. ### Out of scope (and why) - A stock photograph and template library, which is a licensing operation rather than a feature and is the main thing the subscription actually buys. - Cloud sync and collaborative editing, which turn a local document into a distributed system with conflicts to resolve. - Direct publishing to social networks, because the posting APIs change and a broken publish button is worse than a copied file. - Colour-managed print output, which needs profile handling well beyond drawing to a screen canvas. ### README must contain - That one render function serves both preview and export, and that this is the reason they agree - That fonts are awaited before rendering, and what happens when a font is unavailable - That imported images are kept as untouched originals and exports render from them - That the canvas maximum is probed at startup and oversized exports are refused or tiled - That presets are editable rows with a checked date, not constants in the code
# Agent instructions · Stencil indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with a React and TypeScript interface, One pure render function over a document, drawing to a 2D canvas, A versioned JSON document with assets stored beside it, Fonts loaded and awaited before any render, with the resolved family recorded, Offscreen render at an explicit scale, then read the bytes back and check them. 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 - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it.
# Build plan · Stencil A local graphic editor for social images whose hard problems are one renderer shared by preview and export, text that lays out identically at every scale, and an export that fails loudly rather than handing back a blank canvas. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A document and one renderer Every pixel on screen comes from a pure function of the document. ### Steps 1. Write the render function first, then the editor around it One function from document and scale to pixels, with no hidden state. Files: `src/render/render.ts`, `src/model/document.ts` 2. Make every edit a patch Undo is then a list, not a pile of special cases. Files: `src/model/patch.ts`, `src/model/history.ts` 3. Assert the two draw paths agree An incremental draw and a full redraw must produce identical pixels. Files: `tests/render-parity.test.ts` ### Done when - [ ] A full redraw matches the incremental draw pixel for pixel - [ ] Undo and redo restore the document over a long sequence - [ ] Every saved file records its format version - [ ] No drawing happens outside the render function ### Watch out - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. ## Phase 2 · Text that wraps the same at every scale A headline exported at four times the preview scale breaks lines in the same places. ### Steps 1. Await the font before rendering The font loading interface tells you when the face is really there. Files: `src/render/fonts.ts`, `tests/font-race.test.ts` 2. Measure and wrap in document units Scaling a wrapped line is fine, wrapping a scaled line is not. Files: `src/render/text.ts` 3. Record the resolved family per layer So a missing font is a visible state, not a different picture. Files: `src/model/document.ts`, `src/ui/LayerPanel.tsx` ### Done when - [ ] Line breaks match at scale one and scale four - [ ] A render before font load waits or fails, never substitutes - [ ] Missing fonts are shown as missing - [ ] Wrapping is computed in document units ### Watch out - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. ## Phase 3 · Images that keep their original Importing a photograph never damages the file the user chose. ### Steps 1. Copy in, hash, never modify The asset folder holds originals. Derived copies live elsewhere. Files: `src/assets/import.ts` 2. Honour orientation metadata once, in the renderer Applying it twice is how a photograph ends up sideways in the export only. Files: `src/render/image.ts`, `tests/orientation.test.ts` 3. Render exports from the original The preview copy exists for speed and nothing else. Files: `src/render/render.ts` ### Done when - [ ] Imported originals hash equal to the source - [ ] A rotated photograph matches in preview and export - [ ] Export reads the original asset - [ ] Asset deletion checks for other references ### Watch out - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. ## Phase 4 · An export that cannot fail quietly Either a correct file lands on disk or the user is told why not. ### Steps 1. Probe the canvas maximum at startup Draw, read a pixel back, and store what actually worked. Files: `src/export/limits.ts` 2. Verify the written file by decoding it Bytes on disk that no decoder accepts are not an export. Files: `src/export/write.ts`, `tests/export-verify.test.ts` 3. Write atomically and clean up on failure A half-written image in the output folder will be sent to someone. Files: `src/export/write.ts` ### Done when - [ ] An oversized export is refused or tiled, never blank - [ ] Written files are decoded and checked before success - [ ] Export rows record scale, format and pixel size - [ ] A failed export leaves no partial file ### Watch out - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. ## Not in this build - A stock and template library. - Cloud sync or collaboration. - Direct publishing to social networks. - Print colour management. ## After v1, if you want it - A background removal step, once you have decided which model licence you are willing to ship under - Brand kits that are just saved colour and font sets applied to a document - Batch export of one document across every preset, which reuses the export verification you already wrote
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where bundles are saved. Back this up like any other document folder. DOCUMENTS_DIR=~/Documents/Stencil # Required. Imported originals, addressed by hash. Never written to after import. ASSETS_DIR=~/Documents/Stencil/.assets # Required. Also runs on window blur. Writes to a temporary file and replaces atomically. AUTOSAVE_SECONDS=20 # Required. Upper bound offered in the interface, further limited by the probed canvas maximum. MAX_EXPORT_SCALE=4 # Required. Where finished images land. Failed exports leave nothing here. EXPORT_DIR=~/Pictures/Stencil
# Stencil · product brief ## Problem Stencil's solo core is compact: build a focused local social graphic design editor with import, a small set of transformations, undo, and standards-based export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on editor polish, assets, algorithms. ## Product outcome A desktop editor where what you see is what you get because there is only one renderer, where an oversized export is refused instead of blank, and where the document file still opens after the app has changed. ## 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 - A stock and template library. - Cloud sync or collaboration. - Direct publishing to social networks. - Print colour management. - cloud collaboration and mobile apps - direct publishing reliability as platform APIs change - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases ## Success criteria - Full redraw and incremental draw are confirmed identical - A pre-font-load render is confirmed to wait or fail - An oversized export is confirmed refused or tiled - Written exports are confirmed decoded before success - An old-format fixture document is confirmed to open
# Build brief · Stencil 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 social graphic editor that exports exactly the pixels you saw to replace Stencil. 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 One rule: the preview and the export are the same renderer. The quick way to build this is a canvas you draw to while editing plus a second code path that redraws bigger for export. That second path is where every 'it looked different in the app' bug lives: a font that had not finished loading, a shadow scaled by the wrong factor, a text box that wrapped at a different width. Write one pure function from document plus scale to pixels. The preview calls it at the display scale, the export calls it at the export scale, and the only difference between them is that one number. The corollary is that the document, not the canvas, is the truth. Undo, autosave and the file format all act on the document. The canvas is a view that can be thrown away and redrawn at any time without losing anything, which is also what makes a crash survivable. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript interface · a desktop app with real file access and no upload step - Rendering: One pure render function over a document, drawing to a 2D canvas · preview and export cannot diverge if there is only one of them - Document: A versioned JSON document with assets stored beside it · the file is the product, so it has to open next year - Text: Fonts loaded and awaited before any render, with the resolved family recorded · an export that raced the font load is the classic silent wrong output - Export: Offscreen render at an explicit scale, then read the bytes back and check them · a canvas past its size limit draws nothing and raises no error ### Data model (create this before Phase 1) - documents(id, format_version, name, width, height, background, created_at, updated_at) - layers(id, document_id, ordinal, kind, x, y, w, h, rotation, opacity, blend, locked, hidden) · kind is text, image, shape or group - text_layers(layer_id, content, font_family, font_file_ref, size, line_height, letter_spacing, align, colour, wrap_width) - image_layers(layer_id, asset_id, crop_x, crop_y, crop_w, crop_h, fit) - assets(id, path, sha256, mime, width, height, bytes, imported_at) · the original file, never the resized copy - presets(id, name, width, height, source_note, edited_at) · sizes are data the user can edit, not constants in code - exports(id, document_id, preset_id, scale, path, pixel_w, pixel_h, bytes, renderer_version, at) - history(document_id, ordinal, patch_json, at) · undo is a stack of document patches, so the canvas can be rebuilt from nothing ### Phase 1 · A document and one renderer Build: Define the document schema with a format version. Write render(document, scale, target) and call it for the preview. Build selection, move, resize and z-order as patches applied to the document, with undo and redo replaying patches rather than touching the canvas. Trap: Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. Done when: Redrawing from the document after any edit produces the same image as the incremental draw, tested by comparing two renders pixel for pixel. Undo and redo restore the document exactly, tested over a scripted sequence of twenty edits. The format version is written into every saved file. No drawing code exists outside the render function, verified by review. Do not build yet: No text layout, image import, export or presets. ### Phase 2 · Text that wraps the same at every scale Build: Load every font through the font loading interface and await it before the first render that uses it. Measure and wrap in document units, then scale, never the other way round. Record the resolved family per text layer so a missing font is visible rather than silent. Trap: Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. Done when: Line breaks are identical at scale one and scale four, tested by comparing break positions rather than pixels. A render requested before its font has loaded either waits or fails, and never silently substitutes, tested by delaying the load. A layer whose font is unavailable is shown as unavailable in the interface. Wrapping is computed in document units, verified by a test at three scales. Do not build yet: No image import, export or presets yet. ### Phase 3 · Images that keep their original Build: Copy the chosen file into the asset folder, hash it, read its real dimensions and any orientation metadata, and store the original untouched. Generate a display-sized copy for the preview and keep the original for export. Crop and fit are document values, not edits to the file. Trap: Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. Done when: The imported original is byte identical to the source file, tested by comparing hashes. A photograph carrying orientation metadata appears the same way in preview and export, tested with a rotated sample. Export renders from the original, not the preview copy, asserted in the suite. Deleting a document offers to delete only assets no other document references. Do not build yet: No export pipeline or presets yet. ### Phase 4 · An export that cannot fail quietly Build: Compute the target pixel size from the preset and the scale, and compare it against a maximum probed at startup by attempting a draw and reading a pixel back. Render offscreen, read the bytes, check the decoded dimensions and non-zero size, then write. Offer lossless and lossy formats with the quality recorded in the export row. Trap: A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. Done when: An export request above the probed maximum is refused with the numbers shown, or rendered in tiles, and never writes a blank file, tested by forcing a small maximum. Every written file is decoded back and its dimensions and byte count checked before success is reported. The export row records preset, scale, format, quality and pixel size. A failed export leaves no partial file behind. Do not build yet: No presets management, autosave or project bundles yet. ### Phase 5 · Files, presets and getting out Build: Save a document as a folder or single bundle containing the JSON and its assets. Autosave on a timer and on blur, writing to a temporary file and replacing atomically, with recovery offered on the next launch. Ship the platform sizes as editable preset rows carrying the date they were checked, and let the user add their own. Trap: Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them. Done when: A document saved by an older format version opens, tested with a fixture file committed to the repository. Killing the app mid-edit leaves the last autosave intact and recovery is offered on launch, tested by killing the process. Presets are editable rows showing when they were last checked. Export produces standard image files with no application-specific metadata required to reopen them. Do not build yet: Nothing after this. ### Out of scope (and why) - A stock photograph and template library, which is a licensing operation rather than a feature and is the main thing the subscription actually buys. - Cloud sync and collaborative editing, which turn a local document into a distributed system with conflicts to resolve. - Direct publishing to social networks, because the posting APIs change and a broken publish button is worse than a copied file. - Colour-managed print output, which needs profile handling well beyond drawing to a screen canvas. ### README must contain - That one render function serves both preview and export, and that this is the reason they agree - That fonts are awaited before rendering, and what happens when a font is unavailable - That imported images are kept as untouched originals and exports render from them - That the canvas maximum is probed at startup and oversized exports are refused or tiled - That presets are editable rows with a checked date, not constants in the code
# Architecture · Stencil ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with a React and TypeScript interface | a desktop app with real file access and no upload step | | Rendering | One pure render function over a document, drawing to a 2D canvas | preview and export cannot diverge if there is only one of them | | Document | A versioned JSON document with assets stored beside it | the file is the product, so it has to open next year | | Text | Fonts loaded and awaited before any render, with the resolved family recorded | an export that raced the font load is the classic silent wrong output | | Export | Offscreen render at an explicit scale, then read the bytes back and check them | a canvas past its size limit draws nothing and raises no error | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Document | Schema, patches, undo and the format version. | Any storage, provided the format version travels with the file. | | Renderer | Document plus scale to pixels, and nothing else. | Any drawing backend, provided preview and export keep calling the same function. | | Assets | Untouched originals addressed by hash. | Any store, provided originals are never rewritten. | | Export | Limit probing, offscreen render and verification. | Any encoder, provided the written file is decoded and checked. | | Presets | Editable sizes with source and date. | Not swappable for constants in code. | ## 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 · Where bundles are saved. Back this up like any other document folder. - `ASSETS_DIR` · required · Imported originals, addressed by hash. Never written to after import. - `AUTOSAVE_SECONDS` · required · Also runs on window blur. Writes to a temporary file and replaces atomically. - `MAX_EXPORT_SCALE` · required · Upper bound offered in the interface, further limited by the probed canvas maximum. - `EXPORT_DIR` · required · Where finished images land. Failed exports leave nothing here. ## 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 · Stencil product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with a React and TypeScript interface, One pure render function over a document, drawing to a 2D canvas, A versioned JSON document with assets stored beside it, Fonts loaded and awaited before any render, with the resolved family recorded, Offscreen render at an explicit scale, then read the bytes back and check them. - 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 - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. - Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them.
# Delivery milestones · Stencil Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A document and one renderer Every pixel on screen comes from a pure function of the document. ### Steps 1. Write the render function first, then the editor around it One function from document and scale to pixels, with no hidden state. Files: `src/render/render.ts`, `src/model/document.ts` 2. Make every edit a patch Undo is then a list, not a pile of special cases. Files: `src/model/patch.ts`, `src/model/history.ts` 3. Assert the two draw paths agree An incremental draw and a full redraw must produce identical pixels. Files: `tests/render-parity.test.ts` ### Done when - [ ] A full redraw matches the incremental draw pixel for pixel - [ ] Undo and redo restore the document over a long sequence - [ ] Every saved file records its format version - [ ] No drawing happens outside the render function ### Watch out - Mutating canvas state directly for a drag and syncing the document afterwards means undo restores a document the canvas disagrees with, and the disagreement only shows up after a redraw when the user has forgotten what they did. Apply the patch, then redraw from the document, every time, even while dragging. ## M2 · Text that wraps the same at every scale A headline exported at four times the preview scale breaks lines in the same places. ### Steps 1. Await the font before rendering The font loading interface tells you when the face is really there. Files: `src/render/fonts.ts`, `tests/font-race.test.ts` 2. Measure and wrap in document units Scaling a wrapped line is fine, wrapping a scaled line is not. Files: `src/render/text.ts` 3. Record the resolved family per layer So a missing font is a visible state, not a different picture. Files: `src/model/document.ts`, `src/ui/LayerPanel.tsx` ### Done when - [ ] Line breaks match at scale one and scale four - [ ] A render before font load waits or fails, never substitutes - [ ] Missing fonts are shown as missing - [ ] Wrapping is computed in document units ### Watch out - Rendering text before the font has loaded draws the fallback face at the fallback metrics, and since it happens in milliseconds the preview looks right by the time anyone looks at it while the export, which runs once, keeps the wrong face forever. Await the font, and make the renderer refuse to draw a text layer whose font is not loaded. ## M3 · Images that keep their original Importing a photograph never damages the file the user chose. ### Steps 1. Copy in, hash, never modify The asset folder holds originals. Derived copies live elsewhere. Files: `src/assets/import.ts` 2. Honour orientation metadata once, in the renderer Applying it twice is how a photograph ends up sideways in the export only. Files: `src/render/image.ts`, `tests/orientation.test.ts` 3. Render exports from the original The preview copy exists for speed and nothing else. Files: `src/render/render.ts` ### Done when - [ ] Imported originals hash equal to the source - [ ] A rotated photograph matches in preview and export - [ ] Export reads the original asset - [ ] Asset deletion checks for other references ### Watch out - Downscaling on import to keep the preview quick means the export can only ever be as sharp as the preview copy, and the loss is discovered when someone prints or posts at full size. Keep the original, render the preview from a derived copy, and render the export from the original. ## M4 · An export that cannot fail quietly Either a correct file lands on disk or the user is told why not. ### Steps 1. Probe the canvas maximum at startup Draw, read a pixel back, and store what actually worked. Files: `src/export/limits.ts` 2. Verify the written file by decoding it Bytes on disk that no decoder accepts are not an export. Files: `src/export/write.ts`, `tests/export-verify.test.ts` 3. Write atomically and clean up on failure A half-written image in the output folder will be sent to someone. Files: `src/export/write.ts` ### Done when - [ ] An oversized export is refused or tiled, never blank - [ ] Written files are decoded and checked before success - [ ] Export rows record scale, format and pixel size - [ ] A failed export leaves no partial file ### Watch out - A canvas larger than the platform maximum is not an error: the documentation states that exceeding the maximum dimensions or area renders the canvas unusable and drawing commands do not work, and one mobile platform caps it at 4,096 by 4,096, so a four times export of a large design returns a blank or empty file. Probe the real maximum at startup, refuse or tile above it, and verify the written file by decoding it. ## M5 · Files, presets and getting out (production only) A document opens next year, presets are editable data, and nothing is trapped in the app. ### Steps 1. Bundle the document with its assets A file that references images you no longer have is not a file. Files: `src/file/bundle.ts` 2. Autosave atomically and offer recovery Temporary file, flush, replace, then recover on next launch. Files: `src/file/autosave.ts`, `tests/recovery.test.ts` 3. Keep presets as data with a checked date Platform sizes change and a constant in code cannot be corrected by the user. Files: `src/presets/presets.ts`, `src/ui/PresetEditor.tsx` ### Done when - [ ] An old-version fixture document opens - [ ] A kill mid-edit is recoverable on next launch - [ ] Presets are editable and show a checked date - [ ] Exports are standard image files ### Watch out - Hard-coding the platform sizes in the code means the day a network changes its recommended dimensions every graphic made after that is quietly the wrong shape, and the fix needs a release. Make presets rows with a checked-on date shown in the interface, and let the user edit them.
# Operations · Stencil ## Backup The documents folder and the asset folder together, since a bundle without its assets is a file that cannot be rendered. Hash-addressed assets make a backup cheap to deduplicate. ## Restore Restore both folders, open the oldest document you have, and export it. If that export matches the one taken before the restore, the pipeline is intact. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a local app the checks live in the suite: render parity between draw paths, font race behaviour, export verification, and an old-format fixture that still opens. ## Incident checklist A wrong export is the incident, and it is nearly always one of three things: a font that had not loaded, an orientation applied twice, or a canvas past its maximum. The export row records scale and pixel size, which tells you which one immediately. 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 - [ ] Full redraw and incremental draw are confirmed identical - [ ] A pre-font-load render is confirmed to wait or fail - [ ] An oversized export is confirmed refused or tiled - [ ] Written exports are confirmed decoded before success - [ ] An old-format fixture document is confirmed to open ## Launch constraint Do not market omitted Stencil 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. Where bundles are saved. Back this up like any other document folder. DOCUMENTS_DIR=~/Documents/Stencil # Required. Imported originals, addressed by hash. Never written to after import. ASSETS_DIR=~/Documents/Stencil/.assets # Required. Also runs on window blur. Writes to a temporary file and replaces atomically. AUTOSAVE_SECONDS=20 # Required. Upper bound offered in the interface, further limited by the probed canvas maximum. MAX_EXPORT_SCALE=4 # Required. Where finished images land. Failed exports leave nothing here. EXPORT_DIR=~/Pictures/Stencil
$ choose a build depth, inspect the files, then open the complete pack in your agent
Stencil: Creative users pay for speed, output fidelity, nondestructive editing, and years of edge-case work hidden behind direct manipulation.
xcloud collaboration and mobile apps
xdirect publishing reliability as platform APIs change
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 4 free alternatives to Stencil →· no votes, no pay-to-list · just what's real
Stencil pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 10 images saved/downloaded/shared per month; 50 uploads; 10 collections; 10 favorites; 10 Instagram SMS/month. |
| pro | — | $9/user | 50 saved images/month; 250 uploads; 25 collections; 100 favorites; 25 Instagram SMS/month; 5 million photos, 3.1 million icons, 1,350 templates and 7,750 fonts. |
| unlimited | — | $12/user | Unlimited saved images, uploads, collections and favorites; 100 Instagram SMS/month. |
free tier10 saves/downloads/shares per month, 50 uploads, 10 collections, 10 favorites and 10 Instagram SMS/month.
billingmonthly + annual; annual billing advertises 40% savings, but exact current monthly USD amounts were not exposed
hidden costsEach account is licensed to one user and sharing an account across an organisation is prohibited. The money-back guarantee lasts 7 days.
verified 2026-08-14 · source ↗
Vibecode Stencil
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Stencil replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Stencil cost?
Stencil'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 Stencil?
Honestly: cloud collaboration and mobile apps; direct publishing reliability as platform APIs change; 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 Stencil?
Yes: Adobe Express Free (Templates, stock assets, text, resize presets, and real exports in a maintained browser product.) Canva Free (A much larger free template and stock editor for routine social graphics, with no three-posts-and-done download counter.) Desygner Free (A maintained free social-design suite with templates, stock, scheduling, and small-team brand controls.) All 4 curated free alternatives are at vibecodeit.com/stencil/alternatives. The prompt is for when you want it exactly your way.