Files
CableGUI/docs/design.md
mAi e42b351280 docs: design v4.1 — schematic-only bundling, setup templates folded in
Tight pass on m's review of v4 (single commit per head's instruction).

Six locked answers integrated:

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

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

CLAUDE.md mirrors: schematic-only framing, 14-type catalog, setup
templates as a first-class feature, quick-fix UX note.
2026-05-16 00:03:19 +02:00

75 KiB
Raw Blame History

mCables — 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. mCables 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 (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 (§5b.1). Schematic only: when two or more cables share the same endpoint pair, group them into one bundle. No path or trunk geometry — mCables 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)

  • 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).

-- 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 × 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
Screen display Power × 1; HDMI × 1
Keyboard accessory USB × 1
Mouse accessory USB × 1

"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.

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/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.

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

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

mCables 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

                ┌─────────────────────┐
                │ 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), 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 mCables'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 — mCables 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 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.

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

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:

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.1 READY FOR REVIEW