Vibecode BeFunky
track this build4 phases, 12 steps, beginner friendly0%The visible photo + graphic design loop is buildable, but a credible replacement needs more than the first screen. BeFunky earns its keep through editor polish, assets, algorithms, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of BeFunky. 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 ===== # BeFunky · indie build A non-destructive local photo editor whose hard problems are keeping the original file untouched, keeping colour honest through a canvas that is sRGB by default, and making a screen-sized preview agree with a full-resolution export. 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 front end | the file system access is the point, and a browser tab cannot have it | | Rendering | Canvas 2D for preview, the same operation list replayed at full size for export | one code path or the preview is a lie | | Document | A JSON edit stack stored beside the image, never inside it | the original stays byte-identical and the edits stay portable | | Colour | One declared working space, recorded per project, conversions logged | the default canvas colour space quietly changes wide-gamut photographs | | Storage | SQLite for the project index, files on disk for everything large | a database holding megabytes of pixels is a slow database | ## 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 front end and runs the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain** · free - Why: Tauri 2 needs it, and the file handling that keeps the original untouched lives on that side. - Get it: rustup.rs, then the platform build dependencies Tauri lists for your operating system. - Verify: cargo --version prints a version and a Tauri dev build starts - [ ] **Three test photographs you own** · free - Why: One wide-gamut, one with location metadata, one very large. Each exists to make a specific test fail before it passes. - Get it: Take them, or export them from your own library. Do not use stock images with unclear terms. - Verify: One has an embedded non-sRGB profile, one has GPS tags, one exceeds your pixel ceiling - [ ] **One font file you may redistribute** · free - Why: Text rendering references a file, not a family name, so you need a face whose licence permits shipping it beside the project. - Get it: An open licensed family from a font repository, with the licence file kept next to it. - Verify: The licence permits redistribution and the file loads in the renderer - [ ] **Your colour policy, written down** · free - Why: Whether you convert wide-gamut imports to sRGB or refuse them is a decision that changes what users see, and deciding it after the first complaint means choosing whatever is easiest. - Get it: Write one paragraph: the working space, what happens to a non-matching embedded profile, and what the user is told. - Verify: The paragraph is in the README and a test asserts the behaviour it describes ## 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 template and asset library. - Generative editing features. - Print colour management and CMYK. - Cloud projects, sharing and mobile. - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases - cloud collaboration and mobile apps - high-fidelity color, format, and export handling If one of those is essential to you, that is the reason to keep paying for BeFunky, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · BeFunky 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 photo editor that never touches the original file and exports exactly the pixels you were shown to replace BeFunky. 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 The file the user dragged in is not yours to modify. Ever. Open it read-only, hash it on import, and assert at the end of every test run that the hash is unchanged. Every edit is a recipe stored beside the image, and exporting writes a new file with a new name. People drop irreplaceable photographs into editors, and the single worst thing this program can do is save over one. Second rule, which follows from the first: what the preview shows and what the export writes have to be the same picture. A preview computed one way and an export computed another is a bug that ships silently, because nobody compares them until the print comes back wrong. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript front end · the file system access is the point, and a browser tab cannot have it - Rendering: Canvas 2D for preview, the same operation list replayed at full size for export · one code path or the preview is a lie - Document: A JSON edit stack stored beside the image, never inside it · the original stays byte-identical and the edits stay portable - Colour: One declared working space, recorded per project, conversions logged · the default canvas colour space quietly changes wide-gamut photographs - Storage: SQLite for the project index, files on disk for everything large · a database holding megabytes of pixels is a slow database ### Data model (create this before Phase 1) - projects(id, name, source_path, source_sha256, source_width, source_height, created_at, opened_at) - source_profile(project_id, declared_space, embedded_profile_name, converted_to, conversion_note) · what the file claimed and what you did about it - ops(project_id, ordinal, kind, params_json, enabled, created_at) · the edit stack, which IS the document - layers(project_id, id, kind, z, transform_json, asset_id, text_json) · for collages and text - assets(id, path, sha256, mime, width, height, bytes) · imported images, copied, never moved - exports(id, project_id, path, format, quality, long_edge, ops_ordinal_max, metadata_policy, written_at) - Nothing in this model stores pixels that an operation could recompute, which is why undo is free and a project file is a few kilobytes. ### Phase 1 · Open a photograph without changing it Build: Import copies nothing and modifies nothing: record the path, the size and a SHA-256 of the bytes. Decide and record the working colour space. The HTML Standard defines the 2D canvas context setting as colorSpace with a default of 'srgb' (checked against the specification), so anything you draw into a default canvas is in sRGB whether or not the source was. Read the embedded profile name, state what you are doing with it, and store that alongside the project. Trap: Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. Done when: Importing a file and closing the app leaves the source SHA-256 unchanged, asserted in the test suite rather than assumed. The project records the embedded profile name, the working space and any conversion applied. A wide-gamut test image imports with a visible note about the colour handling rather than silently shifting. Opening a file larger than the configured pixel ceiling fails with the actual dimensions in the message, not a blank canvas. Do not build yet: No edits, no undo, no export, no collage. ### Phase 2 · An edit stack, not a pixel buffer Build: Implement crop, rotate, exposure, contrast, saturation and one blur as pure functions of an input surface and a parameter object. Append each to the ops table. Undo removes or disables the last entry and re-renders. Nothing writes pixels back into the source surface. Trap: Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. Done when: Twenty successive edits followed by twenty undos return the canvas to a state pixel-identical to the freshly imported render, tested by comparison. Disabling an operation in the middle of the stack re-renders without it and re-enabling restores the earlier result. No operation writes to the source surface, verified by review and by the import hash test still passing. Memory after a hundred edits is within a stated multiple of the image size. Do not build yet: No preview scaling, export, collage or text yet. ### Phase 3 · A preview that predicts the export Build: Render the preview on a downscaled copy for speed, and export by replaying the same operation list at full resolution. Every parameter measured in pixels is scaled by the render scale. Add a test that renders the same stack at two scales and compares the results after resizing. Trap: A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. Done when: The same operation stack rendered at 25 percent and at full size, with the full size downscaled for comparison, agrees within a stated per-channel tolerance, tested with a stack containing a blur and a feathered crop. Every pixel-denominated parameter is multiplied by the render scale, verified by a test that fails when one is missed. The export path calls the same operation functions as the preview, with no second implementation anywhere. Export time for a large image is reported to the user rather than appearing as a frozen window. Do not build yet: No collage, text or metadata handling yet. ### Phase 4 · Collage and text, where fonts break things Build: Layers with a transform each, a grid layout that reflows, and text with an explicitly chosen font file rather than a family name. Copy the font beside the project. Measure text with the same font at both render scales. Trap: Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. Done when: A collage of four images exports with layer positions matching the preview within a stated tolerance. Text rendered with a project-owned font file produces identical line breaks at both render scales. A project referencing a missing font file refuses to export with a message naming the face, rather than substituting. Adding or removing an image from the grid reflows the remaining ones deterministically, tested. Do not build yet: No template library, no stock assets, no metadata policy yet. ### Phase 5 · Export, metadata and the way out Build: Export to PNG and JPEG with an explicit quality for the lossy format only, since a quality argument means nothing to a lossless one. Strip camera, location and software metadata by default, with an opt-in to keep it and a clear list of what is being kept. Write the project and its ops list to a documented JSON file that another program could read. Trap: Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained. Done when: An exported JPEG contains no location, camera serial or original filename unless the user explicitly opted in, verified by reading the output file in the test suite. Quality is applied to the lossy format and ignored with a note for the lossless one. The project JSON is documented well enough that the ops list can be read without the application, and a round trip through it reproduces the same export. The source file hash is still unchanged at the end of the full end-to-end test. Do not build yet: Nothing after this. ### Out of scope (and why) - A template and stock asset library, because the value there is licensed content, not code, and shipping someone else's assets is the part that gets you a letter. - Background removal and generative fill at the quality people expect, which is model work and an ongoing bill rather than a weekend. - Print colour management and CMYK separation, which needs profiles, proofing and hardware you probably do not have. - Cloud projects, sharing and mobile apps, each of which is a larger project than the editor. ### README must contain - That the source file is opened read-only, hashed on import and verified unchanged by the test suite - Which colour space the editor works in, that the canvas default is sRGB, and what happens to a wide-gamut import - That preview and export replay the same operation list, and that pixel parameters are scaled by render scale - That exports strip camera and location metadata by default and how to opt in - The project JSON format, so the edit stack outlives this application ===== AGENTS.md ===== # Agent instructions · BeFunky indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with a React and TypeScript front end, Canvas 2D for preview, the same operation list replayed at full size for export, A JSON edit stack stored beside the image, never inside it, One declared working space, recorded per project, conversions logged, SQLite for the project index, files on disk for everything large. 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 - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. ===== BUILD_PLAN.md ===== # Build plan · BeFunky A non-destructive local photo editor whose hard problems are keeping the original file untouched, keeping colour honest through a canvas that is sRGB by default, and making a screen-sized preview agree with a full-resolution export. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Open a photograph without changing it An imported file is read, hashed and left alone, and the colour decision is made explicitly rather than by accident. ### Steps 1. Hash on import and assert it at teardown The one test that would have saved every photograph an editor has ever destroyed. Files: `src-tauri/src/import.rs`, `tests/source-untouched.test.ts` 2. Read the embedded profile and record the decision Not to implement colour management, but so the app can say what it did. Files: `src/colour.ts`, `src/types/project.ts` 3. Set a pixel ceiling with a real message A forty megapixel file on an eight gigabyte machine is a decision, not a crash. Files: `src/import.ts` ### Done when - [ ] The source hash is unchanged after a session, asserted in tests - [ ] The project records profile, working space and any conversion - [ ] A wide-gamut import shows a colour note rather than shifting silently - [ ] An oversized file is refused with its dimensions in the message ### Watch out - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. ## Phase 2 · An edit stack, not a pixel buffer Every adjustment is an entry in an ordered list, so undo, reorder and disable are free. ### Steps 1. Write operations as pure functions Input surface plus parameters out, no shared mutable bitmap. Files: `src/ops/index.ts`, `src/ops/exposure.ts` 2. Cache the render at the last unchanged step Re-rendering ten operations on every slider drag is the reason people say this is slow. Files: `src/render/cache.ts` 3. Test the round trip pixel by pixel Undo that is nearly right is undo that is wrong. Files: `tests/undo-roundtrip.test.ts` ### Done when - [ ] Twenty edits and twenty undos are pixel-identical to the import - [ ] Disabling a middle operation re-renders correctly - [ ] No operation mutates the source surface - [ ] Memory stays within the stated multiple after a hundred edits ### Watch out - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. ## Phase 3 · A preview that predicts the export What you see at screen size is what the full-resolution file contains. ### Steps 1. Thread a render scale through every operation The parameter that is forgotten in exactly one operation, every time. Files: `src/render/scale.ts`, `src/ops/blur.ts` 2. Compare two scales in the suite The only check that catches the forgotten one. Files: `tests/scale-agreement.test.ts` 3. Report export progress A full-resolution replay takes seconds and silence reads as a hang. Files: `src/export/progress.ts` ### Done when - [ ] Two render scales agree within the stated tolerance - [ ] Pixel parameters are scaled, enforced by a failing test when missed - [ ] Preview and export share one implementation - [ ] Export progress is visible ### Watch out - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. ## Phase 4 · Collage and text, where fonts break things A layout of several images plus text renders the same on export as on screen. ### Steps 1. Reference font files, never family names A substituted face is a different layout. Files: `src/text/fonts.ts` 2. Measure text at the render scale Line breaking is the thing that moves between preview and export. Files: `src/text/measure.ts`, `tests/text-scale.test.ts` 3. Make grid reflow deterministic Same inputs, same layout, or the export is a surprise. Files: `src/layout/grid.ts` ### Done when - [ ] Collage layer positions match preview within tolerance - [ ] Text line breaks are identical at both scales - [ ] A missing font refuses to export and names the face - [ ] Grid reflow is deterministic under test ### Watch out - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. ## Not in this build - A stock template and asset library. - Generative editing features. - Print colour management and CMYK. - Cloud projects, sharing and mobile. ## After v1, if you want it - Batch apply of one saved operation stack across a folder, which reuses everything already built - A histogram and clipping warning computed from the cached render rather than from the source - Raw file decoding, which is a genuine project and deserves its own colour decisions ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Projects, the index and copied assets. Never the user's originals. DATA_DIR=~/.local/share/photoedit # Required. Where new files are written. Exports never land beside the source. EXPORT_DIR=~/Pictures/exports # Required. Recorded in every project so an old project still renders the way it did. WORKING_COLOUR_SPACE=srgb # Required. Above this the import is refused with the real dimensions in the message. MAX_IMAGE_PIXELS=60000000 # Required. The preview downscale. The render scale derived from it must reach every operation. PREVIEW_MAX_EDGE=2048 # Required. Default true. Turning it off lists exactly what will be kept. STRIP_METADATA_ON_EXPORT=true
You are building a lean indie version of BeFunky. 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 ===== # BeFunky · indie build A non-destructive local photo editor whose hard problems are keeping the original file untouched, keeping colour honest through a canvas that is sRGB by default, and making a screen-sized preview agree with a full-resolution export. 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 front end | the file system access is the point, and a browser tab cannot have it | | Rendering | Canvas 2D for preview, the same operation list replayed at full size for export | one code path or the preview is a lie | | Document | A JSON edit stack stored beside the image, never inside it | the original stays byte-identical and the edits stay portable | | Colour | One declared working space, recorded per project, conversions logged | the default canvas colour space quietly changes wide-gamut photographs | | Storage | SQLite for the project index, files on disk for everything large | a database holding megabytes of pixels is a slow database | ## 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 front end and runs the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain** · free - Why: Tauri 2 needs it, and the file handling that keeps the original untouched lives on that side. - Get it: rustup.rs, then the platform build dependencies Tauri lists for your operating system. - Verify: cargo --version prints a version and a Tauri dev build starts - [ ] **Three test photographs you own** · free - Why: One wide-gamut, one with location metadata, one very large. Each exists to make a specific test fail before it passes. - Get it: Take them, or export them from your own library. Do not use stock images with unclear terms. - Verify: One has an embedded non-sRGB profile, one has GPS tags, one exceeds your pixel ceiling - [ ] **One font file you may redistribute** · free - Why: Text rendering references a file, not a family name, so you need a face whose licence permits shipping it beside the project. - Get it: An open licensed family from a font repository, with the licence file kept next to it. - Verify: The licence permits redistribution and the file loads in the renderer - [ ] **Your colour policy, written down** · free - Why: Whether you convert wide-gamut imports to sRGB or refuse them is a decision that changes what users see, and deciding it after the first complaint means choosing whatever is easiest. - Get it: Write one paragraph: the working space, what happens to a non-matching embedded profile, and what the user is told. - Verify: The paragraph is in the README and a test asserts the behaviour it describes ## 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 template and asset library. - Generative editing features. - Print colour management and CMYK. - Cloud projects, sharing and mobile. - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases - cloud collaboration and mobile apps - high-fidelity color, format, and export handling If one of those is essential to you, that is the reason to keep paying for BeFunky, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · BeFunky 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 photo editor that never touches the original file and exports exactly the pixels you were shown to replace BeFunky. 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 The file the user dragged in is not yours to modify. Ever. Open it read-only, hash it on import, and assert at the end of every test run that the hash is unchanged. Every edit is a recipe stored beside the image, and exporting writes a new file with a new name. People drop irreplaceable photographs into editors, and the single worst thing this program can do is save over one. Second rule, which follows from the first: what the preview shows and what the export writes have to be the same picture. A preview computed one way and an export computed another is a bug that ships silently, because nobody compares them until the print comes back wrong. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript front end · the file system access is the point, and a browser tab cannot have it - Rendering: Canvas 2D for preview, the same operation list replayed at full size for export · one code path or the preview is a lie - Document: A JSON edit stack stored beside the image, never inside it · the original stays byte-identical and the edits stay portable - Colour: One declared working space, recorded per project, conversions logged · the default canvas colour space quietly changes wide-gamut photographs - Storage: SQLite for the project index, files on disk for everything large · a database holding megabytes of pixels is a slow database ### Data model (create this before Phase 1) - projects(id, name, source_path, source_sha256, source_width, source_height, created_at, opened_at) - source_profile(project_id, declared_space, embedded_profile_name, converted_to, conversion_note) · what the file claimed and what you did about it - ops(project_id, ordinal, kind, params_json, enabled, created_at) · the edit stack, which IS the document - layers(project_id, id, kind, z, transform_json, asset_id, text_json) · for collages and text - assets(id, path, sha256, mime, width, height, bytes) · imported images, copied, never moved - exports(id, project_id, path, format, quality, long_edge, ops_ordinal_max, metadata_policy, written_at) - Nothing in this model stores pixels that an operation could recompute, which is why undo is free and a project file is a few kilobytes. ### Phase 1 · Open a photograph without changing it Build: Import copies nothing and modifies nothing: record the path, the size and a SHA-256 of the bytes. Decide and record the working colour space. The HTML Standard defines the 2D canvas context setting as colorSpace with a default of 'srgb' (checked against the specification), so anything you draw into a default canvas is in sRGB whether or not the source was. Read the embedded profile name, state what you are doing with it, and store that alongside the project. Trap: Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. Done when: Importing a file and closing the app leaves the source SHA-256 unchanged, asserted in the test suite rather than assumed. The project records the embedded profile name, the working space and any conversion applied. A wide-gamut test image imports with a visible note about the colour handling rather than silently shifting. Opening a file larger than the configured pixel ceiling fails with the actual dimensions in the message, not a blank canvas. Do not build yet: No edits, no undo, no export, no collage. ### Phase 2 · An edit stack, not a pixel buffer Build: Implement crop, rotate, exposure, contrast, saturation and one blur as pure functions of an input surface and a parameter object. Append each to the ops table. Undo removes or disables the last entry and re-renders. Nothing writes pixels back into the source surface. Trap: Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. Done when: Twenty successive edits followed by twenty undos return the canvas to a state pixel-identical to the freshly imported render, tested by comparison. Disabling an operation in the middle of the stack re-renders without it and re-enabling restores the earlier result. No operation writes to the source surface, verified by review and by the import hash test still passing. Memory after a hundred edits is within a stated multiple of the image size. Do not build yet: No preview scaling, export, collage or text yet. ### Phase 3 · A preview that predicts the export Build: Render the preview on a downscaled copy for speed, and export by replaying the same operation list at full resolution. Every parameter measured in pixels is scaled by the render scale. Add a test that renders the same stack at two scales and compares the results after resizing. Trap: A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. Done when: The same operation stack rendered at 25 percent and at full size, with the full size downscaled for comparison, agrees within a stated per-channel tolerance, tested with a stack containing a blur and a feathered crop. Every pixel-denominated parameter is multiplied by the render scale, verified by a test that fails when one is missed. The export path calls the same operation functions as the preview, with no second implementation anywhere. Export time for a large image is reported to the user rather than appearing as a frozen window. Do not build yet: No collage, text or metadata handling yet. ### Phase 4 · Collage and text, where fonts break things Build: Layers with a transform each, a grid layout that reflows, and text with an explicitly chosen font file rather than a family name. Copy the font beside the project. Measure text with the same font at both render scales. Trap: Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. Done when: A collage of four images exports with layer positions matching the preview within a stated tolerance. Text rendered with a project-owned font file produces identical line breaks at both render scales. A project referencing a missing font file refuses to export with a message naming the face, rather than substituting. Adding or removing an image from the grid reflows the remaining ones deterministically, tested. Do not build yet: No template library, no stock assets, no metadata policy yet. ### Phase 5 · Export, metadata and the way out Build: Export to PNG and JPEG with an explicit quality for the lossy format only, since a quality argument means nothing to a lossless one. Strip camera, location and software metadata by default, with an opt-in to keep it and a clear list of what is being kept. Write the project and its ops list to a documented JSON file that another program could read. Trap: Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained. Done when: An exported JPEG contains no location, camera serial or original filename unless the user explicitly opted in, verified by reading the output file in the test suite. Quality is applied to the lossy format and ignored with a note for the lossless one. The project JSON is documented well enough that the ops list can be read without the application, and a round trip through it reproduces the same export. The source file hash is still unchanged at the end of the full end-to-end test. Do not build yet: Nothing after this. ### Out of scope (and why) - A template and stock asset library, because the value there is licensed content, not code, and shipping someone else's assets is the part that gets you a letter. - Background removal and generative fill at the quality people expect, which is model work and an ongoing bill rather than a weekend. - Print colour management and CMYK separation, which needs profiles, proofing and hardware you probably do not have. - Cloud projects, sharing and mobile apps, each of which is a larger project than the editor. ### README must contain - That the source file is opened read-only, hashed on import and verified unchanged by the test suite - Which colour space the editor works in, that the canvas default is sRGB, and what happens to a wide-gamut import - That preview and export replay the same operation list, and that pixel parameters are scaled by render scale - That exports strip camera and location metadata by default and how to opt in - The project JSON format, so the edit stack outlives this application ===== AGENTS.md ===== # Agent instructions · BeFunky indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with a React and TypeScript front end, Canvas 2D for preview, the same operation list replayed at full size for export, A JSON edit stack stored beside the image, never inside it, One declared working space, recorded per project, conversions logged, SQLite for the project index, files on disk for everything large. 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 - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. ===== BUILD_PLAN.md ===== # Build plan · BeFunky A non-destructive local photo editor whose hard problems are keeping the original file untouched, keeping colour honest through a canvas that is sRGB by default, and making a screen-sized preview agree with a full-resolution export. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Open a photograph without changing it An imported file is read, hashed and left alone, and the colour decision is made explicitly rather than by accident. ### Steps 1. Hash on import and assert it at teardown The one test that would have saved every photograph an editor has ever destroyed. Files: `src-tauri/src/import.rs`, `tests/source-untouched.test.ts` 2. Read the embedded profile and record the decision Not to implement colour management, but so the app can say what it did. Files: `src/colour.ts`, `src/types/project.ts` 3. Set a pixel ceiling with a real message A forty megapixel file on an eight gigabyte machine is a decision, not a crash. Files: `src/import.ts` ### Done when - [ ] The source hash is unchanged after a session, asserted in tests - [ ] The project records profile, working space and any conversion - [ ] A wide-gamut import shows a colour note rather than shifting silently - [ ] An oversized file is refused with its dimensions in the message ### Watch out - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. ## Phase 2 · An edit stack, not a pixel buffer Every adjustment is an entry in an ordered list, so undo, reorder and disable are free. ### Steps 1. Write operations as pure functions Input surface plus parameters out, no shared mutable bitmap. Files: `src/ops/index.ts`, `src/ops/exposure.ts` 2. Cache the render at the last unchanged step Re-rendering ten operations on every slider drag is the reason people say this is slow. Files: `src/render/cache.ts` 3. Test the round trip pixel by pixel Undo that is nearly right is undo that is wrong. Files: `tests/undo-roundtrip.test.ts` ### Done when - [ ] Twenty edits and twenty undos are pixel-identical to the import - [ ] Disabling a middle operation re-renders correctly - [ ] No operation mutates the source surface - [ ] Memory stays within the stated multiple after a hundred edits ### Watch out - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. ## Phase 3 · A preview that predicts the export What you see at screen size is what the full-resolution file contains. ### Steps 1. Thread a render scale through every operation The parameter that is forgotten in exactly one operation, every time. Files: `src/render/scale.ts`, `src/ops/blur.ts` 2. Compare two scales in the suite The only check that catches the forgotten one. Files: `tests/scale-agreement.test.ts` 3. Report export progress A full-resolution replay takes seconds and silence reads as a hang. Files: `src/export/progress.ts` ### Done when - [ ] Two render scales agree within the stated tolerance - [ ] Pixel parameters are scaled, enforced by a failing test when missed - [ ] Preview and export share one implementation - [ ] Export progress is visible ### Watch out - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. ## Phase 4 · Collage and text, where fonts break things A layout of several images plus text renders the same on export as on screen. ### Steps 1. Reference font files, never family names A substituted face is a different layout. Files: `src/text/fonts.ts` 2. Measure text at the render scale Line breaking is the thing that moves between preview and export. Files: `src/text/measure.ts`, `tests/text-scale.test.ts` 3. Make grid reflow deterministic Same inputs, same layout, or the export is a surprise. Files: `src/layout/grid.ts` ### Done when - [ ] Collage layer positions match preview within tolerance - [ ] Text line breaks are identical at both scales - [ ] A missing font refuses to export and names the face - [ ] Grid reflow is deterministic under test ### Watch out - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. ## Not in this build - A stock template and asset library. - Generative editing features. - Print colour management and CMYK. - Cloud projects, sharing and mobile. ## After v1, if you want it - Batch apply of one saved operation stack across a folder, which reuses everything already built - A histogram and clipping warning computed from the cached render rather than from the source - Raw file decoding, which is a genuine project and deserves its own colour decisions ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Projects, the index and copied assets. Never the user's originals. DATA_DIR=~/.local/share/photoedit # Required. Where new files are written. Exports never land beside the source. EXPORT_DIR=~/Pictures/exports # Required. Recorded in every project so an old project still renders the way it did. WORKING_COLOUR_SPACE=srgb # Required. Above this the import is refused with the real dimensions in the message. MAX_IMAGE_PIXELS=60000000 # Required. The preview downscale. The render scale derived from it must reach every operation. PREVIEW_MAX_EDGE=2048 # Required. Default true. Turning it off lists exactly what will be kept. STRIP_METADATA_ON_EXPORT=true
You are building a production product version of BeFunky. 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 ===== # BeFunky · product brief ## Problem The visible photo + graphic design loop is buildable, but a credible replacement needs more than the first screen. BeFunky earns its keep through editor polish, assets, algorithms, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome An editor that cannot destroy an original, tells you which colour space you are working in, and writes an export that matches the screen down to the blur radius. ## 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 template and asset library. - Generative editing features. - Print colour management and CMYK. - Cloud projects, sharing and mobile. - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases - cloud collaboration and mobile apps - high-fidelity color, format, and export handling ## Success criteria - The source hash is confirmed unchanged end to end - Two render scales are confirmed to agree within tolerance - Exports are confirmed free of location metadata by default - A missing font is confirmed to refuse rather than substitute - Undo is confirmed pixel-identical over twenty steps ===== BRIEF.md ===== # Build brief · BeFunky 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 photo editor that never touches the original file and exports exactly the pixels you were shown to replace BeFunky. 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 The file the user dragged in is not yours to modify. Ever. Open it read-only, hash it on import, and assert at the end of every test run that the hash is unchanged. Every edit is a recipe stored beside the image, and exporting writes a new file with a new name. People drop irreplaceable photographs into editors, and the single worst thing this program can do is save over one. Second rule, which follows from the first: what the preview shows and what the export writes have to be the same picture. A preview computed one way and an export computed another is a bug that ships silently, because nobody compares them until the print comes back wrong. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript front end · the file system access is the point, and a browser tab cannot have it - Rendering: Canvas 2D for preview, the same operation list replayed at full size for export · one code path or the preview is a lie - Document: A JSON edit stack stored beside the image, never inside it · the original stays byte-identical and the edits stay portable - Colour: One declared working space, recorded per project, conversions logged · the default canvas colour space quietly changes wide-gamut photographs - Storage: SQLite for the project index, files on disk for everything large · a database holding megabytes of pixels is a slow database ### Data model (create this before Phase 1) - projects(id, name, source_path, source_sha256, source_width, source_height, created_at, opened_at) - source_profile(project_id, declared_space, embedded_profile_name, converted_to, conversion_note) · what the file claimed and what you did about it - ops(project_id, ordinal, kind, params_json, enabled, created_at) · the edit stack, which IS the document - layers(project_id, id, kind, z, transform_json, asset_id, text_json) · for collages and text - assets(id, path, sha256, mime, width, height, bytes) · imported images, copied, never moved - exports(id, project_id, path, format, quality, long_edge, ops_ordinal_max, metadata_policy, written_at) - Nothing in this model stores pixels that an operation could recompute, which is why undo is free and a project file is a few kilobytes. ### Phase 1 · Open a photograph without changing it Build: Import copies nothing and modifies nothing: record the path, the size and a SHA-256 of the bytes. Decide and record the working colour space. The HTML Standard defines the 2D canvas context setting as colorSpace with a default of 'srgb' (checked against the specification), so anything you draw into a default canvas is in sRGB whether or not the source was. Read the embedded profile name, state what you are doing with it, and store that alongside the project. Trap: Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. Done when: Importing a file and closing the app leaves the source SHA-256 unchanged, asserted in the test suite rather than assumed. The project records the embedded profile name, the working space and any conversion applied. A wide-gamut test image imports with a visible note about the colour handling rather than silently shifting. Opening a file larger than the configured pixel ceiling fails with the actual dimensions in the message, not a blank canvas. Do not build yet: No edits, no undo, no export, no collage. ### Phase 2 · An edit stack, not a pixel buffer Build: Implement crop, rotate, exposure, contrast, saturation and one blur as pure functions of an input surface and a parameter object. Append each to the ops table. Undo removes or disables the last entry and re-renders. Nothing writes pixels back into the source surface. Trap: Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. Done when: Twenty successive edits followed by twenty undos return the canvas to a state pixel-identical to the freshly imported render, tested by comparison. Disabling an operation in the middle of the stack re-renders without it and re-enabling restores the earlier result. No operation writes to the source surface, verified by review and by the import hash test still passing. Memory after a hundred edits is within a stated multiple of the image size. Do not build yet: No preview scaling, export, collage or text yet. ### Phase 3 · A preview that predicts the export Build: Render the preview on a downscaled copy for speed, and export by replaying the same operation list at full resolution. Every parameter measured in pixels is scaled by the render scale. Add a test that renders the same stack at two scales and compares the results after resizing. Trap: A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. Done when: The same operation stack rendered at 25 percent and at full size, with the full size downscaled for comparison, agrees within a stated per-channel tolerance, tested with a stack containing a blur and a feathered crop. Every pixel-denominated parameter is multiplied by the render scale, verified by a test that fails when one is missed. The export path calls the same operation functions as the preview, with no second implementation anywhere. Export time for a large image is reported to the user rather than appearing as a frozen window. Do not build yet: No collage, text or metadata handling yet. ### Phase 4 · Collage and text, where fonts break things Build: Layers with a transform each, a grid layout that reflows, and text with an explicitly chosen font file rather than a family name. Copy the font beside the project. Measure text with the same font at both render scales. Trap: Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. Done when: A collage of four images exports with layer positions matching the preview within a stated tolerance. Text rendered with a project-owned font file produces identical line breaks at both render scales. A project referencing a missing font file refuses to export with a message naming the face, rather than substituting. Adding or removing an image from the grid reflows the remaining ones deterministically, tested. Do not build yet: No template library, no stock assets, no metadata policy yet. ### Phase 5 · Export, metadata and the way out Build: Export to PNG and JPEG with an explicit quality for the lossy format only, since a quality argument means nothing to a lossless one. Strip camera, location and software metadata by default, with an opt-in to keep it and a clear list of what is being kept. Write the project and its ops list to a documented JSON file that another program could read. Trap: Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained. Done when: An exported JPEG contains no location, camera serial or original filename unless the user explicitly opted in, verified by reading the output file in the test suite. Quality is applied to the lossy format and ignored with a note for the lossless one. The project JSON is documented well enough that the ops list can be read without the application, and a round trip through it reproduces the same export. The source file hash is still unchanged at the end of the full end-to-end test. Do not build yet: Nothing after this. ### Out of scope (and why) - A template and stock asset library, because the value there is licensed content, not code, and shipping someone else's assets is the part that gets you a letter. - Background removal and generative fill at the quality people expect, which is model work and an ongoing bill rather than a weekend. - Print colour management and CMYK separation, which needs profiles, proofing and hardware you probably do not have. - Cloud projects, sharing and mobile apps, each of which is a larger project than the editor. ### README must contain - That the source file is opened read-only, hashed on import and verified unchanged by the test suite - Which colour space the editor works in, that the canvas default is sRGB, and what happens to a wide-gamut import - That preview and export replay the same operation list, and that pixel parameters are scaled by render scale - That exports strip camera and location metadata by default and how to opt in - The project JSON format, so the edit stack outlives this application ===== ARCHITECTURE.md ===== # Architecture · BeFunky ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with a React and TypeScript front end | the file system access is the point, and a browser tab cannot have it | | Rendering | Canvas 2D for preview, the same operation list replayed at full size for export | one code path or the preview is a lie | | Document | A JSON edit stack stored beside the image, never inside it | the original stays byte-identical and the edits stay portable | | Colour | One declared working space, recorded per project, conversions logged | the default canvas colour space quietly changes wide-gamut photographs | | Storage | SQLite for the project index, files on disk for everything large | a database holding megabytes of pixels is a slow database | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | Read-only open, hashing, profile reading. | Any decoder, provided the hash assertion still passes. | | Ops | Pure adjustment functions and the edit stack. | Any implementation, provided nothing mutates the source surface. | | Render | Scale-aware replay shared by preview and export. | Not swappable for two implementations, which is the bug this design exists to prevent. | | Layout | Layers, grid reflow and file-referenced text. | Any layout engine, provided fonts are files and not family names. | | Export | Format, quality, metadata policy, project JSON. | Any writer, provided metadata stripping is verified by reading the output back. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATA_DIR` · required · Projects, the index and copied assets. Never the user's originals. - `EXPORT_DIR` · required · Where new files are written. Exports never land beside the source. - `WORKING_COLOUR_SPACE` · required · Recorded in every project so an old project still renders the way it did. - `MAX_IMAGE_PIXELS` · required · Above this the import is refused with the real dimensions in the message. - `PREVIEW_MAX_EDGE` · required · The preview downscale. The render scale derived from it must reach every operation. - `STRIP_METADATA_ON_EXPORT` · required · Default true. Turning it off lists exactly what will be kept. ## 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 · BeFunky product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with a React and TypeScript front end, Canvas 2D for preview, the same operation list replayed at full size for export, A JSON edit stack stored beside the image, never inside it, One declared working space, recorded per project, conversions logged, SQLite for the project index, files on disk for everything large. - 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 - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. - Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained. ===== MILESTONES.md ===== # Delivery milestones · BeFunky Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Open a photograph without changing it An imported file is read, hashed and left alone, and the colour decision is made explicitly rather than by accident. ### Steps 1. Hash on import and assert it at teardown The one test that would have saved every photograph an editor has ever destroyed. Files: `src-tauri/src/import.rs`, `tests/source-untouched.test.ts` 2. Read the embedded profile and record the decision Not to implement colour management, but so the app can say what it did. Files: `src/colour.ts`, `src/types/project.ts` 3. Set a pixel ceiling with a real message A forty megapixel file on an eight gigabyte machine is a decision, not a crash. Files: `src/import.ts` ### Done when - [ ] The source hash is unchanged after a session, asserted in tests - [ ] The project records profile, working space and any conversion - [ ] A wide-gamut import shows a colour note rather than shifting silently - [ ] An oversized file is refused with its dimensions in the message ### Watch out - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. ## M2 · An edit stack, not a pixel buffer Every adjustment is an entry in an ordered list, so undo, reorder and disable are free. ### Steps 1. Write operations as pure functions Input surface plus parameters out, no shared mutable bitmap. Files: `src/ops/index.ts`, `src/ops/exposure.ts` 2. Cache the render at the last unchanged step Re-rendering ten operations on every slider drag is the reason people say this is slow. Files: `src/render/cache.ts` 3. Test the round trip pixel by pixel Undo that is nearly right is undo that is wrong. Files: `tests/undo-roundtrip.test.ts` ### Done when - [ ] Twenty edits and twenty undos are pixel-identical to the import - [ ] Disabling a middle operation re-renders correctly - [ ] No operation mutates the source surface - [ ] Memory stays within the stated multiple after a hundred edits ### Watch out - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. ## M3 · A preview that predicts the export What you see at screen size is what the full-resolution file contains. ### Steps 1. Thread a render scale through every operation The parameter that is forgotten in exactly one operation, every time. Files: `src/render/scale.ts`, `src/ops/blur.ts` 2. Compare two scales in the suite The only check that catches the forgotten one. Files: `tests/scale-agreement.test.ts` 3. Report export progress A full-resolution replay takes seconds and silence reads as a hang. Files: `src/export/progress.ts` ### Done when - [ ] Two render scales agree within the stated tolerance - [ ] Pixel parameters are scaled, enforced by a failing test when missed - [ ] Preview and export share one implementation - [ ] Export progress is visible ### Watch out - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. ## M4 · Collage and text, where fonts break things A layout of several images plus text renders the same on export as on screen. ### Steps 1. Reference font files, never family names A substituted face is a different layout. Files: `src/text/fonts.ts` 2. Measure text at the render scale Line breaking is the thing that moves between preview and export. Files: `src/text/measure.ts`, `tests/text-scale.test.ts` 3. Make grid reflow deterministic Same inputs, same layout, or the export is a surprise. Files: `src/layout/grid.ts` ### Done when - [ ] Collage layer positions match preview within tolerance - [ ] Text line breaks are identical at both scales - [ ] A missing font refuses to export and names the face - [ ] Grid reflow is deterministic under test ### Watch out - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. ## M5 · Export, metadata and the way out (production only) Files leave in open formats, without carrying data the user did not mean to publish. ### Steps 1. Strip metadata by default and prove it Read the exported file back in the test and assert the tags are gone. Files: `src/export/metadata.ts`, `tests/no-exif-leak.test.ts` 2. Document the project file format An edit stack nobody else can read is a trap with extra steps. Files: `docs/project-format.md` 3. Run the untouched-source assertion end to end Import, edit, export, close, compare the hash. Files: `tests/e2e-source-untouched.test.ts` ### Done when - [ ] Exports carry no location or camera metadata by default - [ ] Quality applies only to the lossy format - [ ] The project JSON is documented and round trips - [ ] The source hash is unchanged after a full end-to-end run ### Watch out - Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained. ===== OPERATIONS.md ===== # Operations · BeFunky ## Backup The project directory and the assets directory together, since a project references assets by path and hash. Originals are not backed up by this app because it never owns them, and the README says so plainly. ## Restore Restore and open a project written before the last format change. If an old ops list no longer renders, the format version was not respected, and that is a bug to fix rather than a migration to skip. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a local tool the checks are the suite: the source hash assertion, the two-scale agreement test, the metadata leak test and the undo round trip. Any of them failing means the product promise is broken. ## Incident checklist The only real incident here is a modified or lost original, and the hash assertion is what turns that from a discovery months later into a failing test today. 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 - [ ] The source hash is confirmed unchanged end to end - [ ] Two render scales are confirmed to agree within tolerance - [ ] Exports are confirmed free of location metadata by default - [ ] A missing font is confirmed to refuse rather than substitute - [ ] Undo is confirmed pixel-identical over twenty steps ## Launch constraint Do not market omitted BeFunky 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. Projects, the index and copied assets. Never the user's originals. DATA_DIR=~/.local/share/photoedit # Required. Where new files are written. Exports never land beside the source. EXPORT_DIR=~/Pictures/exports # Required. Recorded in every project so an old project still renders the way it did. WORKING_COLOUR_SPACE=srgb # Required. Above this the import is refused with the real dimensions in the message. MAX_IMAGE_PIXELS=60000000 # Required. The preview downscale. The render scale derived from it must reach every operation. PREVIEW_MAX_EDGE=2048 # Required. Default true. Turning it off lists exactly what will be kept. STRIP_METADATA_ON_EXPORT=true
# BeFunky · indie build A non-destructive local photo editor whose hard problems are keeping the original file untouched, keeping colour honest through a canvas that is sRGB by default, and making a screen-sized preview agree with a full-resolution export. 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 front end | the file system access is the point, and a browser tab cannot have it | | Rendering | Canvas 2D for preview, the same operation list replayed at full size for export | one code path or the preview is a lie | | Document | A JSON edit stack stored beside the image, never inside it | the original stays byte-identical and the edits stay portable | | Colour | One declared working space, recorded per project, conversions logged | the default canvas colour space quietly changes wide-gamut photographs | | Storage | SQLite for the project index, files on disk for everything large | a database holding megabytes of pixels is a slow database | ## 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 front end and runs the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain** · free - Why: Tauri 2 needs it, and the file handling that keeps the original untouched lives on that side. - Get it: rustup.rs, then the platform build dependencies Tauri lists for your operating system. - Verify: cargo --version prints a version and a Tauri dev build starts - [ ] **Three test photographs you own** · free - Why: One wide-gamut, one with location metadata, one very large. Each exists to make a specific test fail before it passes. - Get it: Take them, or export them from your own library. Do not use stock images with unclear terms. - Verify: One has an embedded non-sRGB profile, one has GPS tags, one exceeds your pixel ceiling - [ ] **One font file you may redistribute** · free - Why: Text rendering references a file, not a family name, so you need a face whose licence permits shipping it beside the project. - Get it: An open licensed family from a font repository, with the licence file kept next to it. - Verify: The licence permits redistribution and the file loads in the renderer - [ ] **Your colour policy, written down** · free - Why: Whether you convert wide-gamut imports to sRGB or refuse them is a decision that changes what users see, and deciding it after the first complaint means choosing whatever is easiest. - Get it: Write one paragraph: the working space, what happens to a non-matching embedded profile, and what the user is told. - Verify: The paragraph is in the README and a test asserts the behaviour it describes ## 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 template and asset library. - Generative editing features. - Print colour management and CMYK. - Cloud projects, sharing and mobile. - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases - cloud collaboration and mobile apps - high-fidelity color, format, and export handling If one of those is essential to you, that is the reason to keep paying for BeFunky, and the README should say so rather than pretend.
# Build brief · BeFunky 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 photo editor that never touches the original file and exports exactly the pixels you were shown to replace BeFunky. 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 The file the user dragged in is not yours to modify. Ever. Open it read-only, hash it on import, and assert at the end of every test run that the hash is unchanged. Every edit is a recipe stored beside the image, and exporting writes a new file with a new name. People drop irreplaceable photographs into editors, and the single worst thing this program can do is save over one. Second rule, which follows from the first: what the preview shows and what the export writes have to be the same picture. A preview computed one way and an export computed another is a bug that ships silently, because nobody compares them until the print comes back wrong. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript front end · the file system access is the point, and a browser tab cannot have it - Rendering: Canvas 2D for preview, the same operation list replayed at full size for export · one code path or the preview is a lie - Document: A JSON edit stack stored beside the image, never inside it · the original stays byte-identical and the edits stay portable - Colour: One declared working space, recorded per project, conversions logged · the default canvas colour space quietly changes wide-gamut photographs - Storage: SQLite for the project index, files on disk for everything large · a database holding megabytes of pixels is a slow database ### Data model (create this before Phase 1) - projects(id, name, source_path, source_sha256, source_width, source_height, created_at, opened_at) - source_profile(project_id, declared_space, embedded_profile_name, converted_to, conversion_note) · what the file claimed and what you did about it - ops(project_id, ordinal, kind, params_json, enabled, created_at) · the edit stack, which IS the document - layers(project_id, id, kind, z, transform_json, asset_id, text_json) · for collages and text - assets(id, path, sha256, mime, width, height, bytes) · imported images, copied, never moved - exports(id, project_id, path, format, quality, long_edge, ops_ordinal_max, metadata_policy, written_at) - Nothing in this model stores pixels that an operation could recompute, which is why undo is free and a project file is a few kilobytes. ### Phase 1 · Open a photograph without changing it Build: Import copies nothing and modifies nothing: record the path, the size and a SHA-256 of the bytes. Decide and record the working colour space. The HTML Standard defines the 2D canvas context setting as colorSpace with a default of 'srgb' (checked against the specification), so anything you draw into a default canvas is in sRGB whether or not the source was. Read the embedded profile name, state what you are doing with it, and store that alongside the project. Trap: Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. Done when: Importing a file and closing the app leaves the source SHA-256 unchanged, asserted in the test suite rather than assumed. The project records the embedded profile name, the working space and any conversion applied. A wide-gamut test image imports with a visible note about the colour handling rather than silently shifting. Opening a file larger than the configured pixel ceiling fails with the actual dimensions in the message, not a blank canvas. Do not build yet: No edits, no undo, no export, no collage. ### Phase 2 · An edit stack, not a pixel buffer Build: Implement crop, rotate, exposure, contrast, saturation and one blur as pure functions of an input surface and a parameter object. Append each to the ops table. Undo removes or disables the last entry and re-renders. Nothing writes pixels back into the source surface. Trap: Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. Done when: Twenty successive edits followed by twenty undos return the canvas to a state pixel-identical to the freshly imported render, tested by comparison. Disabling an operation in the middle of the stack re-renders without it and re-enabling restores the earlier result. No operation writes to the source surface, verified by review and by the import hash test still passing. Memory after a hundred edits is within a stated multiple of the image size. Do not build yet: No preview scaling, export, collage or text yet. ### Phase 3 · A preview that predicts the export Build: Render the preview on a downscaled copy for speed, and export by replaying the same operation list at full resolution. Every parameter measured in pixels is scaled by the render scale. Add a test that renders the same stack at two scales and compares the results after resizing. Trap: A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. Done when: The same operation stack rendered at 25 percent and at full size, with the full size downscaled for comparison, agrees within a stated per-channel tolerance, tested with a stack containing a blur and a feathered crop. Every pixel-denominated parameter is multiplied by the render scale, verified by a test that fails when one is missed. The export path calls the same operation functions as the preview, with no second implementation anywhere. Export time for a large image is reported to the user rather than appearing as a frozen window. Do not build yet: No collage, text or metadata handling yet. ### Phase 4 · Collage and text, where fonts break things Build: Layers with a transform each, a grid layout that reflows, and text with an explicitly chosen font file rather than a family name. Copy the font beside the project. Measure text with the same font at both render scales. Trap: Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. Done when: A collage of four images exports with layer positions matching the preview within a stated tolerance. Text rendered with a project-owned font file produces identical line breaks at both render scales. A project referencing a missing font file refuses to export with a message naming the face, rather than substituting. Adding or removing an image from the grid reflows the remaining ones deterministically, tested. Do not build yet: No template library, no stock assets, no metadata policy yet. ### Phase 5 · Export, metadata and the way out Build: Export to PNG and JPEG with an explicit quality for the lossy format only, since a quality argument means nothing to a lossless one. Strip camera, location and software metadata by default, with an opt-in to keep it and a clear list of what is being kept. Write the project and its ops list to a documented JSON file that another program could read. Trap: Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained. Done when: An exported JPEG contains no location, camera serial or original filename unless the user explicitly opted in, verified by reading the output file in the test suite. Quality is applied to the lossy format and ignored with a note for the lossless one. The project JSON is documented well enough that the ops list can be read without the application, and a round trip through it reproduces the same export. The source file hash is still unchanged at the end of the full end-to-end test. Do not build yet: Nothing after this. ### Out of scope (and why) - A template and stock asset library, because the value there is licensed content, not code, and shipping someone else's assets is the part that gets you a letter. - Background removal and generative fill at the quality people expect, which is model work and an ongoing bill rather than a weekend. - Print colour management and CMYK separation, which needs profiles, proofing and hardware you probably do not have. - Cloud projects, sharing and mobile apps, each of which is a larger project than the editor. ### README must contain - That the source file is opened read-only, hashed on import and verified unchanged by the test suite - Which colour space the editor works in, that the canvas default is sRGB, and what happens to a wide-gamut import - That preview and export replay the same operation list, and that pixel parameters are scaled by render scale - That exports strip camera and location metadata by default and how to opt in - The project JSON format, so the edit stack outlives this application
# Agent instructions · BeFunky indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with a React and TypeScript front end, Canvas 2D for preview, the same operation list replayed at full size for export, A JSON edit stack stored beside the image, never inside it, One declared working space, recorded per project, conversions logged, SQLite for the project index, files on disk for everything large. 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 - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing.
# Build plan · BeFunky A non-destructive local photo editor whose hard problems are keeping the original file untouched, keeping colour honest through a canvas that is sRGB by default, and making a screen-sized preview agree with a full-resolution export. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Open a photograph without changing it An imported file is read, hashed and left alone, and the colour decision is made explicitly rather than by accident. ### Steps 1. Hash on import and assert it at teardown The one test that would have saved every photograph an editor has ever destroyed. Files: `src-tauri/src/import.rs`, `tests/source-untouched.test.ts` 2. Read the embedded profile and record the decision Not to implement colour management, but so the app can say what it did. Files: `src/colour.ts`, `src/types/project.ts` 3. Set a pixel ceiling with a real message A forty megapixel file on an eight gigabyte machine is a decision, not a crash. Files: `src/import.ts` ### Done when - [ ] The source hash is unchanged after a session, asserted in tests - [ ] The project records profile, working space and any conversion - [ ] A wide-gamut import shows a colour note rather than shifting silently - [ ] An oversized file is refused with its dimensions in the message ### Watch out - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. ## Phase 2 · An edit stack, not a pixel buffer Every adjustment is an entry in an ordered list, so undo, reorder and disable are free. ### Steps 1. Write operations as pure functions Input surface plus parameters out, no shared mutable bitmap. Files: `src/ops/index.ts`, `src/ops/exposure.ts` 2. Cache the render at the last unchanged step Re-rendering ten operations on every slider drag is the reason people say this is slow. Files: `src/render/cache.ts` 3. Test the round trip pixel by pixel Undo that is nearly right is undo that is wrong. Files: `tests/undo-roundtrip.test.ts` ### Done when - [ ] Twenty edits and twenty undos are pixel-identical to the import - [ ] Disabling a middle operation re-renders correctly - [ ] No operation mutates the source surface - [ ] Memory stays within the stated multiple after a hundred edits ### Watch out - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. ## Phase 3 · A preview that predicts the export What you see at screen size is what the full-resolution file contains. ### Steps 1. Thread a render scale through every operation The parameter that is forgotten in exactly one operation, every time. Files: `src/render/scale.ts`, `src/ops/blur.ts` 2. Compare two scales in the suite The only check that catches the forgotten one. Files: `tests/scale-agreement.test.ts` 3. Report export progress A full-resolution replay takes seconds and silence reads as a hang. Files: `src/export/progress.ts` ### Done when - [ ] Two render scales agree within the stated tolerance - [ ] Pixel parameters are scaled, enforced by a failing test when missed - [ ] Preview and export share one implementation - [ ] Export progress is visible ### Watch out - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. ## Phase 4 · Collage and text, where fonts break things A layout of several images plus text renders the same on export as on screen. ### Steps 1. Reference font files, never family names A substituted face is a different layout. Files: `src/text/fonts.ts` 2. Measure text at the render scale Line breaking is the thing that moves between preview and export. Files: `src/text/measure.ts`, `tests/text-scale.test.ts` 3. Make grid reflow deterministic Same inputs, same layout, or the export is a surprise. Files: `src/layout/grid.ts` ### Done when - [ ] Collage layer positions match preview within tolerance - [ ] Text line breaks are identical at both scales - [ ] A missing font refuses to export and names the face - [ ] Grid reflow is deterministic under test ### Watch out - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. ## Not in this build - A stock template and asset library. - Generative editing features. - Print colour management and CMYK. - Cloud projects, sharing and mobile. ## After v1, if you want it - Batch apply of one saved operation stack across a folder, which reuses everything already built - A histogram and clipping warning computed from the cached render rather than from the source - Raw file decoding, which is a genuine project and deserves its own colour decisions
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Projects, the index and copied assets. Never the user's originals. DATA_DIR=~/.local/share/photoedit # Required. Where new files are written. Exports never land beside the source. EXPORT_DIR=~/Pictures/exports # Required. Recorded in every project so an old project still renders the way it did. WORKING_COLOUR_SPACE=srgb # Required. Above this the import is refused with the real dimensions in the message. MAX_IMAGE_PIXELS=60000000 # Required. The preview downscale. The render scale derived from it must reach every operation. PREVIEW_MAX_EDGE=2048 # Required. Default true. Turning it off lists exactly what will be kept. STRIP_METADATA_ON_EXPORT=true
# BeFunky · product brief ## Problem The visible photo + graphic design loop is buildable, but a credible replacement needs more than the first screen. BeFunky earns its keep through editor polish, assets, algorithms, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome An editor that cannot destroy an original, tells you which colour space you are working in, and writes an export that matches the screen down to the blur radius. ## 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 template and asset library. - Generative editing features. - Print colour management and CMYK. - Cloud projects, sharing and mobile. - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases - cloud collaboration and mobile apps - high-fidelity color, format, and export handling ## Success criteria - The source hash is confirmed unchanged end to end - Two render scales are confirmed to agree within tolerance - Exports are confirmed free of location metadata by default - A missing font is confirmed to refuse rather than substitute - Undo is confirmed pixel-identical over twenty steps
# Build brief · BeFunky 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 photo editor that never touches the original file and exports exactly the pixels you were shown to replace BeFunky. 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 The file the user dragged in is not yours to modify. Ever. Open it read-only, hash it on import, and assert at the end of every test run that the hash is unchanged. Every edit is a recipe stored beside the image, and exporting writes a new file with a new name. People drop irreplaceable photographs into editors, and the single worst thing this program can do is save over one. Second rule, which follows from the first: what the preview shows and what the export writes have to be the same picture. A preview computed one way and an export computed another is a bug that ships silently, because nobody compares them until the print comes back wrong. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with a React and TypeScript front end · the file system access is the point, and a browser tab cannot have it - Rendering: Canvas 2D for preview, the same operation list replayed at full size for export · one code path or the preview is a lie - Document: A JSON edit stack stored beside the image, never inside it · the original stays byte-identical and the edits stay portable - Colour: One declared working space, recorded per project, conversions logged · the default canvas colour space quietly changes wide-gamut photographs - Storage: SQLite for the project index, files on disk for everything large · a database holding megabytes of pixels is a slow database ### Data model (create this before Phase 1) - projects(id, name, source_path, source_sha256, source_width, source_height, created_at, opened_at) - source_profile(project_id, declared_space, embedded_profile_name, converted_to, conversion_note) · what the file claimed and what you did about it - ops(project_id, ordinal, kind, params_json, enabled, created_at) · the edit stack, which IS the document - layers(project_id, id, kind, z, transform_json, asset_id, text_json) · for collages and text - assets(id, path, sha256, mime, width, height, bytes) · imported images, copied, never moved - exports(id, project_id, path, format, quality, long_edge, ops_ordinal_max, metadata_policy, written_at) - Nothing in this model stores pixels that an operation could recompute, which is why undo is free and a project file is a few kilobytes. ### Phase 1 · Open a photograph without changing it Build: Import copies nothing and modifies nothing: record the path, the size and a SHA-256 of the bytes. Decide and record the working colour space. The HTML Standard defines the 2D canvas context setting as colorSpace with a default of 'srgb' (checked against the specification), so anything you draw into a default canvas is in sRGB whether or not the source was. Read the embedded profile name, state what you are doing with it, and store that alongside the project. Trap: Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. Done when: Importing a file and closing the app leaves the source SHA-256 unchanged, asserted in the test suite rather than assumed. The project records the embedded profile name, the working space and any conversion applied. A wide-gamut test image imports with a visible note about the colour handling rather than silently shifting. Opening a file larger than the configured pixel ceiling fails with the actual dimensions in the message, not a blank canvas. Do not build yet: No edits, no undo, no export, no collage. ### Phase 2 · An edit stack, not a pixel buffer Build: Implement crop, rotate, exposure, contrast, saturation and one blur as pure functions of an input surface and a parameter object. Append each to the ops table. Undo removes or disables the last entry and re-renders. Nothing writes pixels back into the source surface. Trap: Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. Done when: Twenty successive edits followed by twenty undos return the canvas to a state pixel-identical to the freshly imported render, tested by comparison. Disabling an operation in the middle of the stack re-renders without it and re-enabling restores the earlier result. No operation writes to the source surface, verified by review and by the import hash test still passing. Memory after a hundred edits is within a stated multiple of the image size. Do not build yet: No preview scaling, export, collage or text yet. ### Phase 3 · A preview that predicts the export Build: Render the preview on a downscaled copy for speed, and export by replaying the same operation list at full resolution. Every parameter measured in pixels is scaled by the render scale. Add a test that renders the same stack at two scales and compares the results after resizing. Trap: A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. Done when: The same operation stack rendered at 25 percent and at full size, with the full size downscaled for comparison, agrees within a stated per-channel tolerance, tested with a stack containing a blur and a feathered crop. Every pixel-denominated parameter is multiplied by the render scale, verified by a test that fails when one is missed. The export path calls the same operation functions as the preview, with no second implementation anywhere. Export time for a large image is reported to the user rather than appearing as a frozen window. Do not build yet: No collage, text or metadata handling yet. ### Phase 4 · Collage and text, where fonts break things Build: Layers with a transform each, a grid layout that reflows, and text with an explicitly chosen font file rather than a family name. Copy the font beside the project. Measure text with the same font at both render scales. Trap: Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. Done when: A collage of four images exports with layer positions matching the preview within a stated tolerance. Text rendered with a project-owned font file produces identical line breaks at both render scales. A project referencing a missing font file refuses to export with a message naming the face, rather than substituting. Adding or removing an image from the grid reflows the remaining ones deterministically, tested. Do not build yet: No template library, no stock assets, no metadata policy yet. ### Phase 5 · Export, metadata and the way out Build: Export to PNG and JPEG with an explicit quality for the lossy format only, since a quality argument means nothing to a lossless one. Strip camera, location and software metadata by default, with an opt-in to keep it and a clear list of what is being kept. Write the project and its ops list to a documented JSON file that another program could read. Trap: Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained. Done when: An exported JPEG contains no location, camera serial or original filename unless the user explicitly opted in, verified by reading the output file in the test suite. Quality is applied to the lossy format and ignored with a note for the lossless one. The project JSON is documented well enough that the ops list can be read without the application, and a round trip through it reproduces the same export. The source file hash is still unchanged at the end of the full end-to-end test. Do not build yet: Nothing after this. ### Out of scope (and why) - A template and stock asset library, because the value there is licensed content, not code, and shipping someone else's assets is the part that gets you a letter. - Background removal and generative fill at the quality people expect, which is model work and an ongoing bill rather than a weekend. - Print colour management and CMYK separation, which needs profiles, proofing and hardware you probably do not have. - Cloud projects, sharing and mobile apps, each of which is a larger project than the editor. ### README must contain - That the source file is opened read-only, hashed on import and verified unchanged by the test suite - Which colour space the editor works in, that the canvas default is sRGB, and what happens to a wide-gamut import - That preview and export replay the same operation list, and that pixel parameters are scaled by render scale - That exports strip camera and location metadata by default and how to opt in - The project JSON format, so the edit stack outlives this application
# Architecture · BeFunky ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with a React and TypeScript front end | the file system access is the point, and a browser tab cannot have it | | Rendering | Canvas 2D for preview, the same operation list replayed at full size for export | one code path or the preview is a lie | | Document | A JSON edit stack stored beside the image, never inside it | the original stays byte-identical and the edits stay portable | | Colour | One declared working space, recorded per project, conversions logged | the default canvas colour space quietly changes wide-gamut photographs | | Storage | SQLite for the project index, files on disk for everything large | a database holding megabytes of pixels is a slow database | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | Read-only open, hashing, profile reading. | Any decoder, provided the hash assertion still passes. | | Ops | Pure adjustment functions and the edit stack. | Any implementation, provided nothing mutates the source surface. | | Render | Scale-aware replay shared by preview and export. | Not swappable for two implementations, which is the bug this design exists to prevent. | | Layout | Layers, grid reflow and file-referenced text. | Any layout engine, provided fonts are files and not family names. | | Export | Format, quality, metadata policy, project JSON. | Any writer, provided metadata stripping is verified by reading the output back. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATA_DIR` · required · Projects, the index and copied assets. Never the user's originals. - `EXPORT_DIR` · required · Where new files are written. Exports never land beside the source. - `WORKING_COLOUR_SPACE` · required · Recorded in every project so an old project still renders the way it did. - `MAX_IMAGE_PIXELS` · required · Above this the import is refused with the real dimensions in the message. - `PREVIEW_MAX_EDGE` · required · The preview downscale. The render scale derived from it must reach every operation. - `STRIP_METADATA_ON_EXPORT` · required · Default true. Turning it off lists exactly what will be kept. ## 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 · BeFunky product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with a React and TypeScript front end, Canvas 2D for preview, the same operation list replayed at full size for export, A JSON edit stack stored beside the image, never inside it, One declared working space, recorded per project, conversions logged, SQLite for the project index, files on disk for everything large. - 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 - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. - Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained.
# Delivery milestones · BeFunky Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Open a photograph without changing it An imported file is read, hashed and left alone, and the colour decision is made explicitly rather than by accident. ### Steps 1. Hash on import and assert it at teardown The one test that would have saved every photograph an editor has ever destroyed. Files: `src-tauri/src/import.rs`, `tests/source-untouched.test.ts` 2. Read the embedded profile and record the decision Not to implement colour management, but so the app can say what it did. Files: `src/colour.ts`, `src/types/project.ts` 3. Set a pixel ceiling with a real message A forty megapixel file on an eight gigabyte machine is a decision, not a crash. Files: `src/import.ts` ### Done when - [ ] The source hash is unchanged after a session, asserted in tests - [ ] The project records profile, working space and any conversion - [ ] A wide-gamut import shows a colour note rather than shifting silently - [ ] An oversized file is refused with its dimensions in the message ### Watch out - Loading a photograph shot in a wide-gamut space into a default canvas converts it to sRGB on the way in, and the user sees a flatter, duller version of their own picture with no message anywhere explaining why. Read the embedded profile, declare the working space in the project, show the user which one they are editing in, and record the conversion so the export can say what happened. ## M2 · An edit stack, not a pixel buffer Every adjustment is an entry in an ordered list, so undo, reorder and disable are free. ### Steps 1. Write operations as pure functions Input surface plus parameters out, no shared mutable bitmap. Files: `src/ops/index.ts`, `src/ops/exposure.ts` 2. Cache the render at the last unchanged step Re-rendering ten operations on every slider drag is the reason people say this is slow. Files: `src/render/cache.ts` 3. Test the round trip pixel by pixel Undo that is nearly right is undo that is wrong. Files: `tests/undo-roundtrip.test.ts` ### Done when - [ ] Twenty edits and twenty undos are pixel-identical to the import - [ ] Disabling a middle operation re-renders correctly - [ ] No operation mutates the source surface - [ ] Memory stays within the stated multiple after a hundred edits ### Watch out - Applying each adjustment destructively to a working bitmap is faster to write and traps you within a day, because undo then means keeping a full copy of the image for every step and a twelve step history on a large photograph exhausts memory. Keep the operation list and re-render from the source, cached at the last unchanged step. ## M3 · A preview that predicts the export What you see at screen size is what the full-resolution file contains. ### Steps 1. Thread a render scale through every operation The parameter that is forgotten in exactly one operation, every time. Files: `src/render/scale.ts`, `src/ops/blur.ts` 2. Compare two scales in the suite The only check that catches the forgotten one. Files: `tests/scale-agreement.test.ts` 3. Report export progress A full-resolution replay takes seconds and silence reads as a hang. Files: `src/export/progress.ts` ### Done when - [ ] Two render scales agree within the stated tolerance - [ ] Pixel parameters are scaled, enforced by a failing test when missed - [ ] Preview and export share one implementation - [ ] Export progress is visible ### Watch out - A blur radius of eight pixels on a preview downscaled to a quarter is a blur radius of thirty two pixels in the export, and so is every feather, stroke width, corner radius and vignette falloff in the stack, which is why the exported file looks nothing like the screen. Carry a render scale through every operation and multiply pixel-denominated parameters by it. ## M4 · Collage and text, where fonts break things A layout of several images plus text renders the same on export as on screen. ### Steps 1. Reference font files, never family names A substituted face is a different layout. Files: `src/text/fonts.ts` 2. Measure text at the render scale Line breaking is the thing that moves between preview and export. Files: `src/text/measure.ts`, `tests/text-scale.test.ts` 3. Make grid reflow deterministic Same inputs, same layout, or the export is a surprise. Files: `src/layout/grid.ts` ### Done when - [ ] Collage layer positions match preview within tolerance - [ ] Text line breaks are identical at both scales - [ ] A missing font refuses to export and names the face - [ ] Grid reflow is deterministic under test ### Watch out - Setting a font by family name means the export silently substitutes whatever the system has when the named face is missing, and the line breaks move, so a caption that fitted on screen overruns the frame in the file. Reference a font file the project owns and refuse to render with a substitute, saying which face is missing. ## M5 · Export, metadata and the way out (production only) Files leave in open formats, without carrying data the user did not mean to publish. ### Steps 1. Strip metadata by default and prove it Read the exported file back in the test and assert the tags are gone. Files: `src/export/metadata.ts`, `tests/no-exif-leak.test.ts` 2. Document the project file format An edit stack nobody else can read is a trap with extra steps. Files: `docs/project-format.md` 3. Run the untouched-source assertion end to end Import, edit, export, close, compare the hash. Files: `tests/e2e-source-untouched.test.ts` ### Done when - [ ] Exports carry no location or camera metadata by default - [ ] Quality applies only to the lossy format - [ ] The project JSON is documented and round trips - [ ] The source hash is unchanged after a full end-to-end run ### Watch out - Copying the source metadata into the export because it seems polite republishes the GPS coordinates where the photograph was taken, which for a picture taken at home is the user's address, attached to a file they are about to put on the internet. Strip by default, keep only on an explicit choice, and list exactly what is retained.
# Operations · BeFunky ## Backup The project directory and the assets directory together, since a project references assets by path and hash. Originals are not backed up by this app because it never owns them, and the README says so plainly. ## Restore Restore and open a project written before the last format change. If an old ops list no longer renders, the format version was not respected, and that is a bug to fix rather than a migration to skip. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a local tool the checks are the suite: the source hash assertion, the two-scale agreement test, the metadata leak test and the undo round trip. Any of them failing means the product promise is broken. ## Incident checklist The only real incident here is a modified or lost original, and the hash assertion is what turns that from a discovery months later into a failing test today. 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 - [ ] The source hash is confirmed unchanged end to end - [ ] Two render scales are confirmed to agree within tolerance - [ ] Exports are confirmed free of location metadata by default - [ ] A missing font is confirmed to refuse rather than substitute - [ ] Undo is confirmed pixel-identical over twenty steps ## Launch constraint Do not market omitted BeFunky 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. Projects, the index and copied assets. Never the user's originals. DATA_DIR=~/.local/share/photoedit # Required. Where new files are written. Exports never land beside the source. EXPORT_DIR=~/Pictures/exports # Required. Recorded in every project so an old project still renders the way it did. WORKING_COLOUR_SPACE=srgb # Required. Above this the import is refused with the real dimensions in the message. MAX_IMAGE_PIXELS=60000000 # Required. The preview downscale. The render scale derived from it must reach every operation. PREVIEW_MAX_EDGE=2048 # Required. Default true. Turning it off lists exactly what will be kept. STRIP_METADATA_ON_EXPORT=true
$ choose a build depth, inspect the files, then open the complete pack in your agent
BeFunky: Creative users pay for speed, output fidelity, nondestructive editing, and years of edge-case work hidden behind direct manipulation.
xpixel-perfect professional tooling
xlarge template and asset libraries
xcolor-management edge cases
xcloud collaboration and mobile apps
xhigh-fidelity color, format, and export handling
Don't feel like building it? These folks already made it free.
all 4 free alternatives to BeFunky →· no votes, no pay-to-list · just what's real
BeFunky pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0 | $0 | 100+ digital effects; no account is required; the official page does not publish a numeric export cap. |
| plus | — | $6.99 | Full paid editor, collage and design feature set; public pages do not expose a numeric AI-credit allowance. |
| pro | — | $12.49 | 5,000 Batch AI credits/month plus the paid feature set. |
free tier100+ digital effects; no numeric export cap is published.
billingmonthly + annual; exact annual rates are public, while current monthly USD checkout amounts were not exposed
hidden costsPro's included Batch AI credits do not roll over. Purchased top-up credits remain recoverable for 90 days after downgrade or cancellation; mobile feature availability can differ from web.
verified 2026-08-14 · source ↗
Vibecode BeFunky
Kinda. The core of BeFunky is buildable in a weekend with the prompt on this page, but there are real gaps: pixel-perfect professional tooling, large template and asset libraries. Read the honest list above before committing.
How much does BeFunky cost?
BeFunky'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 BeFunky?
Honestly: pixel-perfect professional tooling; large template and asset libraries; color-management edge cases; cloud collaboration and mobile apps; high-fidelity color, format, and export handling. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to BeFunky?
Yes: Adobe Express Free (A cleaner replacement for the template-and-collage half, with usable photo tools and exports on the free plan.) Canva Free (Collages, social templates, and enough photo cleanup for ordinary humans; the serious retouching tools are elsewhere.) Desygner Free (A free template editor with photo tools, collages, and brand basics; less glossy, but it keeps exporting.) All 4 curated free alternatives are at vibecodeit.com/befunky/alternatives. The prompt is for when you want it exactly your way.