Files
CableGUI/docs/design.md
mAi 2a46ce744b 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:46:20 +02:00

1252 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# mCables — Design v4
Cable-management **framework + solver** for m's setup. Inventor shift 1
design, revised through v2 (rescope to multi-project framework), v3
(global cable_types + guardrails), and now **v4 — solver-as-core**.
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
the *visual-grammar reference*, not a bootstrap import target),
`mai-memory` (`mcables`, `m`), and the live mDock services for deploy
conventions (§10). v4 driven by m's product-vision clarification:
> "we provide a cable manager — I say what devices we have, the app tells
> me how to bundle cables and how the most efficient connection looks like"
mCables shifts from a manual draw-and-click editor to a **solver** that
takes a list of devices + the connections m needs and emits the cable
plan + bundle recommendations. The manual editor stays (it's the only way
to inspect + tweak the plan) but is no longer the primary surface.
> **What changed in v4** (new mental model on top of v3 mechanics)
> - **Hybrid device-type catalog** (§2.1, §3.1). A built-in `device_types`
> table seeds common devices (NAS, PC, Mac, TV, Soundbar, Switch, fritz,
> ChromeCast, SteamLink, IOx-3/6/8, Notebook, …) with default port
> profiles (`device_type_ports` rows: cable_type + count + label).
> Adding a device → pick a type → ports auto-seed. m can override per
> instance (this PC has 3 USB, not 2). Catalog is extendable per project.
> - **`connection_requirements` table** (§2.2). m declares "NAS must
> connect to Switch via RJ45" once. Many per device. The solver consumes
> these.
> - **`POST /api/projects/:pid/solve` endpoint** (§3.2). Reads devices +
> their ports + connection_requirements + frame positions, emits a diff
> of `cables` + `bundles`. Two modes: `?preview=1` returns the diff
> without applying; default applies.
> - **Solver objective: maximum bundling** (§5). Prefer routes that
> consolidate cables into trunks even at higher total length. Visually
> cleaner setups, easier mental model. v0 uses the v3 same-endpoints
> bundle rule; path-based bundling is slice 6+.
> - **UI: device-type dropdown** on device-create, **Connection
> Requirements** left panel, **Solve** button next to Export. Inspector
> shows type + ports + unmet requirements (selected device) or the
> driving requirement + bundle (selected cable).
> - **Slices reshape** (§8). Catalog seeding lands early (slice 1.5); the
> solver MVP and connection-requirements UI move ahead of the
> bundle-rendering polish.
>
> **What carried over from v3 (unchanged in v4)**
> - mCables is a framework: top-level `projects`, each backed by one
> `.excalidraw` drawing. `UNIQUE(projects.name)`.
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
> tri-state on PATCH.
> - IO diamonds = wall-outlet terminators (type=Power by convention).
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
> - No cable inventory metadata; visual + connectivity structure only.
> - DB at `./data/mcables.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
> - Deploy on mDock under `/home/m/stacks/mcables/`, raw docker-compose.
>
> **What's superseded in v4**
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
> tweak path on the solver output, but is no longer the *primary* device-
> connecting flow. The solve button is the headline action.
> - The v3 §8 slice order changes — catalog + types-driven devices + solver
> come earlier; the manual-draw-cable slice slides later. See new §8.
---
## 0. The seed drawing — visual grammar reference
`Cable-Management.excalidraw` on mxdrw.msbls.de is **not** ingested at
runtime. It is the visual-grammar reference we lock the export onto so that
when m rebuilds LOFT and OFFICE inside mCables, the exported `.excalidraw`
looks like the seed.
Concrete numbers from the live file (180 elements):
| Kind | Count | Excalidraw shape | What it represents |
|---|---|---|---|
| Frames | 2 | `frame` (`name`) | Sub-areas inside a project (`desk`, `rack`, …) |
| Devices | 27 | `rectangle` with bound text | Hardware items |
| Ports | 74 | `ellipse` ~12×9 | Connectors on a device edge, colour = cable type |
| Cables | 31 | `arrow` | Typed connections between ports/devices/outlets |
| IO markers | 6 | `diamond` text=`IO` | **Wall outlet / power-entry terminators** (type=Power) |
| Legend | 5 | `text` | Colour key in the top-left of the frame |
| Lines | 5 | `line` | **Decorative** (separator under the legend). Ignored. |
**Legend → cable type → colour**, picked up directly from the seed:
| Type | Colour | Hex |
|---|---|---|
| Power | red | `#e03131` |
| USB | green | `#2f9e44` |
| HDMI | blue | `#1971c2` |
| DP | purple | `#9c36b5` |
| RJ45 | yellow | `#ffd500` |
Three observations about the seed's visual grammar — these constrain the
**exporter** (§4):
1. **Ports sit on a device edge as small ellipses (~12×9)**, coloured by
cable type. They are not children of the device in the Excalidraw sense
(no `containerId`/`boundElements` link) — purely positional. When we
export from mCables we mimic that: port ellipse at `(device.x +
port.x_offset, device.y + port.y_offset)`, stroke colour = type colour.
2. **Cable arrows bind to elements**. In the seed: 44 endpoints to ellipses
(ports), 12 to whole rectangles (device-level, no specific port), 3 to
diamonds (wall outlets). Our exporter sets `startBinding.elementId` /
`endBinding.elementId` to whichever Excalidraw element ID we wrote for
the port / device / IO marker.
3. **IO diamonds = wall outlets.** They are terminals: a cable goes from a
device-port → an IO marker, meaning "this cable plugs into a wall socket
outside the diagram". They are always type=Power in m's setup but the
schema doesn't enforce that (a future "network jack in the wall"
wouldn't fit, and we can lift the constraint then).
---
## 1. Frontend stack — vanilla JS + SVG
**Locked**: vanilla ES modules (TS-typed via JSDoc, no build step) + SVG
diagram surface, served from a single Go binary via `embed.FS`.
Why this fits m: matches the no-build-step preference; same single-binary
aesthetic as `m`, `mai`, `youpcms`, `mExDraw`. Type-checking is opt-in via
`make typecheck` (`tsc --noEmit`), not gating runtime. SVG is one DOM node
per port/device/cable → trivial hit-testing, CSS-driven colouring by
`data-type=hdmi`, drag via pointer events + `getScreenCTM()`.
Escape hatch only if state for half-drawn cables + multi-select gets
painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
---
## 2. SQLite schema
`./data/mcables.db` (project-local, gitignored). WAL mode, FKs on.
Driver: **`modernc.org/sqlite`** (cgo-free — clean cross-compile, simple
Dockerfile).
```sql
-- 001_init.sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- A project IS a drawing. LOFT and OFFICE are separate projects.
-- One project ↔ one .excalidraw file in mExDraw.
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "LOFT", "OFFICE"
drawing_name TEXT NOT NULL, -- mExDraw drawing name, e.g. "LOFT.excalidraw"
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Cable types: GLOBAL legend, one set shared across all projects.
-- Migration 001 seeds the 5 defaults (Power/USB/HDMI/DP/RJ45) once.
-- Renaming or recolouring a type from anywhere in the UI propagates to
-- every project's legend and to every cable already typed as it.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "Power", "USB", "HDMI", "DP", "RJ45"
color TEXT NOT NULL, -- "#e03131"
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- v4 — device-type catalog. Seeded built-in types live globally (so
-- multiple projects share the "NAS" definition without duplication).
-- Per-project custom types are also allowed (project_id non-null for those).
-- Renaming a built-in type doesn't propagate retroactively to existing
-- devices that already had their ports seeded — they own their port set
-- from the moment they were created.
CREATE TABLE device_types (
id INTEGER PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
-- NULL = built-in (shared), non-null = project-custom
name TEXT NOT NULL, -- "NAS", "PC", "TV", "Switch", "IOx-8", "Custom-Foo"
kind TEXT NOT NULL DEFAULT 'generic',
-- coarse category for UI grouping: 'storage', 'compute',
-- 'display', 'audio', 'network', 'hub', 'accessory',
-- 'generic'
icon TEXT, -- emoji or short symbol (🖥, 📺, 🔊, 📡) — UI hint
description TEXT NOT NULL DEFAULT '',
built_in INTEGER NOT NULL DEFAULT 0, -- 1 for migration-seeded rows, 0 for user-created
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name) -- two projects can both have a custom "Foo";
-- built-ins (project_id NULL) get UNIQUE on name globally
);
CREATE INDEX device_types_project_idx ON device_types(project_id);
-- v4 — port profile per device type. "NAS has 1 Power + 1 RJ45" is two
-- rows; "PC has 1 Power + 1 RJ45 + 1 HDMI + 2 USB" is four rows.
-- When a device is created with type_id=X, the seeder inserts `count`
-- rows into the `ports` table for each device_type_ports entry,
-- numbering label as "<label_prefix> N" if count > 1.
CREATE TABLE device_type_ports (
id INTEGER PRIMARY KEY,
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE CASCADE,
cable_type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label_prefix TEXT NOT NULL DEFAULT '', -- "HDMI", "USB", "Power" — UI label root
count INTEGER NOT NULL DEFAULT 1 CHECK (count >= 1),
-- Position hint: the seeder lays ports along the device edge using
-- these biases (0..1 along the edge fraction). NULL = even spread.
edge TEXT NOT NULL DEFAULT 'bottom' CHECK (edge IN ('top','bottom','left','right')),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX device_type_ports_type_idx ON device_type_ports(device_type_id);
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
CREATE TABLE frames (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0,
width REAL NOT NULL DEFAULT 1200,
height REAL NOT NULL DEFAULT 800,
excalidraw_id TEXT, -- stable across exports
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX frames_project_idx ON frames(project_id);
-- Devices live in a frame (and transitively in a project).
-- Stored project_id is denormalised for cheap project-scoped queries; FK
-- to frame_id is the structural truth. Both are kept consistent in code.
--
-- v4 — type_id (nullable) lets a device inherit its port profile from
-- a `device_types` row. Once ports are seeded the device "owns" them;
-- changing/clearing type_id later does not retroactively re-seed (m's
-- per-instance overrides survive). Custom freeform devices (no template)
-- keep type_id NULL — that's the v3 "just a rectangle" device.
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
type_id INTEGER REFERENCES device_types(id) ON DELETE SET NULL,
-- v4: nullable; SET NULL on type delete so we don't
-- cascade-delete a device the user still wants
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#1e1e1e',
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name), -- no two devices in one project share a name
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX devices_project_idx ON devices(project_id);
CREATE INDEX devices_frame_idx ON devices(frame_id);
CREATE INDEX devices_type_idx ON devices(type_id);
-- Ports belong to a device. x_offset/y_offset are relative to the device's
-- top-left so ports follow when the device moves. project_id denormalised.
CREATE TABLE ports (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT, -- optional ("HDMI 1", "USB-C rear")
x_offset REAL NOT NULL,
y_offset REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX ports_project_idx ON ports(project_id);
CREATE INDEX ports_device_idx ON ports(device_id);
CREATE INDEX ports_type_idx ON ports(type_id);
-- IO markers = wall outlets / power-entry terminators.
-- One end of a Power cable. They are NOT bridges and they do NOT pair.
CREATE TABLE io_markers (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
label TEXT NOT NULL DEFAULT 'IO', -- "Wall A", "UPS rear", …
x REAL NOT NULL,
y REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX io_markers_project_idx ON io_markers(project_id);
CREATE INDEX io_markers_frame_idx ON io_markers(frame_id);
-- A cable. Each endpoint is exactly one of (port, device, io-marker).
-- All foreign-key targets must be in the same project_id as the cable —
-- enforced in code (the CHECK below only enforces the one-non-null rule).
CREATE TABLE cables (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT,
from_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
from_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
from_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
to_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
to_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (
(from_port_id IS NOT NULL) + (from_device_id IS NOT NULL) + (from_io_id IS NOT NULL) = 1
),
CHECK (
(to_port_id IS NOT NULL) + (to_device_id IS NOT NULL) + (to_io_id IS NOT NULL) = 1
),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX cables_project_idx ON cables(project_id);
CREATE INDEX cables_from_port_idx ON cables(from_port_id);
CREATE INDEX cables_to_port_idx ON cables(to_port_id);
CREATE INDEX cables_from_device_idx ON cables(from_device_id);
CREATE INDEX cables_to_device_idx ON cables(to_device_id);
CREATE INDEX cables_type_idx ON cables(type_id);
-- Bundles: named groups of cables that physically run together, within
-- a single project (a bundle does not span LOFT ↔ OFFICE).
CREATE TABLE bundles (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name)
);
CREATE INDEX bundles_project_idx ON bundles(project_id);
CREATE TABLE bundle_cables (
bundle_id INTEGER NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
PRIMARY KEY (bundle_id, cable_id)
);
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
-- v4 — connection_requirements: the input m gives the solver.
-- "NAS must connect to Switch via RJ45" is one row. Many per device.
--
-- preferred_cable_type_id is the cable type m intends — the solver
-- needs it to match port colours. NULL means "solver picks" (the solver
-- will pick the unique cable_type that is compatible with both ends'
-- available port types; if ambiguous it surfaces an error for m).
--
-- must_connect = 1 (default) means the solver MUST satisfy this; an
-- unsatisfiable must_connect surfaces as a hard error in the solve
-- result. must_connect = 0 = "nice to have, drop if you run out of
-- ports". Used for templates that over-spec.
--
-- The (from_device_id, to_device_id) pair is normalised on insert so
-- (A,B) and (B,A) are the same requirement — UNIQUE on the unordered
-- pair + cable type prevents duplicates.
CREATE TABLE connection_requirements (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
notes TEXT NOT NULL DEFAULT '',
-- Order-normalised pair: lo = MIN(from, to), hi = MAX(from, to). Set
-- in code on insert; the UNIQUE then prevents (A,B,Power) AND
-- (B,A,Power) from coexisting. Stored alongside the m-facing
-- from/to so the UI doesn't have to denormalise.
pair_lo INTEGER NOT NULL,
pair_hi INTEGER NOT NULL,
CHECK (from_device_id != to_device_id),
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id);
CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi);
CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id);
CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id);
```
### 2.1 Migration sequence
- **001_init.sql** (v3) — projects, frames, devices (no type_id), ports,
cable_types (5 seeded), io_markers, cables, bundles, bundle_cables.
- **002_device_catalog.sql** (v4 NEW) — `device_types` +
`device_type_ports`. Seeds the built-in catalog (§2.2). Adds
`devices.type_id` (`ALTER TABLE devices ADD COLUMN type_id INTEGER
REFERENCES device_types(id) ON DELETE SET NULL`) and the matching
index.
- **003_connection_requirements.sql** (v4 NEW) — `connection_requirements`.
Slice 1 already shipped 001. Slices 1.5 (catalog) and 1.6 (requirements)
land 002 and 003.
### 2.2 Built-in catalog seed (002 INSERTs)
The 11 built-in types m's setup uses today, with their default port
profiles. Stored as `(project_id NULL, built_in 1)`:
| `device_types.name` | `kind` | Default ports (cable_type × count) |
|---|---|---|
| NAS | storage | Power × 1; RJ45 × 1 |
| PC | compute | Power × 1; RJ45 × 1; HDMI × 1; USB × 2 |
| Mac | compute | Power × 1; HDMI × 1; USB × 2 |
| Notebook | compute | Power × 1; USB × 2 |
| TV | display | Power × 1; HDMI × 2 |
| Soundbar | audio | Power × 1; HDMI × 1 |
| Switch | network | Power × 1; RJ45 × 5 |
| fritz | network | Power × 1; RJ45 × 4 |
| ChromeCast | display | Power × 1; HDMI × 1 |
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
| IOx-3 | hub | Power × 1; (3× port slots — concrete cable type per slot is set at instantiation; defaults to USB × 3 for v0) |
| IOx-6 | hub | Power × 1; USB × 6 |
| IOx-8 | hub | Power × 1; USB × 8 |
"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing
shows them in red because most carry Power, but they also hub USB). v0
seeds them as USB hubs; m overrides per-instance. The catalog is editable
in the UI (slice 4.5 — "Manage device types") so m can refine the IOx-3
profile once and not re-override every instance.
m can also add **project-custom types** at any time (UI: "+ New device
type" inside the device-create modal) with `project_id = current`.
### 2.3 Why ports are still instance-owned
When m picks a type to create a device, the seeder calls `count` × INSERT
into `ports`. From that moment on, ports are instance-level rows owned by
that device. Deleting a port from this PC doesn't touch other PCs;
changing a type's port profile (in slice 4.5) doesn't retroactively
re-seed already-created devices — it only affects subsequent device
creations.
Trade-off acknowledged: m may want a "re-seed from type" action later
(slice 5+) to wipe + reset a device's ports. Out of v0 scope; not
blocked by the schema.
**FK shape — why `project_id` on every project-scoped row, not just transitively:**
The structural truth is `cable → port → device → frame → project`. But
project-scoped queries ("give me all cables in OFFICE") would otherwise need
three joins. Denormalising `project_id` onto every project-scoped row is a
small, load-bearing pragma: `cables WHERE project_id=?` is a one-column
index hit. The cost: code must keep `project_id` consistent with `frame_id`
/ `device_id` on insert+update. That's enforced at the Go layer
(`internal/db/store.go` setter functions), not by SQL — `CHECK` constraints
in SQLite can't reference another table.
`cable_types` is the **one global table** — it has no `project_id`.
Cables reference it cross-project. Renaming or recolouring a type updates
the legend everywhere immediately and re-renders every cable of that type
on the next paint.
`ON DELETE CASCADE` from `projects` cleanly wipes a project's whole subgraph
in one statement, which is what we want when m says "delete OFFICE". The
cascade does **not** touch `cable_types` (no FK to projects).
---
## 3. Go HTTP API
Single binary `cmd/mcables`, `net/http`, no router framework. Listens on
`0.0.0.0:7777` by default (overridable via `MCABLES_ADDR`). Static frontend
from `embed.FS` at `/`, JSON API under `/api/`.
```
GET / → index.html (embedded)
GET /assets/* → JS/CSS/SVG (embedded)
GET /api/healthz → 200 ok
# Projects — top-level
GET /api/projects → [Project, …]
POST /api/projects ← {name, drawing_name?, description?}
If drawing_name is omitted, server defaults to
"<name>.excalidraw". No cable-type seeding —
cable_types is global (see /api/cable-types).
GET /api/projects/:pid → full snapshot
{project, frames, devices, ports, cables,
io_markers, bundles}
Plus the global cable_types (clients can also
fetch them via /api/cable-types). Editor's
one-shot loader.
PATCH /api/projects/:pid ← partial {name, drawing_name, description}
DELETE /api/projects/:pid?confirm=<name> Confirmation guardrail — the query param must
equal the project's current name. 400 if missing
or mismatched. Cascades through all child rows
(frames, devices, ports, cables, io_markers,
bundles, bundle_cables). Does NOT touch
cable_types.
# Cable types — GLOBAL, NOT under a project
GET /api/cable-types → [CableType, …]
POST /api/cable-types ← {name, color} # name must be unique globally
PATCH /api/cable-types/:id ← {name?, color?} # affects every project's legend + every cable using this type
DELETE /api/cable-types/:id # blocked if any cable still references it (ON DELETE RESTRICT)
# Inside a project — everything below scoped under :pid
GET /api/projects/:pid/frames
POST /api/projects/:pid/frames ← {name, x, y, width, height}
PATCH /api/projects/:pid/frames/:id
DELETE /api/projects/:pid/frames/:id
GET /api/projects/:pid/devices
POST /api/projects/:pid/devices ← {name, type_id?, frame_id?, x, y, width, height, color?}
v4: type_id (optional) seeds ports from the catalog;
without it, a freeform device (no ports) is created.
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag). type_id can be set or cleared;
clearing does NOT delete existing ports (instance-owned).
DELETE /api/projects/:pid/devices/:id
GET /api/projects/:pid/devices/:id/ports
POST /api/projects/:pid/devices/:id/ports ← {type_id, x_offset, y_offset, label?}
PATCH /api/projects/:pid/ports/:id
DELETE /api/projects/:pid/ports/:id
GET /api/projects/:pid/cables
POST /api/projects/:pid/cables ← {type_id, from_{port|device|io}_id,
to_{port|device|io}_id, label?}
PATCH /api/projects/:pid/cables/:id
DELETE /api/projects/:pid/cables/:id
GET /api/projects/:pid/io-markers
POST /api/projects/:pid/io-markers ← {frame_id?, label, x, y}
PATCH /api/projects/:pid/io-markers/:id
DELETE /api/projects/:pid/io-markers/:id
GET /api/projects/:pid/bundles → [{Bundle, cable_ids: [int]}, …]
POST /api/projects/:pid/bundles ← {name, cable_ids: [int]}
GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …] (see §5)
PATCH /api/projects/:pid/bundles/:id
DELETE /api/projects/:pid/bundles/:id
# v4 — Device-type catalog (mostly global, project-scoped writes for custom rows)
GET /api/device-types → built-in catalog (project_id NULL) — read-only listing
GET /api/projects/:pid/device-types → built-ins + this project's custom types, merged
POST /api/projects/:pid/device-types ← {name, kind?, icon?, description?, ports: [{cable_type_id, count, label_prefix?, edge?}]}
Creates a project-custom row (built_in=0); inserts
device_type_ports rows in the same transaction.
PATCH /api/projects/:pid/device-types/:id ← partial. Only project-custom types are PATCHable;
mutating a built-in row → 403 (UI hides edit affordance).
Editing ports replaces the device_type_ports rows;
existing devices' ports are NOT retroactively reseeded.
DELETE /api/projects/:pid/device-types/:id Only project-custom; built-ins → 403.
ON DELETE SET NULL on devices.type_id so devices
keep their already-seeded ports.
# v4 — Connection requirements (the solver's input)
GET /api/projects/:pid/connection-requirements → [ConnectionRequirement, …]
POST /api/projects/:pid/connection-requirements ← {from_device_id, to_device_id,
preferred_cable_type_id?, must_connect?, notes?}
Server normalises (from, to) into (pair_lo, pair_hi)
before insert; duplicate (project, pair_lo, pair_hi,
preferred_cable_type_id) → 409 conflict.
PATCH /api/projects/:pid/connection-requirements/:id
DELETE /api/projects/:pid/connection-requirements/:id
# v4 — Solver
POST /api/projects/:pid/solve ← {} (or {?preview=1} to compute without applying)
→ {
cables_added: [Cable, …],
cables_kept: [int, …], # ids preserved by the diff
cables_removed: [int, …], # ids deleted (auto cables only)
bundles_added: [{Bundle, cable_ids: [int]}, …],
bundles_removed: [int, …],
unsatisfied: [{requirement_id, reason}, …],
warnings: [string, …],
}
Default applies in a single transaction. ?preview=1
returns the same shape without writing. User-created
cables (auto=0 in the cables table; see §5.1) are
never touched — the solver only adds/removes its own.
# Sync — export only in MVP
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
(overwrites previous version; mExDraw keeps
git-version-history sidecar)
```
### 3.1 v4 wire-shape additions
- `ConnectionRequirement` (response):
`{id, project_id, from_device_id, to_device_id, preferred_cable_type_id|null, must_connect: bool, notes, created_at, updated_at}`.
- `DeviceType` (response):
`{id, project_id|null, name, kind, icon|null, description, built_in: bool, ports: [{cable_type_id, count, label_prefix, edge, sort_order}]}`.
- `cables` gets an `auto: bool` field on the row (slice 5.5 migration adds
the column with default 0; the solver sets 1 on its own creations). The
v3 cable rows m hand-drew keep `auto=0`. `POST /api/.../cables`
continues to default `auto=0`; only the solver writes `auto=1`.
No `POST /api/sync/import` in MVP. Import is post-MVP and only ever serves
a one-shot migration use case (e.g. seeding LOFT from the legacy
Cable-Management drawing if m later changes his mind).
All write endpoints return the updated row. Errors are
`{error: "string", details?: any}`. No auth.
mExDraw HTTP credentials live in `MEXDRAW_BASE_URL` (e.g.
`https://mxdrw.msbls.de`) + `MEXDRAW_TOKEN` (bearer). The exporter calls
`PUT $MEXDRAW_BASE_URL/api/drawings/<drawing_name>.excalidraw` with the
generated scene JSON.
---
## 4. Export — DB → Excalidraw (visual-grammar conformance)
mCables generates a `.excalidraw` scene from a project's rows. The seed
drawing's grammar is the contract.
### 4.1 Element mapping
| DB row | Excalidraw element | Notes |
|---|---|---|
| `projects.drawing_name` | drawing filename in mExDraw | one drawing per project |
| `frames` | `type=frame`, `name=frames.name` | x/y/width/height straight across |
| `devices` | `type=rectangle` + bound `text` with `name` | `strokeColor=color`, `frameId=frames.excalidraw_id` |
| `ports` | `type=ellipse`, ~12×9 | `strokeColor=type.color`, absolute pos = `(device.x + port.x_offset, device.y + port.y_offset)`, no containerId binding (matches seed) |
| `io_markers` | `type=diamond` with bound `text=label` | small (~30×30), `strokeColor` = the Power cable type's colour |
| `cables` | `type=arrow` | `strokeColor=type.color`, `startBinding.elementId` = port/device/io excalidraw_id, same for end |
| `cable_types` legend (global) | one `type=text` row per `cable_types` row, top-left of the project's first frame | `strokeColor=color`, `text=name`. Pulled from the global table, regenerated each export. |
| `bundles` | (rendering open question — see §5) | post-MVP: render as a thick path; v0: ignored on export |
### 4.2 Element IDs are stable across exports
Every mCables row carries `excalidraw_id` (TEXT, generated on first export
via `crypto/rand` → 21-char Excalidraw-style ID). On re-export the same row
reuses the same ID. This means:
- m's `.excalidraw` collaborator-cursors, element-comments, and undo
history survive a re-export.
- If m manually edits a port colour in Excalidraw (someday, once import
exists), we can match it back to the right DB row by ID.
### 4.3 What is *not* in the export
- The legend's decorative separator lines (the 5 `type=line` elements in
the seed) — purely visual, m said they're not load-bearing.
- Big "enclosure" rectangles like the seed's `tAs8zMDI` desk-surface. In
v0 those are imported as plain devices when m draws them, and exported
as plain rectangles too. No zone/enclosure concept in the schema.
### 4.4 Wall-outlet IO markers
A cable with `to_io_id != NULL` exports to an arrow whose `endBinding`
points to the IO diamond's element ID. The diamond is rendered with a small
`IO` text label (or `m.label` if customised). No pair link.
---
## 5. Bundle detection — project-scoped
A *bundle* is a set of cables that physically run together. Bundles never
cross projects (a LOFT bundle and an OFFICE bundle are separate).
MVP detection rule, on `GET /api/projects/:pid/bundles/suggestions`:
```
Within project :pid, group cables by (from_endpoint, to_endpoint):
from_endpoint = (kind, id) where kind ∈ {port, device, io} and id = whichever *_id is set
to_endpoint = same shape
Treat the endpoint pair as unordered: {A, B} == {B, A}
A candidate suggestion = any group with ≥ 2 cables.
```
i.e. "two or more cables run between the same two endpoints" → almost
certainly a bundle. Types in the group can be mixed (Power + USB + HDMI
from desk → wall).
Suggestions are reviewed in the UI; clicking *Accept* creates a real
`bundles` row (`auto=0`). m can also create bundles manually by
shift-clicking cables.
Rendering bundles in the SVG view is a slice 6+ concern; in the export
they're ignored in v0 (open question §9).
---
## 5b. v4 — Solver
The solver is the headline addition in v4. m's product-vision sentence
maps onto it directly:
> "I say what devices we have, the app tells me how to bundle cables and
> how the most efficient connection looks like"
The solver reads a project's `devices` (with their `ports`) and
`connection_requirements`, and writes a set of solver-owned `cables`
(rows with `auto=1`) + `bundles`. m's hand-drawn cables (`auto=0`) are
left strictly alone — the solver only adds and removes its own.
### 5b.1 Objective: maximum bundling
Locked in by m. Prefer routes that consolidate cables into shared trunks
even at higher total length. Visually cleaner setups; easier to manage
physically (one cable bundle along the floor, not five strands).
Concretely: when assigning a cable to a path, the solver minimises the
**count of distinct trunks**, breaking ties by total length. v0
approximates a "trunk" with the pair of device endpoints (the v3 rule);
slice 6+ adds path-based trunks via frame-edge corridors.
### 5b.2 Algorithm (v0)
Pure function. No graph search; no LP. Single pass with greedy port
allocation.
```
solve(project) ⇒ {add, remove, bundles, unsatisfied}:
let auto_cables_before = SELECT * FROM cables WHERE project=p AND auto=1
let port_free := {port_id -> bool} initialised TRUE for every port
minus ports already used by manual cables (auto=0)
for each requirement r in order(must_connect DESC, id ASC):
let ct = r.preferred_cable_type_id
?? auto_pick_cable_type(r.from_device, r.to_device)
?? fail("ambiguous")
let pa = first_free_port(r.from_device, ct, port_free)
let pb = first_free_port(r.to_device, ct, port_free)
if !pa or !pb:
if r.must_connect: unsatisfied.push({r.id, reason})
else: skip
continue
port_free[pa] = port_free[pb] = false
add.push(cable{type=ct, from_port=pa, to_port=pb, auto=1})
// Bundle by endpoint-pair (v3 rule, applied only to auto cables).
for each (device_a, device_b) pair with ≥ 2 add-cables:
bundles_add.push({auto=1, cables: those add-cables})
// Diff against auto_cables_before to compute remove[] (any prior auto
// cable whose (from, to, type) doesn't appear in add[]).
remove = auto_cables_before - add
return {add, remove, bundles_add, unsatisfied}
```
`first_free_port(device, cable_type, free_map)` picks the lowest-id port
on the device whose `type_id` matches and that is still free, returning
NULL if none. The `lowest-id` tiebreak is deterministic so repeated
solves produce the same plan.
`auto_pick_cable_type(from, to)` (used when `preferred_cable_type_id` is
NULL): find the set of cable types `T = ports(from).types ∩
ports(to).types`. If `|T| == 1`, return it. If `|T| > 1`, fail
("ambiguous; specify preferred_cable_type_id"). The UI surfaces this
as a "specify type" inline edit on the requirement.
### 5b.3 Solver-owned vs. user-owned cables
`cables.auto` distinguishes them.
| Operation | Effect on `auto=0` cables | Effect on `auto=1` cables |
|---|---|---|
| POST /api/.../cables (m draws by hand) | inserts auto=0 | n/a |
| PATCH cables (m moves endpoint, relabels) | applies | applies (and the cable is "promoted" to auto=0 — m owns it now) |
| DELETE cables | applies | applies |
| POST /api/.../solve | left alone (their used ports are reserved before the solver runs) | replaced wholesale (remove[] + add[] in one tx) |
This way a manual cable m doesn't want the solver to second-guess
survives every solve. If m wants the solver to take it over, he deletes
his hand-drawn cable and re-solves; the solver re-creates an equivalent
auto cable.
### 5b.4 When solver fails
Three classes of failure surface in the response's `unsatisfied[]`:
1. **No compatible cable type** — `T = ports(from).types ∩
ports(to).types` is empty (e.g. a Power-only device to an HDMI-only
device). UX: edit the requirement to specify, or add a port on one of
the devices.
2. **Ambiguous cable type** — `|T| > 1`, no preferred set. UX: pick a
type on the requirement.
3. **No free port** — the cable type matches but every port on one side
is already used. UX: drop a must_connect=0 requirement, or add ports.
The solver does **not** auto-add ports to a device. Reason: m said
"override per instance"; auto-adding crosses that line. The UI surfaces
the unmet requirement with a "+ Add port" affordance on the device
inspector instead.
### 5b.5 Preview vs. apply
`?preview=1` returns the same shape without writing. The UI shows a diff
modal with `add[]`, `remove[]`, `unsatisfied[]`; m clicks Apply to fire
the same endpoint without `preview=1`. Default (no flag) applies
immediately — useful for live-solve mode (open question §9).
---
## 6. Sync — export-only for v0
```
┌─────────────────────┐
│ mCables DB (truth) │
└──────────┬──────────┘
export ▼
(push) ┌────────────────────────┐
│ <project>.excalidraw │
│ on mxdrw.msbls.de │
└────────────────────────┘
```
- mCables UI → DB: synchronous (every drag/add/remove persists immediately).
- DB → Excalidraw: **manual** button "Export to Excalidraw" in the header,
per project. Calls `POST /api/projects/:pid/sync/export`.
- Excalidraw → DB: **not implemented** in v0. Anything m draws in
Excalidraw stays in Excalidraw until he redraws it in mCables.
This keeps the v0 scope tight: no conflict resolution, no element-diff
import, no auto-debounce. mExDraw keeps its own version history (git
sidecar in the mdraw deploy) so a bad export is recoverable from there.
When mxdrw is unreachable: the export button shows a tooltip and disables;
the editor keeps working against the local DB.
Post-MVP, import returns as a one-shot migration tool (separate
`mcables-migrate` CLI tool, not part of the running server) for seeding
new projects from existing `.excalidraw` files.
---
## 7. UI flows
The editor lives at `/`. Layout:
```
┌────────────────────────────────────────────────────────────────────┐
│ mCables [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
├────────┬───────────────────────────────────────────────────────────┤
│ │ │
│ Legend │ │
│ │ │
│ Power │ Diagram surface (SVG) │
│ USB │ │
│ HDMI │ ┌─desk─────────────┐ ┌─rack──────────┐ │
│ DP │ │ [Mac] [Screen] … │ │ [NAS] [fritz] │ │
│ RJ45 │ └──────────────────┘ └───────────────┘ │
│ + Type │ │
│ │ │
│ Tools │ │
│ + Dev │ │
│ + Frm │ │
│ + IO │ │
│ draw │ │
│ │ │
├────────┴───────────────────────────────────────────────────────────┤
│ Inspector (selection-dependent: project / frame / device / port / │
│ cable / bundle details and actions) │
└────────────────────────────────────────────────────────────────────┘
```
### Flow: pick a project
Header has a dropdown "LOFT ▾". Clicking it lists all projects from
`GET /api/projects`; clicking one swaps the diagram (`GET /api/projects/:pid`
loads the full snapshot in one round-trip). The picker also shows a
`+ New Project` action → modal with `name`, `drawing_name` (defaults to
`<name>.excalidraw`), `description` → `POST /api/projects` → switches to
the new project (which has 5 seeded cable types and no frames yet).
The currently active project's id is kept in URL state
(`/?project=LOFT`) so reload returns to the same project.
### Flow: add a frame
1. `+ Frm` in the left toolbar (or `F`).
2. Click + drag on the canvas → rubber-band rectangle becomes a frame.
3. Name prompt centered in the frame; Enter → `POST .../frames`.
### Flow: add a device (v4 — type-aware)
1. `+ Dev` (or `D`) → click on canvas → device placeholder appears.
2. **First field in the inline namer: type dropdown** (replaces the
v1 plain-name input). Options pulled from
`GET /api/projects/:pid/device-types` — built-ins listed first
grouped by `kind`, then project-custom rows, then `Custom (no type)`.
Typing in the dropdown filters by `name` (m types "n" → NAS jumps
to top). Below the dropdown: a name input pre-filled with the type
name + a digit if a same-named device already exists ("PC", "PC-2").
3. Hit Enter → `POST .../devices` with `type_id` + name. The server
seeds the ports from `device_type_ports` in the same transaction
and returns the device with its `ports`.
4. Picking `Custom (no type)` keeps the v3 behaviour: rectangle, no
ports, m adds ports manually via the inspector.
5. The device renders with its ports already visible along the
configured edge.
### Flow: add a port
Select a device → inspector shows `+ Port` button. Click → cursor becomes
a "ghost port" of the active cable type (legend selection). Snap to device
edge → click commits → `POST .../devices/:id/ports`.
### Flow: draw a cable
Click a port → port highlights. Hover any other endpoint (port / device /
IO marker) → preview cable drawn in the source's type colour. Click commits
→ `POST .../cables`. `Shift`-click to bind to a whole device. Click an IO
diamond to terminate at a wall outlet.
### Flow: add an IO marker (wall outlet)
`+ IO` (or `I`) → click on canvas → small diamond placed → optional label
text edit → `POST .../io-markers`. By design, the only cables that
terminate at an IO marker are Power cables, but the schema doesn't enforce
that — the UI shows a soft warning if m draws a non-Power cable to an IO.
### Flow: pick / edit a cable type
Legend on the left is interactive and **global** (the same legend shows up
in every project). Click a row → that type becomes the active "drawing
type" for the current project's session. Drag the swatch → colour picker →
updates `cable_types.color` via `PATCH /api/cable-types/:id`. `+ Type` at
the bottom → "new cable type" modal — `POST /api/cable-types`. Names are
globally unique.
The modal for editing / adding shows a banner:
*"Cable types are shared across all projects. Renaming or recolouring
affects every project that uses this type."* Deleting a type that's still
in use by any cable returns a 400 with the offending cable count — the
client surfaces it as an inline error in the modal.
### Flow: drag a device
Pointer-drag → live `transform` on the SVG; on `pointerup`,
`PATCH .../devices/:id` persists `x, y`. Ports follow because their
offsets are relative.
### Flow: bundles
In the inspector with nothing else selected, "Bundle suggestions" pulls
`.../bundles/suggestions`. Each suggestion shows the cables highlighted
on the diagram + an Accept button. Manual: shift-click multiple cables →
"Group as bundle" → name it → save.
### v4 — Flow: declare connection requirements
The left sidebar gains a **Requirements** section under the legend:
```
Cable types
Power, USB, HDMI, DP, RJ45, + Type
Requirements ← new in v4
NAS ↔ Switch RJ45 must
PC ↔ TV HDMI must
Mac ↔ Soundbar HDMI nice
+ Requirement
```
Click `+ Requirement` → modal with two device pickers (autocomplete from
the project's current devices), a cable-type picker (defaults to
auto-resolve if the device pair has only one matching type), and a
must/nice toggle. `POST .../connection-requirements`.
Alternative gesture (no tool armed, no selection): **drag from device A
to device B** to seed a requirement modal with the pair pre-filled. The
solver-edge preview drags out from the source device's edge in a thin
dashed line until release.
m can also right-click a requirement row → edit / delete.
### v4 — Flow: run the solver
Header gains a **Solve** button next to **Export**.
1. Click Solve (or `S`) → `POST /api/projects/:pid/solve?preview=1`.
2. A diff modal opens listing `add[]`, `remove[]`, `unsatisfied[]` — the
canvas behind it dims and previews the new cables in a translucent
stroke + the to-be-removed cables in a strikethrough red.
3. Buttons:
- **Apply** → fires `POST .../solve` (no `preview`), applies in one
transaction, closes the modal, re-renders canvas with the real
cables in place.
- **Cancel** → leaves everything as it was.
4. Unsatisfied requirements get their own list at the bottom of the
modal, each with a quick-action button: "Specify type", "+ Add port
to device X", or "Drop requirement (set must=0)".
If `unsatisfied[]` is non-empty, the Solve button stays in a
soft-error state (yellow) until either every requirement is satisfiable
or m explicitly accepts the partial plan.
### v4 — Inspector states
| Selection | Inspector shows |
|---|---|
| nothing | empty, with "Bundle suggestions" + "Project requirements" headlines |
| project header | name, drawing_name, description (editable), device count, requirement count, Solve / Export buttons |
| frame | name (editable), x/y/w/h, contained-device count, delete |
| **device** | name + type + icon, ports grid (type / label / connected? / +Port), **unmet requirements list** (red badges with quick-fix), delete |
| **port** | type, label, parent device, current cable (if any), delete |
| **cable (auto=1)** | source/target, type, driving requirement (clickable → opens requirement edit), parent bundle (if any), label, "Promote to manual" (sets auto=0) |
| cable (auto=0) | as v3 — type, source/target, label, delete |
| bundle | name, member cables (clickable to focus), trunk segment description, auto-detected flag |
### Keyboard
`P` switch project, `F` add frame, `D` add device, `I` add IO marker,
`T` start cable from selected port, `R` add requirement,
**`S` solve project (v4)**, `E` export, `Esc` cancel, `Backspace` delete
selection, `?` show shortcuts.
---
## 8. First slices — v4 reshape
Slices 1 + 2 have shipped (see git history). v4 inserts new slices ahead
of the original 3-5 because the solver depends on the catalog + the
requirements model, not on manual cable drawing. The old "manual port +
cable draw" slice is still in scope as a tweak path on the solver
output, but it follows the solver instead of leading.
| # | Slice | Status | What's shipped |
|---|---|---|---|
| 1 | **Bootstrap + project CRUD + global cable_types** | ✅ shipped | See git: branch `mai/picasso/slice-1-bootstrap`. |
| 2 | **Frames + devices + drag** | ✅ shipped | See git: branch `mai/picasso/slice-2-frames-devices`. |
| **3 (was 4)** | **IO markers + cable-type editing** | pending | Unchanged scope. `+ IO` places a wall-outlet diamond. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new global types. |
| **4 (NEW)** | **Device-type catalog + type-aware device create** | pending | Migration 002: `device_types` + `device_type_ports`, seeded with the 11 built-ins (§2.2). Migration adds `devices.type_id`. API: `GET /api/device-types`, `GET /api/projects/:pid/device-types`. Frontend: the +Dev inline namer becomes a type dropdown + name input; choosing a built-in type seeds the device's ports on the backend. Picking `Custom (no type)` falls back to v3 freeform. m can create a typed NAS + see its Power + RJ45 ports appear on the canvas. |
| **4.5 (NEW)** | **Manage device-type catalog (per project)** | pending | Modal: `POST/PATCH/DELETE /api/projects/:pid/device-types` for project-custom rows. Edit affordance hidden for built-ins. Lets m add an exotic device type without contributing to the built-in catalog. Validation: a custom type can't share a name with a built-in (already enforced by `UNIQUE(project_id, name)` + a separate code-level check against built-ins). |
| **5 (NEW)** | **Connection requirements UI + CRUD** | pending | Migration 003: `connection_requirements`. API: full CRUD under `/api/projects/:pid/connection-requirements`. Frontend: left-sidebar "Requirements" section, `+ Requirement` modal (autocomplete from project's current devices, cable-type picker, must/nice toggle). Drag from device A to device B gestures the same modal pre-filled. Inspector for a selected device lists its requirements. |
| **6 (NEW)** | **Solver MVP + Solve button** | pending | `POST /api/projects/:pid/solve` with `?preview=1` support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling (slice 6.5 is path-based bundling). Migration adds `cables.auto`. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan → applies. |
| **7 (was 3, slimmed)** | **Manual port + manual cable draw** | pending | The v3 flow as a tweak path on solver output. `+ Port` on an instance-owned device; click-port → click-port creates a hand-drawn cable (`auto=0`). Used to override the solver's choices or to extend its plan. |
| **8 (was 5)** | **Export to mxdrw.msbls.de** | pending | `POST .../sync/export` writes a `.excalidraw` scene per the visual grammar (§4). Bundles ignored on export in v0. |
Slices 9+ (not promised for the first coder shift):
- Path-based bundling: instead of endpoint-pair bundling, group cables that share a frame-edge corridor or a wall-axis (§5b.1 "trunk segment" definition).
- Live-solve mode: re-run solver on every device/requirement edit with a debounce + previewed-but-not-applied diff in a toast.
- Setup templates (Living Room, Home Office, Server Rack): a `setup_templates` table + `POST .../apply-template` that pre-populates `connection_requirements` from an archetype.
- Bundle rendering in the SVG (thick path with mixed-colour fan-out) and in the export.
- "Re-seed from type" action on a device.
- Cable inventory metadata (length/SKU) if m later wants it.
- Dark mode.
---
## 9. Open questions for m — v4
v3 closed all its v2 questions. v4 raises six new ones, all about the
solver semantics and UX. Worth resolving before slice 4 starts so the
coder shift doesn't backtrack:
1. **Where do paths come from?** v0 draws straight lines port-to-port +
bundles by endpoint-pair. Three candidates for slice 6.5/9:
(a) auto-route through frame edges (cables exit a device toward the
nearest frame edge, traverse along edges, enter the target frame);
(b) m draws **cable-tray polylines** on the canvas and cables snap to
them; (c) Steiner-tree-ish path optimisation per trunk. I lean (b) +
(a) as fallback — m gets the manual override when his layout is
non-obvious, otherwise the system routes for him. Confirm direction.
2. **Live solve or button-only?** Two modes available:
- **Button-only** (locked default) — m hits Solve, sees the diff,
applies. Simple; no surprises.
- **Live** — solver re-runs on every device/requirement edit with a
debounce, results land in a toast "12 cables, 3 bundles, 1 unmet
— review?". More responsive, costs ~10ms of compute per edit.
I'd ship button-only first (slice 6); add live as an opt-in toggle
(slice 9+). Confirm or escalate to "live always".
3. **No-compatible-port-pair UX when solving.** Three options:
(a) Surface as "unsatisfiable" and let m manually add a port via the
device inspector (current §5b.4 stance).
(b) Auto-add the missing port to the device on the m's confirmation
(single-button "Add HDMI to PC and re-solve").
(c) Auto-add silently — bad UX, surprise mutations.
I lean (a) with a one-click quick-fix that does (b). Confirm.
4. **Setup templates — v4 or post-MVP?** "Living Room" / "Home Office" /
"Server Rack" archetypes that pre-populate `connection_requirements`.
I left this out of the v4 slice list (designed in §8 "slices 9+") to
keep v4 tight. m can build the same effect by adding requirements
manually first time. Confirm: post-MVP OK, or do you want me to fold
it into slice 5/6?
5. **Catalog distribution: code seed vs. JSON file.** Two paths:
(a) seed in migration 002 via SQL INSERTs (today's design — locked-in,
schema versioned, no user override of built-ins).
(b) seed from a curated JSON file at `internal/db/catalog/builtin.json`
that the migration reads. m or contributors can extend the file by
PR; rebuild image; new built-ins appear.
I lean (a) for v0 (simpler, doesn't need a file-loader). Open
question: do you anticipate growing the built-in list often? If yes,
(b) starts paying off after the second addition.
6. **Promoting a solver cable to manual.** §5b.3 says PATCHing an
`auto=1` cable flips it to `auto=0` so the next solve doesn't replace
it. Two surface variants:
(a) Implicit: any PATCH that touches type/from/to on an auto cable
promotes it. m never sees the flag.
(b) Explicit: a "Promote to manual" button on the cable inspector
(current §7 stance). PATCHes that only update labels stay auto.
I leaned (b) for clarity ("the solver might overwrite this — promote
to protect"). Confirm or override.
---
## 10. Deployment on mDock (raw docker)
Inspected mDock's live services on 2026-05-15 to lock the conventions
before writing this:
- All m-built services on mDock live under `/home/m/stacks/<project>/`
with a single `docker-compose.yml`. Older services in `/home/m/<project>/`
use the same pattern; the canonical-new path is `stacks/`.
- Compose v2 (`docker compose`), images built from Gitea container registry
(`mgit.msbls.de/m/<project>:latest`), `restart: unless-stopped` on every
service, `container_name: <project>` explicit.
- Host port mappings: deliberately collision-free across the host. Existing
high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878
(radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr).
**Port 7777 is free** — taking it for mCables.
- Bind-mount volumes: `/home/m/<project>-data:/app/data` is the canonical
pattern (mgreen). For project-local data we put `data/` *next to* the
compose file so a `git pull && docker compose up -d` is the whole deploy:
`/home/m/stacks/mcables/data:/app/data`.
- Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin
pattern). mCables only needs `MEXDRAW_TOKEN` for export.
- No reverse proxy on mDock. Services expose ports directly on the LAN
(mDock = `192.168.178.131` / Tailscale `mdock`). Public exposure goes via
mlake/Dokploy + Caddy when needed — out of scope for mCables (LAN-only).
- Auto-deploy via the Gitea Actions self-hosted runner already installed
on mDock (`/home/m/act-runner/`, label `self-hosted:host`). Push to
`main` → workflow on mDock → `docker compose up --build -d`.
### Repo layout for mCables
```
mCables/
├── cmd/mcables/main.go # Go binary
├── internal/
│ ├── db/ # migrations + store
│ ├── importer/ # post-MVP only (not in MVP)
│ ├── exporter/ # DB → .excalidraw
│ └── server/ # net/http handlers
├── web/ # embedded static frontend
│ ├── index.html
│ ├── main.js # ES module entry
│ ├── style.css
│ └── lib/... # SVG helpers, store, components
├── data/ # mCables runtime DB lives here (gitignored)
│ └── .gitkeep
├── docs/design.md # this file
├── Dockerfile
├── docker-compose.yml
├── .gitea/workflows/deploy.yml
├── .gitignore # data/, *.db, *.db-wal, *.db-shm
├── Makefile # build, typecheck, test, run
├── go.mod / go.sum
└── README.md
```
### Dockerfile sketch
Multi-stage; the final image is `scratch` because `modernc.org/sqlite` is
pure Go.
```dockerfile
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/mcables ./cmd/mcables
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/mcables /app/mcables
ENV MCABLES_ADDR=0.0.0.0:7777
ENV MCABLES_DB=/app/data/mcables.db
USER nonroot:nonroot
EXPOSE 7777
ENTRYPOINT ["/app/mcables"]
```
### docker-compose.yml (on mDock at `/home/m/stacks/mcables/`)
```yaml
services:
mcables:
image: mgit.msbls.de/m/mcables:latest
container_name: mcables
restart: unless-stopped
ports:
- "7777:7777"
environment:
- TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de
env_file:
- /home/m/secrets/mcables/.env # contains MEXDRAW_TOKEN
volumes:
- /home/m/stacks/mcables/data:/app/data
```
LAN URL: `http://mdock:7777` (or `http://192.168.178.131:7777`).
### Gitea Actions deploy workflow
`.gitea/workflows/deploy.yml`:
```yaml
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t mgit.msbls.de/m/mcables:latest .
- name: Push image
run: |
echo "${{ secrets.GITEA_TOKEN }}" | \
docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/m/mcables:latest
- name: Up
run: |
cd /home/m/stacks/mcables
docker compose pull
docker compose up -d
```
### Local-development run (no Docker)
```
make run # go run ./cmd/mcables → :7777 against ./data/mcables.db
make typecheck # tsc --noEmit on web/
make test # go test ./...
```
The repo has `data/` checked-in-empty (with `.gitkeep`); `data/*.db*` is
gitignored.
---
DESIGN v4 READY FOR REVIEW