Export button is no longer disabled. On click it POSTs to the export
endpoint and shows a toast next to the button:
✓ Exported · open in mxdrw (with viewer URL)
✗ Export failed — <detail>
Two changes to close the UX hole m hit on slice 6 — Apply Template
appeared to do nothing because (a) the canvas wasn't refreshed cleanly
and (b) the cables hadn't been computed yet.
Backend (internal/server/solver.go applyTemplate handler):
- After ApplyTemplate succeeds, run Solve(false) inside the same
request. Combined response shape:
{ template_apply: <ApplyTemplateResult>, solve: <SolveResult> }
- Opt out with ?solve=0 for power-users who want to inspect the
seeded devices/requirements before the solver runs. Response in that
case is { template_apply: ... } only.
- If Solve fails after a successful apply, return
{ template_apply, solve_error: "..." } so the frontend can recover
(devices are still there; m can hit Solve manually).
Frontend (web/static/main.js apply-template modal submit):
- Replaced the bare re-snapshot with a call to activateProject(pid).
That's the canonical project-load path — it re-hydrates ALL
collections (frames, devices, ports, io_markers, cables, bundles,
requirements, cable_types, device_types), clears state.selection
so a stale pre-apply selection can't linger, and routes through the
same render() the URL-state hydration uses on initial page load.
- The slice-6 inlined re-snapshot missed the device_types refresh +
selection reset, which I suspect was what made the canvas look
stuck — render()ing with state.selection.kind="cable_type" or
"requirement" pointing at a not-yet-loaded row.
Hand-test (local): Living Room + auto-solve produces 4 devices + 3
requirements + 3 cables; ?solve=0 leaves cables empty. Snapshot
includes the cables on auto-solve path.
+Port (device inspector):
- New button on the device inspector arms a port-placement tool with
the device + currently-active cable type pre-selected.
- Click anywhere on the canvas: snapToDeviceEdge() finds the closest
edge of the selected device, clamps the perpendicular coord, POSTs a
new port. The new port renders immediately (state.ports.push +
render()).
- Per-port × delete button in the inspector ports grid.
Manual cable draw:
- Port circles are now clickable (slice 4 had pointer-events:none).
- Click a port → starts a cable draw with that port as the source
(state.cableDrawFromPortID, port highlighted via .cable-from class).
- Click another port → POSTs a cable with from_port_id + to_port_id,
type derived from source port, auto=false. If the target port's type
differs, confirm-prompt warns m before committing.
- Shift+click target port → binds to the target's parent device
(to_device_id) instead of the port.
- Click an IO marker mid-draw → terminates the cable with to_io_id.
- Esc cancels the draw + clears state.cableDrawFromPortID.
- "Draw cable" toolbar button is now enabled (data-tool=cable, keyboard
is implicit via port-click). armTool() teardown clears the source-port
state.
Cable inspector tweak (slice 6 callback):
- "driver" row now renders as a clickable button showing the
requirement's "FromName ↔ ToName" instead of the raw id; click jumps
the inspector to that requirement.
CSS:
- tool-port + tool-cable add the same crosshair cursor as the other
tools (descendant-targeted with !important to beat svg-draggable's
grab cursor — same fix-pattern as slice 3's cursor-cache pass).
- .port-circle.cable-from gives the source port a glow.
- .btn-link styles for inspector inline buttons.
Header gains a Solve button (keyboard S) + Apply template button.
Canvas:
- Cables render as straight lines port→port (or device-centre when the
endpoint is a whole device, or io-marker centre). Auto-cables get a
dashed stroke; manual cables (auto=0) solid. Stroke colour = cable_type.
- Click a cable to select it → inspector pane updates.
Solve preview-diff modal:
- Calls POST .../solve?preview=1 on open.
- Renders cables_added, cables_removed, bundles_added in colour-coded
lists. Unsatisfied entries get a class="unmet" badge + one-click
quick-fix:
* "no free <type> port" → "+ Add <type> port to <device> and re-solve"
fires POST .../devices/:id/ports-and-resolve in one round-trip and
re-renders the preview.
* "ambiguous cable type" → "Specify cable type…" re-opens the
requirement modal.
* "no compatible cable type" with a preferred type → "+ Add port…"
quick-fix on the from-side device.
- Apply → POST .../solve (no preview) → re-snapshot to pick up new
cable ids + bundle assignments.
Cable inspector (kind=cable):
- Shows type, from-endpoint, to-endpoint labels.
- For solver-owned cables, shows the driving requirement (best-effort
match by unordered device pair + type) and a "Promote to manual"
button (PATCH with `promote: true` flips auto→0).
- Delete button on both auto and manual cables.
Apply-template flow:
- "Apply template…" header button opens a wide modal with a template
dropdown (Living Room / Home Office / Server Rack) + a preview panel
showing each device row (skip checkbox + editable name input) and
the template's requirements.
- Submit → POST .../apply-template with name_overrides + skip_devices,
then re-snapshot.
State + snapshot:
- state.cables, state.bundles, state.setupTemplates added.
- activateProject pulls them from the snapshot; teardown on switch.
Snapshot now carries connection_requirements; state.requirements is
populated on project switch.
Sidebar:
- New "Requirements" section between Cable types and Tools.
- Each row shows "A ↔ B · cable-type" plus a must/nice badge. Clicking
a row selects the requirement (inspector pane updates).
+ Requirement modal:
- Device-pair pickers (autocompletes from the project's current devices).
- Cable-type picker with "— solver picks —" as the first option (saves
preferred_cable_type_id as null on the wire).
- "Must connect" checkbox (default on); notes textarea.
- POSTs to /api/projects/:pid/connection-requirements. 409 collisions
(reversed-pair duplicates) surface as inline form errors.
Drag-from-A-to-B gesture:
- New tool `req` (keyboard R + "Drag req A→B" button). Arming the tool
+ pointerdown on a device starts a dashed-line preview. Pointerup on
another device opens the modal with from/to pre-filled. Anywhere
else cancels. Crosshair cursor while armed.
Inspector:
- Device pane gains a "Requirements" section listing every requirement
involving the selected device, sorted by the other device's name.
Each row is clickable → inspector jumps to that requirement.
- New `requirement` selection kind with its own inspector renderer
showing from/to, cable type, must/nice toggle button, debounced
notes textarea, "Edit" (re-opens modal), and Delete.
Delete of a device cleans up its requirements in local state (server
already CASCADEs the rows).
Modal-driven +Dev (replaces the v3 inline namer):
- Tool armed → click on canvas captures the click position + frame_id
from frameAt(p), then opens a #modal-new-device dialog.
- Dialog has a <select> grouped by `kind` for built-ins, then
project-custom rows, then "Custom (no type)" at the bottom.
- Default selection is the first built-in (NAS). Name input is
auto-pre-filled to <type-name>, bumping to <type-name>-N if a name
collision is detected in the current device list.
- Submit POSTs name + type_id + x/y/w/h + frame_id. Server seeds the
ports in the same transaction; we re-snapshot to pick them up.
Canvas:
- After each device's <rect> + label, render the device's ports as
white-filled <circle>s with stroke = the port's cable_type colour.
- Position: (device.x + port.x_offset, device.y + port.y_offset). The
seeder's "evenly along the edge" layout means ports already sit on
the device's bottom edge by default and follow the device on drag
(because they re-render from the same x/y on every renderCanvas).
- Ports themselves are `pointer-events: none` for slice 4 — selection
remains device-level. Per-port click semantics ship in slice 7
(manual cable draw).
Inspector device pane:
- New "type" row showing the type name + a "(custom)" badge for
project-custom types, or "Custom (no type)" for freeform.
- New "Ports" section with one row per seeded port: cable-type-colour
swatch, label, "unconnected" placeholder. Label falls back to the
cable type's name when the seeded label_prefix was blank.
State + snapshot:
- state.ports populated from snap.ports; cleared on project switch /
404.
- state.deviceTypes hydrated from GET /api/projects/:pid/device-types
after the snapshot loads. Failure of that fetch is non-fatal — the
+Dev modal just shows "Custom (no type)" only.
- Delete-device cleans up its ports from state.ports too (server-side
CASCADE already handles persistence).
Slice 3 frontend.
+ IO tool (keyboard `I`):
- Single-click on canvas places a 30x30 diamond (rotated <rect>) at the
point, with the Power-cable_type colour fill (red-ish).
- Inline namer prompts for a label; empty → server defaults to "IO".
- Drop-point determines initial frame_id via the existing frameAt()
point-in-rect logic, same as devices.
Render:
- io_markers come from snap.io_markers in the snapshot loader. Each
renders as a <rect> with rotate(45) around its centre + a small text
label below the diamond. Selection halo on stroke-width.
- Drag is the same pointer-event flow as devices; on pointerup, PATCH
x,y + recompute frame_id from the new centre. Cross-frame moves
update frame_id with explicit null on the wire when leaving all frames.
- Frame-drag now also relocates contained IO markers (mirrors the
device-cascade pattern). Single PATCH per IO marker on release.
Cable-type inspector:
- Clicking a legend row now sets state.selection = {kind:"cable_type", id}
in addition to toggling activeTypeId. The inspector renders the cable
type's details (name + colour, both editable, with the
"shared across projects" banner from v3 §7), a used-by counter (0
until slice 7 ships cables), and a Delete button that surfaces the
RESTRICT in_use_by_cables count from the server.
- Debounced rename via the existing bindDebouncedRename helper.
Inspector frame view picks up an "IO" count alongside the device count.
Background click + Esc clear the selection (existing behaviour, now
covers cable_type too).
Hand-tested via the API equivalents: 3 IO markers created (free, in
frame, default-label), PATCH x,y + frame_id-to-null all work, cross-
project frame_id rejected with 400, DELETE 9999 returns 404. Snapshot
shape post-slice-3: {frames, devices, io_markers, cable_types} all
populated, ports/cables/bundles still [].
startDrag set state.selection but didn't render until pointerup's onUp
ran — and onUp can throw on `e.currentTarget.classList.remove` if the
event reference is stale after pointer capture release, which leaves
the inspector stuck on 'Nothing selected.'
One-line fix: call render() right after state.selection assignment so
the inspector + halo update from pointerdown, independent of whether
onUp completes cleanly. The drag-completion render at the end of onUp
stays — when both fire it's idempotent (renders are pure functions of
state).
Root cause traced by sherlock with Playwright (docs/sherlock-+dev-bug.md
on the sherlock branch). The previous routing fix at 94869f3 was
necessary but not sufficient: placeDeviceAt() now reaches promptInline()
correctly, but the synchronous input.focus() is undone ~6ms later by
the browser's compatibility-mousedown default — which blurs the active
element when the mousedown landed on a non-focusable target (SVG rect /
SVG root). The blur listener then ran done(null) and ripped the
<foreignObject> out before m could type a name.
Primary fix: e.preventDefault() at the top of both armed-tool branches
in onCanvasPointerDown. Suppresses the focus-shifting default so the
input keeps focus.
+Frame is also wrapped for symmetry. It wasn't strictly affected (its
namer runs from pointerup, not pointerdown) but preventDefault avoids
a subtle text-selection side effect during rubber-band drag.
Secondary fix in promptInline.done(): clear activeNamer *before*
fo.remove(). Enter-key triggers a synchronous blur listener which
re-enters done() — if remove() ran first, the re-entry hit a
"node no longer a child" pageerror. Reordering makes the re-entry a
no-op (activeNamer is already null).
Verified locally: served /main.js shows e.preventDefault() inside both
tool branches and the reordered done() body. go test -race ./... still
green.
Issue 1 — cursor lies about armed tool. .svg-draggable { cursor: grab }
on frame/device rects beat the .canvas-wrap.tool-device #canvas {
cursor: crosshair } rule because element-level wins over descendant.
m saw "grab" hovering a frame with +Dev armed and thought the tool was
broken even though clicks routed correctly after the previous fix. Add
a descendant rule with !important so tool-armed wraps any child cursor:
.canvas-wrap.tool-frame #canvas *,
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
Issue 2 — stale browser cache after each redeploy. http.FileServerFS
served embedded assets with no Cache-Control header, so browsers held
on to the previous main.js/style.css until hard-reload. New noCache
middleware on the static handler emits Cache-Control: no-cache. Note:
embedded FS files have zero ModTime, so http.FileServer suppresses
Last-Modified — every fetch is a fresh 200 rather than a 304. Fine at
~30KB of JS+CSS, and fixes the staleness problem completely.
Middleware is wrapped only around the static handler. /api/* responses
write their own headers and aren't touched.
Verified locally:
curl -I /main.js → Cache-Control: no-cache
curl -I /style.css → Cache-Control: no-cache + contains the new rule
curl -I /api/healthz → unaffected (no Cache-Control from us)
go test -race ./... still green.
onCanvasPointerDown returned early whenever the click landed on a
[data-device-id] or [data-frame-id] element so the per-element drag
handlers wouldn't get hijacked. Problem: this early-return fired BEFORE
the tool check, so clicking +Dev inside an existing frame never reached
placeDeviceAt().
Reordered: tool-armed branches run first and short-circuit. Only when
no tool is armed does the "click started on a child element — leave it
alone" guard kick in. End behaviour:
- +Dev anywhere (incl. inside a frame) drops a device. frame_id
auto-resolves via the existing frameAt() point-in-rect.
- +Frm anywhere (incl. inside an existing frame) starts a rubber-band;
rare but not harmful.
- No tool armed: clicking a device/frame still goes to its own handler
(drag / select). Clicking empty canvas still clears selection.
Hand-tested via the served /main.js + the equivalent backend POST/PATCH
sequence: device-in-frame, device-outside, device-drag, frame-drag with
cascaded device patches — all work.
Renders the slice-2 backend on the empty canvas from slice 1.
Canvas:
- Frames render as dashed-stroke rects with top-left label, slightly
tinted fill. Devices render as solid-stroke rects with centred label
in device.color.
- Selection halo via .selected class (stroke-width bump).
- Empty-state hint disappears once any geometry exists.
Tools (left sidebar + keyboard):
- F / + Frame — rubber-band rect on the canvas. <80×60 cancels. On
release, inline foreignObject namer → POST /api/projects/:pid/frames.
- D / + Device — single click places a 100×35 device centred at the
click. Inline namer → POST devices. Drop-point determines initial
frame_id via point-in-rect against all frames (smallest bbox wins).
- Esc cancels active tool / inline namer / clears selection.
Drag (pointer events + svg getScreenCTM):
- Devices: drag updates x/y live via transform, persists via
PATCH .../devices/:id on pointerup. Also recomputes frame_id from
drop point and includes "frame_id": null|<id> if it changed.
- Frames: dragging a frame moves its contained devices visually too;
on pointerup, single PATCH for the frame + one PATCH per moved device.
Children-batch is computed at pointerdown and only sent on release —
no per-pointermove network traffic.
Inspector:
- Frame selection: name (debounced rename), x/y/w/h, device count,
Delete button (confirm prompt — devices keep existing, frame_id → NULL
via the schema's ON DELETE SET NULL).
- Device selection: name (debounced rename), colour picker
(change-event PATCH, no debounce), x/y/w/h, current frame, Delete.
- Background click clears selection.
devicePatch wire format uses tri-state frame_id: key absent = leave,
key:null = clear, key:<int> = move. Frontend uses `null` explicitly
when a device drops outside all frames.