Vibecode Linearity Curve
track this build4 phases, 12 steps, beginner friendly0%The visible vector design loop is buildable, but a credible replacement needs more than the first screen. Linearity Curve 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 Linearity Curve. 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 ===== # Linearity Curve · indie build A vector editor whose hard problems are one path representation that every operation can trust, boolean operations that stay undoable, and an export that reports what it had to throw away. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application in TypeScript, document model kept separate from the renderer | the renderer is replaceable and the document is not | | Geometry | Cubic Beziers only, with arcs, quadratics and polylines converted at import | every operation gets written once instead of six times, five of them wrongly | | Boolean ops | A maintained path boolean library, not a sweep line of your own | tangential intersections are where hand-written boolean code loops forever | | Rendering | Canvas 2D with dirty rectangle redraw, hit testing against the model | picking by reading pixels breaks on opacity, strokes and high density displays | | Document | Versioned JSON as the save format, with SVG and PDF as export targets | SVG cannot express half of what a vector editor knows about a shape | | Undo | A log of operations and their inverses, not snapshots of the scene | snapshots of a large document eat memory and lose the selection | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer with TypeScript** · free - Why: Runs the editor and its test suite. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A maintained path boolean library** · free - Why: Boolean operations on Bezier paths are the part of this project most likely to produce quietly wrong geometry if written from scratch. - Get it: Choose a library with a test suite covering tangential and self-intersecting cases, and read its issue tracker before committing. - Verify: A fixture of two tangentially touching circles returns a result rather than hanging - [ ] **A set of awkward SVG files** · free - Why: Import correctness is only demonstrable against files that use the commands you would rather not implement. - Get it: Assemble files using elliptical arcs, smooth shorthands, nested transforms, even-odd fills and text, from your own work or from public domain artwork. - Verify: Each fixture imports and exports with a written report - [ ] **What the exporter does with a non-centred stroke** · free - Why: The target format cannot express it, so either you decide now or the exporter decides for you and nobody finds out until the artwork is printed. - Get it: Read the current SVG Strokes draft, confirm stroke-alignment is still not normative in SVG 2, and write down your rule. - Verify: The rule is in the README and asserted in a test ## 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: - Real-time collaboration. - A text shaping and layout engine of your own. - Print colour, spot inks and separations. - A template and asset library. - cloud collaboration and mobile apps - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases If one of those is essential to you, that is the reason to keep paying for Linearity Curve, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Linearity Curve 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 vector editor whose document model keeps what the file format cannot to replace Linearity Curve. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application in TypeScript, document model kept separate from the renderer · the renderer is replaceable and the document is not - Geometry: Cubic Beziers only, with arcs, quadratics and polylines converted at import · every operation gets written once instead of six times, five of them wrongly - Boolean ops: A maintained path boolean library, not a sweep line of your own · tangential intersections are where hand-written boolean code loops forever - Rendering: Canvas 2D with dirty rectangle redraw, hit testing against the model · picking by reading pixels breaks on opacity, strokes and high density displays - Document: Versioned JSON as the save format, with SVG and PDF as export targets · SVG cannot express half of what a vector editor knows about a shape - Undo: A log of operations and their inverses, not snapshots of the scene · snapshots of a large document eat memory and lose the selection ### Data model (create this before Phase 1) - documents(id, name, format_version, width, height, unit, created_at, updated_at) - nodes(id, document_id, parent_id, ordinal, kind, transform, visible, locked, name) · kind is group, path, text, image or boolean - paths(node_id, subpaths_json, fill_rule, closed) · cubic segments only, everything normalised into this shape at import - styles(node_id, fill_json, strokes_json, opacity, blend_mode) · a stroke carries width, alignment, caps, joins and dash pattern - boolean_ops(node_id, op, operand_order_json) · operands stay as hidden children so the operation is still undoable a year later - ops(id, document_id, ordinal, kind, payload_json, inverse_json) · undo replays inverses, and a drag is one coalesced entry - export_reports(id, document_id, target, at, dropped_json) · what the exporter could not represent, written every time you export - Stroke alignment is stored because the editor supports it and the export format does not. Export is where that gets resolved, loudly. ### Phase 1 · One path representation and nothing else Build: Import SVG and normalise: elliptical arcs, quadratics, smooth shorthands, horizontal and vertical lines and the primitive shapes all become cubic subpaths. Collapse each node's transform list into a single matrix. Record the tolerance used for arc conversion in the document so a later round trip can be compared against the same number. Trap: Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. Done when: An SVG fixture using A, Q, T, S, H and V commands plus rect, circle and polygon imports to cubic segments only, asserted over the parsed document. A circle imported and exported round trips within the recorded tolerance, measured by sampling both outlines. Every node carries one matrix rather than a transform list, checked after import. A path with a subpath that is not closed keeps that distinction, because fill rules depend on it. Do not build yet: No editing, boolean operations, undo or export yet. ### Phase 2 · Direct manipulation that stays smooth Build: Hit test a point against path geometry with a tolerance expressed in document units divided by zoom. Redraw only dirty rectangles. Support marquee selection, node editing, handles and snapping to a grid and to other objects' bounds. Trap: Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. Done when: Clicking a transparent shape selects it and clicking a hole in an even-odd path does not, both tested against the model rather than the canvas. Hit tolerance scales with zoom so a one unit stroke is selectable at ten percent and at a thousand percent. A drag of fifty nodes redraws only the affected rectangle, verified by instrumenting the redraw. Snapping can be turned off and the unsnapped coordinate is what gets stored. Do not build yet: No boolean operations, undo or export yet. ### Phase 3 · Boolean operations that stay undoable Build: Use a maintained path boolean library. Store the result as a boolean node whose operands remain as hidden children with their order recorded. Expose a flatten action that destroys the operands deliberately, and never do it automatically. Trap: Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. Done when: Editing an operand of a boolean node recomputes the result without the user re-applying the operation, tested. Operand order is stored and reversing it changes a subtraction, asserted. Two shapes that touch tangentially produce a result rather than hanging, tested with a fixture built for it. Flattening is a separate explicit action and is recorded in the undo log like any other. Do not build yet: No undo log, text handling or export yet. ### Phase 4 · Undo as a log of inverses Build: Model each mutation as an operation carrying enough to invert it. Coalesce continuous gestures into a single entry on release. Restore selection as part of undo, and persist the log with the document so reopening a file does not discard its history. Trap: Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. Done when: A drag of a node produces one undo entry, not one per pointer move, asserted by counting log entries. Undo restores the selection that existed before the operation, tested. Undoing and redoing a hundred mixed operations returns the document to a byte-identical serialisation, tested as a property. Memory used by the log for a stated large document stays under a stated ceiling, measured. Do not build yet: No export or document format migration yet. ### Phase 5 · Export that tells you what it dropped Build: Write an export report on every export listing each document feature that had to be approximated or lost, with the node names. Resolve inner and outer strokes by outlining the path. Decide per export whether text stays as text or becomes outlines, and say which. Keep the versioned JSON document as the thing you actually save. Trap: A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. Trap: Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it. Done when: Exporting a shape with a non-centred stroke outlines it and the export report names that node, tested. Every export writes a report row listing dropped or approximated features, and an export with nothing lost says so explicitly. Re-importing an exported SVG produces a document that rasterises to within a stated pixel difference of the original, tested. Opening a document written by an earlier format version still works, tested against a stored fixture from the first release. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaboration, which is a different architecture entirely and would decide the document model rather than sit on top of it. - A text shaping and layout engine of your own, because ligatures, bidirectional text and complex scripts are years of work that the platform already does. - Print colour, spot inks and separations, which need a colour management pipeline and profiles rather than a weekend of code. - A template and asset library, which is a content business and not an engineering one. ### README must contain - That the save format is the versioned JSON document and that SVG and PDF are exports, not saves - That inner and outer strokes cannot be expressed in SVG and are outlined on export, with the report naming which nodes - That every curve is stored as a cubic Bezier and the tolerance used to convert arcs at import - That boolean operations keep their operands and that flattening is a deliberate action - That undo is a log of inverse operations, what it does not cover, and where it is persisted ===== AGENTS.md ===== # Agent instructions · Linearity Curve indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application in TypeScript, document model kept separate from the renderer, Cubic Beziers only, with arcs, quadratics and polylines converted at import, A maintained path boolean library, not a sweep line of your own, Canvas 2D with dirty rectangle redraw, hit testing against the model, Versioned JSON as the save format, with SVG and PDF as export targets, A log of operations and their inverses, not snapshots of the scene. 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 - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. ===== BUILD_PLAN.md ===== # Build plan · Linearity Curve A vector editor whose hard problems are one path representation that every operation can trust, boolean operations that stay undoable, and an export that reports what it had to throw away. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One path representation and nothing else Every curve in the document is a cubic Bezier, whatever the file it came from used. ### Steps 1. Normalise every segment type at import One representation is the decision the rest of the editor rests on. Files: `src/geometry/normalise.ts`, `src/io/svg-import.ts` 2. Convert arcs with a stated tolerance and record it An unrecorded tolerance makes a round trip failure unarguable. Files: `src/geometry/arc-to-cubic.ts`, `tests/arc-tolerance.test.ts` 3. Collapse transform lists into one matrix per node Nested transforms are where coordinates quietly stop matching. Files: `src/geometry/transform.ts` ### Done when - [ ] Mixed-command SVG imports to cubic segments only - [ ] A circle round trips within the recorded tolerance - [ ] Each node carries a single matrix - [ ] Open and closed subpaths stay distinct ### Watch out - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. ## Phase 2 · Direct manipulation that stays smooth Selecting, dragging and editing nodes runs against the document model at interactive speed. ### Steps 1. Hit test against geometry, never against pixels Opacity and device pixel ratio both break the pixel approach. Files: `src/interaction/hit-test.ts`, `tests/hit-test.test.ts` 2. Scale the pick tolerance by zoom A hairline has to stay clickable when you are zoomed out. Files: `src/interaction/hit-test.ts` 3. Redraw dirty rectangles only A full repaint per pointer move is what makes an editor feel cheap. Files: `src/render/dirty.ts` ### Done when - [ ] A transparent shape is selectable and a path hole is not - [ ] Pick tolerance scales with zoom - [ ] A drag repaints only the dirty rectangle - [ ] Snapping never alters the stored coordinate ### Watch out - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. ## Phase 3 · Boolean operations that stay undoable Union, subtract, intersect and exclude produce a node that still remembers its operands. ### Steps 1. Use a maintained boolean library Writing your own is how you meet tangential intersections. Files: `src/geometry/boolean.ts`, `package.json` 2. Keep operands as hidden children The operation stays editable for as long as the document lives. Files: `src/model/boolean-node.ts` 3. Make flattening explicit and undoable Destroying the operands should take a deliberate click. Files: `src/commands/flatten.ts`, `tests/boolean.test.ts` ### Done when - [ ] Editing an operand recomputes the boolean result - [ ] Operand order is stored and matters for subtraction - [ ] Tangential shapes produce a result rather than hanging - [ ] Flatten is explicit and appears in the undo log ### Watch out - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. ## Phase 4 · Undo as a log of inverses Every change is an operation with an inverse, and a drag is one entry rather than four hundred. ### Steps 1. Record an inverse per operation Not a snapshot. The difference is the whole memory budget. Files: `src/history/ops.ts` 2. Coalesce gestures on release One drag is one thing the user did. Files: `src/history/coalesce.ts` 3. Round trip a hundred operations in a property test Undo bugs hide in the combination, not the single case. Files: `tests/history.property.test.ts` ### Done when - [ ] One undo entry per drag - [ ] Undo restores the prior selection - [ ] A hundred mixed operations round trip exactly - [ ] Log memory stays under the stated ceiling ### Watch out - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. ## Not in this build - Real-time collaboration. - A text shaping and layout engine of your own. - Print colour, spot inks and separations. - A template and asset library. ## After v1, if you want it - Variable width strokes, which need the offsetting code to be correct before anything else is worth attempting - A plugin surface over the operation log, which is the only extension point that keeps undo working - Symbols and instances, which are a second tree over the same nodes and worth doing once booleans are settled ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where saved documents live. This is the data you cannot regenerate. DOCUMENT_DIR=./documents # Required. Maximum deviation in document units when converting arcs to cubics. Recorded in each document. ARC_TOLERANCE=0.01 # Required. Pick radius in document units before scaling by zoom. HIT_TOLERANCE_UNITS=3 # Required. Ceiling for the undo log. Exceeding it trims the oldest entries and says so. HISTORY_MAX_BYTES=64000000 # Required. Where export reports are written. One per export, including the clean ones. EXPORT_REPORT_DIR=./exports/reports
You are building a lean indie version of Linearity Curve. 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 ===== # Linearity Curve · indie build A vector editor whose hard problems are one path representation that every operation can trust, boolean operations that stay undoable, and an export that reports what it had to throw away. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application in TypeScript, document model kept separate from the renderer | the renderer is replaceable and the document is not | | Geometry | Cubic Beziers only, with arcs, quadratics and polylines converted at import | every operation gets written once instead of six times, five of them wrongly | | Boolean ops | A maintained path boolean library, not a sweep line of your own | tangential intersections are where hand-written boolean code loops forever | | Rendering | Canvas 2D with dirty rectangle redraw, hit testing against the model | picking by reading pixels breaks on opacity, strokes and high density displays | | Document | Versioned JSON as the save format, with SVG and PDF as export targets | SVG cannot express half of what a vector editor knows about a shape | | Undo | A log of operations and their inverses, not snapshots of the scene | snapshots of a large document eat memory and lose the selection | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer with TypeScript** · free - Why: Runs the editor and its test suite. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A maintained path boolean library** · free - Why: Boolean operations on Bezier paths are the part of this project most likely to produce quietly wrong geometry if written from scratch. - Get it: Choose a library with a test suite covering tangential and self-intersecting cases, and read its issue tracker before committing. - Verify: A fixture of two tangentially touching circles returns a result rather than hanging - [ ] **A set of awkward SVG files** · free - Why: Import correctness is only demonstrable against files that use the commands you would rather not implement. - Get it: Assemble files using elliptical arcs, smooth shorthands, nested transforms, even-odd fills and text, from your own work or from public domain artwork. - Verify: Each fixture imports and exports with a written report - [ ] **What the exporter does with a non-centred stroke** · free - Why: The target format cannot express it, so either you decide now or the exporter decides for you and nobody finds out until the artwork is printed. - Get it: Read the current SVG Strokes draft, confirm stroke-alignment is still not normative in SVG 2, and write down your rule. - Verify: The rule is in the README and asserted in a test ## 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: - Real-time collaboration. - A text shaping and layout engine of your own. - Print colour, spot inks and separations. - A template and asset library. - cloud collaboration and mobile apps - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases If one of those is essential to you, that is the reason to keep paying for Linearity Curve, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Linearity Curve 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 vector editor whose document model keeps what the file format cannot to replace Linearity Curve. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application in TypeScript, document model kept separate from the renderer · the renderer is replaceable and the document is not - Geometry: Cubic Beziers only, with arcs, quadratics and polylines converted at import · every operation gets written once instead of six times, five of them wrongly - Boolean ops: A maintained path boolean library, not a sweep line of your own · tangential intersections are where hand-written boolean code loops forever - Rendering: Canvas 2D with dirty rectangle redraw, hit testing against the model · picking by reading pixels breaks on opacity, strokes and high density displays - Document: Versioned JSON as the save format, with SVG and PDF as export targets · SVG cannot express half of what a vector editor knows about a shape - Undo: A log of operations and their inverses, not snapshots of the scene · snapshots of a large document eat memory and lose the selection ### Data model (create this before Phase 1) - documents(id, name, format_version, width, height, unit, created_at, updated_at) - nodes(id, document_id, parent_id, ordinal, kind, transform, visible, locked, name) · kind is group, path, text, image or boolean - paths(node_id, subpaths_json, fill_rule, closed) · cubic segments only, everything normalised into this shape at import - styles(node_id, fill_json, strokes_json, opacity, blend_mode) · a stroke carries width, alignment, caps, joins and dash pattern - boolean_ops(node_id, op, operand_order_json) · operands stay as hidden children so the operation is still undoable a year later - ops(id, document_id, ordinal, kind, payload_json, inverse_json) · undo replays inverses, and a drag is one coalesced entry - export_reports(id, document_id, target, at, dropped_json) · what the exporter could not represent, written every time you export - Stroke alignment is stored because the editor supports it and the export format does not. Export is where that gets resolved, loudly. ### Phase 1 · One path representation and nothing else Build: Import SVG and normalise: elliptical arcs, quadratics, smooth shorthands, horizontal and vertical lines and the primitive shapes all become cubic subpaths. Collapse each node's transform list into a single matrix. Record the tolerance used for arc conversion in the document so a later round trip can be compared against the same number. Trap: Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. Done when: An SVG fixture using A, Q, T, S, H and V commands plus rect, circle and polygon imports to cubic segments only, asserted over the parsed document. A circle imported and exported round trips within the recorded tolerance, measured by sampling both outlines. Every node carries one matrix rather than a transform list, checked after import. A path with a subpath that is not closed keeps that distinction, because fill rules depend on it. Do not build yet: No editing, boolean operations, undo or export yet. ### Phase 2 · Direct manipulation that stays smooth Build: Hit test a point against path geometry with a tolerance expressed in document units divided by zoom. Redraw only dirty rectangles. Support marquee selection, node editing, handles and snapping to a grid and to other objects' bounds. Trap: Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. Done when: Clicking a transparent shape selects it and clicking a hole in an even-odd path does not, both tested against the model rather than the canvas. Hit tolerance scales with zoom so a one unit stroke is selectable at ten percent and at a thousand percent. A drag of fifty nodes redraws only the affected rectangle, verified by instrumenting the redraw. Snapping can be turned off and the unsnapped coordinate is what gets stored. Do not build yet: No boolean operations, undo or export yet. ### Phase 3 · Boolean operations that stay undoable Build: Use a maintained path boolean library. Store the result as a boolean node whose operands remain as hidden children with their order recorded. Expose a flatten action that destroys the operands deliberately, and never do it automatically. Trap: Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. Done when: Editing an operand of a boolean node recomputes the result without the user re-applying the operation, tested. Operand order is stored and reversing it changes a subtraction, asserted. Two shapes that touch tangentially produce a result rather than hanging, tested with a fixture built for it. Flattening is a separate explicit action and is recorded in the undo log like any other. Do not build yet: No undo log, text handling or export yet. ### Phase 4 · Undo as a log of inverses Build: Model each mutation as an operation carrying enough to invert it. Coalesce continuous gestures into a single entry on release. Restore selection as part of undo, and persist the log with the document so reopening a file does not discard its history. Trap: Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. Done when: A drag of a node produces one undo entry, not one per pointer move, asserted by counting log entries. Undo restores the selection that existed before the operation, tested. Undoing and redoing a hundred mixed operations returns the document to a byte-identical serialisation, tested as a property. Memory used by the log for a stated large document stays under a stated ceiling, measured. Do not build yet: No export or document format migration yet. ### Phase 5 · Export that tells you what it dropped Build: Write an export report on every export listing each document feature that had to be approximated or lost, with the node names. Resolve inner and outer strokes by outlining the path. Decide per export whether text stays as text or becomes outlines, and say which. Keep the versioned JSON document as the thing you actually save. Trap: A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. Trap: Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it. Done when: Exporting a shape with a non-centred stroke outlines it and the export report names that node, tested. Every export writes a report row listing dropped or approximated features, and an export with nothing lost says so explicitly. Re-importing an exported SVG produces a document that rasterises to within a stated pixel difference of the original, tested. Opening a document written by an earlier format version still works, tested against a stored fixture from the first release. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaboration, which is a different architecture entirely and would decide the document model rather than sit on top of it. - A text shaping and layout engine of your own, because ligatures, bidirectional text and complex scripts are years of work that the platform already does. - Print colour, spot inks and separations, which need a colour management pipeline and profiles rather than a weekend of code. - A template and asset library, which is a content business and not an engineering one. ### README must contain - That the save format is the versioned JSON document and that SVG and PDF are exports, not saves - That inner and outer strokes cannot be expressed in SVG and are outlined on export, with the report naming which nodes - That every curve is stored as a cubic Bezier and the tolerance used to convert arcs at import - That boolean operations keep their operands and that flattening is a deliberate action - That undo is a log of inverse operations, what it does not cover, and where it is persisted ===== AGENTS.md ===== # Agent instructions · Linearity Curve indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application in TypeScript, document model kept separate from the renderer, Cubic Beziers only, with arcs, quadratics and polylines converted at import, A maintained path boolean library, not a sweep line of your own, Canvas 2D with dirty rectangle redraw, hit testing against the model, Versioned JSON as the save format, with SVG and PDF as export targets, A log of operations and their inverses, not snapshots of the scene. 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 - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. ===== BUILD_PLAN.md ===== # Build plan · Linearity Curve A vector editor whose hard problems are one path representation that every operation can trust, boolean operations that stay undoable, and an export that reports what it had to throw away. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One path representation and nothing else Every curve in the document is a cubic Bezier, whatever the file it came from used. ### Steps 1. Normalise every segment type at import One representation is the decision the rest of the editor rests on. Files: `src/geometry/normalise.ts`, `src/io/svg-import.ts` 2. Convert arcs with a stated tolerance and record it An unrecorded tolerance makes a round trip failure unarguable. Files: `src/geometry/arc-to-cubic.ts`, `tests/arc-tolerance.test.ts` 3. Collapse transform lists into one matrix per node Nested transforms are where coordinates quietly stop matching. Files: `src/geometry/transform.ts` ### Done when - [ ] Mixed-command SVG imports to cubic segments only - [ ] A circle round trips within the recorded tolerance - [ ] Each node carries a single matrix - [ ] Open and closed subpaths stay distinct ### Watch out - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. ## Phase 2 · Direct manipulation that stays smooth Selecting, dragging and editing nodes runs against the document model at interactive speed. ### Steps 1. Hit test against geometry, never against pixels Opacity and device pixel ratio both break the pixel approach. Files: `src/interaction/hit-test.ts`, `tests/hit-test.test.ts` 2. Scale the pick tolerance by zoom A hairline has to stay clickable when you are zoomed out. Files: `src/interaction/hit-test.ts` 3. Redraw dirty rectangles only A full repaint per pointer move is what makes an editor feel cheap. Files: `src/render/dirty.ts` ### Done when - [ ] A transparent shape is selectable and a path hole is not - [ ] Pick tolerance scales with zoom - [ ] A drag repaints only the dirty rectangle - [ ] Snapping never alters the stored coordinate ### Watch out - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. ## Phase 3 · Boolean operations that stay undoable Union, subtract, intersect and exclude produce a node that still remembers its operands. ### Steps 1. Use a maintained boolean library Writing your own is how you meet tangential intersections. Files: `src/geometry/boolean.ts`, `package.json` 2. Keep operands as hidden children The operation stays editable for as long as the document lives. Files: `src/model/boolean-node.ts` 3. Make flattening explicit and undoable Destroying the operands should take a deliberate click. Files: `src/commands/flatten.ts`, `tests/boolean.test.ts` ### Done when - [ ] Editing an operand recomputes the boolean result - [ ] Operand order is stored and matters for subtraction - [ ] Tangential shapes produce a result rather than hanging - [ ] Flatten is explicit and appears in the undo log ### Watch out - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. ## Phase 4 · Undo as a log of inverses Every change is an operation with an inverse, and a drag is one entry rather than four hundred. ### Steps 1. Record an inverse per operation Not a snapshot. The difference is the whole memory budget. Files: `src/history/ops.ts` 2. Coalesce gestures on release One drag is one thing the user did. Files: `src/history/coalesce.ts` 3. Round trip a hundred operations in a property test Undo bugs hide in the combination, not the single case. Files: `tests/history.property.test.ts` ### Done when - [ ] One undo entry per drag - [ ] Undo restores the prior selection - [ ] A hundred mixed operations round trip exactly - [ ] Log memory stays under the stated ceiling ### Watch out - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. ## Not in this build - Real-time collaboration. - A text shaping and layout engine of your own. - Print colour, spot inks and separations. - A template and asset library. ## After v1, if you want it - Variable width strokes, which need the offsetting code to be correct before anything else is worth attempting - A plugin surface over the operation log, which is the only extension point that keeps undo working - Symbols and instances, which are a second tree over the same nodes and worth doing once booleans are settled ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where saved documents live. This is the data you cannot regenerate. DOCUMENT_DIR=./documents # Required. Maximum deviation in document units when converting arcs to cubics. Recorded in each document. ARC_TOLERANCE=0.01 # Required. Pick radius in document units before scaling by zoom. HIT_TOLERANCE_UNITS=3 # Required. Ceiling for the undo log. Exceeding it trims the oldest entries and says so. HISTORY_MAX_BYTES=64000000 # Required. Where export reports are written. One per export, including the clean ones. EXPORT_REPORT_DIR=./exports/reports
You are building a production product version of Linearity Curve. 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 ===== # Linearity Curve · product brief ## Problem The visible vector design loop is buildable, but a credible replacement needs more than the first screen. Linearity Curve earns its keep through editor polish, assets, algorithms, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A vector editor with one internal path representation, boolean operations that stay editable, undo that costs inverses rather than snapshots, and an export that names everything it could not carry across. ## 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 - Real-time collaboration. - A text shaping and layout engine of your own. - Print colour, spot inks and separations. - A template and asset library. - cloud collaboration and mobile apps - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases ## Success criteria - Mixed-command SVG is confirmed to import as cubics only - Hit testing is confirmed to run against geometry rather than pixels - Boolean operands are confirmed to survive the operation - A drag is confirmed to produce one undo entry - Every export is confirmed to write a loss report naming affected nodes ===== BRIEF.md ===== # Build brief · Linearity Curve 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 vector editor whose document model keeps what the file format cannot to replace Linearity Curve. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application in TypeScript, document model kept separate from the renderer · the renderer is replaceable and the document is not - Geometry: Cubic Beziers only, with arcs, quadratics and polylines converted at import · every operation gets written once instead of six times, five of them wrongly - Boolean ops: A maintained path boolean library, not a sweep line of your own · tangential intersections are where hand-written boolean code loops forever - Rendering: Canvas 2D with dirty rectangle redraw, hit testing against the model · picking by reading pixels breaks on opacity, strokes and high density displays - Document: Versioned JSON as the save format, with SVG and PDF as export targets · SVG cannot express half of what a vector editor knows about a shape - Undo: A log of operations and their inverses, not snapshots of the scene · snapshots of a large document eat memory and lose the selection ### Data model (create this before Phase 1) - documents(id, name, format_version, width, height, unit, created_at, updated_at) - nodes(id, document_id, parent_id, ordinal, kind, transform, visible, locked, name) · kind is group, path, text, image or boolean - paths(node_id, subpaths_json, fill_rule, closed) · cubic segments only, everything normalised into this shape at import - styles(node_id, fill_json, strokes_json, opacity, blend_mode) · a stroke carries width, alignment, caps, joins and dash pattern - boolean_ops(node_id, op, operand_order_json) · operands stay as hidden children so the operation is still undoable a year later - ops(id, document_id, ordinal, kind, payload_json, inverse_json) · undo replays inverses, and a drag is one coalesced entry - export_reports(id, document_id, target, at, dropped_json) · what the exporter could not represent, written every time you export - Stroke alignment is stored because the editor supports it and the export format does not. Export is where that gets resolved, loudly. ### Phase 1 · One path representation and nothing else Build: Import SVG and normalise: elliptical arcs, quadratics, smooth shorthands, horizontal and vertical lines and the primitive shapes all become cubic subpaths. Collapse each node's transform list into a single matrix. Record the tolerance used for arc conversion in the document so a later round trip can be compared against the same number. Trap: Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. Done when: An SVG fixture using A, Q, T, S, H and V commands plus rect, circle and polygon imports to cubic segments only, asserted over the parsed document. A circle imported and exported round trips within the recorded tolerance, measured by sampling both outlines. Every node carries one matrix rather than a transform list, checked after import. A path with a subpath that is not closed keeps that distinction, because fill rules depend on it. Do not build yet: No editing, boolean operations, undo or export yet. ### Phase 2 · Direct manipulation that stays smooth Build: Hit test a point against path geometry with a tolerance expressed in document units divided by zoom. Redraw only dirty rectangles. Support marquee selection, node editing, handles and snapping to a grid and to other objects' bounds. Trap: Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. Done when: Clicking a transparent shape selects it and clicking a hole in an even-odd path does not, both tested against the model rather than the canvas. Hit tolerance scales with zoom so a one unit stroke is selectable at ten percent and at a thousand percent. A drag of fifty nodes redraws only the affected rectangle, verified by instrumenting the redraw. Snapping can be turned off and the unsnapped coordinate is what gets stored. Do not build yet: No boolean operations, undo or export yet. ### Phase 3 · Boolean operations that stay undoable Build: Use a maintained path boolean library. Store the result as a boolean node whose operands remain as hidden children with their order recorded. Expose a flatten action that destroys the operands deliberately, and never do it automatically. Trap: Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. Done when: Editing an operand of a boolean node recomputes the result without the user re-applying the operation, tested. Operand order is stored and reversing it changes a subtraction, asserted. Two shapes that touch tangentially produce a result rather than hanging, tested with a fixture built for it. Flattening is a separate explicit action and is recorded in the undo log like any other. Do not build yet: No undo log, text handling or export yet. ### Phase 4 · Undo as a log of inverses Build: Model each mutation as an operation carrying enough to invert it. Coalesce continuous gestures into a single entry on release. Restore selection as part of undo, and persist the log with the document so reopening a file does not discard its history. Trap: Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. Done when: A drag of a node produces one undo entry, not one per pointer move, asserted by counting log entries. Undo restores the selection that existed before the operation, tested. Undoing and redoing a hundred mixed operations returns the document to a byte-identical serialisation, tested as a property. Memory used by the log for a stated large document stays under a stated ceiling, measured. Do not build yet: No export or document format migration yet. ### Phase 5 · Export that tells you what it dropped Build: Write an export report on every export listing each document feature that had to be approximated or lost, with the node names. Resolve inner and outer strokes by outlining the path. Decide per export whether text stays as text or becomes outlines, and say which. Keep the versioned JSON document as the thing you actually save. Trap: A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. Trap: Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it. Done when: Exporting a shape with a non-centred stroke outlines it and the export report names that node, tested. Every export writes a report row listing dropped or approximated features, and an export with nothing lost says so explicitly. Re-importing an exported SVG produces a document that rasterises to within a stated pixel difference of the original, tested. Opening a document written by an earlier format version still works, tested against a stored fixture from the first release. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaboration, which is a different architecture entirely and would decide the document model rather than sit on top of it. - A text shaping and layout engine of your own, because ligatures, bidirectional text and complex scripts are years of work that the platform already does. - Print colour, spot inks and separations, which need a colour management pipeline and profiles rather than a weekend of code. - A template and asset library, which is a content business and not an engineering one. ### README must contain - That the save format is the versioned JSON document and that SVG and PDF are exports, not saves - That inner and outer strokes cannot be expressed in SVG and are outlined on export, with the report naming which nodes - That every curve is stored as a cubic Bezier and the tolerance used to convert arcs at import - That boolean operations keep their operands and that flattening is a deliberate action - That undo is a log of inverse operations, what it does not cover, and where it is persisted ===== ARCHITECTURE.md ===== # Architecture · Linearity Curve ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application in TypeScript, document model kept separate from the renderer | the renderer is replaceable and the document is not | | Geometry | Cubic Beziers only, with arcs, quadratics and polylines converted at import | every operation gets written once instead of six times, five of them wrongly | | Boolean ops | A maintained path boolean library, not a sweep line of your own | tangential intersections are where hand-written boolean code loops forever | | Rendering | Canvas 2D with dirty rectangle redraw, hit testing against the model | picking by reading pixels breaks on opacity, strokes and high density displays | | Document | Versioned JSON as the save format, with SVG and PDF as export targets | SVG cannot express half of what a vector editor knows about a shape | | Undo | A log of operations and their inverses, not snapshots of the scene | snapshots of a large document eat memory and lose the selection | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | Normalising every incoming segment type to cubics. | Any parser, provided nothing but cubics reaches the document. | | Model | Nodes, paths, styles and the boolean tree. | Any store, provided operands survive their operations. | | Interaction | Hit testing, selection, snapping and dirty redraw. | Any renderer, provided picking never touches pixels. | | History | Operations, inverses and coalescing. | Any implementation, provided it is not document snapshots. | | Export | Target adapters and the loss report. | Any target, provided it writes a report naming what it dropped. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DOCUMENT_DIR` · required · Where saved documents live. This is the data you cannot regenerate. - `ARC_TOLERANCE` · required · Maximum deviation in document units when converting arcs to cubics. Recorded in each document. - `HIT_TOLERANCE_UNITS` · required · Pick radius in document units before scaling by zoom. - `HISTORY_MAX_BYTES` · required · Ceiling for the undo log. Exceeding it trims the oldest entries and says so. - `EXPORT_REPORT_DIR` · required · Where export reports are written. One per export, including the clean ones. ## 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 · Linearity Curve product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A desktop application in TypeScript, document model kept separate from the renderer, Cubic Beziers only, with arcs, quadratics and polylines converted at import, A maintained path boolean library, not a sweep line of your own, Canvas 2D with dirty rectangle redraw, hit testing against the model, Versioned JSON as the save format, with SVG and PDF as export targets, A log of operations and their inverses, not snapshots of the scene. - 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 - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. - A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. - Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it. ===== MILESTONES.md ===== # Delivery milestones · Linearity Curve Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One path representation and nothing else Every curve in the document is a cubic Bezier, whatever the file it came from used. ### Steps 1. Normalise every segment type at import One representation is the decision the rest of the editor rests on. Files: `src/geometry/normalise.ts`, `src/io/svg-import.ts` 2. Convert arcs with a stated tolerance and record it An unrecorded tolerance makes a round trip failure unarguable. Files: `src/geometry/arc-to-cubic.ts`, `tests/arc-tolerance.test.ts` 3. Collapse transform lists into one matrix per node Nested transforms are where coordinates quietly stop matching. Files: `src/geometry/transform.ts` ### Done when - [ ] Mixed-command SVG imports to cubic segments only - [ ] A circle round trips within the recorded tolerance - [ ] Each node carries a single matrix - [ ] Open and closed subpaths stay distinct ### Watch out - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. ## M2 · Direct manipulation that stays smooth Selecting, dragging and editing nodes runs against the document model at interactive speed. ### Steps 1. Hit test against geometry, never against pixels Opacity and device pixel ratio both break the pixel approach. Files: `src/interaction/hit-test.ts`, `tests/hit-test.test.ts` 2. Scale the pick tolerance by zoom A hairline has to stay clickable when you are zoomed out. Files: `src/interaction/hit-test.ts` 3. Redraw dirty rectangles only A full repaint per pointer move is what makes an editor feel cheap. Files: `src/render/dirty.ts` ### Done when - [ ] A transparent shape is selectable and a path hole is not - [ ] Pick tolerance scales with zoom - [ ] A drag repaints only the dirty rectangle - [ ] Snapping never alters the stored coordinate ### Watch out - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. ## M3 · Boolean operations that stay undoable Union, subtract, intersect and exclude produce a node that still remembers its operands. ### Steps 1. Use a maintained boolean library Writing your own is how you meet tangential intersections. Files: `src/geometry/boolean.ts`, `package.json` 2. Keep operands as hidden children The operation stays editable for as long as the document lives. Files: `src/model/boolean-node.ts` 3. Make flattening explicit and undoable Destroying the operands should take a deliberate click. Files: `src/commands/flatten.ts`, `tests/boolean.test.ts` ### Done when - [ ] Editing an operand recomputes the boolean result - [ ] Operand order is stored and matters for subtraction - [ ] Tangential shapes produce a result rather than hanging - [ ] Flatten is explicit and appears in the undo log ### Watch out - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. ## M4 · Undo as a log of inverses Every change is an operation with an inverse, and a drag is one entry rather than four hundred. ### Steps 1. Record an inverse per operation Not a snapshot. The difference is the whole memory budget. Files: `src/history/ops.ts` 2. Coalesce gestures on release One drag is one thing the user did. Files: `src/history/coalesce.ts` 3. Round trip a hundred operations in a property test Undo bugs hide in the combination, not the single case. Files: `tests/history.property.test.ts` ### Done when - [ ] One undo entry per drag - [ ] Undo restores the prior selection - [ ] A hundred mixed operations round trip exactly - [ ] Log memory stays under the stated ceiling ### Watch out - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. ## M5 · Export that tells you what it dropped (production only) SVG, PDF and raster export work, and every feature the target cannot express is reported by name. ### Steps 1. Outline non-centred strokes and report it The format has no way to say inside or outside. Files: `src/io/svg-export.ts`, `tests/stroke-alignment.test.ts` 2. Write an export report every time Silent loss is what makes export untrustworthy. Files: `src/io/export-report.ts` 3. Keep an old-format fixture in the suite A format version you cannot open is a document you lost. Files: `tests/fixtures/v1.doc.json`, `tests/migrate.test.ts` ### Done when - [ ] Non-centred strokes are outlined and reported - [ ] Every export writes a report, including an empty one - [ ] An exported SVG re-imports within the pixel tolerance - [ ] A first-release document still opens ### Watch out - A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. - Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it. ===== OPERATIONS.md ===== # Operations · Linearity Curve ## Backup The document directory is the whole product. Back it up on a schedule and keep generations, because a vector document is edited over months and a corruption is noticed long after it happened. ## Restore Restore the directory, open one document from each format version you have ever written, and confirm the export report for a known file matches what it said before. A restore that opens nothing older than last month has not been tested. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a local editor the monitoring lives in the suite: round trip fidelity against the fixtures, undo log memory for a large document, and boolean results for the tangential cases. Run them on every change to the geometry code. ## Incident checklist The incident here is a file that will not open after a format change, or artwork that came back from a printer half a stroke width wrong. The format version field and the export reports are the two records that make either of those diagnosable rather than mysterious. 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 - [ ] Mixed-command SVG is confirmed to import as cubics only - [ ] Hit testing is confirmed to run against geometry rather than pixels - [ ] Boolean operands are confirmed to survive the operation - [ ] A drag is confirmed to produce one undo entry - [ ] Every export is confirmed to write a loss report naming affected nodes ## Launch constraint Do not market omitted Linearity Curve capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where saved documents live. This is the data you cannot regenerate. DOCUMENT_DIR=./documents # Required. Maximum deviation in document units when converting arcs to cubics. Recorded in each document. ARC_TOLERANCE=0.01 # Required. Pick radius in document units before scaling by zoom. HIT_TOLERANCE_UNITS=3 # Required. Ceiling for the undo log. Exceeding it trims the oldest entries and says so. HISTORY_MAX_BYTES=64000000 # Required. Where export reports are written. One per export, including the clean ones. EXPORT_REPORT_DIR=./exports/reports
# Linearity Curve · indie build A vector editor whose hard problems are one path representation that every operation can trust, boolean operations that stay undoable, and an export that reports what it had to throw away. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application in TypeScript, document model kept separate from the renderer | the renderer is replaceable and the document is not | | Geometry | Cubic Beziers only, with arcs, quadratics and polylines converted at import | every operation gets written once instead of six times, five of them wrongly | | Boolean ops | A maintained path boolean library, not a sweep line of your own | tangential intersections are where hand-written boolean code loops forever | | Rendering | Canvas 2D with dirty rectangle redraw, hit testing against the model | picking by reading pixels breaks on opacity, strokes and high density displays | | Document | Versioned JSON as the save format, with SVG and PDF as export targets | SVG cannot express half of what a vector editor knows about a shape | | Undo | A log of operations and their inverses, not snapshots of the scene | snapshots of a large document eat memory and lose the selection | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer with TypeScript** · free - Why: Runs the editor and its test suite. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A maintained path boolean library** · free - Why: Boolean operations on Bezier paths are the part of this project most likely to produce quietly wrong geometry if written from scratch. - Get it: Choose a library with a test suite covering tangential and self-intersecting cases, and read its issue tracker before committing. - Verify: A fixture of two tangentially touching circles returns a result rather than hanging - [ ] **A set of awkward SVG files** · free - Why: Import correctness is only demonstrable against files that use the commands you would rather not implement. - Get it: Assemble files using elliptical arcs, smooth shorthands, nested transforms, even-odd fills and text, from your own work or from public domain artwork. - Verify: Each fixture imports and exports with a written report - [ ] **What the exporter does with a non-centred stroke** · free - Why: The target format cannot express it, so either you decide now or the exporter decides for you and nobody finds out until the artwork is printed. - Get it: Read the current SVG Strokes draft, confirm stroke-alignment is still not normative in SVG 2, and write down your rule. - Verify: The rule is in the README and asserted in a test ## 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: - Real-time collaboration. - A text shaping and layout engine of your own. - Print colour, spot inks and separations. - A template and asset library. - cloud collaboration and mobile apps - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases If one of those is essential to you, that is the reason to keep paying for Linearity Curve, and the README should say so rather than pretend.
# Build brief · Linearity Curve 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 vector editor whose document model keeps what the file format cannot to replace Linearity Curve. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application in TypeScript, document model kept separate from the renderer · the renderer is replaceable and the document is not - Geometry: Cubic Beziers only, with arcs, quadratics and polylines converted at import · every operation gets written once instead of six times, five of them wrongly - Boolean ops: A maintained path boolean library, not a sweep line of your own · tangential intersections are where hand-written boolean code loops forever - Rendering: Canvas 2D with dirty rectangle redraw, hit testing against the model · picking by reading pixels breaks on opacity, strokes and high density displays - Document: Versioned JSON as the save format, with SVG and PDF as export targets · SVG cannot express half of what a vector editor knows about a shape - Undo: A log of operations and their inverses, not snapshots of the scene · snapshots of a large document eat memory and lose the selection ### Data model (create this before Phase 1) - documents(id, name, format_version, width, height, unit, created_at, updated_at) - nodes(id, document_id, parent_id, ordinal, kind, transform, visible, locked, name) · kind is group, path, text, image or boolean - paths(node_id, subpaths_json, fill_rule, closed) · cubic segments only, everything normalised into this shape at import - styles(node_id, fill_json, strokes_json, opacity, blend_mode) · a stroke carries width, alignment, caps, joins and dash pattern - boolean_ops(node_id, op, operand_order_json) · operands stay as hidden children so the operation is still undoable a year later - ops(id, document_id, ordinal, kind, payload_json, inverse_json) · undo replays inverses, and a drag is one coalesced entry - export_reports(id, document_id, target, at, dropped_json) · what the exporter could not represent, written every time you export - Stroke alignment is stored because the editor supports it and the export format does not. Export is where that gets resolved, loudly. ### Phase 1 · One path representation and nothing else Build: Import SVG and normalise: elliptical arcs, quadratics, smooth shorthands, horizontal and vertical lines and the primitive shapes all become cubic subpaths. Collapse each node's transform list into a single matrix. Record the tolerance used for arc conversion in the document so a later round trip can be compared against the same number. Trap: Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. Done when: An SVG fixture using A, Q, T, S, H and V commands plus rect, circle and polygon imports to cubic segments only, asserted over the parsed document. A circle imported and exported round trips within the recorded tolerance, measured by sampling both outlines. Every node carries one matrix rather than a transform list, checked after import. A path with a subpath that is not closed keeps that distinction, because fill rules depend on it. Do not build yet: No editing, boolean operations, undo or export yet. ### Phase 2 · Direct manipulation that stays smooth Build: Hit test a point against path geometry with a tolerance expressed in document units divided by zoom. Redraw only dirty rectangles. Support marquee selection, node editing, handles and snapping to a grid and to other objects' bounds. Trap: Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. Done when: Clicking a transparent shape selects it and clicking a hole in an even-odd path does not, both tested against the model rather than the canvas. Hit tolerance scales with zoom so a one unit stroke is selectable at ten percent and at a thousand percent. A drag of fifty nodes redraws only the affected rectangle, verified by instrumenting the redraw. Snapping can be turned off and the unsnapped coordinate is what gets stored. Do not build yet: No boolean operations, undo or export yet. ### Phase 3 · Boolean operations that stay undoable Build: Use a maintained path boolean library. Store the result as a boolean node whose operands remain as hidden children with their order recorded. Expose a flatten action that destroys the operands deliberately, and never do it automatically. Trap: Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. Done when: Editing an operand of a boolean node recomputes the result without the user re-applying the operation, tested. Operand order is stored and reversing it changes a subtraction, asserted. Two shapes that touch tangentially produce a result rather than hanging, tested with a fixture built for it. Flattening is a separate explicit action and is recorded in the undo log like any other. Do not build yet: No undo log, text handling or export yet. ### Phase 4 · Undo as a log of inverses Build: Model each mutation as an operation carrying enough to invert it. Coalesce continuous gestures into a single entry on release. Restore selection as part of undo, and persist the log with the document so reopening a file does not discard its history. Trap: Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. Done when: A drag of a node produces one undo entry, not one per pointer move, asserted by counting log entries. Undo restores the selection that existed before the operation, tested. Undoing and redoing a hundred mixed operations returns the document to a byte-identical serialisation, tested as a property. Memory used by the log for a stated large document stays under a stated ceiling, measured. Do not build yet: No export or document format migration yet. ### Phase 5 · Export that tells you what it dropped Build: Write an export report on every export listing each document feature that had to be approximated or lost, with the node names. Resolve inner and outer strokes by outlining the path. Decide per export whether text stays as text or becomes outlines, and say which. Keep the versioned JSON document as the thing you actually save. Trap: A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. Trap: Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it. Done when: Exporting a shape with a non-centred stroke outlines it and the export report names that node, tested. Every export writes a report row listing dropped or approximated features, and an export with nothing lost says so explicitly. Re-importing an exported SVG produces a document that rasterises to within a stated pixel difference of the original, tested. Opening a document written by an earlier format version still works, tested against a stored fixture from the first release. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaboration, which is a different architecture entirely and would decide the document model rather than sit on top of it. - A text shaping and layout engine of your own, because ligatures, bidirectional text and complex scripts are years of work that the platform already does. - Print colour, spot inks and separations, which need a colour management pipeline and profiles rather than a weekend of code. - A template and asset library, which is a content business and not an engineering one. ### README must contain - That the save format is the versioned JSON document and that SVG and PDF are exports, not saves - That inner and outer strokes cannot be expressed in SVG and are outlined on export, with the report naming which nodes - That every curve is stored as a cubic Bezier and the tolerance used to convert arcs at import - That boolean operations keep their operands and that flattening is a deliberate action - That undo is a log of inverse operations, what it does not cover, and where it is persisted
# Agent instructions · Linearity Curve indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application in TypeScript, document model kept separate from the renderer, Cubic Beziers only, with arcs, quadratics and polylines converted at import, A maintained path boolean library, not a sweep line of your own, Canvas 2D with dirty rectangle redraw, hit testing against the model, Versioned JSON as the save format, with SVG and PDF as export targets, A log of operations and their inverses, not snapshots of the scene. 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 - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released.
# Build plan · Linearity Curve A vector editor whose hard problems are one path representation that every operation can trust, boolean operations that stay undoable, and an export that reports what it had to throw away. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One path representation and nothing else Every curve in the document is a cubic Bezier, whatever the file it came from used. ### Steps 1. Normalise every segment type at import One representation is the decision the rest of the editor rests on. Files: `src/geometry/normalise.ts`, `src/io/svg-import.ts` 2. Convert arcs with a stated tolerance and record it An unrecorded tolerance makes a round trip failure unarguable. Files: `src/geometry/arc-to-cubic.ts`, `tests/arc-tolerance.test.ts` 3. Collapse transform lists into one matrix per node Nested transforms are where coordinates quietly stop matching. Files: `src/geometry/transform.ts` ### Done when - [ ] Mixed-command SVG imports to cubic segments only - [ ] A circle round trips within the recorded tolerance - [ ] Each node carries a single matrix - [ ] Open and closed subpaths stay distinct ### Watch out - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. ## Phase 2 · Direct manipulation that stays smooth Selecting, dragging and editing nodes runs against the document model at interactive speed. ### Steps 1. Hit test against geometry, never against pixels Opacity and device pixel ratio both break the pixel approach. Files: `src/interaction/hit-test.ts`, `tests/hit-test.test.ts` 2. Scale the pick tolerance by zoom A hairline has to stay clickable when you are zoomed out. Files: `src/interaction/hit-test.ts` 3. Redraw dirty rectangles only A full repaint per pointer move is what makes an editor feel cheap. Files: `src/render/dirty.ts` ### Done when - [ ] A transparent shape is selectable and a path hole is not - [ ] Pick tolerance scales with zoom - [ ] A drag repaints only the dirty rectangle - [ ] Snapping never alters the stored coordinate ### Watch out - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. ## Phase 3 · Boolean operations that stay undoable Union, subtract, intersect and exclude produce a node that still remembers its operands. ### Steps 1. Use a maintained boolean library Writing your own is how you meet tangential intersections. Files: `src/geometry/boolean.ts`, `package.json` 2. Keep operands as hidden children The operation stays editable for as long as the document lives. Files: `src/model/boolean-node.ts` 3. Make flattening explicit and undoable Destroying the operands should take a deliberate click. Files: `src/commands/flatten.ts`, `tests/boolean.test.ts` ### Done when - [ ] Editing an operand recomputes the boolean result - [ ] Operand order is stored and matters for subtraction - [ ] Tangential shapes produce a result rather than hanging - [ ] Flatten is explicit and appears in the undo log ### Watch out - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. ## Phase 4 · Undo as a log of inverses Every change is an operation with an inverse, and a drag is one entry rather than four hundred. ### Steps 1. Record an inverse per operation Not a snapshot. The difference is the whole memory budget. Files: `src/history/ops.ts` 2. Coalesce gestures on release One drag is one thing the user did. Files: `src/history/coalesce.ts` 3. Round trip a hundred operations in a property test Undo bugs hide in the combination, not the single case. Files: `tests/history.property.test.ts` ### Done when - [ ] One undo entry per drag - [ ] Undo restores the prior selection - [ ] A hundred mixed operations round trip exactly - [ ] Log memory stays under the stated ceiling ### Watch out - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. ## Not in this build - Real-time collaboration. - A text shaping and layout engine of your own. - Print colour, spot inks and separations. - A template and asset library. ## After v1, if you want it - Variable width strokes, which need the offsetting code to be correct before anything else is worth attempting - A plugin surface over the operation log, which is the only extension point that keeps undo working - Symbols and instances, which are a second tree over the same nodes and worth doing once booleans are settled
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where saved documents live. This is the data you cannot regenerate. DOCUMENT_DIR=./documents # Required. Maximum deviation in document units when converting arcs to cubics. Recorded in each document. ARC_TOLERANCE=0.01 # Required. Pick radius in document units before scaling by zoom. HIT_TOLERANCE_UNITS=3 # Required. Ceiling for the undo log. Exceeding it trims the oldest entries and says so. HISTORY_MAX_BYTES=64000000 # Required. Where export reports are written. One per export, including the clean ones. EXPORT_REPORT_DIR=./exports/reports
# Linearity Curve · product brief ## Problem The visible vector design loop is buildable, but a credible replacement needs more than the first screen. Linearity Curve earns its keep through editor polish, assets, algorithms, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A vector editor with one internal path representation, boolean operations that stay editable, undo that costs inverses rather than snapshots, and an export that names everything it could not carry across. ## 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 - Real-time collaboration. - A text shaping and layout engine of your own. - Print colour, spot inks and separations. - A template and asset library. - cloud collaboration and mobile apps - pixel-perfect professional tooling - large template and asset libraries - color-management edge cases ## Success criteria - Mixed-command SVG is confirmed to import as cubics only - Hit testing is confirmed to run against geometry rather than pixels - Boolean operands are confirmed to survive the operation - A drag is confirmed to produce one undo entry - Every export is confirmed to write a loss report naming affected nodes
# Build brief · Linearity Curve 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 vector editor whose document model keeps what the file format cannot to replace Linearity Curve. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application in TypeScript, document model kept separate from the renderer · the renderer is replaceable and the document is not - Geometry: Cubic Beziers only, with arcs, quadratics and polylines converted at import · every operation gets written once instead of six times, five of them wrongly - Boolean ops: A maintained path boolean library, not a sweep line of your own · tangential intersections are where hand-written boolean code loops forever - Rendering: Canvas 2D with dirty rectangle redraw, hit testing against the model · picking by reading pixels breaks on opacity, strokes and high density displays - Document: Versioned JSON as the save format, with SVG and PDF as export targets · SVG cannot express half of what a vector editor knows about a shape - Undo: A log of operations and their inverses, not snapshots of the scene · snapshots of a large document eat memory and lose the selection ### Data model (create this before Phase 1) - documents(id, name, format_version, width, height, unit, created_at, updated_at) - nodes(id, document_id, parent_id, ordinal, kind, transform, visible, locked, name) · kind is group, path, text, image or boolean - paths(node_id, subpaths_json, fill_rule, closed) · cubic segments only, everything normalised into this shape at import - styles(node_id, fill_json, strokes_json, opacity, blend_mode) · a stroke carries width, alignment, caps, joins and dash pattern - boolean_ops(node_id, op, operand_order_json) · operands stay as hidden children so the operation is still undoable a year later - ops(id, document_id, ordinal, kind, payload_json, inverse_json) · undo replays inverses, and a drag is one coalesced entry - export_reports(id, document_id, target, at, dropped_json) · what the exporter could not represent, written every time you export - Stroke alignment is stored because the editor supports it and the export format does not. Export is where that gets resolved, loudly. ### Phase 1 · One path representation and nothing else Build: Import SVG and normalise: elliptical arcs, quadratics, smooth shorthands, horizontal and vertical lines and the primitive shapes all become cubic subpaths. Collapse each node's transform list into a single matrix. Record the tolerance used for arc conversion in the document so a later round trip can be compared against the same number. Trap: Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. Done when: An SVG fixture using A, Q, T, S, H and V commands plus rect, circle and polygon imports to cubic segments only, asserted over the parsed document. A circle imported and exported round trips within the recorded tolerance, measured by sampling both outlines. Every node carries one matrix rather than a transform list, checked after import. A path with a subpath that is not closed keeps that distinction, because fill rules depend on it. Do not build yet: No editing, boolean operations, undo or export yet. ### Phase 2 · Direct manipulation that stays smooth Build: Hit test a point against path geometry with a tolerance expressed in document units divided by zoom. Redraw only dirty rectangles. Support marquee selection, node editing, handles and snapping to a grid and to other objects' bounds. Trap: Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. Done when: Clicking a transparent shape selects it and clicking a hole in an even-odd path does not, both tested against the model rather than the canvas. Hit tolerance scales with zoom so a one unit stroke is selectable at ten percent and at a thousand percent. A drag of fifty nodes redraws only the affected rectangle, verified by instrumenting the redraw. Snapping can be turned off and the unsnapped coordinate is what gets stored. Do not build yet: No boolean operations, undo or export yet. ### Phase 3 · Boolean operations that stay undoable Build: Use a maintained path boolean library. Store the result as a boolean node whose operands remain as hidden children with their order recorded. Expose a flatten action that destroys the operands deliberately, and never do it automatically. Trap: Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. Done when: Editing an operand of a boolean node recomputes the result without the user re-applying the operation, tested. Operand order is stored and reversing it changes a subtraction, asserted. Two shapes that touch tangentially produce a result rather than hanging, tested with a fixture built for it. Flattening is a separate explicit action and is recorded in the undo log like any other. Do not build yet: No undo log, text handling or export yet. ### Phase 4 · Undo as a log of inverses Build: Model each mutation as an operation carrying enough to invert it. Coalesce continuous gestures into a single entry on release. Restore selection as part of undo, and persist the log with the document so reopening a file does not discard its history. Trap: Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. Done when: A drag of a node produces one undo entry, not one per pointer move, asserted by counting log entries. Undo restores the selection that existed before the operation, tested. Undoing and redoing a hundred mixed operations returns the document to a byte-identical serialisation, tested as a property. Memory used by the log for a stated large document stays under a stated ceiling, measured. Do not build yet: No export or document format migration yet. ### Phase 5 · Export that tells you what it dropped Build: Write an export report on every export listing each document feature that had to be approximated or lost, with the node names. Resolve inner and outer strokes by outlining the path. Decide per export whether text stays as text or becomes outlines, and say which. Keep the versioned JSON document as the thing you actually save. Trap: A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. Trap: Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it. Done when: Exporting a shape with a non-centred stroke outlines it and the export report names that node, tested. Every export writes a report row listing dropped or approximated features, and an export with nothing lost says so explicitly. Re-importing an exported SVG produces a document that rasterises to within a stated pixel difference of the original, tested. Opening a document written by an earlier format version still works, tested against a stored fixture from the first release. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaboration, which is a different architecture entirely and would decide the document model rather than sit on top of it. - A text shaping and layout engine of your own, because ligatures, bidirectional text and complex scripts are years of work that the platform already does. - Print colour, spot inks and separations, which need a colour management pipeline and profiles rather than a weekend of code. - A template and asset library, which is a content business and not an engineering one. ### README must contain - That the save format is the versioned JSON document and that SVG and PDF are exports, not saves - That inner and outer strokes cannot be expressed in SVG and are outlined on export, with the report naming which nodes - That every curve is stored as a cubic Bezier and the tolerance used to convert arcs at import - That boolean operations keep their operands and that flattening is a deliberate action - That undo is a log of inverse operations, what it does not cover, and where it is persisted
# Architecture · Linearity Curve ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application in TypeScript, document model kept separate from the renderer | the renderer is replaceable and the document is not | | Geometry | Cubic Beziers only, with arcs, quadratics and polylines converted at import | every operation gets written once instead of six times, five of them wrongly | | Boolean ops | A maintained path boolean library, not a sweep line of your own | tangential intersections are where hand-written boolean code loops forever | | Rendering | Canvas 2D with dirty rectangle redraw, hit testing against the model | picking by reading pixels breaks on opacity, strokes and high density displays | | Document | Versioned JSON as the save format, with SVG and PDF as export targets | SVG cannot express half of what a vector editor knows about a shape | | Undo | A log of operations and their inverses, not snapshots of the scene | snapshots of a large document eat memory and lose the selection | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | Normalising every incoming segment type to cubics. | Any parser, provided nothing but cubics reaches the document. | | Model | Nodes, paths, styles and the boolean tree. | Any store, provided operands survive their operations. | | Interaction | Hit testing, selection, snapping and dirty redraw. | Any renderer, provided picking never touches pixels. | | History | Operations, inverses and coalescing. | Any implementation, provided it is not document snapshots. | | Export | Target adapters and the loss report. | Any target, provided it writes a report naming what it dropped. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DOCUMENT_DIR` · required · Where saved documents live. This is the data you cannot regenerate. - `ARC_TOLERANCE` · required · Maximum deviation in document units when converting arcs to cubics. Recorded in each document. - `HIT_TOLERANCE_UNITS` · required · Pick radius in document units before scaling by zoom. - `HISTORY_MAX_BYTES` · required · Ceiling for the undo log. Exceeding it trims the oldest entries and says so. - `EXPORT_REPORT_DIR` · required · Where export reports are written. One per export, including the clean ones. ## 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 · Linearity Curve product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A desktop application in TypeScript, document model kept separate from the renderer, Cubic Beziers only, with arcs, quadratics and polylines converted at import, A maintained path boolean library, not a sweep line of your own, Canvas 2D with dirty rectangle redraw, hit testing against the model, Versioned JSON as the save format, with SVG and PDF as export targets, A log of operations and their inverses, not snapshots of the scene. - 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 - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. - A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. - Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it.
# Delivery milestones · Linearity Curve Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One path representation and nothing else Every curve in the document is a cubic Bezier, whatever the file it came from used. ### Steps 1. Normalise every segment type at import One representation is the decision the rest of the editor rests on. Files: `src/geometry/normalise.ts`, `src/io/svg-import.ts` 2. Convert arcs with a stated tolerance and record it An unrecorded tolerance makes a round trip failure unarguable. Files: `src/geometry/arc-to-cubic.ts`, `tests/arc-tolerance.test.ts` 3. Collapse transform lists into one matrix per node Nested transforms are where coordinates quietly stop matching. Files: `src/geometry/transform.ts` ### Done when - [ ] Mixed-command SVG imports to cubic segments only - [ ] A circle round trips within the recorded tolerance - [ ] Each node carries a single matrix - [ ] Open and closed subpaths stay distinct ### Watch out - Keeping five segment types because the file format has them means boolean operations, offsetting, hit testing and simplification each need five cases, and the arc case is wrong in all four of them. Convert once at the door, keep cubics only, and treat the importer as the only code in the project that has ever heard of an elliptical arc. ## M2 · Direct manipulation that stays smooth Selecting, dragging and editing nodes runs against the document model at interactive speed. ### Steps 1. Hit test against geometry, never against pixels Opacity and device pixel ratio both break the pixel approach. Files: `src/interaction/hit-test.ts`, `tests/hit-test.test.ts` 2. Scale the pick tolerance by zoom A hairline has to stay clickable when you are zoomed out. Files: `src/interaction/hit-test.ts` 3. Redraw dirty rectangles only A full repaint per pointer move is what makes an editor feel cheap. Files: `src/render/dirty.ts` ### Done when - [ ] A transparent shape is selectable and a path hole is not - [ ] Pick tolerance scales with zoom - [ ] A drag repaints only the dirty rectangle - [ ] Snapping never alters the stored coordinate ### Watch out - Hit testing by reading the pixel under the cursor picks the wrong node for anything with opacity, misses a shape whose stroke is wider than its fill, and returns nothing at all once the canvas is scaled for a high density display. Test the point against the geometry, with the tolerance scaled by zoom so a hairline stays clickable at every magnification. ## M3 · Boolean operations that stay undoable Union, subtract, intersect and exclude produce a node that still remembers its operands. ### Steps 1. Use a maintained boolean library Writing your own is how you meet tangential intersections. Files: `src/geometry/boolean.ts`, `package.json` 2. Keep operands as hidden children The operation stays editable for as long as the document lives. Files: `src/model/boolean-node.ts` 3. Make flattening explicit and undoable Destroying the operands should take a deliberate click. Files: `src/commands/flatten.ts`, `tests/boolean.test.ts` ### Done when - [ ] Editing an operand recomputes the boolean result - [ ] Operand order is stored and matters for subtraction - [ ] Tangential shapes produce a result rather than hanging - [ ] Flatten is explicit and appears in the undo log ### Watch out - Flattening a union into one path the moment the user clicks the button means fixing a mistake made ten operations ago requires redrawing both shapes from scratch, which is the single most common complaint about cheap vector editors. Keep the operands, recompute the result when either changes, and make flattening an action with a name. ## M4 · Undo as a log of inverses Every change is an operation with an inverse, and a drag is one entry rather than four hundred. ### Steps 1. Record an inverse per operation Not a snapshot. The difference is the whole memory budget. Files: `src/history/ops.ts` 2. Coalesce gestures on release One drag is one thing the user did. Files: `src/history/coalesce.ts` 3. Round trip a hundred operations in a property test Undo bugs hide in the combination, not the single case. Files: `tests/history.property.test.ts` ### Done when - [ ] One undo entry per drag - [ ] Undo restores the prior selection - [ ] A hundred mixed operations round trip exactly - [ ] Log memory stays under the stated ceiling ### Watch out - Pushing a copy of the whole document onto a stack per change is fine for twenty shapes and unusable for two thousand, and the memory cost appears exactly when the document finally matters. Store the inverse of each operation instead, and treat a pointer drag as one operation recorded when the pointer is released. ## M5 · Export that tells you what it dropped (production only) SVG, PDF and raster export work, and every feature the target cannot express is reported by name. ### Steps 1. Outline non-centred strokes and report it The format has no way to say inside or outside. Files: `src/io/svg-export.ts`, `tests/stroke-alignment.test.ts` 2. Write an export report every time Silent loss is what makes export untrustworthy. Files: `src/io/export-report.ts` 3. Keep an old-format fixture in the suite A format version you cannot open is a document you lost. Files: `tests/fixtures/v1.doc.json`, `tests/migrate.test.ts` ### Done when - [ ] Non-centred strokes are outlined and reported - [ ] Every export writes a report, including an empty one - [ ] An exported SVG re-imports within the pixel tolerance - [ ] A first-release document still opens ### Watch out - A stroke aligned to the inside or the outside of a path has no representation in exported SVG: stroke-alignment was dropped from SVG 2 and lives in the SVG Strokes module, whose own editor's draft says the SVG 2 stroke definition is still the normative one, so exporters silently centre the stroke and the artwork comes back a half stroke width wrong. Outline the stroke into a path on export, and name the nodes you had to do it to in the report. - Exporting text as text produces a file that renders in a different typeface on any machine without that font, and exporting it as outlines produces a file nobody can edit or search. Neither is a default: ask, record the choice in the export report, and embed or subset the font when the target format allows it.
# Operations · Linearity Curve ## Backup The document directory is the whole product. Back it up on a schedule and keep generations, because a vector document is edited over months and a corruption is noticed long after it happened. ## Restore Restore the directory, open one document from each format version you have ever written, and confirm the export report for a known file matches what it said before. A restore that opens nothing older than last month has not been tested. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a local editor the monitoring lives in the suite: round trip fidelity against the fixtures, undo log memory for a large document, and boolean results for the tangential cases. Run them on every change to the geometry code. ## Incident checklist The incident here is a file that will not open after a format change, or artwork that came back from a printer half a stroke width wrong. The format version field and the export reports are the two records that make either of those diagnosable rather than mysterious. 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 - [ ] Mixed-command SVG is confirmed to import as cubics only - [ ] Hit testing is confirmed to run against geometry rather than pixels - [ ] Boolean operands are confirmed to survive the operation - [ ] A drag is confirmed to produce one undo entry - [ ] Every export is confirmed to write a loss report naming affected nodes ## Launch constraint Do not market omitted Linearity Curve capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where saved documents live. This is the data you cannot regenerate. DOCUMENT_DIR=./documents # Required. Maximum deviation in document units when converting arcs to cubics. Recorded in each document. ARC_TOLERANCE=0.01 # Required. Pick radius in document units before scaling by zoom. HIT_TOLERANCE_UNITS=3 # Required. Ceiling for the undo log. Exceeding it trims the oldest entries and says so. HISTORY_MAX_BYTES=64000000 # Required. Where export reports are written. One per export, including the clean ones. EXPORT_REPORT_DIR=./exports/reports
$ choose a build depth, inspect the files, then open the complete pack in your agent
Linearity Curve: Creative users pay for speed, output fidelity, nondestructive editing, and years of edge-case work hidden behind direct manipulation.
xcloud collaboration and mobile apps
xpixel-perfect professional tooling
xlarge template and asset libraries
xcolor-management edge cases
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Linearity Curve pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 50 lifetime AI credits, 1 basic brand kit and 5 GB storage. |
| pro | $30/user | $25/user | 250 AI credits/month and 100 GB storage per editor. |
| business | $80/user | $65/user | 500 AI credits/month and 500 GB storage per editor. |
| enterprise | custom | — | Custom organisation-wide shared credits, SSO and security; public price is not listed. |
free tier50 lifetime AI credits, 1 basic brand kit and 5 GB storage.
billingmonthly + annual
hidden costsPricing is per editor. Education customers can receive 58% off yearly Pro; extra usage beyond included AI credits can require an upgrade or additional purchase.
verified 2026-08-14 · source ↗
Vibecode Linearity Curve
Kinda. The core of Linearity Curve is buildable in a weekend with the prompt on this page, but there are real gaps: cloud collaboration and mobile apps, pixel-perfect professional tooling. Read the honest list above before committing.
How much does Linearity Curve cost?
Linearity Curve'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 Linearity Curve?
Honestly: cloud collaboration and mobile apps; pixel-perfect professional tooling; large template and asset libraries; color-management edge cases. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Linearity Curve?
Yes: Inkscape (The unfashionable workhorse for vector illustration, typography, and production SVG/PDF files across all three desktop operating systems.) Affinity (A free desktop vector-and-layout suite with typography, masks, effects, and professional export; Apple-only convenience is gone, capability is not.) The prompt is for when you want it exactly your way.