Files
CableGUI/docs/design.md
mAi c206a331ec rename: mCables → CableGUI (project + repo + image + paths)
Full project rename per m's call. Single atomic commit because the
codebase rename is a coupled change — go module path, env vars, DB
default, Docker artefact names, and on-disk mDock paths all flip
together.

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

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

go build ./... clean; go test -race ./... green
2026-05-16 15:35:42 +02:00

86 KiB
Raw Permalink Blame History

CableGUI — Design v4.1

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), v4 (solver-as-core), and now v4.1 — six locked answers from m's v4 review.

What changed in v4.1 (tight pass on v4)

  1. CableGUI is a schematic, not a physical-routing tool. Cables are straight lines between endpoints; the solver and the renderer do not care about paths, trunks, frame edges, or cable-tray polylines. "Maximum bundling" reduces to the v3 rule: ≥2 cables between the same endpoint pair → bundle them. All path-routing language has been stripped from §5b.1, §5b.2, §7, §8, §9.
  2. Solver fires on the Solve button (v0). Live-solve stays in §8 slices 9+ as an opt-in toggle.
  3. Unmet-requirement quick-fix: when the solver returns unsatisfied[], the device inspector renders a red badge per unmet requirement with a single button — "+ Add <type> port to <device> and re-solve" — that POSTs a new port to the device AND immediately re-runs POST /api/projects/:pid/solve in the same UI action. See §5b.4 + §7 inspector-states.
  4. Setup templates fold INTO v4.1. New tables setup_templates, setup_template_devices, setup_template_requirements in migration 004 + 3 built-in templates ('Living Room', 'Home Office', 'Server Rack'). New endpoints GET /api/setup-templates and POST /api/projects/:pid/apply-template. UI: a "Templates" panel in the New Project flow + an "Apply template" action on an empty project. See new §2.4 + slice 6 fold-in below.
  5. Catalog distribution: SQL seed in migration 002 (no change).
  6. Promote to manual: explicit button on the cable inspector (no change).

Sources: the live Cable-Management.excalidraw on mxdrw.msbls.de (used as the visual-grammar reference, not a bootstrap import target), mai-memory (cablegui, 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"

CableGUI 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 (§5b.1). Schematic only: when two or more cables share the same endpoint pair, group them into one bundle. No path or trunk geometry — CableGUI is a wiring schematic, not a routing tool. v4.1 strips all path/trunk language from the v4 draft.
  • 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)

  • CableGUI 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/cablegui.db (gitignored). Bind 0.0.0.0:7777 LAN, no auth.
  • Deploy on mDock under /home/m/stacks/cablegui/, 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 CableGUI, 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 CableGUI 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/cablegui.db (project-local, gitignored). WAL mode, FKs on. Driver: modernc.org/sqlite (cgo-free — clean cross-compile, simple Dockerfile).

-- 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) — 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) — connection_requirements. Also adds cables.auto (ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0) so the solver can distinguish its rows from m's hand-drawn ones (§5b.3).
  • 004_setup_templates.sql (v4.1 NEW) — setup_templates + setup_template_devices + setup_template_requirements. Seeds 3 built-in templates ('Living Room', 'Home Office', 'Server Rack').

Slices 1 and 2 already shipped 001. Slice 4 lands 002; slice 5 lands 003; slice 6 lands 004 alongside the solver MVP + templates UI.

2.2 Built-in catalog seed (002 INSERTs)

The 14 built-in types m's setup uses today, with their default port profiles. Stored as (project_id NULL, built_in 1). v4.1 added the three peripheral types (Screen, Keyboard, Mouse) to support the Home Office setup template:

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 In × 1 (top/back); Power Out × 3 (bottom/front)
IOx-6 hub Power In × 1 (top/back); Power Out × 6 (bottom/front)
IOx-8 hub Power In × 1 (top/back); Power Out × 8 (bottom/front)
Screen display Power × 1; HDMI × 1
Keyboard accessory USB × 1
Mouse accessory USB × 1
Multi-plug 3 hub Power In × 1 (top/back); Power Out × 3 (bottom/front)
Multi-plug 4 hub Power In × 1 (top/back); Power Out × 4 (bottom/front)
Multi-plug 5 hub Power In × 1 (top/back); Power Out × 5 (bottom/front)
Multi-plug 6 hub Power In × 1 (top/back); Power Out × 6 (bottom/front)
Wifi-plug accessory Power In × 1 (top/back); Power Out × 1 (bottom/front) — pass-through outlet

