Commit Graph

65 Commits

Author SHA1 Message Date
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
5d055ad521 merge: catalog-power — Multi-plug 3/4/5/6 + Wifi-plug
Migration 005 adds 5 power-distribution device types. Total
device_types now 21.
2026-05-16 02:07:17 +02:00
mAi
93b276875e feat(catalog): migration 005 — power-distribution devices
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.
2026-05-16 02:05:30 +02:00
mAi
205e9eab26 merge: fix mxdrw auth — Bearer → HTTP Basic
mxdrw on mlake uses BASIC_AUTH_USER + BASIC_AUTH_PASS; slice 8's
Bearer design didn't match. Swapped req.SetBasicAuth(MEXDRAW_USER,
MEXDRAW_PASS). DEPLOY-VERIFY drawing on mxdrw confirms end-to-end
export from the deployed image works.
2026-05-16 02:01:16 +02:00
mAi
fe6f86593e fix(export): switch mxdrw auth from Bearer to HTTP Basic
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.
2026-05-16 01:49:23 +02:00
mAi
a7835468a1 merge: slice 8 — Excalidraw export to 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.
2026-05-16 01:42:17 +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
275cb5a55a feat(backend): slice 8 — export scene to mxdrw
- internal/exporter: pure BuildScene + 21-char base62 IDs, port ellipses,
  device rect+text pairs, IO diamonds, arrow bindings, legend texts.
  Bundles intentionally omitted per design §4.1.
- internal/db: PersistExcalidrawIDs idempotent updater per project.
- internal/server: POST /api/projects/:pid/sync/export — loads snapshot,
  mints/reuses excalidraw_ids, PUTs scene to mxdrw with bearer auth.
  Returns viewer URL + element_count + mxdrw response.

Roundtrip hand-tested against mxdrw.msbls.de: scene saved, IDs stable
across re-exports.
2026-05-16 01:35:46 +02:00
mAi
a81dbe2f8c merge: fix apply-template UX hole
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.
2026-05-16 01:24:52 +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
0c7d165ed6 merge: slice 7 — manual ports + cable draw + promote button
picasso shipped (3 commits @ 9625d97):
- backend: ports CRUD endpoints, port-delete cascades cables fix
- frontend: +Port tool with edge-snap, click-port → click-port draws
  auto=0 cable, shift-click=device bind, click IO=terminator,
  clickable driving-requirement link, explicit Promote button
  (PATCH cables with {promote:true} required; label-only PATCH
  preserves auto)
2026-05-16 01:20:48 +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
f9c245fbcc fix(db): cascade-delete cables when a port is removed
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).
2026-05-16 01:18:55 +02:00
mAi
c61bff7cf2 feat(backend): ports CRUD endpoints for slice 7
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.
2026-05-16 01:10:59 +02:00
mAi
1d226844d1 merge: slice 6 — solver MVP + Solve button + setup templates
picasso shipped (3 commits @ c681b01):
- migration 004: setup_templates + setup_template_devices +
  setup_template_requirements, seeded with 3 built-ins (Living Room,
  Home Office, Server Rack)
- store: solver (greedy port allocation, endpoint-pair bundling,
  auto=1 cables), apply-template (creates devices from types + seeds
  requirements), cables + bundles CRUD
- handlers: POST /api/projects/:pid/solve (+ ?preview=1), POST
  /api/projects/:pid/apply-template, combo add-port-and-resolve
  endpoint for the unmet quick-fix, full /cables and /bundles CRUD
- frontend: Solve button in header, preview-diff modal (added/removed
  cables + bundles + unsatisfied list with quick-fix actions), cable
  SVG rendering coloured by type, setup-templates picker on the New
  Project modal
2026-05-16 01:08:41 +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
c8bda7a222 feat(http): solver + cables + bundles + templates endpoints 2026-05-16 01:02:31 +02:00
mAi
b93c42a6e0 feat(db): solver + setup templates + cables/bundles store
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
2026-05-16 01:02:31 +02:00
mAi
75b826c583 merge: slice 5 — connection requirements CRUD + UI
picasso shipped (3 commits @ 6b830a5):
- migration 003: connection_requirements (pair_lo/pair_hi normalisation,
  UNIQUE on the unordered pair + cable_type), plus cables.auto column
  for the slice-6 solver
