Modal-driven +Dev (replaces the v3 inline namer):
- Tool armed → click on canvas captures the click position + frame_id
from frameAt(p), then opens a #modal-new-device dialog.
- Dialog has a <select> grouped by `kind` for built-ins, then
project-custom rows, then "Custom (no type)" at the bottom.
- Default selection is the first built-in (NAS). Name input is
auto-pre-filled to <type-name>, bumping to <type-name>-N if a name
collision is detected in the current device list.
- Submit POSTs name + type_id + x/y/w/h + frame_id. Server seeds the
ports in the same transaction; we re-snapshot to pick them up.
Canvas:
- After each device's <rect> + label, render the device's ports as
white-filled <circle>s with stroke = the port's cable_type colour.
- Position: (device.x + port.x_offset, device.y + port.y_offset). The
seeder's "evenly along the edge" layout means ports already sit on
the device's bottom edge by default and follow the device on drag
(because they re-render from the same x/y on every renderCanvas).
- Ports themselves are `pointer-events: none` for slice 4 — selection
remains device-level. Per-port click semantics ship in slice 7
(manual cable draw).
Inspector device pane:
- New "type" row showing the type name + a "(custom)" badge for
project-custom types, or "Custom (no type)" for freeform.
- New "Ports" section with one row per seeded port: cable-type-colour
swatch, label, "unconnected" placeholder. Label falls back to the
cable type's name when the seeded label_prefix was blank.
State + snapshot:
- state.ports populated from snap.ports; cleared on project switch /
404.
- state.deviceTypes hydrated from GET /api/projects/:pid/device-types
after the snapshot loads. Failure of that fetch is non-fatal — the
+Dev modal just shows "Custom (no type)" only.
- Delete-device cleans up its ports from state.ports too (server-side
CASCADE already handles persistence).
Slice 3 frontend.
+ IO tool (keyboard `I`):
- Single-click on canvas places a 30x30 diamond (rotated <rect>) at the
point, with the Power-cable_type colour fill (red-ish).
- Inline namer prompts for a label; empty → server defaults to "IO".
- Drop-point determines initial frame_id via the existing frameAt()
point-in-rect logic, same as devices.
Render:
- io_markers come from snap.io_markers in the snapshot loader. Each
renders as a <rect> with rotate(45) around its centre + a small text
label below the diamond. Selection halo on stroke-width.
- Drag is the same pointer-event flow as devices; on pointerup, PATCH
x,y + recompute frame_id from the new centre. Cross-frame moves
update frame_id with explicit null on the wire when leaving all frames.
- Frame-drag now also relocates contained IO markers (mirrors the
device-cascade pattern). Single PATCH per IO marker on release.
Cable-type inspector:
- Clicking a legend row now sets state.selection = {kind:"cable_type", id}
in addition to toggling activeTypeId. The inspector renders the cable
type's details (name + colour, both editable, with the
"shared across projects" banner from v3 §7), a used-by counter (0
until slice 7 ships cables), and a Delete button that surfaces the
RESTRICT in_use_by_cables count from the server.
- Debounced rename via the existing bindDebouncedRename helper.
Inspector frame view picks up an "IO" count alongside the device count.
Background click + Esc clear the selection (existing behaviour, now
covers cable_type too).
Hand-tested via the API equivalents: 3 IO markers created (free, in
frame, default-label), PATCH x,y + frame_id-to-null all work, cross-
project frame_id rejected with 400, DELETE 9999 returns 404. Snapshot
shape post-slice-3: {frames, devices, io_markers, cable_types} all
populated, ports/cables/bundles still [].
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.