v5 (migration 005) added the Multi-plug 36 strips and the Wifi-plug pass-through outlet. v6 (migration 006) re-shaped the IOx-* and Multi-plug-* profiles to the "1 in on top / N out on bottom" layout — the IOx-* devices are physical power strips, not USB hubs (m's hardware), and the Multi-plug-* outputs are now visually distinct from the input. Convention: top = back, bottom = front. Existing device instances keep their already-seeded ports per §2.3 — to pick up the new layout, delete + re-create the 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.

2.4 Setup templates (v4.1 NEW)

A setup template is a named recipe of "device-types to add + connection requirements between them" that bootstraps a project from blank to solver-ready in one click. m's three archetypes:

Template name Devices Default requirements
Living Room TV, Soundbar, ChromeCast TV ↔ Soundbar (HDMI, must); TV ↔ ChromeCast (HDMI, must)
Home Office PC, Screen, Keyboard, Mouse PC ↔ Screen (HDMI, must); PC ↔ Keyboard (USB, must); PC ↔ Mouse (USB, must)
Server Rack NAS, Switch, fritz NAS ↔ Switch (RJ45, must); Switch ↔ fritz (RJ45, must); fritz ↔ NAS (Power, nice)

"Screen", "Keyboard", "Mouse" are added to the v4 built-in catalog alongside the existing 11 (Screen: Power × 1 + HDMI × 1; Keyboard: USB × 1; Mouse: USB × 1). Migration 002 grows to seed 14 built-ins.

Schema (004_setup_templates.sql):

-- A named recipe: a list of device types + requirements between them.
CREATE TABLE setup_templates (
    id              INTEGER PRIMARY KEY,
    name            TEXT NOT NULL UNIQUE,
    description     TEXT NOT NULL DEFAULT '',
    built_in        INTEGER NOT NULL DEFAULT 0,
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
);

-- The devices a template stamps into a project. suggested_name is
-- pre-filled into the apply-template form; m can override.
CREATE TABLE setup_template_devices (
    id              INTEGER PRIMARY KEY,
    template_id     INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
    device_type_id  INTEGER NOT NULL REFERENCES device_types(id)    ON DELETE RESTRICT,
    suggested_name  TEXT,                        -- "TV", "Bedroom TV", "Mac (work)"
    sort_order      INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);

-- Requirements between devices in the template, addressed by
-- `setup_template_devices.id` (not the runtime device id — they're
-- resolved at apply time).
CREATE TABLE setup_template_requirements (
    id                       INTEGER PRIMARY KEY,
    template_id              INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
    from_template_device_id  INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
    to_template_device_id    INTEGER NOT NULL REFERENCES setup_template_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)),
    CHECK (from_template_device_id != to_template_device_id)
);
CREATE INDEX setup_template_reqs_template_idx ON setup_template_requirements(template_id);

API:

GET  /api/setup-templates                          → [SetupTemplate {id, name, description, built_in,
                                                                     devices: [{id, device_type_id,
                                                                                device_type: {…},
                                                                                suggested_name, sort_order}],
                                                                     requirements: [{id, from_template_device_id,
                                                                                     to_template_device_id,
                                                                                     preferred_cable_type_id,
                                                                                     must_connect}]}, …]
                                                     Read-only; built-ins are not editable via API in v4.1.