- store + handlers: full CRUD under /api/projects/:pid/connection-requirements
- frontend: Requirements sidebar section, +Requirement modal (device-pair
  autocomplete + cable-type picker + must/nice toggle), drag-A-to-B
  gesture pre-fills the modal, inspector for selected device lists its
  requirements
2026-05-16 00:43:45 +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
9af4b6caa3 feat(http): /api/projects/:pid/connection-requirements full CRUD 2026-05-16 00:37:34 +02:00
mAi
d8637de4a0 feat(db): connection_requirements + cables.auto
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.
2026-05-16 00:37:34 +02:00
mAi
88821c0f21 merge: slice 4 — device-type catalog + type-aware device create
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
2026-05-16 00:33:53 +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
0a34dce398 feat(http): device-type endpoints + type_id on device create/patch
- 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.
2026-05-16 00:27:49 +02:00
mAi
8cb237fe8e feat(db): device_types store + port seeding on device create
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
2026-05-16 00:27:49 +02:00
mAi
2b26f63c86 feat(db): migration 002 — device_types + device_type_ports + devices.type_id + 16 built-ins seeded 2026-05-16 00:27:49 +02:00
mAi
08385b0d9f merge: slice 3 — IO markers + cable-type editing UI
picasso shipped (3 commits @ a3f0586):
- internal/db/io_markers.go: project-scoped CRUD, cross-project FK rejection
- internal/server/io_markers.go: handlers under /api/projects/:pid/io-markers
- web/static: +IO tool with click-place, diamond rendering (SVG polygon),
  drag, inspector for IO + cable-type, interactive legend with native
  colour-picker + delete-blocked-on-use, '+ Type' modal, 'used by N
  cables' counter

37 store tests green with -race.
2026-05-16 00:13:53 +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
d114bfb547 feat: http handlers — IO markers CRUD under /api/projects/:pid/io-markers 2026-05-16 00:06:16 +02:00
mAi
1ea6082948 feat: db store — IO markers CRUD, snapshot wiring
Schema already in 001_init.sql; this is just the Go store layer.

IO markers are project-scoped wall-outlet terminators (a cable's
"this end plugs into a wall socket outside the diagram" endpoint).
Power-by-convention; no schema-level type enforcement.

- CreateIOMarker validates frame_id is in the same project (cross-project
  ref → ErrInvalidInput), defaults label to "IO" when blank.
- GetIOMarker is project-scoped — wrong-project read returns ErrNotFound.
- UpdateIOMarker uses the FrameRef tri-state for frame_id (same as
  DeviceUpdate) so callers can clear it explicitly.
- DeleteIOMarker is direct delete — ON DELETE SET NULL from the schema
  drops the io_markers.frame_id ref cleanly when the frame is deleted
  (verified by TestDeleteFrame_SetsIOMarkerFrameIDToNull).

Snapshot now populates IOMarkers from the store; field type tightened
from []any to []IOMarker.

7 new table-driven tests, all green with -race.
2026-05-16 00:05:40 +02:00
mAi
376ffd8197 merge: design v4.1 — schematic-only, templates folded in
m's review of v4 locked 6 answers. Tight doc pass:
- Schematic-only bundling: dropped trunk-segment/frame-edge/cable-tray
  language. v3 endpoint-pair rule is the only bundle rule.
- Setup templates folded in (not post-MVP): migration 004 with 3
  built-ins (Living Room, Home Office, Server Rack) + 3 new device
  types (Screen, Keyboard, Mouse) + apply-template API in slice 6.
- Unmet-requirement quick-fix: combo endpoint adds a missing port and
  re-solves in one server roundtrip.
- Solver still button-only, catalog still SQL-seeded, promote still
  explicit on cable inspector.

All 9 §9 questions resolved.
2026-05-16 00:03:51 +02:00
mAi
e42b351280 docs: design v4.1 — schematic-only bundling, setup templates folded in
Tight pass on m's review of v4 (single commit per head's instruction).

Six locked answers integrated:

1. mCables is a schematic, not a physical-routing tool. Stripped
   'trunk', 'frame-edge corridor', 'cable tray', 'path optimisation'
   from §5b.1, §5b.2, §7, §8, §9. Bundling reduces to the v3 endpoint-
   pair rule: ≥2 cables between the same A↔B endpoint pair → group as
   one bundle. Anything path-shaped is "out of scope, period" (§8).
