Issue 1 — cursor lies about armed tool. .svg-draggable { cursor: grab }
on frame/device rects beat the .canvas-wrap.tool-device #canvas {
cursor: crosshair } rule because element-level wins over descendant.
m saw "grab" hovering a frame with +Dev armed and thought the tool was
broken even though clicks routed correctly after the previous fix. Add
a descendant rule with !important so tool-armed wraps any child cursor:
.canvas-wrap.tool-frame #canvas *,
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
Issue 2 — stale browser cache after each redeploy. http.FileServerFS
served embedded assets with no Cache-Control header, so browsers held
on to the previous main.js/style.css until hard-reload. New noCache
middleware on the static handler emits Cache-Control: no-cache. Note:
embedded FS files have zero ModTime, so http.FileServer suppresses
Last-Modified — every fetch is a fresh 200 rather than a 304. Fine at
~30KB of JS+CSS, and fixes the staleness problem completely.
Middleware is wrapped only around the static handler. /api/* responses
write their own headers and aren't touched.
Verified locally:
curl -I /main.js → Cache-Control: no-cache
curl -I /style.css → Cache-Control: no-cache + contains the new rule
curl -I /api/healthz → unaffected (no Cache-Control from us)
go test -race ./... still green.
Move the [data-frame-id]/[data-device-id] early-return below the
tool-armed branches in onCanvasPointerDown. With a tool armed,
the canvas-level handler always wins; without a tool, the original
behaviour (frame/device pointerdown handlers capture for drag/select)
is restored.
onCanvasPointerDown returned early whenever the click landed on a
[data-device-id] or [data-frame-id] element so the per-element drag
handlers wouldn't get hijacked. Problem: this early-return fired BEFORE
the tool check, so clicking +Dev inside an existing frame never reached
placeDeviceAt().
Reordered: tool-armed branches run first and short-circuit. Only when
no tool is armed does the "click started on a child element — leave it
alone" guard kick in. End behaviour:
- +Dev anywhere (incl. inside a frame) drops a device. frame_id
auto-resolves via the existing frameAt() point-in-rect.
- +Frm anywhere (incl. inside an existing frame) starts a rubber-band;
rare but not harmful.
- No tool armed: clicking a device/frame still goes to its own handler
(drag / select). Clicking empty canvas still clears selection.
Hand-tested via the served /main.js + the equivalent backend POST/PATCH
sequence: device-in-frame, device-outside, device-drag, frame-drag with
cascaded device patches — all work.
Renders the slice-2 backend on the empty canvas from slice 1.
Canvas:
- Frames render as dashed-stroke rects with top-left label, slightly
tinted fill. Devices render as solid-stroke rects with centred label
in device.color.
- Selection halo via .selected class (stroke-width bump).
- Empty-state hint disappears once any geometry exists.
Tools (left sidebar + keyboard):
- F / + Frame — rubber-band rect on the canvas. <80×60 cancels. On
release, inline foreignObject namer → POST /api/projects/:pid/frames.
- D / + Device — single click places a 100×35 device centred at the
click. Inline namer → POST devices. Drop-point determines initial
frame_id via point-in-rect against all frames (smallest bbox wins).
- Esc cancels active tool / inline namer / clears selection.
Drag (pointer events + svg getScreenCTM):
- Devices: drag updates x/y live via transform, persists via
PATCH .../devices/:id on pointerup. Also recomputes frame_id from
drop point and includes "frame_id": null|<id> if it changed.
- Frames: dragging a frame moves its contained devices visually too;
on pointerup, single PATCH for the frame + one PATCH per moved device.
Children-batch is computed at pointerdown and only sent on release —
no per-pointermove network traffic.
Inspector:
- Frame selection: name (debounced rename), x/y/w/h, device count,
Delete button (confirm prompt — devices keep existing, frame_id → NULL
via the schema's ON DELETE SET NULL).
- Device selection: name (debounced rename), colour picker
(change-event PATCH, no debounce), x/y/w/h, current frame, Delete.
- Background click clears selection.
devicePatch wire format uses tri-state frame_id: key absent = leave,
key:null = clear, key:<int> = move. Frontend uses `null` explicitly
when a device drops outside all frames.
All 8 endpoints (list, create, patch, delete) for both resources. Path
params parsed via Go 1.22 ServeMux PathValue.
devicePatch uses json.RawMessage for frame_id so the wire format
distinguishes:
- key absent → leave as-is
- "frame_id": null → clear (device leaves all frames)
- "frame_id": 42 → move to that frame
parseFrameRef translates that into the store's db.FrameRef tri-state.
Sentinel-error mapping unchanged (writeError covers ErrInvalidInput,
ErrConflict, ErrNotFound, etc.). Cross-project frame_id refs surface as
400.
Snapshot now populates frames + devices from the DB (slice 1 left them as
empty arrays).
Frame store:
- CreateFrame requires positive width/height; rejects empty name; UNIQUE
(project_id, name) collisions surface as ErrConflict via mapWriteErr.
- GetFrame is project-scoped — wrong-project read returns ErrNotFound.
- UpdateFrame applies a partial; project_id is not exposed (moving a
frame across projects would orphan its devices).
- DeleteFrame relies on the schema's ON DELETE SET NULL to drop
devices' frame_id refs cleanly; verified by test.
Device store:
- CreateDevice defaults color to #1e1e1e if blank; rejects empty name,
non-positive size; validates frame_id is in the same project (returns
ErrInvalidInput on cross-project ref).
- UpdateDevice uses a FrameRef tri-state for frame_id so callers can
distinguish "leave alone" from "clear to NULL" from "move to frame X".
- Cross-project frame_id on PATCH is rejected with ErrInvalidInput.
- ListDevices supports an optional frame_id filter.
13 new table-driven tests, all green with -race.
mAi got admin on m/mCables but Gitea container packages are
user-namespace-scoped — repo-collab perm is insufficient. Pushed
once using m's ~/.netrc token, deleted the mAi/mcables stub.
Compose now references mgit.msbls.de/m/mcables:latest.
m granted mAi admin on m/mCables, but Gitea's container registry is
user-namespace-scoped (not repo-collab-scoped) so the push had to go
through m's own credentials for this one administrative move:
docker login mgit.msbls.de -u m -p <m's token>
docker push mgit.msbls.de/m/mcables:latest
Image digest sha256:76624f17… is identical to the one previously living
at mgit.msbls.de/mai/mcables:latest — same build, just retagged.
Drops the workaround comment from the compose file. The mai/mcables
package will be deleted via API after the deploy verifies.
picasso shipped (commit 8a31f0a on mai/picasso/deploy-mdock):
- Dockerfile: multi-stage golang:1.23-alpine -> distroless/static
- docker-compose.yml at repo root (raw-docker pattern, not Dokploy)
- .dockerignore
- README deploy section
Live: http://mdock:7777 (image sha256:76624f17, 12.2MB).
Persistence verified across compose restart.
Note: mAi lacks write on m/ in Gitea, so image lives at
mgit.msbls.de/mAi/mcables:latest. m can retag once mAi gets write
on m/mCables (see docker-compose.yml comment).
Pulls the deploy infra forward from §10 so m can see slice 1 on his LAN.
- Dockerfile: multi-stage golang:1.25-alpine → distroless/static-debian12.
CGO_ENABLED=0 (modernc.org/sqlite is pure Go). USER 1000:1000 so the
bind-mount on mDock (owned by m:m) is writable without chowning the
host dir. -trimpath + -s -w; 12.2MB final image.
- docker-compose.yml: matches the mDock convention surveyed earlier
(container_name explicit, restart: unless-stopped, env_file in
/home/m/secrets/mcables/.env, bind-mount /home/m/stacks/mcables/data,
port 7777 exposed on LAN). Image temporarily under the mai/ namespace
on mgit.msbls.de because mAi doesn't have write access to m/* today —
documented in a comment so retagging is one line when permissions land.
- .dockerignore: keeps .git, .worktrees, .m, data/, docs/, *.md,
editor cruft out of the build context.
Manual deploy verified end-to-end:
- docker build → image sha256:76624f17 (12.2MB)
- mAi-authenticated push to mgit.msbls.de/mai/mcables:latest
- ssh mdock anonymous pull works (registry allows public reads on this
namespace)
- POST /api/projects {"name":"LOFT"} returns the row, GET /api/projects
shows it; docker compose restart preserves it on disk; second GET
still shows LOFT.
Gitea Actions auto-deploy left for a follow-up task per the head's
instruction — gets us the moving parts right first.
Adds table-driven store tests:
- projects: drawing_name auto-default, explicit-name accept, empty-name
reject, duplicate-name conflict, ordered list, GetProject not-found,
partial PATCH semantics, blank-drawing-name re-default on PATCH,
?confirm=<name> guardrail (wrong / empty / correct), snapshot returns
the 5 globally-seeded cable_types
- cable_types: 5 seeded with the legend colours, global UNIQUE(name),
rename + recolour, RESTRICT-blocked delete when a cable references the
type (with count surfaced via CountCablesUsingType), unused delete
succeeds, project deletion does NOT cascade into cable_types
go test -race ./... passes. Updates README.md with run instructions,
env vars, the slice-1 API surface, and the slice roadmap.
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.
Sync project instructions with design v2:
- Framework framing: top-level `projects` table, LOFT/OFFICE/… as
separate projects, frames as sub-zones inside a project.
- DB path moved from ~/.m/mcables.db to ./data/mcables.db (gitignored).
- Frontend stack locked: vanilla JS modules + SVG, no build step,
TypeScript types via JSDoc, Preact-via-CDN-ESM as fallback.
- Deploy: raw docker on mDock under /home/m/stacks/mcables/ — explicitly
NOT Dokploy. Port 7777, no reverse proxy, no auth (LAN-trusted).
- mExDraw access: raw HTTP API (mcp__mexdraw__* not configured for this
project), one-way export only.
- Seed drawing reframed as visual-grammar reference, NOT a runtime
import target. IO markers documented as wall-outlet terminators
(type=Power), not inter-frame bridges.
- Out-of-scope list updated: no auth, no inventory fields, no runtime
import. Worker-preference slices re-aligned with the new design.
Revision after m's answers (2026-05-15):
- mCables is a framework. Top-level `projects` table; LOFT and OFFICE
are separate projects, each backed by one drawing. project_id is
denormalised onto every row for cheap project-scoped queries; CASCADE
from projects wipes a project's whole subgraph.
- IO diamonds are wall-outlet terminators (type=Power), not inter-frame
bridges. paired_with_id removed.
- No runtime importer. The seed Cable-Management.excalidraw is the
visual-grammar reference for the exporter only. /api/sync/import is
dropped from MVP; only /api/sync/export remains (one-way, manual).
- No cable inventory fields. Strictly visual structure for v0.
- DB at ./data/mcables.db (project-local, gitignored).
- Deploy: raw docker on mDock under /home/m/stacks/mcables/ (NOT Dokploy).
Conventions verified live (mgreen, mgeo, msports-garmin patterns).
Port 7777, container_name mcables, image from Gitea registry, Gitea
Actions self-hosted runner builds + deploys on push to main.
- Bind 0.0.0.0:7777 on the LAN. No auth.
- UI gains a projects picker; all CRUD endpoints scoped under
/api/projects/:pid/.
- Slices re-planned: empty bootstrap → frame+device → port+cable →
IO+cable-type editing → export.
- Open questions trimmed; six new ones (drawing-name policy, device
uniqueness, non-Power IO, bundle export, cross-project cable types,
delete guardrail).
Ends with DESIGN v2 READY FOR REVIEW.
Inventor shift 1. Reads the live Cable-Management.excalidraw on
mxdrw.msbls.de, lands on:
- vanilla JS modules + SVG diagram (no build step) served by a single
Go binary with embed.FS
- SQLite schema with frames, devices, ports, cable_types, cables,
io_markers, bundles (cgo-free driver)
- HTTP API under /api/, /api/state as the editor's one-shot loader
- importer that respects port-on-edge geometry (ports are positional,
not containerId-bound) and resolves arrow endpoints to port/device/IO
- bundle detection MVP: same-endpoints-pair → suggestion
- sync model: DB authoritative, Excalidraw is a projection, manual
import/export buttons with element-ID stability and conflict flagging
- five vertical slices for the coder shift, smallest end-to-end first
- nine open questions for m before code starts
Ends with DESIGN READY FOR REVIEW.