33 Commits

Author SHA1 Message Date
mAi
1c234f3f46 feat(ui): bottom-right resize handle on frames
m: 'We should also be able to resize frames, the same way we do with
devices.' Mirrors the device-resize pattern (89686d0).

- 10×10 SVG handle drawn at each frame's bottom-right corner with class
  .frame-resize-handle + cursor: nwse-resize. Appended after the label
  so it sits on top of the rect and wins the pointerdown.
- startFrameResize captures the pointer, stops propagation so the
  rect's pointerdown (= startDrag 'frame') doesn't also fire, and
  updates f.width / f.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 200×150 minimum during the drag (frames need more room
  than devices since they host devices + IO markers + clamps).
- On pointerup: PATCH /api/projects/:pid/frames/:id with the new width
  + height. Contained children stay at their absolute positions — the
  frame body drag is what moves them; resize only changes the frame's
  own bounds, so devices/IO markers/clamps inside don't shift.
2026-05-17 17:19:53 +02:00
mAi
55f8a06560 fix(ui): frame label is a clickable drag grip
Frame rect interior is occluded by devices/cables in SVG render order, so
clicking the frame to select/drag it was unreliable. Drop pointer-events:none
from .frame-label and bind the same pointerdown→startDrag('frame',id) as the
rect — the top-left label text is now a deterministic grip.
2026-05-16 19:32:14 +02:00
mAi
c206a331ec rename: mCables → CableGUI (project + repo + image + paths)
Full project rename per m's call. Single atomic commit because the
codebase rename is a coupled change — go module path, env vars, DB
default, Docker artefact names, and on-disk mDock paths all flip
together.

- go.mod: module mgit.msbls.de/m/mcables → mgit.msbls.de/m/cablegui
- cmd/mcables → cmd/cablegui (git mv)
- All Go imports rewritten to the new module path
- Env vars: MCABLES_ADDR/MCABLES_DB → CABLEGUI_ADDR/CABLEGUI_DB
- DB default path: data/mcables.db → data/cablegui.db
- Dockerfile + docker-compose.yml: image, container_name, env vars,
  bind-mount /home/m/stacks/mcables → /home/m/stacks/cablegui,
  secrets /home/m/secrets/mcables → /home/m/secrets/cablegui