2. Solver button-only for v0 (no change). Live-solve parked at 9+.
3. Unmet-requirement quick-fix: red badge on the affected device in the
   inspector with a single "+ Add <type> port to <device> and re-solve"
   button per §5b.4. New endpoint
   POST /api/projects/:pid/devices/:id/ports-and-resolve chains the
   port insert + the solve re-run in one transaction.
4. Setup templates fold INTO v4.1. New §2.4 with the schema for
   setup_templates + setup_template_devices + setup_template_requirements
   (migration 004), 3 built-in templates seeded (Living Room, Home
   Office, Server Rack). New API: GET /api/setup-templates,
   POST /api/projects/:pid/apply-template. New UI flow: "or start from
   a template" section in the New Project modal + an "Apply template"
   action on empty projects. Built-in catalog grows to 14 types
   (adds Screen, Keyboard, Mouse).
5. Catalog SQL seed in migration 002 (no change).
6. Promote-to-manual: explicit button on cable inspector (no change).

§8 slice 6 absorbs the templates work alongside the solver MVP.
§9 closes all six v4 questions; no open design questions remain.
Trailer changes to "DESIGN v4.1 READY FOR REVIEW".

CLAUDE.md mirrors: schematic-only framing, 14-type catalog, setup
templates as a first-class feature, quick-fix UX note.
2026-05-16 00:03:19 +02:00
mAi
e862a06e9d docs: design v4 — solver-as-core, hybrid device-type catalog, requirements
Big rescope driven by m's product-vision clarification: mCables is a
cable-management framework with a solver as its core value prop, not a
manual draw-and-click editor. m declares devices + required connections
between them; the solver emits the cable plan + bundle recommendations,
optimising for maximum bundling.

Schema additions (migrations 002 + 003):
- device_types (catalog) — built-ins (project_id NULL) + project-custom
  (project_id non-null). 11 built-in types seeded with default port
  profiles (NAS, PC, Mac, TV, Soundbar, Switch, fritz, ChromeCast,
  SteamLink, IOx-3/6/8, Notebook).
- device_type_ports (profile rows: cable_type × count × edge).
- devices.type_id (nullable). Picking a type seeds ports once;
  instance-owned thereafter (no retroactive re-seed).
- connection_requirements (per-project, from/to device + preferred type
  + must_connect flag, with order-normalised pair_lo/pair_hi for
  duplicate prevention).
- cables.auto (slice 5.5 migration) — distinguishes solver-owned cables
  from user-drawn ones.

API additions:
- GET /api/device-types (built-ins only, read-only) and
  GET /api/projects/:pid/device-types (built-ins + project-custom merged)
- POST/PATCH/DELETE under /api/projects/:pid/device-types (project-custom
  only; built-ins are 403)
- /api/projects/:pid/connection-requirements full CRUD
- POST /api/projects/:pid/solve with ?preview=1 — pure-function solver
  (greedy port allocation, endpoint-pair bundling for v0); returns
  add[], remove[], bundles_added[], unsatisfied[], warnings[]

Solver algorithm (§5b):
- Read project devices + ports + connection_requirements + manual cables
- Assign each requirement a (port_a, port_b) using the preferred cable
  type (or auto-pick if exactly one type matches both ends)
- Bundle by endpoint-pair (v3 rule, applied to auto cables only)
- Surface unsatisfied requirements per class (no compat type / ambiguous
  type / no free port) — does NOT auto-add ports; UI quick-fix instead
- ?preview=1 returns the diff without writing; default applies in a tx

UI additions:
- Device-create modal: type dropdown (built-ins grouped by kind, then
  project-custom, then "Custom (no type)" for the v3 freeform fallback)
- Left-sidebar Requirements section with + Requirement button
- Header Solve button (S keybinding) → preview modal → Apply
- Inspector for selected device: type, ports grid, unmet requirements
  with red badges + quick-fix actions
- Inspector for selected auto cable: driving requirement, parent bundle,
  Promote-to-manual button

