merge: slice 7 — manual ports + cable draw + promote button

picasso shipped (3 commits @ 9625d97):
- backend: ports CRUD endpoints, port-delete cascades cables fix
- frontend: +Port tool with edge-snap, click-port → click-port draws
  auto=0 cable, shift-click=device bind, click IO=terminator,
  clickable driving-requirement link, explicit Promote button
  (PATCH cables with {promote:true} required; label-only PATCH
  preserves auto)
This commit is contained in:
mAi
2026-05-16 01:20:48 +02:00
6 changed files with 558 additions and 12 deletions

View File

@@ -2,8 +2,187 @@ package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// PortCreate is the create-shape for POST /api/projects/:pid/devices/:id/ports.
type PortCreate struct {
TypeID int64
Label string
XOffset float64
YOffset float64
}
// PortUpdate is the partial-update shape.
type PortUpdate struct {
TypeID *int64
Label *string
XOffset *float64
YOffset *float64
}
// CreatePort inserts a port on a device. The device must exist in the
// project; the cable type must exist globally.
func (s *Store) CreatePort(projectID, deviceID int64, p PortCreate) (*Port, error) {
if _, err := s.GetDevice(projectID, deviceID); err != nil {
return nil, err
}
if _, err := s.GetCableType(p.TypeID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, p.TypeID)
}
return nil, err
}
label := strings.TrimSpace(p.Label)
var labelArg any
if label != "" {
labelArg = label
}
res, err := s.db.Exec(
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, deviceID, p.TypeID, labelArg, p.XOffset, p.YOffset,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetPort(projectID, id)
}
// GetPort loads a port by id, project-scoped.
func (s *Store) GetPort(projectID, id int64) (*Port, error) {
var p Port
var label, ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
excalidraw_id, created_at, updated_at
FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if label.Valid {
v := label.String
p.Label = &v
}
if ex.Valid {
p.ExcalidrawID = &ex.String
}
return &p, nil
}
// UpdatePort applies a partial update.
func (s *Store) UpdatePort(projectID, id int64, u PortUpdate) (*Port, error) {
cur, err := s.GetPort(projectID, id)
if err != nil {
return nil, err
}
if u.TypeID != nil {
if _, err := s.GetCableType(*u.TypeID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, *u.TypeID)
}
return nil, err
}
cur.TypeID = *u.TypeID
}
if u.Label != nil {
v := strings.TrimSpace(*u.Label)
if v == "" {
cur.Label = nil
} else {
cur.Label = &v
}
}
if u.XOffset != nil {
cur.XOffset = *u.XOffset
}
if u.YOffset != nil {
cur.YOffset = *u.YOffset
}
var labelArg any
if cur.Label != nil {
labelArg = *cur.Label
}
if _, err := s.db.Exec(
`UPDATE ports
SET type_id = ?, label = ?, x_offset = ?, y_offset = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
cur.TypeID, labelArg, cur.XOffset, cur.YOffset, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetPort(projectID, id)
}
// DeletePort removes a port from a device. The schema's
// ON DELETE SET NULL on cables.from_port_id / to_port_id collides with
// the cable's CHECK ((from_port|from_device|from_io) = 1 non-null), so
// we instead cascade-delete any cables that referenced the port on
// either side — same effect from m's POV: the cable is gone, m can
// re-draw if he still wants it.
func (s *Store) DeletePort(projectID, id int64) error {
if _, err := s.GetPort(projectID, id); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
`DELETE FROM cables WHERE project_id = ? AND (from_port_id = ? OR to_port_id = ?)`,
projectID, id, id,
); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return tx.Commit()
}
// ListPortsForDevice returns every port on one device, project-scoped.
func (s *Store) ListPortsForDevice(projectID, deviceID int64) ([]Port, error) {
rows, err := s.db.Query(
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
excalidraw_id, created_at, updated_at
FROM ports WHERE project_id = ? AND device_id = ? ORDER BY id`,
projectID, deviceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Port{}
for rows.Next() {
var p Port
var label, ex sql.NullString
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
if label.Valid {
v := label.String
p.Label = &v
}
if ex.Valid {
p.ExcalidrawID = &ex.String
}
out = append(out, p)
}
return out, rows.Err()
}
// ListPortsForProject returns every port in a project, ordered by
// device_id + id so callers can group cheaply.
func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) {

114
internal/server/ports.go Normal file
View File

@@ -0,0 +1,114 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
type portCreate struct {
TypeID int64 `json:"type_id"`
Label string `json:"label,omitempty"`
XOffset float64 `json:"x_offset"`
YOffset float64 `json:"y_offset"`
}
type portPatch struct {
TypeID *int64 `json:"type_id,omitempty"`
Label *string `json:"label,omitempty"`
XOffset *float64 `json:"x_offset,omitempty"`
YOffset *float64 `json:"y_offset,omitempty"`
}
func (h *handlers) listPortsForDevice(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
ps, err := h.store.ListPortsForDevice(pid, id)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ps)
}
func (h *handlers) createPort(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body portCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.CreatePort(pid, id, db.PortCreate{
TypeID: body.TypeID, Label: body.Label,
XOffset: body.XOffset, YOffset: body.YOffset,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (h *handlers) patchPort(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body portPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.UpdatePort(pid, id, db.PortUpdate{
TypeID: body.TypeID, Label: body.Label,
XOffset: body.XOffset, YOffset: body.YOffset,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, p)
}
func (h *handlers) deletePort(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeletePort(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -51,6 +51,12 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker)
mux.HandleFunc("DELETE /api/projects/{pid}/io-markers/{id}", h.deleteIOMarker)
// Ports — slice 7 lets m add/edit/remove instance ports on a device.
mux.HandleFunc("GET /api/projects/{pid}/devices/{id}/ports", h.listPortsForDevice)
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports", h.createPort)
mux.HandleFunc("PATCH /api/projects/{pid}/ports/{id}", h.patchPort)
mux.HandleFunc("DELETE /api/projects/{pid}/ports/{id}", h.deletePort)
// Device-type catalog. Built-ins are read-only; project-custom rows
// support full CRUD scoped to the project.
mux.HandleFunc("GET /api/device-types", h.listBuiltInDeviceTypes)

View File

@@ -46,7 +46,7 @@
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
</ul>
</section>
</aside>

View File

@@ -56,8 +56,13 @@ const state = {
/** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | null */
/** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
tool: /** @type {string|null} */ (null),
/** Slice-7 transient state for the +Port tool. */
portToolDevice: /** @type {number|null} */ (null),
portToolTypeID: /** @type {number|null} */ (null),
/** Slice-7: when the user clicked a source port, this is its id. */
cableDrawFromPortID: /** @type {number|null} */ (null),
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle", id: number} | null} */ selection: null,
};
@@ -104,6 +109,12 @@ const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
const createPort = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports`, body);
const patchPort = (pid, id, body) => api("PATCH", `/projects/${pid}/ports/${id}`, body);
const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`);
const createCableAPI = (pid, body) => api("POST", `/projects/${pid}/cables`, body);
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body);
@@ -305,11 +316,15 @@ function renderCanvas() {
const cx = d.x + prt.x_offset;
const cy = d.y + prt.y_offset;
const color = cableTypeColor.get(prt.type_id) || "#888";
const cls = "port-circle" + (state.cableDrawFromPortID === prt.id ? " cable-from" : "");
const c = svgEl("circle", {
cx, cy, r: 5,
class: "port-circle",
class: cls,
stroke: color,
"data-port-id": prt.id,
});
// Slice 7: port-click drives the manual cable-draw flow.
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
g.append(c);
}
@@ -339,7 +354,17 @@ function renderCanvas() {
label.textContent = m.label;
g.append(rect, label);
gIO.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "io", m.id));
rect.addEventListener("pointerdown", (e) => {
// Slice 7: if a cable draw is in progress, terminate the cable on
// this IO marker instead of starting a drag.
if (state.cableDrawFromPortID != null) {
e.stopPropagation();
e.preventDefault();
finishCableDrawAtIO(m);
return;
}
startDrag(e, "io", m.id);
});
}
// Cables — straight lines between resolved endpoint anchors.
@@ -469,9 +494,25 @@ function renderInspectorCable(body, id) {
body.querySelector("#cab-type").textContent = ct ? `${ct.name}` : `type #${c.type_id}`;
body.querySelector("#cab-from").textContent = fromLabel;
body.querySelector("#cab-to").textContent = toLabel;
body.querySelector("#cab-driver").textContent = drivingReq
? `requirement #${drivingReq.id}`
: (c.auto ? "(no matching requirement)" : "—");
const driverCell = body.querySelector("#cab-driver");
if (drivingReq) {
const deviceByID2 = new Map(state.devices.map((d) => [d.id, d]));
const an = deviceByID2.get(drivingReq.from_device_id)?.name ?? "?";
const bn = deviceByID2.get(drivingReq.to_device_id)?.name ?? "?";
const link = document.createElement("button");
link.type = "button";
link.className = "btn-link";
link.style.padding = "0";
link.textContent = `${an}${bn}`;
link.title = "Jump to this requirement";
link.addEventListener("click", () => {
state.selection = { kind: "requirement", id: drivingReq.id };
render();
});
driverCell.append(link);
} else {
driverCell.textContent = c.auto ? "(no matching requirement)" : "—";
}
if (c.auto) {
body.querySelector("#cab-promote").addEventListener("click", async () => {
@@ -557,10 +598,12 @@ function renderInspectorDevice(body, id) {
const portsHtml = ports.length
? ports.map((p) => `
<div class="port-row">
<div class="port-row" data-port-id="${p.id}">
<span class="swatch" style="background:${cableTypeColor.get(p.type_id) || "#888"}"></span>
<span class="label">${escapeHtml(p.label ?? cableTypeName.get(p.type_id) ?? "Port")}</span>
<span class="conn">unconnected</span>
<span class="conn">
<button type="button" class="btn-link port-del" data-port-id="${p.id}" title="Delete port">×</button>
</span>
</div>`).join("")
: `<p class="muted" style="font-size:12px">No ports yet.</p>`;
@@ -607,6 +650,9 @@ function renderInspectorDevice(body, id) {
</dl>
<p class="section-title">Ports</p>
<div id="dev-ports">${portsHtml}</div>
<div class="inspector-actions" style="margin-top: 4px;">
<button type="button" class="btn btn-tiny" id="dev-add-port">+ Port</button>
</div>
<p class="section-title">Requirements</p>
<div id="dev-reqs">${reqsHtml}</div>
<div class="inspector-actions">
@@ -669,6 +715,35 @@ function renderInspectorDevice(body, id) {
render();
});
});
// +Port — arms the port-placement gesture. Active cable type comes
// from the legend selection; if none, defaults to the first cable_type.
body.querySelector("#dev-add-port").addEventListener("click", () => {
if (!state.active) return;
const typeID = state.activeTypeId ?? state.cableTypes[0]?.id;
if (!typeID) { alert("Pick a cable type in the legend first"); return; }
armPortTool(d.id, typeID);
});
// Per-port delete.
body.querySelectorAll(".port-del").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
if (!state.active) return;
const pid = Number(btn.getAttribute("data-port-id"));
if (!pid) return;
if (!confirm("Delete this port?")) return;
try {
await deletePort(state.active.id, pid);
state.ports = state.ports.filter((p) => p.id !== pid);
// Cables that referenced the port get from_port_id/to_port_id
// set to NULL by the schema — refresh from snapshot.
const snap = await getSnapshot(state.active.id);
state.cables = snap.cables || [];
render();
} catch (ex) { alert(`Delete failed: ${ex.message}`); }
});
});
}
function renderInspectorRequirement(body, id) {
@@ -1120,9 +1195,25 @@ function armTool(tool) {
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
wrap.classList.toggle("tool-port", tool === "port");
wrap.classList.toggle("tool-cable", tool === "cable");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
if (tool !== "port") {
state.portToolDevice = null;
state.portToolTypeID = null;
}
if (tool !== "cable") {
state.cableDrawFromPortID = null;
}
}
/** Slice 7: device inspector arms +Port for a specific device + type. */
function armPortTool(deviceID, typeID) {
state.portToolDevice = deviceID;
state.portToolTypeID = typeID;
armTool("port");
}
function bindTools() {
@@ -1134,7 +1225,7 @@ function bindTools() {
// Avoid stealing keys while user is typing into an input.
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); }
else if (e.key === "f" || e.key === "F") armTool("frame");
else if (e.key === "d" || e.key === "D") armTool("device");
else if (e.key === "i" || e.key === "I") armTool("io");
@@ -1176,6 +1267,11 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
if (state.tool === "port") {
e.preventDefault();
placePortAt(p);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
@@ -1354,6 +1450,136 @@ function openNewDeviceModal(geom) {
};
}
/** Snap (x, y) onto the closest edge of `device`. Returns the (x_off,
* y_off) relative to the device's top-left + a debug-friendly edge name. */
function snapToDeviceEdge(device, x, y) {
// Distance from the point to each of the four edges.
const dxLeft = Math.abs(x - device.x);
const dxRight = Math.abs((device.x + device.width) - x);
const dyTop = Math.abs(y - device.y);
const dyBottom = Math.abs((device.y + device.height) - y);
const min = Math.min(dxLeft, dxRight, dyTop, dyBottom);
// Clamp the perpendicular coordinate so the port sits *on* the rect.
const localX = Math.max(0, Math.min(device.width, x - device.x));
const localY = Math.max(0, Math.min(device.height, y - device.y));
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
return { xOff: localX, yOff: device.height, edge: "bottom" };
}
/** Port-click flow:
* 1) No source picked yet → this port becomes the source. Highlight it.
* 2) Source already picked → this port is the target. POST a cable
* with `from_port_id` / `to_port_id`, type from the source port,
* auto=0. Shift-click flips the target to "bind to whole device"
* (uses `to_device_id` instead). */
function onPortPointerDown(e, port) {
if (!state.active) return;
if (state.tool && state.tool !== "cable") return; // other tool wins
e.stopPropagation();
e.preventDefault();
if (state.cableDrawFromPortID == null) {
state.cableDrawFromPortID = port.id;
armTool("cable"); // get the crosshair cursor + visual cue
render();
return;
}
if (state.cableDrawFromPortID === port.id) {
// Cancel — clicked the same port again.
state.cableDrawFromPortID = null;
armTool(null);
render();
return;
}
finishCableDrawAt(port, e.shiftKey);
}
async function finishCableDrawAt(targetPort, shiftKey) {
if (!state.active) return;
const fromPortID = state.cableDrawFromPortID;
state.cableDrawFromPortID = null;
armTool(null);
if (fromPortID == null) return;
const sourcePort = state.ports.find((p) => p.id === fromPortID);
if (!sourcePort) { render(); return; }
// Body: shift-click on a port = bind to that port's parent device
// (whole-device cable) instead of the port. Plain click = port-to-port.
const body = {
type_id: sourcePort.type_id,
auto: false,
from: { port_id: fromPortID },
to: shiftKey ? { device_id: targetPort.device_id } : { port_id: targetPort.id },
};
if (!shiftKey && targetPort.type_id !== sourcePort.type_id) {
if (!confirm(`Target port is a different cable type. Connect anyway?`)) {
render();
return;
}
}
try {
const c = await createCableAPI(state.active.id, body);
state.cables.push(c);
state.selection = { kind: "cable", id: c.id };
render();
} catch (e) {
alert(`Create cable failed: ${e.message}`);
render();
}
}
/** Click on an IO marker while a cable draw is in progress → terminate
* the cable on that IO. Plugged into the IO marker's pointerdown
* handler in renderCanvas. */
async function finishCableDrawAtIO(ioMarker) {
if (!state.active) return;
const fromPortID = state.cableDrawFromPortID;
state.cableDrawFromPortID = null;
armTool(null);
if (fromPortID == null) return;
const sourcePort = state.ports.find((p) => p.id === fromPortID);
if (!sourcePort) { render(); return; }
const body = {
type_id: sourcePort.type_id,
auto: false,
from: { port_id: fromPortID },
to: { io_id: ioMarker.id },
};
try {
const c = await createCableAPI(state.active.id, body);
state.cables.push(c);
state.selection = { kind: "cable", id: c.id };
render();
} catch (e) {
alert(`Create cable failed: ${e.message}`);
render();
}
}
async function placePortAt(p) {
if (!state.active) return;
const did = state.portToolDevice;
const tid = state.portToolTypeID;
if (did == null || tid == null) { armTool(null); return; }
const dev = state.devices.find((d) => d.id === did);
if (!dev) { armTool(null); return; }
const snap = snapToDeviceEdge(dev, p.x, p.y);
try {
const port = await createPort(state.active.id, did, {
type_id: tid,
x_offset: snap.xOff,
y_offset: snap.yOff,
});
state.ports.push(port);
armTool(null);
render();
} catch (e) {
alert(`Add port failed: ${e.message}`);
armTool(null);
}
}
async function placeIOMarkerAt(p) {
if (!state.active) return;
armTool(null);

View File

@@ -213,7 +213,28 @@ body {
.canvas-wrap.tool-device #canvas,
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; }
.canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-port #canvas,
.canvas-wrap.tool-port #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
.btn-link {
background: transparent;
border: 0;
color: var(--text-muted);
cursor: pointer;
font: inherit;
padding: 0 4px;
line-height: 1;
}
.btn-link:hover { color: var(--danger); }
/* Highlight a port that's been picked as the cable-draw source. */
.port-circle.cable-from {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
/* IO markers — diamonds. Power-by-convention, so the default fill is
the Power cable_type colour (#e03131). Rotated 45° rect is the
@@ -246,7 +267,7 @@ body {
fill: #fff;
stroke: var(--text);
stroke-width: 2;
pointer-events: none; /* slice 4 — selection happens at device-level */
cursor: crosshair;
}
.port-row {