- Makefile: bin target + run/build commands point at cmd/cablegui
- .gitignore + .dockerignore: /mcables → /cablegui
- README, docs/design.md, CLAUDE.md: prose + paths + image name
- web/static/index.html: <title> + brand
- web/static/main.js + web/web.go: header comment
- internal/exporter: Scene.Source "mcables" → "cablegui"
- internal/server/export.go: error-detail secrets path
- internal/db/migrations/*.sql: header comments (mCables vN → CableGUI vN)

Memory group_id kept as "mcables" to preserve existing memory continuity.
Documented as historical in CLAUDE.md.

go build ./... clean; go test -race ./... green
2026-05-16 15:35:42 +02:00
mAi
2933bb8662 fix(ui): left-click-drag on empty canvas pans the view
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.
2026-05-16 14:05:46 +02:00
mAi
98fe040364 merge: v5 — cable routing via clamps (all 6 slices)
picasso shipped on a single branch (6 commits @ 813d59b):
- Migration 007: clamps + cable_clamps with PK(cable_id,ord) +
  UNIQUE(cable_id,clamp_id). Store helpers (CRUD + Attach with
  two-pass shift + Detach gap-close + Reorder).
- HTTP endpoints under /clamps and /cables/:cid/clamps.
- Frontend: +Clamp tool + canvas placement + frame-drag carries
  clamps + clamp inspector with cables-through list and
  cascade-with-confirm delete.
- Polyline cable render through clamps. Mid-segment drag picks
  nearest segment; pointerup snaps to existing clamp within
  MID_SNAP_PX/zoom or creates fresh.
- Bundle viz: shared segments get a thick striped overlay (width
  min(12,2+N), gradient stripes by count desc / id asc).
  ×N badge on clamps with ≥2 cables.
- Export: clamps as 12x12 rounded squares (Excalidraw rectangles);
  cable arrows carry mid-vertices through clamps; bundle viz stays
  viewer-only (Excalidraw can't represent gradient strokes).
2026-05-16 14:04:37 +02:00
mAi
2cbefd3146 feat(v5 slice 5): shared-segment bundle viz + clamp count badges
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.
2026-05-16 13:54:57 +02:00
mAi
fee9bc5d26 feat(ui): remove '+ Type' button from sidebar legend
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.
2026-05-16 13:50:49 +02:00
mAi
04e7e86a52 feat(v5 slice 4): cable polyline through clamps + mid-segment drag
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.
2026-05-16 13:50:44 +02:00
mAi
6af076a5e0 feat(v5 slice 3): clamp render + Place tool + inspector
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.
2026-05-16 13:48:07 +02:00
mAi
0ecd9c8b4a feat(ui): double-click a port to start a cable draw
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.
2026-05-16 13:29:02 +02:00
mAi
17e6b5e91c feat(ui): cable endpoint replug — drag handles to a new target
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.
2026-05-16 13:11:33 +02:00
mAi
89686d0c1f feat(ui): bottom-right resize handle on devices
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.
2026-05-16 12:59:51 +02:00
mAi
6c31802522 feat(ui): canvas zoom + pan via SVG viewBox
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.
2026-05-16 12:05:24 +02:00
mAi
9aa395854d feat(ui): requirements live in the device inspector + admin 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.
2026-05-16 11:59:08 +02:00
mAi
6cd5925f4c feat(ui): admin modal — projects + cable types + device types + templates
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.
2026-05-16 11:51:05 +02:00
mAi
61bc1dcf43 feat(ui): port editor + add-port form in the sidebar inspector
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.
2026-05-16 11:40:45 +02:00
mAi
86264d1284 fix(ui): device colour now actually shows on the canvas
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.
2026-05-16 11:23:47 +02:00
mAi
b28fc0c565 fix(ui): even-spacing relayout on every port-set change
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.
2026-05-16 11:19:16 +02:00
mAi
491db730eb fix(ui): +Port feedback + snap dedup + startDrag closure-capture
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.
2026-05-16 11:12:13 +02:00
mAi
82cf5a3052 fix(ui): port UX — coloured fill, selectable, edge picker
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.
2026-05-16 02:15:11 +02:00
mAi
8a6e8c8406 feat(ui): wire Export button — POST /sync/export + toast
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>
2026-05-16 01:35:50 +02:00
mAi
2cd981d3ae fix: apply-template auto-solves + frontend reloads via activateProject
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.
2026-05-16 01:23:37 +02:00
mAi
9625d97efc feat(ui): +Port tool + manual cable draw + driving-req link
+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.
2026-05-16 01:18:55 +02:00
mAi
c681b01aff feat(ui): Solve flow + setup-templates apply + cable rendering
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.
2026-05-16 01:07:20 +02:00
mAi
6b830a54b9 feat(ui): connection requirements — sidebar + modal + drag-A-to-B + inspector
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).
2026-05-16 00:42:26 +02:00
mAi
7f0b6e4fab feat(ui): type-aware device creation + port rendering
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).
2026-05-16 00:31:55 +02:00
mAi
a3f0586296 feat: frontend — IO markers + cable-type inspector
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 [].
2026-05-16 00:12:24 +02:00
mAi
29e221e080 fix(ui): inspector now updates on device/frame selection
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).
2026-05-15 23:38:12 +02:00
mAi
12804619b2 fix(ui): +Dev inline-namer kept getting blurred by default mousedown
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.
2026-05-15 23:17:44 +02:00
mAi
28a376a7f3 fix(ui+server): tool cursor wins on canvas children; no-cache static assets
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.
2026-05-15 20:38:48 +02:00
mAi
94869f342e fix(ui): +Dev inside a frame was silently dropped
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.
2026-05-15 20:33:17 +02:00
mAi
b15913124a feat: frontend — frames + devices on SVG, tools, drag, inspector
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.
2026-05-15 18:22:49 +02:00
mAi
c13000ee7e feat: frontend shell — project picker, legend, modals (new project / cable type / delete), URL ?project= state 2026-05-15 16:45:29 +02:00