Files
CableGUI/docs/design.md
mAi c690113ea1 docs: design v3 — global cable_types, device UNIQUE, delete guardrail
Tight pass on round-4 answers (single commit per head's request):

- cable_types is GLOBAL — drop project_id, UNIQUE(name). Migration 001
  seeds the 5 defaults once; POST /api/projects no longer seeds them.
  API moves to top-level /api/cable-types. Renaming/recolouring affects
  every project. CASCADE from projects does not touch cable_types.
- devices: UNIQUE (project_id, name) added.
- projects: drawing_name defaults to "<name>.excalidraw" server-side
  on POST when omitted; editable via PATCH.
- DELETE /api/projects/:pid requires ?confirm=<name>; server checks
  name match, returns 400 if missing or mismatched.
- io_markers: no type_id (Power-by-convention, UI soft-warn). Confirmed
  v0 stance.
- Bundles ignored on export — carries over from v2.
- §0 changelog rewritten as "what changed in v3 / what carried over".
- §2 schema rewritten; FK-shape paragraph updated to call out the one
  global table.
- §3 endpoints: cable-types moved to top level; POST/DELETE projects
  show new defaults + guardrail semantics.
- §4 export table notes cable_types pulled from global.
- §7 "edit cable type" flow gains the cross-project-effect banner +
  ON DELETE RESTRICT inline-error UX.
- §8 slice 1 rewritten: no per-project seeding; legend reads global.
- §9 all six v2 questions marked resolved with the v3 answer per item.
- Trailer changes to "DESIGN v3 READY — coder shift gated".
- CLAUDE.md mirrors: global cable_types, device UNIQUE per project,
  drawing_name default, delete guardrail.
2026-05-15 16:32:20 +02:00

37 KiB
Raw Blame History

mCables — Design v3

Cable-management framework for m's setup. Inventor shift 1 design, revised after m's round-4 answers (2026-05-15) — for m's review.

Sources: the live Cable-Management.excalidraw on mxdrw.msbls.de (used as the visual-grammar reference, not as a bootstrap import target), mai-memory (mcables, m), and a live survey of mDock services for the deploy conventions (§10).

What changed in v3 (mechanical deltas on top of v2)

  • cable_types is now a global table — one set shared across all projects. Migration 001 seeds the 5 defaults once. POST /api/projects no longer seeds types. API moved to top-level /api/cable-types. Renaming/recolouring a type affects every project.
  • devices gains UNIQUE (project_id, name) — no two devices in the same project can share a name.
  • projects.drawing_name is auto-filled <name>.excalidraw server-side when omitted on POST; editable via PATCH.
  • DELETE /api/projects/:pid requires ?confirm=<name> query param; server checks it matches the project's current name. 400 otherwise.

What carried over from v2

  • mCables is a framework: top-level projects table; LOFT and OFFICE are separate projects, each backed by one drawing.
  • No runtime importer. The seed drawing is reference material only. /api/sync/import is out of MVP; only POST .../sync/export ships.
  • IO diamonds are wall-outlet terminators (type=Power by convention, not enforced in schema). UI soft-warns on non-Power cables to an IO.
  • No cable inventory metadata. Purely visual structure for v0.
  • DB at ./data/mcables.db (project-local, gitignored).
  • Deploy: raw docker / docker-compose on mDock (not Dokploy).
  • Bind 0.0.0.0:7777 on the LAN, no auth.

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'))
);

-- 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.
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,
    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);

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

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, frame_id?, x, y, width, height, color?}
PATCH  /api/projects/:pid/devices/:id               (e.g. {x, y} on drag)
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

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

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


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.

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

Unchanged from v1: + Dev (or D) → click on canvas → rectangle placed (falls into whichever frame it lands in) → name → POST .../devices.

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.

Keyboard

P switch project (opens picker), F add frame, D add device, I add IO marker, T start cable from selected port, E export current project, Esc cancel, Backspace delete selection, ? show shortcuts.


8. First slices

Each slice ends with something m can click. The first coder shift takes slices 14 as the MVP; slice 5 (export) is the round-trip end.

# Slice What's shipped
1 Bootstrap + project CRUD cmd/mcables Go binary, SQLite migrations. Migration 001 seeds the 5 default cable types (Power/USB/HDMI/DP/RJ45) globally, once. internal/db store. POST /api/projects auto-fills drawing_name = <name>.excalidraw when omitted. DELETE /api/projects/:pid?confirm=<name> with name-match guardrail. GET /api/projects lists them. GET /api/projects/:pid returns a (mostly empty) snapshot. GET /api/cable-types returns the 5 seeded rows. Frontend index.html + main.js shows the project picker, a "+ New Project" modal, and an empty SVG canvas with the legend rendered from the global cable_types table. m can create LOFT, see it picked, see no devices.
2 Add frame, add device, drag-to-position + Frm and + Dev tools work. Devices and frames persist. Drag-to-position writes back to DB on pointerup. Reload returns to the same layout. m builds LOFT's desk and rack frames and drops in his first devices.
3 Add port, draw cable + Port (with a device selected) places type-coloured ports on device edges with offsets. Click-port → click-port creates a cable. Cables auto-route as straight lines. Inspector shows the cable's type, endpoints, label. m wires up the first end-to-end cable.
4 IO markers + cable-type editing + IO places a wall-outlet diamond. Cable-from-port → IO commits as to_io_id. Legend swatch is a colour picker; renaming a type updates the legend on the fly. + Type adds new types. m can fully recreate LOFT's visual model from scratch.
5 Export to mxdrw.msbls.de POST .../sync/export generates a .excalidraw scene that reproduces the seed's visual grammar (ports as positional ellipses, IO as diamonds, legend as text in the top-left), writes it via mExDraw API, and stores the assigned excalidraw_ids for stability on re-export. m sees LOFT in Excalidraw and confirms the look matches the seed.

Slices 6+ (not promised for the first coder shift): bundle suggestions UI; bundle rendering (thick path with mixed-colour fan-out); cable type "warn on cross-type port-to-port"; cable inventory metadata (length/SKU) if m later wants it; dark mode.


9. Open questions for m — all resolved in v3

All six v2 questions are now answered. Locked answers:

  1. Drawing-name policy → server-side default <name>.excalidraw on POST when omitted; editable via PATCH. (§3)
  2. Device-name uniqueness within a projectUNIQUE (project_id, devices.name) enforced at the schema level. (§2)
  3. Non-Power IO markers → no type_id on io_markers for v0. Power-by-convention; UI soft-warns on non-Power cables to an IO. (§2, §7)
  4. Bundle render in export v1 → bundles ignored on export until slice 6+. (§4, §5)
  5. Cross-project cable typescable_types is fully global. One shared legend; renaming/recolouring affects every project. (§2, §3, §7)
  6. Project deletion guardrailDELETE /api/projects/:pid?confirm=<name> required; server validates name match, returns 400 otherwise. (§3)

No open design questions remain. The coder shift is gated on m's go/no-go for v3 — 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 v3 READY — coder shift gated