The design forks behind cluzter — the options that were on the table, what was chosen, and why. Companion to the spec, not a summary of it: the spec records the resolved design, this note records how it got there.
For Agents
Source spec (do not duplicate — read it for full detail):
/Users/levander/coding/cluzter/docs/superpowers/specs/2026-07-30-cluzter-wiring-diagram-editor-design.md. Status: design approved 2026-07-30, no code yet.Two decisions reversed mid-conversation — the scale model (§2) and the renderer (§3). The reversals are the valuable part of this note, because in both cases the first answer was defensible and still wrong, and the reason it was wrong is not recoverable from the final spec.
Decisions at a glance
| # | Fork | Chosen | One-line WHY |
|---|---|---|---|
| 1 | Source of truth | GUI-first, opaque .cluz project file | Layout has nowhere to live in a text DSL; killed the round-trip problem and the file watcher outright |
| 2 | Scale model | Two millimetres that never mix ⟲ reversed | The target diagram genre is schematic, not to scale — one mm unit conflated layout with wire length |
| 3 | Renderer | SVG, no canvas library ⟲ re-decided | These diagrams are dominated by text; DOM text is measurable, crisp, selectable and keyboard-traversable |
| 4 | Entry model | Both draw-first and omnibox | User chose both, so the jot parser gains three consumers instead of one |
| 5 | Terminations | One polymorphic union (cavity·splice·ground·fuse) | Adding pigtails or shielded drains later never touches Wire |
| 6 | Collapse | Three-state auto | pinned-open | pinned-shut | auto default = progressive disclosure with zero management; pins are the override |
⟲ = the answer changed during the design conversation.
1. Source of truth — GUI-first opaque project file
Options considered
| Option | Shape |
|---|---|
| Text DSL as the source of truth | Extend the jot grammar into a real DSL; the GUI is a renderer over a text file the user also edits directly |
| Bidirectional sync | Text file and GUI both writable, kept in step by a file watcher and a serializer |
| GUI-first, opaque project file ✅ | One .cluz archive owned by the app. Text is an input format, never the store |
Chosen: GUI-first. The document lives in .cluz (a zip: manifest.json + project.json + assets/), the app owns it, and nothing else writes it.
Why: A wiring diagram carries a large amount of information that has nowhere to live in a text DSL — every position, route, waypoint, trunk polyline, enterAt/exitAt, underlay placement, scale, rotation, opacity and two-point calibration. A text DSL either omits all of it (and then the GUI is authoritative anyway, so the DSL is a lie) or encodes it (and then the “text” format is a serialization no human edits by hand).
That single observation kills both alternatives at once:
- The round-trip problem disappears. There is no “regenerate the text from the canvas” step that could lose hand-placed geometry, reorder entities, or reformat a file the user was mid-edit in.
- The file watcher disappears. No external writer means no watcher, no debounce, no reload-vs-local-changes prompt, and no two-writer merge conflict.
Consequence: the jot grammar survives, but demoted from store to input — one grammar, three consumers (see §4). And because the file is app-owned and opaque, manifest.schemaVersion plus an ordered array of forward migrations was included from the start rather than deferred: roughly fifteen lines, against the alternative of a schema change silently orphaning hand-traced work.
2. Scale model — the reversal from “canvas units are millimetres”
This answer reversed
First answer: canvas units are millimetres. Draw at real scale, and a wire’s length falls out of its drawn path for free — one unit, no calibration, no typing, cut list generated from geometry.
What reversed it: a reference image of the target diagram genre. The target is a colorized factory schematic, and it is emphatically not to scale — a tail lamp is drawn the same size as a generator, and runs are spaced for legibility rather than length. Nothing on that sheet has a true dimension. “Canvas units are millimetres” was answering a CAD question about a document that is not a CAD drawing.
Final resolution: two distinct millimetre units that never mix.
| Unit | Governs | Source |
|---|---|---|
| paper mm | All layout — coordinates, stroke weights, text sizes, symbol dimensions, bundle pitch, page size | Drawn |
| world mm | Wire.lengthMm only | Ruler on a calibrated underlay, or typed |
Why two rather than one: the naive fix would have been to keep one unit and simply accept that lengths are wrong. The failure that prevents is not cosmetic — a schematic-derived length produces a silently wrong cut list, which is the one output where being wrong costs money and wire. See Deriving wire length from a schematic path.
Three structural consequences fall out, and each is a guard rather than a convention:
lengthMmis never derived fromroute. The model forbids it — there is no code path from a drawn path to a length. A missing length blocks the cut list only; it must not stop drawing.- There is no world-mm point type. World mm is only ever a scalar length, so
PaperPoint/PaperPath/PaperSizeare the only geometry primitives and a mixed-unit expression is a type error rather than a review catch. - The underlay is a real canvas layer, not a modal measuring instrument. It is drawn on top of, carries an optional two-point calibration (
p1,p2,worldMm) yielding a world-mm-per-paper-mm ratio, and that ratio powers the ruler tool. Measuring is a normal thing you do on a layer, not a mode you enter and leave.
Second-order effect: because the sheet is explicitly not to scale, print composition owes the reader a “not to scale — lengths in cut list” note. That is not decoration; it is the disclaimer that makes an unscaled sheet honest.
3. Renderer — SVG, decided twice
This answer was challenged and then re-decided the same way, on different grounds
First recommendation: SVG.
The challenge:
/Users/levander/coding/szig/felmeresalready solves world-space CAD-lite in-house — a substantial React + Konva floorplan app with a pure-TS engine, world-space geometry, snapping, layers, undo/redo, and PNG/SVG export. A working in-house precedent for the react-konva path is a real argument, and it made the SVG recommendation look like it had been reached by default rather than by reasoning. See szig-felmeres.Re-decided as SVG anyway — on independent grounds, not by reusing the first recommendation.
The grounds that actually decided it: automotive wiring diagrams are dominated by text. Every wire carries a colour code, a gauge and a purpose; every cavity a stamped number; every connector a designator. That imposes four requirements DOM text satisfies and canvas does not:
- Labels readable at any zoom — not resampled from a rasterized cache
- Measurable bounding boxes — needed for overlap decisions; this is the seam that makes greedy label-collision culling a follow-on rather than a rewrite
- Crisp glyphs rather than resampled ones
- Selectable text in the exported PDF — a wiring sheet you cannot search is half a document
DOM keyboard traversal across cavities is a fifth win for a tool this data-entry-heavy, and a sixth benefit is that the raster underlay and the vector diagram sit under one shared transform, so a calibrated scan cannot drift from the diagram at any zoom.
Why element count did not veto it: a full Tracker is roughly 1500 wires and 100 connectors — about 8k nodes at maximum detail with LOD disabled. LOD is mandatory, so the realistic peak is around 2k. That is not a threat at this scope.
User directive — do not mirror szig/felmeres
Recorded verbatim in the spec as §3.3: “The design must not mirror
/Users/levander/coding/szig/felmeres. Architectural choices here are made on their own merits.” felmeres is not a template, a reference implementation, or a source of patterns for cluzter. It is documented in the vault (szig-felmeres) purely so it is findable as its own thing.The one genuine similarity — a pure-TS engine with zero renderer imports — was arrived at independently and is a common shape for this kind of app, not a borrowing.
Escape hatch kept open: rendering is a pure scene(model, viewport, tier) → DrawList, so if SVG node count ever does become uncomfortable, a canvas backend is a mechanical swap rather than a rewrite. The decision is reversible by construction; that is why it was safe to make on the text argument alone.
4. Entry model — the user chose both
Options considered: draw-first with a toolbar (discoverable, but naive draw-first costs roughly five actions per wire), or an omnibox that accepts a typed jot line (fast, but opaque and unteachable on its own).
Chosen: both. Not a compromise — a deliberate double.
Why it matters more than it looks: it means the jot grammar now has three consumers instead of one.
| Consumer | Context |
|---|---|
| Phone capture | The predecessor: dictate or thumb-type a wire while standing at the car |
| File import | Bulk-load the existing wiring_diagram jots rather than orphaning them |
| In-app omnibox | New. Type eng Bl/G ecu.5 ign.3 IGt signal and get a wire |
One grammar with three consumers is the argument for keeping that grammar stable, and it is why jot import is a phase (P7) rather than a nice-to-have.
The constraint both paths share: they must emit the same commands. That is what makes undo coherent across a mixed session where some wires were drawn and some typed, and it is the reason undo is a stack of { do, undo } closures rather than document snapshots.
What “both” forced into the design:
- The thousand-clicks problem had to be solved on the draw-first side anyway — sticky tools (the tool stays armed, Esc disarms, Space-drag always pans), property inheritance (the wire tool remembers last colour/gauge/harness, shown in the toolbar so it never surprises), and multi-pin drag (rubber-band cavities 1–4 on C101, drag to 1–4 on C102, get four wires in one gesture). Four wires drop from ~20 actions to two gestures.
- The omnibox doubles as a command palette, so the second path pays for itself twice.
- Typing a designator that does not exist creates a provisional connector for it — the same mechanism as dragging a wire off a pin into empty space, and the reason
Connector.typeIdis nullable.
Risk this hedges: if draw-first proves slow in practice despite the three mechanics above, two escape hatches already exist (omnibox, bulk import) without changing the paradigm.
5. Terminations — one polymorphic union, not special-cased wire fields
Options considered
| Option | Shape |
|---|---|
Special-cased fields on Wire | fromConnectorId + fromCavity, plus nullable spliceId, groundId, fuseBoxPosition… on both ends |
| One discriminated union ✅ | Wire.from and Wire.to are each a TerminationPoint |
Chosen: one union with four kinds — cavity (connectorId + cavityIndex), splice (spliceId), ground (groundId), and a rated fuse/relay cavity (boxId + position, where the box’s position carries kind: fuse | relay | link, ratingA and circuitName).
Why: the special-cased version needs a new nullable field pair on Wire for every new termination kind, and every consumer — router, validator, cut list, BOM, print — has to learn the new field. With a union, adding pigtails or shielded drains later never touches Wire; it adds a variant, and the compiler enumerates every site that must handle it. Pigtails and shielded drains are explicit v1 non-goals precisely because this makes them cheap to add later rather than urgent now.
Why splices had to be first-class at all: the predecessor had no representation for a wire tapping another mid-run — a documented gap, worked around by modelling the tap as a named connector. Two things depend on fixing it properly:
- Splice nodes make their wires electrically common, which is what lets the fuse-overload check sum genuine downstream load instead of counting wires that happen to touch a fuse.
- A splice carries a
method(crimp|weld|solder-shrink) that belongs in the BOM. A fake connector cannot.
Why the fuse position carries a rating: it makes the fuse-overload check a property of the document rather than a lookup the user has to remember, and it puts the ISO 8820 / SAE J1888 colour code in reach for rendering. The seeded fuse colours and DIN 72552 relay terminal numbers are asserted from published standards and are flagged in the spec as worth one spot-check against the actual fuse box before a printed sheet is trusted to them.
6. Collapse — three states, with auto as the default
Options considered
| Option | Failure |
|---|---|
| Zoom-driven only | Cannot hold one harness open to see a whole run, nor shut one to inspect its neighbour at full detail |
| Manual only | Loses progressive disclosure entirely; every harness becomes a thing to manage |
| Three-state ✅ | — |
Chosen: each Harness carries state: 'auto' | 'pinned-open' | 'pinned-shut'.
auto— zoom decides, via the LOD tier. This is the default, which is the whole point: progressive disclosure works out of the box with zero management, and most harnesses are never touched.pinned-shut— stays one stroke at any zoom, so a neighbour can be inspected at full detail without a wall of parallel wires in the way.pinned-open— stays expanded when zoomed out, to follow a whole run end to end.
Why auto had to be the default rather than an opt-in: if the default were manual, the zoom-driven behaviour would only exist for users who went looking for it, and the LOD system’s main user-visible payoff would be invisible.
Pinned state requires a visible badge
Non-negotiable, and it falls straight out of the three states: without a badge, a
pinned-shutharness looks exactly like anautoharness whose expansion is broken. The badge is what makes the feature debuggable by the person using it.
How much collapse is worth depends on paper size — which is a real design finding, not a caveat. “Fit to sheet” lands in a different LOD tier depending on the sheet: an A3 dash harness fits inside T2, where every label is already visible and collapse rarely fires; an A1 full-vehicle sheet fits at T1, where it is essential. The feature’s value scales with the drawing.
How it animates, and why that is a decision: collapse is discrete — one state change at the tier boundary — and CSS animates the gap. Expanded paths are the collapsed path offset perpendicular, so the vertex structure is identical and d is CSS-interpolable. One React re-render at the threshold; the browser does the fan-out. Where d transitions are unsupported it degrades to an instant snap, with no functional loss. This is what let the animation exist without a per-frame React path — see the two-clock model below.
Smaller forks worth keeping
Each of these had a plausible alternative that was rejected for a specific reason.
| Decision | Rejected alternative | Why |
|---|---|---|
Two clocks — model clock (~1/s, edits, React) vs viewport clock (60/s, pan/zoom, imperative transform on the root <g>) | One React-driven update rate | Conflating them is what makes canvas editors feel sluggish. The only bridge is the LOD tier, so React wakes 3–4 times across a whole zoom sweep instead of 60×/s |
engine/ may not import store/ or view/ — enforced as a CI lint boundary | Convention + code review | Aspirational boundaries rot. Checkable in CI is what keeps the entire rule set testable without a browser |
Connector.typeId is nullable = provisional connector | Require a type before placing | Tracing a scan routinely means knowing where a wire goes before knowing what connector is there. Blocks BOM export, never the diagram |
population on the instance, not the type | Cavities-filled declared on the connector type | Difference between a BOM that orders eight terminals and one that orders the five actually crimped |
gaugeMm2 is a number | An enum of standard gauges | An enum fails on the first 0.85 mm² wire. AWG is a display conversion |
enterAt/exitAt are normalised arc length in [0,1] | Trunk vertex indices | A wire’s breakout point stays put when a trunk vertex is inserted |
.cluz is a zip | One JSON with base64 images | Underlays are multi-megabyte scans: base64 inflates ~33% and makes every save rewrite megabytes. Cost of the zip is one 8 kB dependency (fflate) |
| Snap threshold in screen px | Paper mm | Snapping feels identical at every zoom instead of getting stickier as you zoom in |
| Slack applied per wire, then summed | One percentage on the grand total | A 3 m run needs more absolute slack than a 200 mm one, and the cut list has to show the length actually cut. Wire.lengthMm stays the measured value |
| Bundle offsetting is arithmetic — offset each axis-aligned segment’s line, take one coordinate from each of two consecutive offset lines | General polyline offsetting with miters | Routes are orthogonal, so no miter math is needed. Named as the primary property-test target because a trunk segment shorter than the offset would invert the corner |
| No component kit (no shadcn/Radix) | Standard React component library | For a canvas plus a toolbar and two panels it is more opinion than help. Addable if a genuine combobox need appears |
schemaVersion + forward migrations from day 1 | Add versioning when the schema first changes | ~15 lines, against a schema change silently orphaning hand-traced work |
Inherited from the predecessor
Four decisions carried forward from the jot → WireViz pipeline, and the two documented gaps in it that became requirements here.
| Predecessor decision | Status in cluzter |
|---|---|
| WireViz chosen because pins are addressable endpoints and it generates a BOM | Same requirement — cluzter models pins natively rather than delegating |
Connector name is identity (ecu vs ECU silently made two nodes) | Fixed. id is generated, designator is display-only; collision is a validation warning — see cluzter-wiring-gotchas |
The harness column exists to collapse wires into one cable | Generalised into the three-state collapse system (§6) |
Pin numbers remapped to sequential indices while pinlabels show the stamped number | Same split — cavityIndex is positional, stampedLabel is what is moulded on the part |
The two gaps: wire gauge (would have been a 6th jot field) and splices. Both are first-class in cluzter — WireSpec.gaugeMm2 and the splice termination variant (§5).
Related
- cluzter — project entry point and the seven-phase plan
- cluzter-wiring-gotchas — the failure modes that constrained the model
- pipeline — the predecessor pipeline and its own four design decisions
- szig-felmeres — the react-konva CAD-lite app that challenged the renderer decision, and was explicitly rejected as a template
- 2026-07-29-hardcut-design — same vehicle, same JIS colour convention