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

#ForkChosenOne-line WHY
1Source of truthGUI-first, opaque .cluz project fileLayout has nowhere to live in a text DSL; killed the round-trip problem and the file watcher outright
2Scale modelTwo millimetres that never mixreversedThe target diagram genre is schematic, not to scale — one mm unit conflated layout with wire length
3RendererSVG, no canvas libraryre-decidedThese diagrams are dominated by text; DOM text is measurable, crisp, selectable and keyboard-traversable
4Entry modelBoth draw-first and omniboxUser chose both, so the jot parser gains three consumers instead of one
5TerminationsOne polymorphic union (cavity·splice·ground·fuse)Adding pigtails or shielded drains later never touches Wire
6CollapseThree-state auto | pinned-open | pinned-shutauto 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

OptionShape
Text DSL as the source of truthExtend the jot grammar into a real DSL; the GUI is a renderer over a text file the user also edits directly
Bidirectional syncText file and GUI both writable, kept in step by a file watcher and a serializer
GUI-first, opaque project fileOne .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.

UnitGovernsSource
paper mmAll layout — coordinates, stroke weights, text sizes, symbol dimensions, bundle pitch, page sizeDrawn
world mmWire.lengthMm onlyRuler 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:

  • lengthMm is never derived from route. 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 / PaperSize are 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/felmeres already 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:

  1. Labels readable at any zoom — not resampled from a rasterized cache
  2. Measurable bounding boxes — needed for overlap decisions; this is the seam that makes greedy label-collision culling a follow-on rather than a rewrite
  3. Crisp glyphs rather than resampled ones
  4. 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.

ConsumerContext
Phone captureThe predecessor: dictate or thumb-type a wire while standing at the car
File importBulk-load the existing wiring_diagram jots rather than orphaning them
In-app omniboxNew. 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.typeId is 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

OptionShape
Special-cased fields on WirefromConnectorId + fromCavity, plus nullable spliceId, groundId, fuseBoxPosition… on both ends
One discriminated unionWire.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

OptionFailure
Zoom-driven onlyCannot hold one harness open to see a whole run, nor shut one to inspect its neighbour at full detail
Manual onlyLoses 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-shut harness looks exactly like an auto harness 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.

DecisionRejected alternativeWhy
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 rateConflating 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 boundaryConvention + code reviewAspirational boundaries rot. Checkable in CI is what keeps the entire rule set testable without a browser
Connector.typeId is nullable = provisional connectorRequire a type before placingTracing 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 typeCavities-filled declared on the connector typeDifference between a BOM that orders eight terminals and one that orders the five actually crimped
gaugeMm2 is a numberAn enum of standard gaugesAn 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 indicesA wire’s breakout point stays put when a trunk vertex is inserted
.cluz is a zipOne JSON with base64 imagesUnderlays 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 pxPaper mmSnapping feels identical at every zoom instead of getting stickier as you zoom in
Slack applied per wire, then summedOne percentage on the grand totalA 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 linesGeneral polyline offsetting with mitersRoutes 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 libraryFor 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 1Add 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 decisionStatus in cluzter
WireViz chosen because pins are addressable endpoints and it generates a BOMSame 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 cableGeneralised into the three-state collapse system (§6)
Pin numbers remapped to sequential indices while pinlabels show the stamped numberSame 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).

  • 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