POST /api/projects/:pid/apply-template             ← {
                                                       template_id: <int>,
                                                       name_overrides: { <template_device_id>: "<name>", … },
                                                       skip_devices: [<template_device_id>, …]    # optional
                                                     }
                                                     → {
                                                       devices_added: [Device, …],
                                                       requirements_added: [ConnectionRequirement, …],
                                                       skipped_devices: [{template_device_id, reason}, …]
                                                     }
                                                     Idempotency:
                                                     - A name collision with an existing device in the
                                                       project skips that template device (reason = "name
                                                       already in use"). Caller can pass `name_overrides`
                                                       to resolve.
                                                     - Requirements whose endpoints both resolve fire;
                                                       any whose endpoint was skipped are themselves
                                                       skipped (logged in `requirements_skipped[]` — same
                                                       shape).
                                                     The whole call runs in a single transaction.

The seed migration creates the 3 built-ins + their template_devices and template_requirements rows referencing the 14 built-in device_types and the 5 built-in cable_types. No project_id anywhere — templates are global.

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/cablegui, net/http, no router framework. Listens on 0.0.0.0:7777 by default (overridable via CABLEGUI_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.

# v4 — Solver quick-fix combo endpoint (powers the inspector's
# "+ Add <type> port to <device> and re-solve" button — §5b.4).
POST   /api/projects/:pid/devices/:id/ports-and-resolve
                                                     ← {type_id: <int>,
                                                       label?: <str>,
                                                       x_offset?: <num>, y_offset?: <num>}
                                                     → {port: Port, solve: <solve response>}
                                                     Single tx: inserts the port + re-runs solve. Used by
                                                     the quick-fix UI so the unmet badge resolves in one
                                                     server round-trip.

# v4.1 — Setup templates
GET    /api/setup-templates                         → [SetupTemplate, …]
                                                     Read-only listing of built-in (and any project-custom,
                                                     post-v4.1) templates with their device/requirement
                                                     shapes (see §2.4).
POST   /api/projects/:pid/apply-template            ← {template_id: <int>,
                                                       name_overrides?: { <template_device_id>: "<name>" },
                                                       skip_devices?: [<template_device_id>, …]}
                                                     → {devices_added: [Device, …],
                                                       requirements_added: [ConnectionRequirement, …],
                                                       skipped_devices: [{template_device_id, reason}, …],
                                                       requirements_skipped: [{template_requirement_id, reason}, …]}
                                                     Idempotent in spirit: name collisions surface in
                                                     skipped_devices; m resolves with name_overrides on
                                                     re-apply. Whole call is one transaction.

# 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)

CableGUI 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 CableGUI 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 — schematic only

CableGUI is a schematic, not a physical-routing tool. Cables are straight lines between endpoints; the solver has no model of walls, floors, cable trays, or path geometry. "Maximum bundling" therefore reduces to a single rule on the schematic:

When two or more cables share the same endpoint pair (device A ↔ device B), group them into one bundle.

This is the v3 endpoint-pair rule, applied to the solver's output. m's "visually cleaner setups" benefit comes from the bundle being a single labelled set in the inspector + a single mixed-colour glyph in the render (slice 9+), rather than from any path optimisation. Anything about trunks, frame-edge corridors, or auto-routing is out of scope — filed for "post-v0 ambient" in §8.

5b.2 Algorithm (v0)

Pure function. No graph search; no LP; no path optimisation. 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 — quick-fix UX

Three classes of failure surface in the response's unsatisfied[]:

  1. No compatible cable typeT = ports(from).types ∩ ports(to).types is empty (e.g. a Power-only device to an HDMI-only device).
  2. Ambiguous cable type|T| > 1, no preferred set on the requirement.
  3. No free port — the cable type matches but every port on one side is already used.

The solver does not auto-add ports without m's consent. v4.1 ships an explicit one-click quick-fix per class of failure, surfaced as a red badge on the affected device in the inspector (§7) and as a button on each unsatisfied[] entry in the preview-diff modal:

Failure class Quick-fix button What it does
No compatible cable type "+ Add <preferred_type> port to <device> and re-solve" POST /api/projects/:pid/devices/:id/ports with type_id=preferred_type + sensible default offset, then immediately POST /solve again. The preferred_type is the requirement's preferred_cable_type_id. If the requirement has no preferred type, the button reads "Specify cable type" and opens an inline cable-type picker on the requirement instead.
Ambiguous cable type "Specify cable type" Opens an inline picker on the requirement row with the candidates from T pre-listed. On select → PATCH the requirement → re-solve.
No free port "+ Add <type> port to <device> and re-solve" Same as the no-compat case but the type is already determined (it's the requirement's preferred or auto-picked type). Adds a port on whichever side ran out (the response's reason carries which_side).

All three quick-fixes do their work in a single round-trip request from the UI perspective: the click fires a POST that either chains the port insert + the re-solve server-side, or fires both calls back-to-back from the client (server-side chaining is simpler — see §3.2 for the endpoint shape).

The quick-fix never adds a port silently; the button text always names the device + cable type so m sees what's about to mutate.

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. Live-solve (no Solve button — every requirement edit triggers a debounced re-solve) is parked at slice 9+ as an opt-in.


6. Sync — export-only for v0

                ┌─────────────────────┐
                │ CableGUI DB (truth)  │
                └──────────┬──────────┘
                           │
              export       ▼
              (push)   ┌────────────────────────┐
                       │ <project>.excalidraw   │
                       │   on mxdrw.msbls.de    │
                       └────────────────────────┘
  • CableGUI 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 CableGUI.

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 cablegui-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:

┌────────────────────────────────────────────────────────────────────┐
│ CableGUI   [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), descriptionPOST /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.

v4.1 — Flow: apply a setup template

The New Project modal gains a "or start from a template" section under the description field. Each built-in template ('Living Room', 'Home Office', 'Server Rack') is a clickable card listing its devices + the requirement edges between them. Selecting one expands an inline override form:

  • A pre-filled name for each template device (m can edit each, e.g. rename TV to Bedroom TV).
  • Per-device "skip" checkbox.

On Create, the server does POST /api/projects first; on success, immediately fires POST /api/projects/:pid/apply-template with the collected overrides. The response's devices_added + requirements_added are merged into the local snapshot and the project switches to it, already populated.

For an already-existing empty project, the inspector's project header shows an "Apply template" action that opens the same override form without the project-create round-trip.

Once the template has stamped its devices + requirements, hit Solve (§7 "Flow: run the solver") to produce the wired diagram.

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 with red badges. Each badge carries a single quick-fix button — "+ Add <type> port to <device> and re-solve" (no-compat-type / no-free-port cases) or "Specify cable type" (ambiguous case) per §5b.4. 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), the endpoint pair (Device A ↔ Device B), 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 (v4.1 EXPANDED) Solver MVP + Solve button + setup templates pending POST /api/projects/:pid/solve with ?preview=1 support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling. Migration 003 adds cables.auto. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan + unmet requirements (each with its quick-fix button per §5b.4) → applies. Folded in v4.1: setup templates. Migration 004 adds setup_templates + setup_template_devices + setup_template_requirements and seeds 3 built-ins ('Living Room', 'Home Office', 'Server Rack'). API: GET /api/setup-templates, POST /api/projects/:pid/apply-template. UI: a "Templates" section in the New Project modal + an "Apply template" action on empty projects → seeds devices + requirements in one transaction → Solve produces the wired diagram.
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):

  • Live-solve mode: re-run solver on every device/requirement edit with a debounce + previewed-but-not-applied diff in a toast. Opt-in toggle in project settings.
  • Bundle rendering in the SVG (a single thick line with mixed-colour stops between the endpoint pair, plus a small badge with the cable count). Cables in a bundle still render as their individual lines underneath; the bundle is a visual overlay m can toggle.
  • "Re-seed from type" action on a device.
  • Custom setup templates (m authors them in-UI, not just the built-in three).
  • Cable inventory metadata (length/SKU) if m later wants it.
  • Dark mode.

Out of scope, period (would change CableGUI's mental model): path routing, cable-tray polylines, frame-edge corridors, wall-axis bundling, 3D, anything that treats a cable as more than a labelled endpoint pair.


9. Open questions for m — all closed in v4.1

The six v4 questions are now answered. Locked answers:

  1. Where do paths come from?Nowhere — CableGUI is a schematic. Cables are straight lines between endpoints. The solver does not route, the renderer does not route, and "maximum bundling" reduces to the endpoint-pair rule (§5b.1). Anything resembling a path, trunk, cable tray, or frame-edge corridor is out of scope, period (§8 "Out of scope, period").
  2. Live solve or button-only?Button-only for v0. Live-solve stays parked at slice 9+ as an opt-in.
  3. No-compatible-port-pair UX.Explicit quick-fix. The unsatisfied-requirement badge in the inspector carries a single button — "+ Add <type> port to <device> and re-solve" — that POSTs the port AND fires /solve in one UI action. The button text always names the device + type, so m sees what's about to mutate (§5b.4 + §7).
  4. Setup templates.Folded INTO v4.1, in slice 6. Migration 004 adds setup_templates + child tables + 3 built-ins. GET /api/setup-templates and POST /api/projects/:pid/apply-template ship alongside the solver (§2.4 + §3 + slice 6 in §8). Custom templates (m authors his own) parked at slice 9+.
  5. Catalog distribution.SQL seed in migration 002. No external file loader.
  6. Promote to manual.Explicit button on the cable inspector (§7 row "cable (auto=1)"). PATCHes that only update labels stay auto.

No open design questions remain. The coder shift is gated on m's go/no-go for v4.1 — not on any unanswered design question from picasso.


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 CableGUI.
  • 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/cablegui/data:/app/data.
  • Secrets via env_file: /home/m/secrets/<project>/.env (msports-garmin pattern). CableGUI 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 CableGUI (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 CableGUI

CableGUI/
├── cmd/cablegui/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/                      # CableGUI 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.

# 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/cablegui ./cmd/cablegui

FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/cablegui /app/cablegui
ENV CABLEGUI_ADDR=0.0.0.0:7777
ENV CABLEGUI_DB=/app/data/cablegui.db
USER nonroot:nonroot
EXPOSE 7777
ENTRYPOINT ["/app/cablegui"]

docker-compose.yml (on mDock at /home/m/stacks/cablegui/)

services:
  cablegui:
    image: mgit.msbls.de/m/cablegui:latest
    container_name: cablegui
    restart: unless-stopped
    ports:
      - "7777:7777"
    environment:
      - TZ=Europe/Berlin
      - CABLEGUI_ADDR=0.0.0.0:7777
      - CABLEGUI_DB=/app/data/cablegui.db
      - MEXDRAW_BASE_URL=https://mxdrw.msbls.de
    env_file:
      - /home/m/secrets/cablegui/.env       # contains MEXDRAW_TOKEN
    volumes:
      - /home/m/stacks/cablegui/data:/app/data

LAN URL: http://mdock:7777 (or http://192.168.178.131:7777).

Gitea Actions deploy workflow

.gitea/workflows/deploy.yml:

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/cablegui:latest .
      - name: Push image
        run: |
          echo "${{ secrets.GITEA_TOKEN }}" | \
            docker login mgit.msbls.de -u mAi --password-stdin
          docker push mgit.msbls.de/m/cablegui:latest
      - name: Up
        run: |
          cd /home/m/stacks/cablegui
          docker compose pull
          docker compose up -d

Local-development run (no Docker)

make run          # go run ./cmd/cablegui → :7777 against ./data/cablegui.db
make typecheck    # tsc --noEmit on web/
make test         # go test ./...

The repo has data/ checked-in-empty (with .gitkeep); data/*.db* is gitignored.


11. v5 — Cable routing via clamps

m's bundling primitive: a clamp is a physical anchor on the canvas (think cable tie / clip). A cable routes from its from endpoint, through zero or more clamps in order, to its to endpoint. Two cables that share an ordered pair of consecutive clamps are visibly bundled along that segment — no detection pass, no inference: the overlap is the bundle.

This replaces the abandoned waypoints + segment-detection approach. v0's straight-line schematic stays as the empty-clamps case (cable_clamps is empty for a fresh solver-emitted cable).

11.1 Schema (migration 007)

CREATE TABLE clamps (
    id              INTEGER PRIMARY KEY,
    project_id      INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    x               REAL NOT NULL,
    y               REAL NOT NULL,
    label           TEXT NOT NULL DEFAULT '',
    frame_id        INTEGER REFERENCES frames(id) ON DELETE SET 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 clamps_project_idx ON clamps(project_id);

CREATE TABLE cable_clamps (
    cable_id        INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
    clamp_id        INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
    ord             INTEGER NOT NULL,    -- 1..N along from→to
    PRIMARY KEY (cable_id, ord),
    UNIQUE (cable_id, clamp_id)          -- a cable can't visit the same clamp twice
);
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);

frame_id on clamps mirrors devices + IO markers — m can put a clamp inside a frame and the frame-drag carries it.

UNIQUE (cable_id, clamp_id) blocks loops. ord is a small int, 1-based; nothing requires it to be contiguous (m can renumber 1, 2, 3 → 1, 3, 5 during edits and the renderer is fine with that), but the UI keeps them contiguous on every mutation for sanity.

11.2 Cable rendering model

Each cable resolves to a polyline [from-anchor, clamp₁, clamp₂, …, clampₙ, to-anchor] where:

  • from-anchor / to-anchor come from the existing anchorForEndpoint resolver (port / device / IO).
  • clamp anchors are (clamp.x, clamp.y) directly — clamps don't have a width/height to centre.

For N=0 clamps the result is the v0 straight line. For N≥1 we render a <polyline> instead of a <line>.

The endpoint-replug handles from §10 (cable-replug) stay on the first and last vertices. Mid-polyline vertices get their own clamp-handle — small grab points only on the selected cable, which behave like clamp-detach when dragged onto empty canvas (drop a clamp off the cable's path).

11.3 Bundle visualisation — derived from shared segments

A segment is a directed pair (A, B) where A and B are consecutive nodes of a cable's polyline. Two cables share a segment when their polyline contains the same A→B (or B→A — segment matching is undirected).

For each segment, compute cables[] — the cables that traverse it. If len(cables) ≥ 2, render the segment as a single thick line on top of the individual ones:

  • Width: 2 + N px (N = cable count). Caps at ~12 px.
  • Colour: a striped pattern, one stripe per distinct cable type in the bundle, ordered by cable_type.id. SVG <linearGradient> with hard stops produces the stripe band cheaply; render it on a sibling <polyline> over the individual lines.
  • Tooltip: <title> child listing the cables ("Power · USB · HDMI").

At a clamp where ≥ 2 cables meet, the clamp icon (10×10 rounded square) shows a small count badge (×N) when N > 1. At fan-out points (endpoint with no clamp before it on the polyline) the individual coloured lines re-emerge, so m sees which port each strand goes to.

Shared-segment computation is O(C·N̄) where C = #cables and N̄ = average polyline length. For a v0-sized project (≤ ~30 cables, ≤ ~5 clamps per cable) this is trivial. We rebuild the segment map on every renderCanvas — no caching layer.

11.4 UI gestures

+ Clamp tool (C shortcut, also a sidebar button):

  • Click empty canvas → place a clamp at the cursor (POST /clamps). Standalone clamp — not on any cable yet.
  • Click a cable line → insert this clamp into that cable. The new clamp sits at the click position (snapped to the nearest point on the cable's polyline) and its ord is computed so it falls between the two existing vertices it lies between.

Drag a cable's mid-segment:

  • Pointerdown on a cable line (not on an endpoint handle) and drag. Live preview shows a bend at the cursor. Pointerup:
    • If the cursor is within snap-radius (~16 px) of an existing clamp: insert that clamp into the cable's polyline at the right ord.
    • Otherwise: create a fresh clamp at the release point and insert it.

Clamp inspector (selecting a clamp on the canvas):

  • Position (x, y editable + label)
  • "Cables through this clamp": list with each cable's two endpoints, click → select that cable
  • "Remove from this cable" (per row) → DELETE the matching cable_clamps row; cable's polyline collapses around the gap.
  • "Delete clamp" → cascade-removes from every cable_clamps row.

Right-click on a clamp icon ON a cable → "Remove from this cable" inline.

Frame drag carries clamps the same way it carries devices + IO markers (clamp.frame_id mirrors the existing pattern, drag handler already iterates frame-contained items).

11.5 Relationship to the existing bundles table

Recommendation: keep bundles and bundle_cables, repurpose them.

  • Implicit/auto bundles → derived live from shared clamp segments. No DB rows. The §5 GET /bundles/suggestions endpoint stays useful as a "you might want to route these through the same clamps" hint.
  • Explicit named bundles → still in the bundles table. m names a group ("desk → wall trunk"), the UI offers "route all members through these clamps" as a one-click action. Useful for the case where m wants a stable label on a logical bundle that isn't yet routed.

Migration 007 leaves bundles + bundle_cables untouched. A v6 cleanup can drop them if m decides the explicit-named path isn't worth keeping.

11.6 Solver coupling

The v0 solver still emits straight cables — no clamp rows. m hand-routes after Solve. The solver's preview-diff is unaffected (solver compares endpoint pairs; clamp routing is independent of the endpoint identity).

Future v5.1: solver-suggested clamps based on shared paths between endpoint pairs. Out of scope here.

11.7 Export to mxdrw

Clamps map to small diamond elements (separate from IO markers — IO diamonds are red wall-outlets; clamps are grey routing points). excalidraw_id is stable across re-exports per the existing pattern.

Cable arrows become Excalidraw arrow elements with mid-points (the clamp positions) when N≥1 — Excalidraw supports multi-vertex arrows via the points array. Each startBinding / endBinding resolves to the from/to anchor's excalidraw_id; mid-vertices are unbound.

Bundle visualisation (thick striped lines on shared segments) is not exported in v0 — Excalidraw doesn't natively support gradient strokes, and the mxdrw round-trip would lose them. We export each cable as its own polyline; bundling is a viewer-only concept.

11.8 API additions

POST   /api/projects/:pid/clamps                   { x, y, label?, frame_id? } → Clamp
PATCH  /api/projects/:pid/clamps/:id               { x?, y?, label?, frame_id? } → Clamp
DELETE /api/projects/:pid/clamps/:id

POST   /api/projects/:pid/cables/:cid/clamps       { clamp_id, ord? } → CableClamp
DELETE /api/projects/:pid/cables/:cid/clamps/:cmid

# Convenience: re-order clamps on a cable in one call
PUT    /api/projects/:pid/cables/:cid/clamps       { clamp_ids: [int, int, …] }

Snapshot endpoint grows two arrays:

  • clamps: []Clamp
  • cable_clamps: []{ cable_id, clamp_id, ord }

11.9 Open questions for m

  1. Clamp icon shape. Diamond (overlaps visually with IO markers when zoomed out), small filled circle (overlaps with port circles), or rounded square 10×10? Recommend rounded square — distinct from everything else on the canvas today.
  2. Snap radius when inserting onto a cable. ~16 px world-units feels right at 1× zoom. Should it scale with zoom (visual constant) or stay world-constant (gesture stays the same regardless of zoom)? Recommend visual constant — divide by current zoom.
  3. Clamp deletion when shared. If a clamp is used by 4 cables and m clicks "Delete clamp", do we (a) refuse with a "still in use" prompt, (b) cascade-remove from all 4 cables, or (c) cascade silently? Current draft says cascade silently. Worth a confirmation?
  4. Bundle stripe order. Cable-type id is stable but arbitrary; visual order on a thick line affects readability. Order by stripe-count (Power first if 3 Power + 1 USB), or by cable-type-id (deterministic but unrelated to importance)? Recommend by-count, ties broken by id.
  5. Solver respect for existing routing. When m re-runs Solve after hand-routing, should the solver preserve existing clamp routing on user-owned (auto=0) cables? Auto cables are wiped + rebuilt, so their clamps disappear with them — that's expected. But manual cables with clamps should clearly keep them. Confirm.

11.10 Slice plan (post-design)

  1. Schema migration + tx-aware store helpers (Create/Update/DeleteClamp, AttachClampToCable, DetachClampFromCable, ReorderClamps).
  2. HTTP endpoints + snapshot extension.
  3. Frontend: clamp render + + Clamp tool + canvas placement (no cable attach yet).
  4. Cable polyline render via clamps, mid-segment drag-to-clamp, clamp inspector.
  5. Shared-segment bundle visualisation (gradient stripe + count badge).
  6. Export pipeline extension — mxdrw arrows with mid-points + clamp diamonds. Bundle viz stays viewer-only.

DESIGN v5 READY FOR REVIEW