From 2a46ce744b6531630b7f4bfdfaf5b2b94de3790b Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 15 May 2026 23:46:20 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20design=20v4=20=E2=80=94=20solver-as-cor?= =?UTF-8?q?e,=20hybrid=20device-type=20catalog,=20requirements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Big rescope driven by m's product-vision clarification: mCables is a cable-management framework with a solver as its core value prop, not a manual draw-and-click editor. m declares devices + required connections between them; the solver emits the cable plan + bundle recommendations, optimising for maximum bundling. Schema additions (migrations 002 + 003): - device_types (catalog) — built-ins (project_id NULL) + project-custom (project_id non-null). 11 built-in types seeded with default port profiles (NAS, PC, Mac, TV, Soundbar, Switch, fritz, ChromeCast, SteamLink, IOx-3/6/8, Notebook). - device_type_ports (profile rows: cable_type × count × edge). - devices.type_id (nullable). Picking a type seeds ports once; instance-owned thereafter (no retroactive re-seed). - connection_requirements (per-project, from/to device + preferred type + must_connect flag, with order-normalised pair_lo/pair_hi for duplicate prevention). - cables.auto (slice 5.5 migration) — distinguishes solver-owned cables from user-drawn ones. API additions: - GET /api/device-types (built-ins only, read-only) and GET /api/projects/:pid/device-types (built-ins + project-custom merged) - POST/PATCH/DELETE under /api/projects/:pid/device-types (project-custom only; built-ins are 403) - /api/projects/:pid/connection-requirements full CRUD - POST /api/projects/:pid/solve with ?preview=1 — pure-function solver (greedy port allocation, endpoint-pair bundling for v0); returns add[], remove[], bundles_added[], unsatisfied[], warnings[] Solver algorithm (§5b): - Read project devices + ports + connection_requirements + manual cables - Assign each requirement a (port_a, port_b) using the preferred cable type (or auto-pick if exactly one type matches both ends) - Bundle by endpoint-pair (v3 rule, applied to auto cables only) - Surface unsatisfied requirements per class (no compat type / ambiguous type / no free port) — does NOT auto-add ports; UI quick-fix instead - ?preview=1 returns the diff without writing; default applies in a tx UI additions: - Device-create modal: type dropdown (built-ins grouped by kind, then project-custom, then "Custom (no type)" for the v3 freeform fallback) - Left-sidebar Requirements section with + Requirement button - Header Solve button (S keybinding) → preview modal → Apply - Inspector for selected device: type, ports grid, unmet requirements with red badges + quick-fix actions - Inspector for selected auto cable: driving requirement, parent bundle, Promote-to-manual button Slice reshape (§8): - Slices 1, 2 shipped. v4 inserts: 4 = catalog + type-aware device create, 4.5 = catalog management, 5 = requirements CRUD + UI, 6 = solver MVP + Solve button. Old "manual port + manual cable draw" slides to slice 7 as a tweak path on solver output. Export becomes slice 8. Six new open questions (§9) for m to gate before slice 4: 1. Path source (auto-route through frame edges / user cable-trays / Steiner-tree)? 2. Live-solve vs. button-only? 3. UX when solver has no compatible port pair? 4. Setup templates in v4 or post-MVP? 5. Catalog as code seed or JSON file? 6. Auto-promote vs. explicit Promote-to-manual on solver cable edits? CLAUDE.md updated to reflect the solver-core framing, hybrid catalog, connection-requirements model, and auto/manual cable distinction. Trailer changes to "DESIGN v4 READY FOR REVIEW". --- CLAUDE.md | 74 ++++-- docs/design.md | 607 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 591 insertions(+), 90 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a563568..4f939f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,11 +2,18 @@ ## Project Overview -Cable-management **framework** for m's setup. Each cable-managed environment -(LOFT, OFFICE, …) is a separate **mCables project**, and each project is -backed by exactly one Excalidraw drawing. The framework provides a visual -web interface backed by a Go HTTP API and SQLite, plus an export pipeline -that writes `.excalidraw` files via mExDraw. +Cable-management **framework + solver** for m's setup. m declares his +**devices** and the **connection requirements** between them ("NAS must +connect to Switch via RJ45"). mCables runs a solver that emits the cable +plan + bundle recommendations, optimising for **maximum bundling** — +prefer fewer trunks even at higher total length. The visual editor is +still there for tweaking the plan, but the solver is the headline. + +Each cable-managed environment (LOFT, OFFICE, …) is a separate +**mCables project**, and each project is backed by exactly one Excalidraw +drawing. The framework provides a visual web interface backed by a Go +HTTP API and SQLite, plus an export pipeline that writes `.excalidraw` +files via mExDraw. **Memory group_id:** `mcables` @@ -19,13 +26,19 @@ interface. The backend serves the UI and the API; there is no - A reusable framework for tracking devices, ports, cables, cable types, bundles, frames — **scoped per project** (LOFT and OFFICE are separate projects, each a separate drawing). -- A visual editor in the browser: switch projects, add frames/devices/ports, - click ports to wire up cables, pick cable types from a per-project legend. +- A **solver** that, given the project's devices + connection + requirements, emits the cable plan + bundle recommendations. Objective: + maximum bundling (fewer trunks, even at higher total length). +- A **hybrid device-type catalog**: built-in types (NAS, PC, Mac, TV, + Switch, fritz, ChromeCast, SteamLink, Notebook, Soundbar, IOx-3/6/8) + with default port profiles, extensible per project. Picking a type on + device-create seeds the device's ports automatically; m overrides per + instance. +- A visual editor for switching projects, adding frames/devices, declaring + requirements, running the solver, and tweaking the resulting plan. - A one-way export from the DB to the corresponding `.excalidraw` drawing on `mxdrw.msbls.de` whenever m clicks Export — DB is authoritative, Excalidraw is the projection. -- Bundle detection: parallel cables along the same path within a project - get grouped + colour-bundled in the diagram. ## Architecture @@ -45,16 +58,31 @@ interface. The backend serves the UI and the API; there is no - **Frames** sub-divide a project (LOFT has `desk`, `rack`, `media`; OFFICE has `desk`, `server`). Frames are not projects — they're zones within one drawing. -- Every device, port, cable, IO marker, and bundle is **project-scoped** - (`project_id` denormalised onto every row, with `ON DELETE CASCADE` from - `projects`). `UNIQUE (project_id, devices.name)` — no two devices in - one project share a name. +- Every device, port, cable, IO marker, bundle, and **connection + requirement** is **project-scoped** (`project_id` denormalised onto + every row, with `ON DELETE CASCADE` from `projects`). + `UNIQUE (project_id, devices.name)` — no two devices in one project + share a name. - **Cable types are global.** A single shared `cable_types` table — no `project_id`. The five defaults (Power/USB/HDMI/DP/RJ45) are seeded by migration 001 once, not per project. Renaming or recolouring a type affects every project's legend immediately. +- **Device types are hybrid.** `device_types` is one global table with + `project_id` NULL for the 11 built-in catalog rows (seeded by + migration 002) and `project_id = current` for project-custom types. + Each `device_type` carries a `device_type_ports` profile that seeds + `ports` rows when a device of that type is created. m can extend the + catalog per project; built-ins are read-only from the API. +- **Connection requirements** (`connection_requirements` table) are the + solver's input. m declares "from_device ↔ to_device, preferred cable + type, must_connect"; the solver assigns ports and emits cables. - **Project deletion guardrail.** `DELETE /api/projects/:pid` requires `?confirm=` matching the project's current name. 400 otherwise. +- **Solver-owned vs. user-owned cables.** `cables.auto = 1` = created by + the solver and replaceable on re-solve. `auto = 0` = hand-drawn by m, + left alone by the solver. PATCHing endpoint or type of an auto cable + promotes it to manual (explicit "Promote to manual" button in the + inspector, per design v4 §5b.3). ## Branch Strategy @@ -137,12 +165,14 @@ Legend colours (global, seeded once by migration 001): ## Worker Preferences -- **First shift = inventor** (design pass): conventions, schema, API, - export pipeline, mDock deploy plan, UI flows, slices. Output: - `docs/design.md` + open questions for m. -- **Second shift = coder** (after m's go on the design): bootstrap repo - skeleton (Go module, SQLite migrations, server, exporter, frontend - scaffold). Take slices 1–4 first (project CRUD, frames/devices, ports - and cables, IO + cable-type editing); slice 5 (Excalidraw export) closes - the round-trip. -- Use **Sonnet** for both — greenfield, structure matters more than depth. +- **Inventor shifts** (design passes): conventions, schema, API, export + pipeline, mDock deploy plan, UI flows, slices. Output: `docs/design.md` + + open questions for m. v1–v4 are versioned in the doc's header callout. +- **Coder shifts** (after m's go on a design version): build to the + current design.md. Current state: slice 1 (project CRUD + global + cable_types) and slice 2 (frames + devices + drag) are merged; design + v4 reshapes slices 3+ (IO + cable-type editing → device-type catalog → + device-type manage → connection-requirements UI → solver → manual + port/cable draw → export). See `docs/design.md` §8 for the current + sequence. +- Use **Sonnet** for both — structure matters more than depth. diff --git a/docs/design.md b/docs/design.md index ef46d7b..bbc38fe 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,36 +1,67 @@ -# mCables — Design v3 +# mCables — Design v4 -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. +Cable-management **framework + solver** for m's setup. Inventor shift 1 +design, revised through v2 (rescope to multi-project framework), v3 +(global cable_types + guardrails), and now **v4 — solver-as-core**. Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as -the *visual-grammar reference*, not as a bootstrap import target), -`mai-memory` (`mcables`, `m`), and a live survey of mDock services for the -deploy conventions (§10). +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: -> **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 `.excalidraw` server-side -> when omitted on POST; editable via PATCH. -> - `DELETE /api/projects/:pid` requires `?confirm=` query param; -> server checks it matches the project's current name. 400 otherwise. +> "we provide a cable manager — I say what devices we have, the app tells +> me how to bundle cables and how the most efficient connection looks like" + +mCables shifts from a manual draw-and-click editor to a **solver** that +takes a list of devices + the connections m needs and emits the cable +plan + bundle recommendations. The manual editor stays (it's the only way +to inspect + tweak the plan) but is no longer the primary surface. + +> **What changed in v4** (new mental model on top of v3 mechanics) +> - **Hybrid device-type catalog** (§2.1, §3.1). A built-in `device_types` +> table seeds common devices (NAS, PC, Mac, TV, Soundbar, Switch, fritz, +> ChromeCast, SteamLink, IOx-3/6/8, Notebook, …) with default port +> profiles (`device_type_ports` rows: cable_type + count + label). +> Adding a device → pick a type → ports auto-seed. m can override per +> instance (this PC has 3 USB, not 2). Catalog is extendable per project. +> - **`connection_requirements` table** (§2.2). m declares "NAS must +> connect to Switch via RJ45" once. Many per device. The solver consumes +> these. +> - **`POST /api/projects/:pid/solve` endpoint** (§3.2). Reads devices + +> their ports + connection_requirements + frame positions, emits a diff +> of `cables` + `bundles`. Two modes: `?preview=1` returns the diff +> without applying; default applies. +> - **Solver objective: maximum bundling** (§5). Prefer routes that +> consolidate cables into trunks even at higher total length. Visually +> cleaner setups, easier mental model. v0 uses the v3 same-endpoints +> bundle rule; path-based bundling is slice 6+. +> - **UI: device-type dropdown** on device-create, **Connection +> Requirements** left panel, **Solve** button next to Export. Inspector +> shows type + ports + unmet requirements (selected device) or the +> driving requirement + bundle (selected cable). +> - **Slices reshape** (§8). Catalog seeding lands early (slice 1.5); the +> solver MVP and connection-requirements UI move ahead of the +> bundle-rendering polish. > -> **What carried over from 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. +> **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 `.excalidraw`. +> - `DELETE /api/projects/:pid?confirm=` 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. --- @@ -134,6 +165,49 @@ CREATE TABLE cable_types ( 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 " 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, @@ -154,10 +228,19 @@ 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, @@ -172,6 +255,7 @@ CREATE TABLE devices ( ); 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. @@ -260,8 +344,105 @@ CREATE TABLE bundle_cables ( PRIMARY KEY (bundle_id, cable_id) ); CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id); + +-- v4 — connection_requirements: the input m gives the solver. +-- "NAS must connect to Switch via RJ45" is one row. Many per device. +-- +-- preferred_cable_type_id is the cable type m intends — the solver +-- needs it to match port colours. NULL means "solver picks" (the solver +-- will pick the unique cable_type that is compatible with both ends' +-- available port types; if ambiguous it surfaces an error for m). +-- +-- must_connect = 1 (default) means the solver MUST satisfy this; an +-- unsatisfiable must_connect surfaces as a hard error in the solve +-- result. must_connect = 0 = "nice to have, drop if you run out of +-- ports". Used for templates that over-spec. +-- +-- The (from_device_id, to_device_id) pair is normalised on insert so +-- (A,B) and (B,A) are the same requirement — UNIQUE on the unordered +-- pair + cable type prevents duplicates. +CREATE TABLE connection_requirements ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL, + must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)), + notes TEXT NOT NULL DEFAULT '', + -- Order-normalised pair: lo = MIN(from, to), hi = MAX(from, to). Set + -- in code on insert; the UNIQUE then prevents (A,B,Power) AND + -- (B,A,Power) from coexisting. Stored alongside the m-facing + -- from/to so the UI doesn't have to denormalise. + pair_lo INTEGER NOT NULL, + pair_hi INTEGER NOT NULL, + CHECK (from_device_id != to_device_id), + UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id); +CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi); +CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id); +CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id); ``` +### 2.1 Migration sequence + +- **001_init.sql** (v3) — projects, frames, devices (no type_id), ports, + cable_types (5 seeded), io_markers, cables, bundles, bundle_cables. +- **002_device_catalog.sql** (v4 NEW) — `device_types` + + `device_type_ports`. Seeds the built-in catalog (§2.2). Adds + `devices.type_id` (`ALTER TABLE devices ADD COLUMN type_id INTEGER + REFERENCES device_types(id) ON DELETE SET NULL`) and the matching + index. +- **003_connection_requirements.sql** (v4 NEW) — `connection_requirements`. + +Slice 1 already shipped 001. Slices 1.5 (catalog) and 1.6 (requirements) +land 002 and 003. + +### 2.2 Built-in catalog seed (002 INSERTs) + +The 11 built-in types m's setup uses today, with their default port +profiles. Stored as `(project_id NULL, built_in 1)`: + +| `device_types.name` | `kind` | Default ports (cable_type × count) | +|---|---|---| +| NAS | storage | Power × 1; RJ45 × 1 | +| PC | compute | Power × 1; RJ45 × 1; HDMI × 1; USB × 2 | +| Mac | compute | Power × 1; HDMI × 1; USB × 2 | +| Notebook | compute | Power × 1; USB × 2 | +| TV | display | Power × 1; HDMI × 2 | +| Soundbar | audio | Power × 1; HDMI × 1 | +| Switch | network | Power × 1; RJ45 × 5 | +| fritz | network | Power × 1; RJ45 × 4 | +| ChromeCast | display | Power × 1; HDMI × 1 | +| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 | +| IOx-3 | hub | Power × 1; (3× port slots — concrete cable type per slot is set at instantiation; defaults to USB × 3 for v0) | +| IOx-6 | hub | Power × 1; USB × 6 | +| IOx-8 | hub | Power × 1; USB × 8 | + +"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing +shows them in red because most carry Power, but they also hub USB). v0 +seeds them as USB hubs; m overrides per-instance. The catalog is editable +in the UI (slice 4.5 — "Manage device types") so m can refine the IOx-3 +profile once and not re-override every instance. + +m can also add **project-custom types** at any time (UI: "+ New device +type" inside the device-create modal) with `project_id = current`. + +### 2.3 Why ports are still instance-owned + +When m picks a type to create a device, the seeder calls `count` × INSERT +into `ports`. From that moment on, ports are instance-level rows owned by +that device. Deleting a port from this PC doesn't touch other PCs; +changing a type's port profile (in slice 4.5) doesn't retroactively +re-seed already-created devices — it only affects subsequent device +creations. + +Trade-off acknowledged: m may want a "re-seed from type" action later +(slice 5+) to wipe + reset a device's ports. Out of v0 scope; not +blocked by the schema. + **FK shape — why `project_id` on every project-scoped row, not just transitively:** The structural truth is `cable → port → device → frame → project`. But @@ -328,8 +509,11 @@ 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) +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 @@ -354,12 +538,63 @@ GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …] PATCH /api/projects/:pid/bundles/:id DELETE /api/projects/:pid/bundles/:id +# v4 — Device-type catalog (mostly global, project-scoped writes for custom rows) +GET /api/device-types → built-in catalog (project_id NULL) — read-only listing +GET /api/projects/:pid/device-types → built-ins + this project's custom types, merged +POST /api/projects/:pid/device-types ← {name, kind?, icon?, description?, ports: [{cable_type_id, count, label_prefix?, edge?}]} + Creates a project-custom row (built_in=0); inserts + device_type_ports rows in the same transaction. +PATCH /api/projects/:pid/device-types/:id ← partial. Only project-custom types are PATCHable; + mutating a built-in row → 403 (UI hides edit affordance). + Editing ports replaces the device_type_ports rows; + existing devices' ports are NOT retroactively reseeded. +DELETE /api/projects/:pid/device-types/:id Only project-custom; built-ins → 403. + ON DELETE SET NULL on devices.type_id so devices + keep their already-seeded ports. + +# v4 — Connection requirements (the solver's input) +GET /api/projects/:pid/connection-requirements → [ConnectionRequirement, …] +POST /api/projects/:pid/connection-requirements ← {from_device_id, to_device_id, + preferred_cable_type_id?, must_connect?, notes?} + Server normalises (from, to) into (pair_lo, pair_hi) + before insert; duplicate (project, pair_lo, pair_hi, + preferred_cable_type_id) → 409 conflict. +PATCH /api/projects/:pid/connection-requirements/:id +DELETE /api/projects/:pid/connection-requirements/:id + +# v4 — Solver +POST /api/projects/:pid/solve ← {} (or {?preview=1} to compute without applying) + → { + cables_added: [Cable, …], + cables_kept: [int, …], # ids preserved by the diff + cables_removed: [int, …], # ids deleted (auto cables only) + bundles_added: [{Bundle, cable_ids: [int]}, …], + bundles_removed: [int, …], + unsatisfied: [{requirement_id, reason}, …], + warnings: [string, …], + } + Default applies in a single transaction. ?preview=1 + returns the same shape without writing. User-created + cables (auto=0 in the cables table; see §5.1) are + never touched — the solver only adds/removes its own. + # Sync — export only in MVP POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw (overwrites previous version; mExDraw keeps git-version-history sidecar) ``` +### 3.1 v4 wire-shape additions + +- `ConnectionRequirement` (response): + `{id, project_id, from_device_id, to_device_id, preferred_cable_type_id|null, must_connect: bool, notes, created_at, updated_at}`. +- `DeviceType` (response): + `{id, project_id|null, name, kind, icon|null, description, built_in: bool, ports: [{cable_type_id, count, label_prefix, edge, sort_order}]}`. +- `cables` gets an `auto: bool` field on the row (slice 5.5 migration adds + the column with default 0; the solver sets 1 on its own creations). The + v3 cable rows m hand-drew keep `auto=0`. `POST /api/.../cables` + continues to default `auto=0`; only the solver writes `auto=1`. + No `POST /api/sync/import` in MVP. Import is post-MVP and only ever serves a one-shot migration use case (e.g. seeding LOFT from the legacy Cable-Management drawing if m later changes his mind). @@ -447,6 +682,118 @@ they're ignored in v0 (open question §9). --- +## 5b. v4 — Solver + +The solver is the headline addition in v4. m's product-vision sentence +maps onto it directly: + +> "I say what devices we have, the app tells me how to bundle cables and +> how the most efficient connection looks like" + +The solver reads a project's `devices` (with their `ports`) and +`connection_requirements`, and writes a set of solver-owned `cables` +(rows with `auto=1`) + `bundles`. m's hand-drawn cables (`auto=0`) are +left strictly alone — the solver only adds and removes its own. + +### 5b.1 Objective: maximum bundling + +Locked in by m. Prefer routes that consolidate cables into shared trunks +even at higher total length. Visually cleaner setups; easier to manage +physically (one cable bundle along the floor, not five strands). + +Concretely: when assigning a cable to a path, the solver minimises the +**count of distinct trunks**, breaking ties by total length. v0 +approximates a "trunk" with the pair of device endpoints (the v3 rule); +slice 6+ adds path-based trunks via frame-edge corridors. + +### 5b.2 Algorithm (v0) + +Pure function. No graph search; no LP. Single pass with greedy port +allocation. + +``` +solve(project) ⇒ {add, remove, bundles, unsatisfied}: + let auto_cables_before = SELECT * FROM cables WHERE project=p AND auto=1 + let port_free := {port_id -> bool} initialised TRUE for every port + minus ports already used by manual cables (auto=0) + + for each requirement r in order(must_connect DESC, id ASC): + let ct = r.preferred_cable_type_id + ?? auto_pick_cable_type(r.from_device, r.to_device) + ?? fail("ambiguous") + let pa = first_free_port(r.from_device, ct, port_free) + let pb = first_free_port(r.to_device, ct, port_free) + if !pa or !pb: + if r.must_connect: unsatisfied.push({r.id, reason}) + else: skip + continue + port_free[pa] = port_free[pb] = false + add.push(cable{type=ct, from_port=pa, to_port=pb, auto=1}) + + // Bundle by endpoint-pair (v3 rule, applied only to auto cables). + for each (device_a, device_b) pair with ≥ 2 add-cables: + bundles_add.push({auto=1, cables: those add-cables}) + + // Diff against auto_cables_before to compute remove[] (any prior auto + // cable whose (from, to, type) doesn't appear in add[]). + remove = auto_cables_before - add + return {add, remove, bundles_add, unsatisfied} +``` + +`first_free_port(device, cable_type, free_map)` picks the lowest-id port +on the device whose `type_id` matches and that is still free, returning +NULL if none. The `lowest-id` tiebreak is deterministic so repeated +solves produce the same plan. + +`auto_pick_cable_type(from, to)` (used when `preferred_cable_type_id` is +NULL): find the set of cable types `T = ports(from).types ∩ +ports(to).types`. If `|T| == 1`, return it. If `|T| > 1`, fail +("ambiguous; specify preferred_cable_type_id"). The UI surfaces this +as a "specify type" inline edit on the requirement. + +### 5b.3 Solver-owned vs. user-owned cables + +`cables.auto` distinguishes them. + +| Operation | Effect on `auto=0` cables | Effect on `auto=1` cables | +|---|---|---| +| POST /api/.../cables (m draws by hand) | inserts auto=0 | n/a | +| PATCH cables (m moves endpoint, relabels) | applies | applies (and the cable is "promoted" to auto=0 — m owns it now) | +| DELETE cables | applies | applies | +| POST /api/.../solve | left alone (their used ports are reserved before the solver runs) | replaced wholesale (remove[] + add[] in one tx) | + +This way a manual cable m doesn't want the solver to second-guess +survives every solve. If m wants the solver to take it over, he deletes +his hand-drawn cable and re-solves; the solver re-creates an equivalent +auto cable. + +### 5b.4 When solver fails + +Three classes of failure surface in the response's `unsatisfied[]`: + +1. **No compatible cable type** — `T = ports(from).types ∩ + ports(to).types` is empty (e.g. a Power-only device to an HDMI-only + device). UX: edit the requirement to specify, or add a port on one of + the devices. +2. **Ambiguous cable type** — `|T| > 1`, no preferred set. UX: pick a + type on the requirement. +3. **No free port** — the cable type matches but every port on one side + is already used. UX: drop a must_connect=0 requirement, or add ports. + +The solver does **not** auto-add ports to a device. Reason: m said +"override per instance"; auto-adding crosses that line. The UI surfaces +the unmet requirement with a "+ Add port" affordance on the device +inspector instead. + +### 5b.5 Preview vs. apply + +`?preview=1` returns the same shape without writing. The UI shows a diff +modal with `add[]`, `remove[]`, `unsatisfied[]`; m clicks Apply to fire +the same endpoint without `preview=1`. Default (no flag) applies +immediately — useful for live-solve mode (open question §9). + +--- + ## 6. Sync — export-only for v0 ``` @@ -528,10 +875,23 @@ The currently active project's id is kept in URL state 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 +### Flow: add a device (v4 — type-aware) -Unchanged from v1: `+ Dev` (or `D`) → click on canvas → rectangle placed -(falls into whichever frame it lands in) → name → `POST .../devices`. +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 @@ -581,54 +941,165 @@ In the inspector with nothing else selected, "Bundle suggestions" pulls on the diagram + an Accept button. Manual: shift-click multiple cables → "Group as bundle" → name it → save. +### v4 — Flow: declare connection requirements + +The left sidebar gains a **Requirements** section under the legend: + +``` +Cable types + Power, USB, HDMI, DP, RJ45, + Type + +Requirements ← new in v4 + NAS ↔ Switch RJ45 must + PC ↔ TV HDMI must + Mac ↔ Soundbar HDMI nice + + Requirement +``` + +Click `+ Requirement` → modal with two device pickers (autocomplete from +the project's current devices), a cable-type picker (defaults to +auto-resolve if the device pair has only one matching type), and a +must/nice toggle. `POST .../connection-requirements`. + +Alternative gesture (no tool armed, no selection): **drag from device A +to device B** to seed a requirement modal with the pair pre-filled. The +solver-edge preview drags out from the source device's edge in a thin +dashed line until release. + +m can also right-click a requirement row → edit / delete. + +### v4 — Flow: run the solver + +Header gains a **Solve** button next to **Export**. + +1. Click Solve (or `S`) → `POST /api/projects/:pid/solve?preview=1`. +2. A diff modal opens listing `add[]`, `remove[]`, `unsatisfied[]` — the + canvas behind it dims and previews the new cables in a translucent + stroke + the to-be-removed cables in a strikethrough red. +3. Buttons: + - **Apply** → fires `POST .../solve` (no `preview`), applies in one + transaction, closes the modal, re-renders canvas with the real + cables in place. + - **Cancel** → leaves everything as it was. +4. Unsatisfied requirements get their own list at the bottom of the + modal, each with a quick-action button: "Specify type", "+ Add port + to device X", or "Drop requirement (set must=0)". + +If `unsatisfied[]` is non-empty, the Solve button stays in a +soft-error state (yellow) until either every requirement is satisfiable +or m explicitly accepts the partial plan. + +### v4 — Inspector states + +| Selection | Inspector shows | +|---|---| +| nothing | empty, with "Bundle suggestions" + "Project requirements" headlines | +| project header | name, drawing_name, description (editable), device count, requirement count, Solve / Export buttons | +| frame | name (editable), x/y/w/h, contained-device count, delete | +| **device** | name + type + icon, ports grid (type / label / connected? / +Port), **unmet requirements list** (red badges with quick-fix), delete | +| **port** | type, label, parent device, current cable (if any), delete | +| **cable (auto=1)** | source/target, type, driving requirement (clickable → opens requirement edit), parent bundle (if any), label, "Promote to manual" (sets auto=0) | +| cable (auto=0) | as v3 — type, source/target, label, delete | +| bundle | name, member cables (clickable to focus), trunk segment description, auto-detected flag | + ### Keyboard -`P` switch project (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. +`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 +## 8. First slices — v4 reshape -Each slice ends with something m can click. The first coder shift takes -slices 1–4 as the MVP; slice 5 (export) is the round-trip end. +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 | 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 = .excalidraw` when omitted. `DELETE /api/projects/:pid?confirm=` 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_id`s for stability on re-export. m sees LOFT in Excalidraw and confirms the look matches the seed. | +| # | Slice | Status | What's shipped | +|---|---|---|---| +| 1 | **Bootstrap + project CRUD + global cable_types** | ✅ shipped | See git: branch `mai/picasso/slice-1-bootstrap`. | +| 2 | **Frames + devices + drag** | ✅ shipped | See git: branch `mai/picasso/slice-2-frames-devices`. | +| **3 (was 4)** | **IO markers + cable-type editing** | pending | Unchanged scope. `+ IO` places a wall-outlet diamond. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new global types. | +| **4 (NEW)** | **Device-type catalog + type-aware device create** | pending | Migration 002: `device_types` + `device_type_ports`, seeded with the 11 built-ins (§2.2). Migration adds `devices.type_id`. API: `GET /api/device-types`, `GET /api/projects/:pid/device-types`. Frontend: the +Dev inline namer becomes a type dropdown + name input; choosing a built-in type seeds the device's ports on the backend. Picking `Custom (no type)` falls back to v3 freeform. m can create a typed NAS + see its Power + RJ45 ports appear on the canvas. | +| **4.5 (NEW)** | **Manage device-type catalog (per project)** | pending | Modal: `POST/PATCH/DELETE /api/projects/:pid/device-types` for project-custom rows. Edit affordance hidden for built-ins. Lets m add an exotic device type without contributing to the built-in catalog. Validation: a custom type can't share a name with a built-in (already enforced by `UNIQUE(project_id, name)` + a separate code-level check against built-ins). | +| **5 (NEW)** | **Connection requirements UI + CRUD** | pending | Migration 003: `connection_requirements`. API: full CRUD under `/api/projects/:pid/connection-requirements`. Frontend: left-sidebar "Requirements" section, `+ Requirement` modal (autocomplete from project's current devices, cable-type picker, must/nice toggle). Drag from device A to device B gestures the same modal pre-filled. Inspector for a selected device lists its requirements. | +| **6 (NEW)** | **Solver MVP + Solve button** | pending | `POST /api/projects/:pid/solve` with `?preview=1` support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling (slice 6.5 is path-based bundling). Migration adds `cables.auto`. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan → applies. | +| **7 (was 3, slimmed)** | **Manual port + manual cable draw** | pending | The v3 flow as a tweak path on solver output. `+ Port` on an instance-owned device; click-port → click-port creates a hand-drawn cable (`auto=0`). Used to override the solver's choices or to extend its plan. | +| **8 (was 5)** | **Export to mxdrw.msbls.de** | pending | `POST .../sync/export` writes a `.excalidraw` scene per the visual grammar (§4). Bundles ignored on export in v0. | -Slices 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. +Slices 9+ (not promised for the first coder shift): +- Path-based bundling: instead of endpoint-pair bundling, group cables that share a frame-edge corridor or a wall-axis (§5b.1 "trunk segment" definition). +- Live-solve mode: re-run solver on every device/requirement edit with a debounce + previewed-but-not-applied diff in a toast. +- Setup templates (Living Room, Home Office, Server Rack): a `setup_templates` table + `POST .../apply-template` that pre-populates `connection_requirements` from an archetype. +- Bundle rendering in the SVG (thick path with mixed-colour fan-out) and in the export. +- "Re-seed from type" action on a device. +- Cable inventory metadata (length/SKU) if m later wants it. +- Dark mode. --- -## 9. Open questions for m — all resolved in v3 +## 9. Open questions for m — v4 -All six v2 questions are now answered. Locked answers: +v3 closed all its v2 questions. v4 raises six new ones, all about the +solver semantics and UX. Worth resolving before slice 4 starts so the +coder shift doesn't backtrack: -1. **Drawing-name policy** → server-side default `.excalidraw` on - POST when omitted; editable via PATCH. (§3) -2. **Device-name uniqueness within a project** → `UNIQUE (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 types** → `cable_types` is fully **global**. One - shared legend; renaming/recolouring affects every project. (§2, §3, §7) -6. **Project deletion guardrail** → `DELETE /api/projects/:pid?confirm=` - required; server validates name match, returns 400 otherwise. (§3) +1. **Where do paths come from?** v0 draws straight lines port-to-port + + bundles by endpoint-pair. Three candidates for slice 6.5/9: + (a) auto-route through frame edges (cables exit a device toward the + nearest frame edge, traverse along edges, enter the target frame); + (b) m draws **cable-tray polylines** on the canvas and cables snap to + them; (c) Steiner-tree-ish path optimisation per trunk. I lean (b) + + (a) as fallback — m gets the manual override when his layout is + non-obvious, otherwise the system routes for him. Confirm direction. -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. +2. **Live solve or button-only?** Two modes available: + - **Button-only** (locked default) — m hits Solve, sees the diff, + applies. Simple; no surprises. + - **Live** — solver re-runs on every device/requirement edit with a + debounce, results land in a toast "12 cables, 3 bundles, 1 unmet + — review?". More responsive, costs ~10ms of compute per edit. + I'd ship button-only first (slice 6); add live as an opt-in toggle + (slice 9+). Confirm or escalate to "live always". + +3. **No-compatible-port-pair UX when solving.** Three options: + (a) Surface as "unsatisfiable" and let m manually add a port via the + device inspector (current §5b.4 stance). + (b) Auto-add the missing port to the device on the m's confirmation + (single-button "Add HDMI to PC and re-solve"). + (c) Auto-add silently — bad UX, surprise mutations. + I lean (a) with a one-click quick-fix that does (b). Confirm. + +4. **Setup templates — v4 or post-MVP?** "Living Room" / "Home Office" / + "Server Rack" archetypes that pre-populate `connection_requirements`. + I left this out of the v4 slice list (designed in §8 "slices 9+") to + keep v4 tight. m can build the same effect by adding requirements + manually first time. Confirm: post-MVP OK, or do you want me to fold + it into slice 5/6? + +5. **Catalog distribution: code seed vs. JSON file.** Two paths: + (a) seed in migration 002 via SQL INSERTs (today's design — locked-in, + schema versioned, no user override of built-ins). + (b) seed from a curated JSON file at `internal/db/catalog/builtin.json` + that the migration reads. m or contributors can extend the file by + PR; rebuild image; new built-ins appear. + I lean (a) for v0 (simpler, doesn't need a file-loader). Open + question: do you anticipate growing the built-in list often? If yes, + (b) starts paying off after the second addition. + +6. **Promoting a solver cable to manual.** §5b.3 says PATCHing an + `auto=1` cable flips it to `auto=0` so the next solve doesn't replace + it. Two surface variants: + (a) Implicit: any PATCH that touches type/from/to on an auto cable + promotes it. m never sees the flag. + (b) Explicit: a "Promote to manual" button on the cable inspector + (current §7 stance). PATCHes that only update labels stay auto. + I leaned (b) for clarity ("the solver might overwrite this — promote + to protect"). Confirm or override. --- @@ -777,4 +1248,4 @@ gitignored. --- -DESIGN v3 READY — coder shift gated +DESIGN v4 READY FOR REVIEW