Slice reshape (§8):
- Slices 1, 2 shipped. v4 inserts: 4 = catalog + type-aware device create,
  4.5 = catalog management, 5 = requirements CRUD + UI, 6 = solver MVP +
  Solve button. Old "manual port + manual cable draw" slides to slice 7
  as a tweak path on solver output. Export becomes slice 8.

Six new open questions (§9) for m to gate before slice 4:
1. Path source (auto-route through frame edges / user cable-trays /
   Steiner-tree)?
2. Live-solve vs. button-only?
3. UX when solver has no compatible port pair?
4. Setup templates in v4 or post-MVP?
5. Catalog as code seed or JSON file?
6. Auto-promote vs. explicit Promote-to-manual on solver cable edits?

CLAUDE.md updated to reflect the solver-core framing, hybrid catalog,
connection-requirements model, and auto/manual cable distinction.

Trailer changes to "DESIGN v4 READY FOR REVIEW".
2026-05-15 23:57:22 +02:00
mAi
4f862e741a merge: fix inspector not updating on device/frame selection
startDrag set state.selection but never re-rendered. One render() call
after the assignment fixes it. Now selecting a device or frame
populates the inspector with name/dims/delete-button as designed.
2026-05-15 23:39:15 +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
c7dfbe010c merge: fix +Dev inline-namer blur (sherlock's preventDefault diagnosis)
Primary fix: e.preventDefault() on the pointerdown for both armed-tool
branches in onCanvasPointerDown. Without it, the browser's default
mousedown action blurs the freshly-focused input in promptInline
(the SVG click target isn't focusable), and the blur handler calls
done(null) before m can type.

Secondary fix: clear activeNamer before fo.remove() in done(), to
prevent a re-entrant pageerror when Enter triggers blur synchronously.
2026-05-15 23:18:57 +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
e12b449169 merge: cursor + cache fixes
- CSS: .canvas-wrap.tool-{frame,device} #canvas, #canvas * { cursor:
  crosshair !important } so frame/device rects don't display grab while
  a tool is armed
- Server: Cache-Control: no-cache on embedded static handler so browsers
  revalidate via ETag instead of serving stale main.js after redeploy
2026-05-15 20:40:07 +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
6d637e1fac merge: fix +Dev inside frame silently dropped
Move the [data-frame-id]/[data-device-id] early-return below the
tool-armed branches in onCanvasPointerDown. With a tool armed,
the canvas-level handler always wins; without a tool, the original
behaviour (frame/device pointerdown handlers capture for drag/select)
is restored.
2026-05-15 20:34:39 +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
a9e6d7aa62 merge: slice 2 — frames + devices + drag-to-position
picasso shipped (3 commits @ b159131):
- internal/db/frames_devices.go: project-scoped CRUD, cross-project FK
  rejection, sentinel errors (duplicate name -> 409, invalid input -> 400)
- internal/server/frames_devices.go: handlers under /api/projects/:pid/
  {frames,devices}, full CRUD
- web/static: SVG rendering + tools (+ Frm rubber-band, + Dev click-place),
  drag with frame-children-follow, inspector with debounced edits

30 store tests green with -race. Hand-test: cross-frame device drag,
frame-drag-with-children, server restart all preserve state.
2026-05-15 18:23:37 +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
21bf00566c feat: http handlers — frames + devices CRUD under /api/projects/:pid/
All 8 endpoints (list, create, patch, delete) for both resources. Path
params parsed via Go 1.22 ServeMux PathValue.

devicePatch uses json.RawMessage for frame_id so the wire format
distinguishes:
  - key absent       → leave as-is
  - "frame_id": null → clear (device leaves all frames)
  - "frame_id": 42   → move to that frame
parseFrameRef translates that into the store's db.FrameRef tri-state.

Sentinel-error mapping unchanged (writeError covers ErrInvalidInput,
ErrConflict, ErrNotFound, etc.). Cross-project frame_id refs surface as
400.
2026-05-15 18:17:43 +02:00
mAi
cf1671e8c1 feat: db store — frames + devices CRUD, project-scoped
Snapshot now populates frames + devices from the DB (slice 1 left them as
empty arrays).

Frame store:
- CreateFrame requires positive width/height; rejects empty name; UNIQUE
  (project_id, name) collisions surface as ErrConflict via mapWriteErr.
- GetFrame is project-scoped — wrong-project read returns ErrNotFound.
- UpdateFrame applies a partial; project_id is not exposed (moving a
  frame across projects would orphan its devices).
- DeleteFrame relies on the schema's ON DELETE SET NULL to drop
  devices' frame_id refs cleanly; verified by test.

Device store:
- CreateDevice defaults color to #1e1e1e if blank; rejects empty name,
  non-positive size; validates frame_id is in the same project (returns
  ErrInvalidInput on cross-project ref).
- UpdateDevice uses a FrameRef tri-state for frame_id so callers can
  distinguish "leave alone" from "clear to NULL" from "move to frame X".
- Cross-project frame_id on PATCH is rejected with ErrInvalidInput.
- ListDevices supports an optional frame_id filter.

13 new table-driven tests, all green with -race.
2026-05-15 18:16:33 +02:00
mAi
d3b660d140 merge: image moved to m/mcables namespace
mAi got admin on m/mCables but Gitea container packages are
user-namespace-scoped — repo-collab perm is insufficient. Pushed
once using m's ~/.netrc token, deleted the mAi/mcables stub.

Compose now references mgit.msbls.de/m/mcables:latest.
2026-05-15 18:12:37 +02:00
mAi
dc5fafeaa8 deploy: image now under m/ namespace on mgit.msbls.de
m granted mAi admin on m/mCables, but Gitea's container registry is
user-namespace-scoped (not repo-collab-scoped) so the push had to go
through m's own credentials for this one administrative move:

    docker login mgit.msbls.de -u m -p <m's token>
    docker push mgit.msbls.de/m/mcables:latest

Image digest sha256:76624f17… is identical to the one previously living
at mgit.msbls.de/mai/mcables:latest — same build, just retagged.

Drops the workaround comment from the compose file. The mai/mcables
package will be deleted via API after the deploy verifies.
2026-05-15 18:11:31 +02:00
mAi
017a77e187 merge: deploy infra to mDock (pulled forward from §10)
picasso shipped (commit 8a31f0a on mai/picasso/deploy-mdock):
- Dockerfile: multi-stage golang:1.23-alpine -> distroless/static
- docker-compose.yml at repo root (raw-docker pattern, not Dokploy)
- .dockerignore
- README deploy section

Live: http://mdock:7777 (image sha256:76624f17, 12.2MB).
Persistence verified across compose restart.

Note: mAi lacks write on m/ in Gitea, so image lives at
mgit.msbls.de/mAi/mcables:latest. m can retag once mAi gets write
on m/mCables (see docker-compose.yml comment).
2026-05-15 18:02:09 +02:00
mAi
8a31f0af60 deploy: Dockerfile + docker-compose.yml for mDock, manual first roll
Pulls the deploy infra forward from §10 so m can see slice 1 on his LAN.

- Dockerfile: multi-stage golang:1.25-alpine → distroless/static-debian12.
  CGO_ENABLED=0 (modernc.org/sqlite is pure Go). USER 1000:1000 so the
  bind-mount on mDock (owned by m:m) is writable without chowning the
  host dir. -trimpath + -s -w; 12.2MB final image.
- docker-compose.yml: matches the mDock convention surveyed earlier
  (container_name explicit, restart: unless-stopped, env_file in
  /home/m/secrets/mcables/.env, bind-mount /home/m/stacks/mcables/data,
  port 7777 exposed on LAN). Image temporarily under the mai/ namespace
  on mgit.msbls.de because mAi doesn't have write access to m/* today —
  documented in a comment so retagging is one line when permissions land.
- .dockerignore: keeps .git, .worktrees, .m, data/, docs/, *.md,
  editor cruft out of the build context.

Manual deploy verified end-to-end:
- docker build → image sha256:76624f17 (12.2MB)
- mAi-authenticated push to mgit.msbls.de/mai/mcables:latest
- ssh mdock anonymous pull works (registry allows public reads on this
  namespace)
- POST /api/projects {"name":"LOFT"} returns the row, GET /api/projects
  shows it; docker compose restart preserves it on disk; second GET
  still shows LOFT.

Gitea Actions auto-deploy left for a follow-up task per the head's
instruction — gets us the moving parts right first.
2026-05-15 18:01:30 +02:00