Canvas zoom shipped pan as middle-drag / Space+drag, which left m unable
to reach a freshly-created frame outside the default viewport — the
only escape was middle-button or holding Space, neither of which is
discoverable.
Empty-canvas left-pointerdown now starts an ambiguous gesture: if the
cursor moves past a 3px screen-space threshold it promotes to a pan
(Excalidraw / Figma standard); below the threshold it falls back to
the historic "click empties the selection" UX so plain clicks still
deselect. Pointerdown on a device, frame, IO marker, port, or cable
keeps routing to its own handler. Middle-drag and Space+drag pan
unchanged.
Excalidraw scene now mirrors the v5 routing model:
- Clamps export as 12×12 grey rounded squares (BackgroundColor=#888888,
StrokeColor=#555555, Roundness type 3). Distinct from the red IO
marker diamonds so wall outlets vs. routing anchors stay readable.
Frame_id propagates into the element's FrameID per the existing
pattern.
- Cable arrows include clamp positions as mid-vertices in the
`points` array. Pre-grouped + sort.Slice-sorted by ord; each
mid-vertex is added as an (x-fromAnchor.x, y-fromAnchor.y) offset.
startBinding / endBinding still point at the from / to endpoint
excalidraw_ids; mid-vertices are unbound (Excalidraw doesn't have
per-vertex binding).
- IDAssignment grows a Clamps map; PersistExcalidrawIDs accepts it
and updates clamps.excalidraw_id on first export so re-exports
reuse the same element ids (collab cursors / undo history survive).
- Bundle-stripe overlay is **viewer-only** — Excalidraw can't
represent gradient strokes losslessly, so we export individual
cable arrows and let the in-app viewer derive the bundle viz.
Tests:
- TestBuildScene_ClampsRenderAsRectangles — 2 clamps → 2 rectangle
elements + 2 ids in IDAssignment.Clamps.
- TestBuildScene_ArrowPointsIncludeClamps — cable with 1 clamp →
arrow.Points has 3 entries; middle vertex equals the clamp's
position relative to fromAnchor.
This closes the v5 slice plan (§11.10). Six slices, one branch,
one redeploy below.
Walks every cable's polyline, keys each vertex by stable identity
(port:N / device:N / io:N / clamp:N), and accumulates cables by
undirected segment-key. Segments with ≥ 2 cables get a thick striped
overlay line in a new <g id="canvas-bundles"> layer, drawn on top of
the individual cable lines so the shared portion reads as a bundle
while endpoints still fan out to each cable's port colour.
- Stripe width: 2 + N px, capped at 12 (design v5 §11.3).
- Stripe order: by distinct cable-type count (ties by id) per
v5 §11.9 q4.
- Implementation: SVG <linearGradient> with hard stops oriented
perpendicular to the segment, registered in a new
<defs id="canvas-defs"> on every render. Bundle <line> uses
stroke="url(#bundle-grad-…)".
- <title> child lists the cable types and total cable count for
hover tooltips.
- Clamp render gains a ×N badge when ≥ 2 cables route through it,
derived independently from state.cableClamps.
Helper rename: cableVertices → cableVerticesWithKeys (returns
{vertices, keys}). The keys array also feeds the shared-segment
detection — keeps the geometry + identity tracking in one pass.
Cable type creation is managed via the admin modal (⚙ → Cable types
tab), which makes the prominent sidebar affordance unnecessary. Drop
the button element and its click handler; the legend itself (rows,
edit button per row, active-type selection) is unchanged.
Cables now render as <polyline> through their cable_clamps in `ord`
sequence. Empty clamp set collapses to a straight from→to line, so
nothing visual changes for unrouted (auto-emitted) cables.
cableVertices(cable, …) resolves the endpoint anchors + each clamp's
(x, y) into the vertex array. Endpoint-replug handles continue to
operate on the first/last vertex.
Mid-segment drag — startCableMidDrag:
- Triggered by pointerdown on a *selected* cable's polyline (button=0,
not on an endpoint handle, no Space pan).
- nearestSegmentIndex + pointSegmentDistance pick which segment m is
bending. The dragged vertex is rendered as a temp inserted point in
the cable's polyline via a module-level cableMidDrag preview.
- On release: snap to the nearest existing clamp within
MID_SNAP_PX / zoom (visual constant per design v5 §11.9 q2), else
POST a fresh clamp at the drop point. Either way, attach to the
cable at ord = segIdx + 1 so the new vertex sits inside the segment
m was bending. A tiny-motion (< 4 world-units) drop is treated as
a plain click-to-select and cancelled.
Snapping to a clamp already on the cable is a no-op (UNIQUE constraint
would 409). Re-fetches cable_clamps from the snapshot after each
attach so ord shifts from the slice-1 attach helper propagate.
Frontend hooks for the v5 routing primitive.
- state gains clamps + cableClamps arrays, hydrated from the snapshot
(`clamps`, `cable_clamps`). Reset on null-project + project-404 paths.
- API helpers: createClamp / patchClamp / deleteClamp + attach / detach /
reorder cable_clamps.
- +Clamp tool button + "C" keyboard shortcut. armTool flips the
tool-clamp class on .canvas-wrap (crosshair cursor).
- onCanvasPointerDown routes tool === "clamp" to placeClampAt, which
POSTs a clamp at the click position. If the click target is on a
cable, the new clamp is also attached to that cable in one go.
- renderCanvas paints clamps as 12×12 rounded squares (per design v5
§11.9 q1) in a new #canvas-clamps <g>. Drag uses the existing
startDrag pipeline (kind="clamp"), which now also moves clamps when
their containing frame is dragged.
- renderInspectorClamp shows label + position + cables-through list +
Delete (with cascade confirm when shared).
Slice 4 wires the clamp into a cable's polyline (mid-segment drag,
visual routing); for now placing a clamp on top of a cable just
attaches it.
Wire the v5 store helpers from slice 1 onto net/http routes:
GET /api/projects/:pid/clamps
POST /api/projects/:pid/clamps
PATCH /api/projects/:pid/clamps/:id
DELETE /api/projects/:pid/clamps/:id
POST /api/projects/:pid/cables/:cid/clamps — attach
PUT /api/projects/:pid/cables/:cid/clamps — reorder
DELETE /api/projects/:pid/cables/:cid/clamps/:cmid — detach
frame_id uses the same json.RawMessage tri-state as device/io patches
(absent / null / int) via the existing parseFrameRef helper.
Snapshot endpoint (GET /api/projects/:id) now carries the clamps[] +
cable_clamps[] arrays surfaced by ListClamps + ListCableClamps in
slice 1, so the frontend gets everything in one round-trip.
Migration 007 introduces the v5 routing primitive:
- clamps table (project-scoped, optional frame_id, excalidraw_id).
- cable_clamps join (cable_id, clamp_id, ord) with PK on (cable_id, ord)
and UNIQUE (cable_id, clamp_id) to block a clamp visiting the same
cable twice.
Store helpers in internal/db/clamps.go:
- CreateClamp / GetClamp / ListClamps / UpdateClamp / DeleteClamp —
standard project-scoped CRUD. UpdateClamp uses FrameRef tri-state.
- AttachClampToCable — appends or inserts at a given ord. Mid-sequence
inserts use a two-pass shift (bump by 10000, settle to ord+1) since
SQLite UPDATE doesn't support ORDER BY and a single bulk +1 would
collide with the UNIQUE (cable_id, ord) PK.
- DetachClampFromCable — removes the row then closes the gap.
- ReorderCableClamps — replaces the whole sequence in one tx.
- ListClampsForCable / ListCableClamps — read helpers.
Snapshot now carries clamps + cable_clamps arrays so the frontend can
hydrate everything in one call.
Tests cover create / update / cascade-delete / attach (append + insert
+ duplicate-rejected) / detach (gap closes) / reorder / snapshot.
Bare 'mcables' pattern in .gitignore + .dockerignore matched cmd/mcables/
in addition to the built binary at repo root. Root-anchored to '/mcables'.
cmd/mcables/main.go now tracked in git. Fresh worktrees / clones build
clean without copying main.go from a sibling.
The bare `mcables` pattern in .gitignore (line 11) and .dockerignore
(line 18) was intended to ignore the built binary at the repo root, but
without a leading slash it also matched the cmd/mcables/ directory. The
result: cmd/mcables/main.go was never tracked in git, and fresh worktrees
had to copy it from a sibling to build.
- Change `mcables` → `/mcables` in both files (still ignores the root
binary; no longer matches the cmd subdirectory).
- Add cmd/mcables/main.go (copied from picasso's worktree, verified
identical to head's main checkout).
Verified: `git check-ignore cmd/mcables/main.go` returns not-ignored;
a touched `./mcables` at the repo root is still ignored via `/mcables`.
`go build ./...` clean.
Adds armTool('cable') so the cursor shows crosshair during the
in-progress draw — matches m's literal 'cursor crosshair' request.
(Picasso shipped a similar fix in parallel due to a head dispatch
race; dropping picasso's variant in favour of this one.)
Double-click a port → enter cable-draw mode from that port without
having to arm the cable tool first. armTool("cable") is called so
the crosshair cursor is active during the draw; the next port-click
hits the existing cable-draw-in-progress branch in onPortPointerDown
and commits the cable. Esc / clicking the source port cancels.
Single-click behaviour (select + open port inspector) is unchanged
because pointerdown still hits onPortPointerDown first; dblclick
upgrades the selection to a cable-draw source.
m's bundling primitive: a clamp is a physical anchor on the canvas;
cables route through clamps in order; cables that share a consecutive
clamp pair are visibly bundled on that segment. Overlap is the bundle —
no detection pass.
Section covers:
- 11.1 Schema: clamps table + cable_clamps join, migration 007. Clamps
carry frame_id so frame-drag carries them.
- 11.2 Cable rendering: <polyline> through [from, clamp₁..n, to];
endpoint-replug handles stay on first/last vertices.
- 11.3 Bundle visualisation: shared segments rendered as a 2+N px
striped line; clamp icon shows ×N count when shared. Computed live
on every renderCanvas — O(C·N̄), trivial at v0 scale.
- 11.4 UI: +Clamp tool (C shortcut), mid-segment drag-to-snap (snap
radius ~16 px / zoom), clamp inspector, right-click remove-from-cable.
- 11.5 Existing bundles table: keep, repurpose. Implicit bundles are
derived from shared clamp segments; explicit named bundles still live
in the table.
- 11.6 Solver coupling: v0 solver still emits straight cables; m
hand-routes after. v5.1 future work for solver-suggested clamps.
- 11.7 Export: clamps export as small grey diamonds; cable arrows use
Excalidraw's points array for mid-vertices. Bundle stripes are
viewer-only (Excalidraw can't represent them losslessly).
- 11.8 API additions: clamp CRUD, attach/detach/reorder cable clamps.
Snapshot grows clamps + cable_clamps arrays.
- 11.9 Five open questions for m (icon shape, snap radius scaling,
cascade-on-delete confirm, stripe order, solver respect for manual
clamp routing).
- 11.10 6-step slice plan post-approval.
DESIGN v5 READY FOR REVIEW
Selected cable shows two endpoint handles (r=7, coloured + halo).
pointerdown on a handle starts an endpoint drag; hitTestEndpointTarget
resolves cursor over port / device / IO marker; pointerup PATCHes the
from_/to_ field. Cancel on empty canvas or same-endpoint drop.
auto=1 cables auto-promote to auto=0 when m successfully drops on a
new valid endpoint.
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).