m can grab either end of a selected cable and drop it on a different
port / device / IO marker. Mechanics:
- Selected cable renders two .cable-handle circles at its endpoints
(handle radius 7, filled in the cable's colour with a white halo +
drop-shadow). Hidden unless the cable is selected so unrelated cables
don't litter the canvas with grab points.
- pointerdown on a handle calls startCableReplug; the module-level
cableReplug = {cableID, end, x, y} drives renderCanvas to anchor the
affected endpoint at the cursor in world coords. Pointermove keeps
the line tracking; pointerup hit-tests the cursor via
elementsFromPoint (skipping the cable-handle itself).
- Drop target:
port → PATCH {from|to: {port_id}}
device → PATCH {from|to: {device_id}}
IO → PATCH {from|to: {io_id}}
empty / same endpoint → cancel (no PATCH)
- When the cable was auto=1 and the drop commits, the PATCH also sends
promote=true so the server flips it to manual — m took control.
- preventDefault + stopPropagation on the handle pointerdown so canvas
panning / cable-line clicks don't interfere. Pointer capture survives
the drag leaving the SVG bounds.
CSS: .cable-handle gets grab cursor + drop-shadow; .replugging on the
canvas-wrap promotes to grabbing during the gesture.
10x10 handle on every device, drag to resize. Min 60x30. On pointerup,
PATCH width/height + relayoutAllEdges so ports re-distribute. stopPropagation
keeps the body drag separate from the handle drag. Works at any zoom.
m: 'I want the size of devices to be customizable. A resize function at
the bottom right corner would be good.'
- 10×10 SVG handle drawn at each device's bottom-right corner with class
.device-resize-handle + cursor: nwse-resize. Subtle grey by default,
darker on hover so m can find it without it dominating the rect.
- startResize captures the pointer, stops propagation so the rect's
pointerdown (= startDrag) doesn't also fire, and updates the local
device.width / .height on every pointermove using svgPoint deltas —
works at any zoom level via the same world-coord conversion the rest
of the canvas uses.
- Clamps to 60×30 minimum during the drag so the rect can't collapse.
- On pointerup: PATCH /devices/:id with the new width + height, then
relayoutAllEdges(deviceID) so ports on every edge redistribute to
their i/(N+1) positions against the new dimensions. Right- and
bottom-edge ports get the visible adjustment; top/left re-space too
but their absolute positions don't change.
state.view = {x,y,zoom} drives SVG viewBox. Zoom clamped 0.2-5x.
- Wheel = zoom around cursor (Excalidraw-style)
- Middle-drag or Space+drag = pan
- 0 or Home = reset
- Header: zoom % indicator + Fit button (bbox + 40px padding)
- URL persists ?z=&px=&py= (cleaned when at default)
- All inputs/hit-tests stay in world coords — no changes needed to
port/cable/drag handlers
m: wheel to zoom around the cursor, drag with middle-mouse / Space-held
to pan, `0` or `Home` to reset, Fit button to frame all content.
Implementation:
- state.view = { x, y, zoom } drives the SVG viewBox via applyViewBox().
Base canvas is 2000×1500; viewBox = (view.x, view.y, 2000/zoom, 1500/zoom).
- Zoom clamped to 0.2x..5x. wheelZoom captures the cursor's world coord
before + after the zoom-step and shifts view.x/y so it stays under
the cursor (Excalidraw-style cursor-anchored zoom).
- startPan captures screen→world scale from getScreenCTM at pointerdown
and converts pointer-move deltas into view.x/y updates — robust across
zoom levels. Triggered by middle-mouse OR Space+drag. Releases pointer
capture + persists the view on pointerup.
- resetView (0 / Home) restores zoom=1, x=0, y=0.
- fitToContent walks frames + devices + IO markers, computes their bbox
with 40px padding, picks zoom = min(BASE_W/bw, BASE_H/bh), and centres
the bbox inside the viewBox (compensating for aspect-ratio meet).
- Header gets a "100%" zoom indicator + Fit button. URL persists view
as ?z=1.200&px=…&py=… so reload returns to the same view.
Because everything goes through viewBox (not CSS transform), svgPoint
still maps screen pixels to world coords via getScreenCTM. Existing
hit-tests, drag, port/cable placement all keep working unchanged.
Device inspector gains a Requirements section + Requirement button
pre-filled with the current device's id. The global Requirements
section is removed from the left sidebar — legend + tools reclaim
the space. All-requirements view moves into the admin modal as a
5th tab.
m wants 'this device connects to ...' declared from the device itself,
not a global sidebar list.
- Device inspector gets a '+ Requirement' button under its Requirements
section. Click pre-fills the modal with from_device_id = this device,
so m only picks the other endpoint + cable type + must/nice.
- Existing requirement rows in the device inspector remain clickable —
they jump to the requirement's own inspector pane.
- New 5th admin tab 'Requirements' carries the all-projects-wide list
with Edit + Delete actions per row and a single '+ Add requirement'
entry point (uses the same modal). Edit/Add close the admin modal
so the requirement modal isn't stacked on top.
- Left sidebar 'Requirements' section + '+ Requirement' button removed.
The legend + tools sections reclaim the freed real estate.
renderRequirements() and the renderRequirements call site in render()
deleted (no consumer left). #btn-add-requirement boot wiring removed.
Header gear ('⚙ Admin') opens a wide modal with four tabs:
- **Projects** — list, rename, edit drawing_name + description, delete
with typed-name confirm. Wires the existing PATCH /projects/:id and
DELETE /projects/:id?confirm=<name> endpoints; renaming was previously
only reachable via the API.
- **Cable types** — full CRUD with the global-scope banner. Mirrors the
legend's quick edit but in a tabular list, plus an inline "+ Add"
form at the bottom.
- **Device types** — built-ins listed read-only with a locked badge
showing kind, description, and port profile (each port row tinted
with the cable_type's colour). Project-custom types under the active
project get editable name / kind / icon / description + Delete.
Port-profile editing on custom types is still deferred (port-profile
reshape will land in a follow-up).
- **Setup templates** — read-only list of built-ins with member devices
and connection requirements expanded under each.
The modal re-fetches projects / cable types / setup templates on open
so it reflects current state regardless of what m did via inspector
panes while it was closed.
Files:
- index.html: ⚙ Admin button + #modal-admin dialog scaffold.
- main.js: patchProject + createDeviceType/patchDeviceType/deleteDeviceType
API helpers; openAdminModal + switchAdminTab + 4 render functions.
- style.css: .admin-shell / .admin-tabs / .admin-row + state classes.
Port inspector now has a Type dropdown (PATCH /ports/:id with
type_id), keeps edge picker + label input + delete + back-link.
Replaces the canvas-armed +Port tool with a sidebar 'Add port' form
(reached via +Port button in the device inspector). Form fields:
Type, Edge, Label with auto-default '<type> <next-index>' that stops
auto-updating once m hand-edits. Submit → POST → relayout edge for
even spacing → selection switches to the new port's editor.
Port rows in the device inspector's list now click-to-select.
Removed scaffolding: tool === 'port' branch, armPortTool,
placePortAt, snapToDeviceEdge, .tool-port cursor CSS.
m: 'Add port' should be a sidebar form, not a two-step canvas gesture.
- Port inspector gains a Type dropdown (read /api/cable-types via
state.cableTypes, PATCH /ports/:id with type_id). Edge picker + label
+ delete from prior shift are unchanged.
- New "Add port" form rendered from selection.kind === "port_new":
Type / Edge / Label, Create + Cancel buttons. Default label is the
next free index for the chosen type on this device ("HDMI 3" if two
HDMIs already live there). Recomputes when m changes the type, but
stops recomputing as soon as m hand-edits the label.
- +Port in the device inspector now flips selection to port_new,
rendering the form. Submit → POST → switch to the new port's editor.
No second canvas click required.
- Clicking a port row in the device inspector's port list selects that
port and opens its editor (same surface as canvas-click).
- "← <device name>" back-link in both port editor and add-port form
jumps back to the device inspector.
Removed: state.tool === "port" branch, armPortTool helper, placePortAt
function, .tool-port CSS, state.portToolDevice / portToolTypeID. The
canvas-armed +Port tool was the user-trip-wire perseus flagged; the
sidebar form replaces it entirely.
snapToDeviceEdge also removed — placePortAt was its only caller; the
edgeCentre + portEdge + relayoutEdge trio fully owns port placement
now.
Port rows in the device inspector get a hover background + pointer
cursor to read as clickable.
ApplyTemplate now creates a frame named after the template
('Living Room' etc, suffixed on collision), computes a uniform grid
(cols=min(ceil(sqrt(N)),4), rows=ceil(N/cols)), and places each
device inside the frame with frame_id set.
Frontend unchanged — activateProject re-hydrates the snapshot
including the new frame.
Tests cover frame creation + in-frame placement + name-collision
suffix. Verified on mDock: Living Room template → frame (200,200,
294×200) with TV/Soundbar at row 0 and ChromeCast wrapping to row 1.
Before: ApplyTemplate dropped devices in a horizontal row at fixed
canvas coords with frame_id NULL — devices appeared anywhere and m
had no way to express "these belong together".
Now: each apply creates a frame named after the template (suffixed
"… 2/3/…" on name collision) and lays the devices out in a uniform
grid inside it. Grid is roughly square (cols = ceil(sqrt(N)), capped
at 4) with 30/50 px gaps and 32/48 px padding. Each device gets the
new frame's id and grid-cell coords.
Schema unchanged. ApplyTemplateResult.frames_added carries the new
frame so the frontend can refresh the canvas without a full snapshot
reload.
Tests:
- TestApplyTemplate_CreatesFrameAndPlacesDevicesInside — frame is
created with the template's name, every device has frame_id set,
every device sits inside the frame rect, no two devices share a
grid cell.
- TestApplyTemplate_FrameNameSuffixOnCollision — pre-existing
"Living Room" frame in the project ⇒ template's frame named
"Living Room 2".
- Existing tests unchanged.
3 commits (491db73, b28fc0c, 86264d1):
- +Port now sets state.selection on the new port → inspector switches
to the port panel + halo shows
- Ports relayout to even spacing along the affected edge on every
add/delete/edge-change (no more invisible stacking)
- startDrag.onUp captures the rect in closure instead of reading
currentTarget after pointerup (no more 'classList of null' spam)
- Device colour: dropped CSS stroke/fill hard-codes, inline style now
paints the rect — picker actually changes the visible colour
All verified end-to-end on the deployed image.
CSS .device-rect hard-coded stroke + fill, overriding the
stroke=${d.color} SVG attribute the JS wrote. Author CSS beats
presentation attributes, so changing the device colour via the
inspector picker was invisible.
Drop the stroke/fill overrides from .device-rect; set both inline
on the rect element instead — stroke = the chosen colour, fill =
a 12% tint via color-mix so the device reads coloured without
becoming garish. Inline style beats class CSS, so the picker works.
Frames + IO markers don't currently expose a colour picker, so no
analogous fix needed there.
m's stronger invariant: ports must never overlap and must line up on
their edge. Replace the slide-collision dedup with full even-spacing
re-layout — for N ports on an edge, position i goes to axis · i/(N+1)
for i=1..N.
- New portEdge(port, dev) — snaps a port's current offsets to the
nearest of the four edges (same heuristic as snapToDeviceEdge).
- New relayoutEdge(deviceID, edge) — re-spaces every port on the
device-edge and PATCHes the ones whose offsets actually change.
Sort key: x_offset for top/bottom, y_offset for left/right —
preserves m's "I dropped it roughly here" order.
Applied on:
- placePortAt — re-layout the edge after the new port is created.
- inspector edge picker — capture oldEdge, PATCH the port to the
centre of newEdge, then re-layout BOTH old and new edges.
- port delete — re-layout the edge the deleted port was on so the
survivors collapse back to even spacing.
snapToDeviceEdge reverted to its pre-dedup shape (drop the existingPorts
arg and resolveCollision helper); the layout invariant is owned by
relayoutEdge now. edgeOf folded into portEdge.
Three changes from sherlock's Playwright debug (docs/sherlock-+port-bug.md):
1. Select the freshly-placed port. placePortAt now sets
state.selection = {kind:"port", id:port.id} before render() so the
inspector switches to the port panel and the .selected halo makes
the new circle visible — fixes m's "+Port does nothing" perception
(the port WAS being created server-side; it just rendered invisibly
stacked under an existing one and the inspector stayed on the device).
2. Snap-to-edge dedup. snapToDeviceEdge now takes the existing ports
on the device; if the computed (xOff, yOff) lands within 8px of a
peer on the same edge, slide along the edge in 16px steps until a
free slot is found. Eliminates pixel-perfect port stacks.
3. startDrag closure-capture. onUp asynchronously referenced
e.currentTarget after pointerup nulled it, throwing a TypeError
in the console on every click-only device selection. Capture
dragTarget in the outer closure and use that inside add/remove.
m: 'IOx-8 should have 8 powerports on the front, one on the back'.
Migration 006 reshapes all 8 power-distribution types (IOx-3/6/8,
Multi-plug 3/4/5/6, Wifi-plug) into 1 Power In on top (back) +
N Power Out on bottom (front).
Existing devices keep their old ports per design §2.3 — delete +
recreate to pick up the new layout.
Verified on mDock: IOx-8 ports = [(top, Power In, 1), (bottom,
Power Out, 8)].
m's actual hardware: IOx-3/6/8 are power strips, not USB hubs. v4 seeded
them as Power × 1 + USB × N which doesn't match reality. Multi-plug 3-6
and Wifi-plug from v5 lumped every Power port on the same bottom edge
without distinguishing input from outputs.
Migration 006 wipes and re-seeds the port profile for all 8
power-distribution types with the canonical 2-row layout:
Power In × 1 on top (back, sort_order 0)
Power Out × N on bottom (front, sort_order 1)
N for each:
IOx-3 / Multi-plug 3 → 3
IOx-6 / Multi-plug 6 → 6
IOx-8 → 8
Multi-plug 4 → 4
Multi-plug 5 → 5
Wifi-plug → 1 (pass-through outlet)
Existing device instances keep their already-seeded ports per design
§2.3 (ports are instance-owned). m needs to delete + recreate any
IOx-* / Multi-plug-* / Wifi-plug instances to pick up the new layout.
Tests:
- TestSeed_PortProfiles: comments updated; totals unchanged (Power In 1
+ Power Out N matches old Power 1 + USB N / Power N).
- TestSeed_PowerHubs (was TestSeed_PowerCatalog, rewritten): table-drives
all 8 affected types. Asserts exactly 2 port rows — top/Power In/1 and
bottom/Power Out/N — plus kind/icon for the v5 catalog entries.
Design §2.2 catalog table refreshed to match.
picasso shipped (1 commit @ 82cf5a3, +157/-28):
- onPortPointerDown rewritten into 4 deterministic branches:
cable-draw-in-progress | no-tool-no-draw | cable-tool | other-tools
(bubble). Other-tools branch is what makes +Port placement work
when the click lands on an existing port — the previous handler
silently returned for any non-cable tool.
- Port circles fill + stroke in cable-type colour. .selected halo.
- New renderInspectorPort: type swatch + label + edge dropdown
(Top/Right/Bottom/Left) + delete. Edge change PATCHes x_offset
and y_offset to the chosen side's centre.
End-to-end verified on deployed image via PATCH /ports/:id round-trip.
Three bundled fixes to slice 7's port flow:
1. Port-pointerdown branches deterministically:
- cable-draw in progress → finish / cancel
- no tool, no draw → select port (inspector opens)
- cable tool → start a draw from this port
- any other tool armed → bubble (so +Port can place a new port even
when the click lands on top of an existing one)
2. Port circles now fill *and* stroke with the cable_type colour so the
port reads as obviously coloured against the device rect. Selection
adds a drop-shadow halo.
3. Port inspector — clicking a port (no other tool armed) selects it
and shows a panel with cable-type swatch, label input, edge selector
(Top / Right / Bottom / Left), and Delete. Changing the edge PATCHes
x_offset / y_offset to the centre of the chosen side.
snapToDeviceEdge already picks the nearest of the four edges, so
placement on +Port lands correctly without further changes.
Adds 5 built-in device_types (project_id NULL, built_in=1):
- Multi-plug 3/4/5/6 (kind=hub, 🔌) — Power × N+1 (1 in + N out)
- Wifi-plug (kind=accessory, 📶) — Power × 2 pass-through outlet
The solver treats every Power port identically regardless of in/out
direction; m knows which end is which from the physical setup.
Tests:
- TestSeed_BuiltInDeviceTypes: built-in count rises from 16 → 21.
- TestSeed_PortProfiles: new entries' port totals.
- TestSeed_PowerCatalog (new, table-driven): asserts kind, icon, and
the single Power port row for each of the 5 new types.
mxdrw expects HTTP Basic Auth (BASIC_AUTH_USER + BASIC_AUTH_PASS on the
server side). Replace MEXDRAW_TOKEN with MEXDRAW_USER + MEXDRAW_PASS,
use req.SetBasicAuth on the export PUT.
Updated docker-compose.yml comment and README env table to match.
Roundtrip verified locally against mxdrw.msbls.de.
picasso shipped (2 commits): internal/exporter pure BuildScene +
Generate21 (crypto/rand base62 IDs), internal/db/excalidraw_ids.go
idempotent persistence, internal/server/export.go POST handler with
bearer auth + 10s timeout, frontend Export button + toast.
6 new exporter tests + 60+ existing all green with -race. Hand-test
roundtrip vs mxdrw confirmed: 20 elements per spec, IDs stable across
re-exports.
Deploy to mDock blocked on MEXDRAW_TOKEN — picasso correctly refused
to fake the secret. m to drop value into /home/m/secrets/mcables/.env
on mdock, then redeploy.
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>
apply-template now auto-solves by default (?solve=0 opt-out for power
users) and returns combined {template_apply, solve} response.
Frontend reloads via activateProject() after Apply, so devices +
cables render immediately without manual Solve click.
Verified: TEST-AUTO project + Living Room template → 3 devices +
2 HDMI cables visible in one round-trip.
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.
The schema has ON DELETE SET NULL on cables.from_port_id /
cables.to_port_id, but the cables CHECK constraint requires exactly one
of (port/device/io) to be non-null per side. Setting both refs to NULL
on a port-delete violates the CHECK, blowing up the DELETE with a 500.
DeletePort now opens a tx, deletes any cable that referenced the port
on either side, then deletes the port. Same observable effect from m's
POV: cables that point at a deleted port are gone (he can re-draw with
the manual cable tool if he still wants them).
New store methods on internal/db/ports.go:
- CreatePort / GetPort / UpdatePort / DeletePort (all project-scoped)
- ListPortsForDevice for the inspector's per-device list
New handlers (internal/server/ports.go):
- GET /api/projects/:pid/devices/:id/ports
- POST /api/projects/:pid/devices/:id/ports ← {type_id, label?, x_offset, y_offset}
- PATCH /api/projects/:pid/ports/:id ← partial
- DELETE /api/projects/:pid/ports/:id (cables ref → ON DELETE SET NULL)
Lets slice 7's +Port tool add/remove instance ports without going
through the type-seeded auto-creation path from slice 4.
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.
Migration 004:
- setup_templates + setup_template_devices + setup_template_requirements
- 3 built-in templates seeded: Living Room (TV+Soundbar+ChromeCast,
2× HDMI), Home Office (PC+Screen+Keyboard+Mouse, 1× HDMI + 2× USB),
Server Rack (NAS+Switch+fritz, 2× RJ45).
Cables store (cables.go):
- CRUD with endpoint validation (port|device|io exactly-one, project-
scoped). Tx-aware: validateEndpointEx + assertCableTypeEx avoid
deadlocks when the solver Apply tx holds the MaxOpenConns(1) connection.
Bundles store (bundles.go):
- CRUD with cable_ids replacement on PATCH. createBundle(ex, …, ownTx)
inherits the caller's tx for solver-internal use; returns a locally-
constructed Bundle when ownTx=false (re-fetching via s.db would
deadlock).
Solver (solver.go) implements design v4.1 §5b.2 exactly:
- Pre-fetch devices/ports/cables/requirements/bundles.
- Reserve ports used by manual cables (auto=0) so the solver can't
reuse them.
- For each requirement (must_connect DESC, id ASC):
* Resolve cable type: preferred, or T = port-types(from) ∩
port-types(to). |T|==0 → unsatisfied "no compat type"; |T|>1 →
"ambiguous"; |T|==1 → that one.
* Pick lowest-id free port on each side. None → unsatisfied with
WhichSide hint + cable-type name.
- Endpoint-pair bundle: ≥2 staged cables between the same device pair
→ auto bundle.
- Diff against existing auto cables by (type_id, MIN(from,to), MAX(from,to))
signature. Matched = kept; new = added; orphans = removed.
- Preview returns the diff without writing; Apply runs in a single tx
that wipes auto bundles, deletes orphan auto cables, inserts new
ones, and rebuilds bundles.
- PortsAndResolve: combo helper for the inspector quick-fix —
inserts a port + re-runs Solve.
Setup-templates store (setup_templates.go):
- List/Get with hydrated devices + requirements.
- ApplyTemplate(projectID, templateID, opts) seeds devices + requirements
in one tx. Per-device name overrides + opt-out. Name collisions skip
the device (skipped_devices); requirements whose endpoints both fail
are also skipped (requirements_skipped). UNIQUE-collision on an
existing requirement is non-fatal; logged in requirements_skipped.
Snapshot: cables + bundles fields tightened to []Cable / []Bundle and
populated from the store.
11 new tests (solver_test.go), all green with -race:
- Basic NAS↔Switch (RJ45) → 1 cable, auto=true
- Ambiguous cable type → unsatisfied
- No free port → unsatisfied with side hint
- Preview doesn't write
- Apply then re-apply → idempotent (kept=N, added=0)
- Manual cable reserves its port → solver can't claim it
- ApplyTemplate Living Room → 3 devices + 2 requirements + 7 ports
(from the device-type port seeder)
- Home Office template then Solve → 3 cables, 0 unsatisfied
- Name-collision pre-existing device → skipped + req-pair skipped
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).
Migration 003 adds the solver's per-project input table + the auto flag
that slice 6 will use to distinguish solver-owned cables from m's
hand-drawn ones.
connection_requirements:
- (from_device_id, to_device_id, preferred_cable_type_id) with
preferred_cable_type_id nullable ("solver picks if exactly one type
matches both ends").
- (pair_lo, pair_hi) is the order-normalised MIN/MAX of (from, to),
stored alongside the m-facing from/to so the UI doesn't have to
denormalise.
- UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id) →
(A,B,T) and (B,A,T) collide; (A,B,Power) + (A,B,RJ45) coexist.
- CHECK (from != to). FK CASCADE from devices → requirement vanishes
if either endpoint device is deleted.
Store + 11 new tests:
- pair normalisation rejects the reversed-direction duplicate
- different cable types on the same pair coexist
- self-loop rejected (ErrInvalidInput)
- cross-project device reference rejected
- two null-cable-type reqs on the same pair both succeed (SQLite NULL
!= NULL in UNIQUE — semantically "solver picks both times", second
wins)
- partial PATCH: preferred_cable_type_id tri-state (leave/set/clear),
must_connect bool, notes string
- device delete cascades to its requirements
- snapshot.connection_requirements is non-nil and populated
cables.auto:
- ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0 CHECK
(auto IN (0,1)). Slice 6 sets 1 from the solver; slice 7's manual
cable POST keeps the default 0.
picasso shipped (4 commits @ 7f0b6e4):
- migration 002: device_types + device_type_ports + devices.type_id,
seeded with 16 built-ins (NAS PC Mac Notebook TV Soundbar Switch
fritz ChromeCast SteamLink IOx-3/6/8 Screen Keyboard Mouse)
- store: type-aware device POST seeds ports transactionally with
even-spread layout along the configured edge
- handlers: /api/device-types (built-ins) + /api/projects/:pid/device-types
(merged with project-custom), 403 on built-in mutations
- frontend: +Dev becomes a type-dropdown grouped by kind + name input
pre-fill, port rendering as SVG circles colour-stroked by cable type
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).
- GET /api/device-types — built-ins only (read-only).
- GET /api/projects/:pid/device-types — built-ins + project-custom merged.
- POST/PATCH/DELETE /api/projects/:pid/device-types — project-custom only.
Mutating a built-in row returns 403 via the new ErrForbidden → 403 map
in writeError.
- devicePatch / deviceCreate JSON shapes accept type_id (tri-state for
PATCH via the existing parseFrameRef helper applied to type_id too).
- POST /api/projects/:pid/devices with type_id seeds ports in one tx
server-side; response carries the device row + the snapshot will then
carry the new ports.
Catalog: 11 built-ins from §2.2 + the v4.1 trio (Screen, Keyboard, Mouse)
seeded in migration 002, totalling 16 built-in types.
Store layer:
- internal/db/device_types.go — CRUD for device_types. Built-ins
(project_id NULL) reject PATCH/DELETE with new ErrForbidden sentinel
(handler maps to HTTP 403). Project-custom types accept full CRUD;
cross-project access returns ErrNotFound. Replacing the port profile
on UPDATE is one transaction.
- internal/db/ports.go — ListPortsForProject for the snapshot loader +
seedPortsFromType(tx, …) used by CreateDevice. Layout is "evenly spaced
along the configured edge", per-edge group ordering by sort_order +
id. Labels are "<prefix>" for count==1 and "<prefix> N" 1-indexed for
count>1.
- Device gains a nullable TypeID + tri-state on UpdateDevice. CreateDevice
validates the type is built-in or a project-custom row of the same
project, then seeds the device's ports in the same transaction.
Snapshot now populates Ports from the store; field type tightened to
[]Port.
Tests (15 new, all green with -race):
- 16 built-ins seeded with correct names + project_id=NULL + built_in=1
- Port-profile totals match the §2.2 table for every built-in type
- Project-custom create + name-collision-with-built-in → 409 (ErrConflict)
- Per-project name UNIQUE — same custom name across projects is fine
- PATCH/DELETE built-in → ErrForbidden
- Cross-project custom PATCH → ErrNotFound
- CreateDevice with NAS type → 2 ports along bottom edge, evenly spaced,
labels set
- CreateDevice with PC type → 5 ports incl. "USB 1" + "USB 2"
- CreateDevice without type_id → 0 ports (freeform fallback)
- Cross-project custom type on CreateDevice → ErrInvalidInput
- Snapshot includes the seeded ports
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 [].