feat(ui): Solve flow + setup-templates apply + cable rendering

Header gains a Solve button (keyboard S) + Apply template button.

Canvas:
- Cables render as straight lines port→port (or device-centre when the
  endpoint is a whole device, or io-marker centre). Auto-cables get a
  dashed stroke; manual cables (auto=0) solid. Stroke colour = cable_type.
- Click a cable to select it → inspector pane updates.

Solve preview-diff modal:
- Calls POST .../solve?preview=1 on open.
- Renders cables_added, cables_removed, bundles_added in colour-coded
  lists. Unsatisfied entries get a class="unmet" badge + one-click
  quick-fix:
  * "no free <type> port" → "+ Add <type> port to <device> and re-solve"
    fires POST .../devices/:id/ports-and-resolve in one round-trip and
    re-renders the preview.
  * "ambiguous cable type" → "Specify cable type…" re-opens the
    requirement modal.
  * "no compatible cable type" with a preferred type → "+ Add port…"
    quick-fix on the from-side device.
- Apply → POST .../solve (no preview) → re-snapshot to pick up new
  cable ids + bundle assignments.

Cable inspector (kind=cable):
- Shows type, from-endpoint, to-endpoint labels.
- For solver-owned cables, shows the driving requirement (best-effort
  match by unordered device pair + type) and a "Promote to manual"
  button (PATCH with `promote: true` flips auto→0).
- Delete button on both auto and manual cables.

Apply-template flow:
- "Apply template…" header button opens a wide modal with a template
  dropdown (Living Room / Home Office / Server Rack) + a preview panel
  showing each device row (skip checkbox + editable name input) and
  the template's requirements.
- Submit → POST .../apply-template with name_overrides + skip_devices,
  then re-snapshot.

State + snapshot:
- state.cables, state.bundles, state.setupTemplates added.
- activateProject pulls them from the snapshot; teardown on switch.
This commit is contained in:
mAi
2026-05-16 01:07:20 +02:00
parent c8bda7a222
commit c681b01aff
3 changed files with 480 additions and 2 deletions

View File

@@ -20,7 +20,9 @@
</button>
</div>
<div class="topbar-spacer"></div>
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn" disabled title="Slice 8">
Export
</button>
</header>
@@ -175,6 +177,35 @@
</form>
</dialog>
<!-- Solve preview-diff (slice 6) -->
<dialog id="modal-solve" class="modal modal-wide" aria-labelledby="sv-title">
<div style="padding: 16px;">
<h2 id="sv-title">Solve preview</h2>
<div id="sv-body" class="sv-body"></div>
<div class="actions" style="margin-top: 12px;">
<button type="button" class="btn btn-primary" id="sv-apply">Apply</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</div>
</dialog>
<!-- Apply template (slice 6) -->
<dialog id="modal-template" class="modal modal-wide" aria-labelledby="tp-title">
<form method="dialog" id="form-template">
<h2 id="tp-title">Apply setup template</h2>
<label class="field">
<span>Template</span>
<select id="tp-select" required></select>
</label>
<div id="tp-preview" class="tp-preview"></div>
<p class="form-error" id="tp-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Apply</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">

View File

@@ -28,6 +28,14 @@
* @typedef {{ id: number, project_id: number, from_device_id: number,
* to_device_id: number, preferred_cable_type_id: number|null,
* must_connect: boolean, notes: string }} ConnectionRequirement
* @typedef {{ id: number, project_id: number, type_id: number,
* label: string|null, auto: boolean,
* from_port_id: number|null, from_device_id: number|null, from_io_id: number|null,
* to_port_id: number|null, to_device_id: number|null, to_io_id: number|null }} Cable
* @typedef {{ id: number, project_id: number, name: string, auto: boolean,
* cable_ids: number[] }} Bundle
* @typedef {{ id: number, name: string, description: string, built_in: boolean,
* devices: any[], requirements: any[] }} SetupTemplate
*/
const API = "/api";
@@ -44,10 +52,13 @@ const state = {
/** @type {Port[]} */ ports: [],
/** @type {IOMarker[]} */ ioMarkers: [],
/** @type {ConnectionRequirement[]} */ requirements: [],
/** @type {Cable[]} */ cables: [],
/** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | null */
tool: /** @type {string|null} */ (null),
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement", id: number} | null} */ selection: null,
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -99,6 +110,14 @@ const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connect
const patchRequirement = (pid, id, body) => api("PATCH", `/projects/${pid}/connection-requirements/${id}`, body);
const deleteRequirement = (pid, id) => api("DELETE", `/projects/${pid}/connection-requirements/${id}`);
const patchCable = (pid, id, body) => api("PATCH", `/projects/${pid}/cables/${id}`, body);
const deleteCable = (pid, id) => api("DELETE", `/projects/${pid}/cables/${id}`);
const solveProject = (pid, preview) => api("POST", `/projects/${pid}/solve${preview ? "?preview=1" : ""}`, {});
const portsAndResolve = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports-and-resolve`, body);
const listSetupTemplates = () => api("GET", `/setup-templates`);
const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
@@ -225,9 +244,11 @@ function renderEmptyHint() {
function renderCanvas() {
const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices");
const gCables = $("#canvas-cables");
const gIO = $("#canvas-io");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
gCables.innerHTML = "";
gIO.innerHTML = "";
for (const f of state.frames) {
@@ -320,6 +341,55 @@ function renderCanvas() {
gIO.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "io", m.id));
}
// Cables — straight lines between resolved endpoint anchors.
// Auto-cables render with dashed stroke so m sees which the solver
// placed; manual cables are solid.
const portByID = new Map(state.ports.map((p) => [p.id, p]));
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
for (const c of state.cables) {
const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
if (!fromAnchor || !toAnchor) continue;
const color = cableTypeColor.get(c.type_id) || "#888";
const line = svgEl("line", {
x1: fromAnchor.x, y1: fromAnchor.y,
x2: toAnchor.x, y2: toAnchor.y,
class: "cable-line" + (c.auto ? " auto" : "") + (state.selection?.kind === "cable" && state.selection.id === c.id ? " selected" : ""),
stroke: color,
"data-cable-id": c.id,
});
line.addEventListener("click", (e) => {
e.stopPropagation();
state.selection = { kind: "cable", id: c.id };
render();
});
gCables.append(line);
}
}
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
* the referenced row has gone missing (rare, but possible mid-edit). */
function anchorForEndpoint(portID, deviceID, ioID, portByID, deviceByID, ioByID) {
if (portID != null) {
const p = portByID.get(portID);
if (!p) return null;
const d = deviceByID.get(p.device_id);
if (!d) return null;
return { x: d.x + p.x_offset, y: d.y + p.y_offset };
}
if (deviceID != null) {
const d = deviceByID.get(deviceID);
if (!d) return null;
return { x: d.x + d.width / 2, y: d.y + d.height / 2 };
}
if (ioID != null) {
const m = ioByID.get(ioID);
if (!m) return null;
return { x: m.x + IO_SIZE / 2, y: m.y + IO_SIZE / 2 };
}
return null;
}
function renderInspector() {
@@ -334,10 +404,97 @@ function renderInspector() {
case "io": return renderInspectorIO(body, state.selection.id);
case "cable_type": return renderInspectorCableType(body, state.selection.id);
case "requirement": return renderInspectorRequirement(body, state.selection.id);
case "cable": return renderInspectorCable(body, state.selection.id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
function renderInspectorCable(body, id) {
const c = state.cables.find((x) => x.id === id);
if (!c) { body.innerHTML = ""; return; }
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
const portByID = new Map(state.ports.map((p) => [p.id, p]));
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
const ct = state.cableTypes.find((t) => t.id === c.type_id);
function endpointLabel(portID, deviceID, ioID) {
if (portID != null) {
const p = portByID.get(portID);
if (!p) return "(missing port)";
const d = deviceByID.get(p.device_id);
return `${d?.name ?? "?"} · ${p.label ?? "port"}`;
}
if (deviceID != null) {
const d = deviceByID.get(deviceID);
return d?.name ?? "(missing device)";
}
if (ioID != null) {
const m = ioByID.get(ioID);
return m?.label ?? "(missing IO)";
}
return "?";
}
const fromLabel = endpointLabel(c.from_port_id, c.from_device_id, c.from_io_id);
const toLabel = endpointLabel(c.to_port_id, c.to_device_id, c.to_io_id);
// Find the driving requirement (auto cable only) — match by
// unordered device pair + (cable type or null).
let drivingReq = null;
if (c.auto) {
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
if (fromDev != null && toDev != null) {
drivingReq = state.requirements.find((r) => {
const same = (r.from_device_id === fromDev && r.to_device_id === toDev)
|| (r.from_device_id === toDev && r.to_device_id === fromDev);
if (!same) return false;
if (r.preferred_cable_type_id == null) return true; // solver-picked match
return r.preferred_cable_type_id === c.type_id;
});
}
}
body.innerHTML = `
<p class="section-title">Cable ${c.auto ? "(solver)" : "(manual)"}</p>
<dl>
<dt>type</dt><dd id="cab-type"></dd>
<dt>from</dt><dd id="cab-from"></dd>
<dt>to</dt><dd id="cab-to"></dd>
<dt>driver</dt><dd id="cab-driver"></dd>
</dl>
<div class="inspector-actions">
${c.auto ? `<button type="button" class="btn btn-tiny" id="cab-promote">Promote to manual</button>` : ""}
<button type="button" class="btn btn-danger btn-tiny" id="cab-delete">Delete</button>
</div>
`;
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)" : "—");
if (c.auto) {
body.querySelector("#cab-promote").addEventListener("click", async () => {
if (!state.active) return;
try {
const updated = await patchCable(state.active.id, c.id, { promote: true });
Object.assign(c, updated);
render();
} catch (e) { alert(`Promote failed: ${e.message}`); }
});
}
body.querySelector("#cab-delete").addEventListener("click", async () => {
if (!state.active) return;
if (!confirm("Delete this cable?")) return;
try {
await deleteCable(state.active.id, c.id);
state.cables = state.cables.filter((x) => x.id !== c.id);
state.selection = null;
render();
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
function renderInspectorFrame(body, id) {
const f = state.frames.find((x) => x.id === id);
if (!f) { body.innerHTML = ""; return; }
@@ -906,6 +1063,8 @@ async function activateProject(id) {
state.ports = [];
state.ioMarkers = [];
state.requirements = [];
state.cables = [];
state.bundles = [];
state.selection = null;
setActiveInURL(null);
render();
@@ -918,6 +1077,8 @@ async function activateProject(id) {
state.devices = snap.devices || [];
state.ioMarkers = snap.io_markers || [];
state.ports = snap.ports || [];
state.cables = snap.cables || [];
state.bundles = snap.bundles || [];
state.requirements = snap.connection_requirements || [];
state.cableTypes = snap.cable_types || [];
state.selection = null;
@@ -941,6 +1102,8 @@ async function activateProject(id) {
state.ports = [];
state.ioMarkers = [];
state.requirements = [];
state.cables = [];
state.bundles = [];
setActiveInURL(null);
render();
} else {
@@ -976,6 +1139,7 @@ function bindTools() {
else if (e.key === "d" || e.key === "D") armTool("device");
else if (e.key === "i" || e.key === "I") armTool("io");
else if (e.key === "r" || e.key === "R") armTool("req");
else if (e.key === "s" || e.key === "S") openSolveModal();
});
// Canvas-level pointerdown handles tool activation + selection clearing.
@@ -1509,6 +1673,215 @@ function openDeleteProjectModal() {
};
}
// ---------- solve flow ---------- //
function openSolveModal() {
if (!state.active) { alert("Pick a project first"); return; }
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-solve"));
const body = $("#sv-body");
body.innerHTML = `<p class="muted">Computing…</p>`;
dlg.showModal();
solveProject(state.active.id, true)
.then((preview) => renderSolvePreview(body, preview))
.catch((e) => { body.innerHTML = `<p class="form-error">${escapeHtml(e.message)}</p>`; });
$("#sv-apply").onclick = async () => {
if (!state.active) return;
try {
const applied = await solveProject(state.active.id, false);
// Refresh from snapshot to pick up new cable ids + bundle assignments.
const snap = await getSnapshot(state.active.id);
state.cables = snap.cables || [];
state.bundles = snap.bundles || [];
state.ports = snap.ports || [];
state.requirements = snap.connection_requirements || [];
dlg.close();
render();
// Surface a brief summary as an alert (slice 9+ can replace with a toast).
const adds = applied.cables_added?.length ?? 0;
const rem = applied.cables_removed?.length ?? 0;
const bun = applied.bundles_added?.length ?? 0;
const un = applied.unsatisfied?.length ?? 0;
const lines = [`Solve applied: +${adds} cables / -${rem} cables / +${bun} bundles`];
if (un > 0) lines.push(`${un} requirement${un === 1 ? "" : "s"} unsatisfied`);
console.log(lines.join("\n"));
} catch (e) {
alert(`Apply failed: ${e.message}`);
}
};
}
function renderSolvePreview(body, preview) {
const reqByID = new Map(state.requirements.map((r) => [r.id, r]));
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
const portByID = new Map(state.ports.map((p) => [p.id, p]));
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
const addsHtml = (preview.cables_added || []).map((c) => {
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
const a = deviceByID.get(fromDev)?.name ?? "?";
const b = deviceByID.get(toDev)?.name ?? "?";
return `<li class="added">+ ${escapeHtml(a)}${escapeHtml(b)} · ${escapeHtml(cableTypeName.get(c.type_id) ?? "?")}</li>`;
}).join("");
const remsHtml = (preview.cables_removed || []).map((id) => `<li class="removed">cable #${id}</li>`).join("");
const bunsHtml = (preview.bundles_added || []).map((b) => `<li class="added">bundle: ${escapeHtml(b.name)}</li>`).join("");
const unmetsHtml = (preview.unsatisfied || []).map((u) => {
const r = reqByID.get(u.requirement_id);
const a = r ? deviceByID.get(r.from_device_id)?.name : "?";
const b = r ? deviceByID.get(r.to_device_id)?.name : "?";
const reqDesc = `${escapeHtml(a ?? "?")}${escapeHtml(b ?? "?")}`;
let action = "";
// Quick-fix per design v4.1 §5b.4.
if ((u.reason || "").startsWith("no free") && u.cable_type && u.which_side) {
const side = u.which_side === "from" ? r.from_device_id : r.to_device_id;
const sideName = deviceByID.get(side)?.name ?? "?";
action = `<span class="quickfix" data-fix="addport" data-device="${side}" data-cable-type="${escapeHtml(u.cable_type)}">+ Add ${escapeHtml(u.cable_type)} port to ${escapeHtml(sideName)} and re-solve</span>`;
} else if ((u.reason || "").startsWith("ambiguous") && r) {
action = `<span class="quickfix" data-fix="picktype" data-req="${r.id}">Specify cable type…</span>`;
} else if ((u.reason || "").startsWith("no compat") && r && r.preferred_cable_type_id != null) {
// No common port type for the preferred — offer to add a port on either device.
const sideName = deviceByID.get(r.from_device_id)?.name ?? "?";
action = `<span class="quickfix" data-fix="addport" data-device="${r.from_device_id}" data-cable-type-id="${r.preferred_cable_type_id}">+ Add port to ${escapeHtml(sideName)} and re-solve</span>`;
}
return `<li class="unmet">⚠️ ${reqDesc} · ${escapeHtml(u.reason)}${action}</li>`;
}).join("");
body.innerHTML = `
${addsHtml ? `<h3>Cables to add</h3><ul>${addsHtml}</ul>` : ""}
${remsHtml ? `<h3>Cables to remove</h3><ul>${remsHtml}</ul>` : ""}
${bunsHtml ? `<h3>Bundles to add</h3><ul>${bunsHtml}</ul>` : ""}
${unmetsHtml ? `<h3>Unsatisfied</h3><ul>${unmetsHtml}</ul>` : ""}
${(addsHtml || remsHtml || bunsHtml || unmetsHtml) ? "" : `<p class="muted">No changes — already solved.</p>`}
`;
body.querySelectorAll(".quickfix").forEach((el) => {
el.addEventListener("click", async () => {
const fix = el.getAttribute("data-fix");
if (fix === "addport") {
const devID = Number(el.getAttribute("data-device"));
let typeID = Number(el.getAttribute("data-cable-type-id"));
if (!typeID) {
const typeName = el.getAttribute("data-cable-type");
const t = state.cableTypes.find((x) => x.name === typeName);
typeID = t ? t.id : null;
}
if (!devID || !typeID) return;
try {
await portsAndResolve(state.active.id, devID, { type_id: typeID });
// Refresh + re-render the preview
const refresh = await solveProject(state.active.id, true);
const snap = await getSnapshot(state.active.id);
state.cables = snap.cables; state.bundles = snap.bundles;
state.ports = snap.ports; state.requirements = snap.connection_requirements;
state.devices = snap.devices;
renderSolvePreview(body, refresh);
render(); // sidebar updates
} catch (e) { alert(`Quick-fix failed: ${e.message}`); }
} else if (fix === "picktype") {
// Open the requirement modal so m can specify a type.
const rid = Number(el.getAttribute("data-req"));
const r = state.requirements.find((x) => x.id === rid);
if (r) openRequirementModal(r);
}
});
});
}
// ---------- apply-template flow ---------- //
async function openApplyTemplateModal() {
if (!state.active) { alert("Pick a project first"); return; }
if (!state.setupTemplates.length) {
state.setupTemplates = await listSetupTemplates();
}
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-template"));
const form = /** @type {HTMLFormElement} */ ($("#form-template"));
const sel = /** @type {HTMLSelectElement} */ ($("#tp-select"));
const preview = $("#tp-preview");
const err = $("#tp-error");
showError(err, "");
sel.innerHTML = "";
for (const t of state.setupTemplates) {
sel.append(new Option(t.name, String(t.id)));
}
sel.onchange = () => renderTemplatePreview(preview, sel.value);
renderTemplatePreview(preview, sel.value);
dlg.showModal();
form.onsubmit = async (e) => {
e.preventDefault();
if (!state.active) return;
const tid = Number(sel.value);
if (!tid) { showError(err, "Pick a template"); return; }
// Collect any per-device name overrides (the preview renders inputs).
const overrides = {};
preview.querySelectorAll("[data-template-device-id]").forEach((row) => {
const did = row.getAttribute("data-template-device-id");
const input = row.querySelector("input.tp-name");
if (input && input.value.trim()) overrides[did] = input.value.trim();
});
const skip = [];
preview.querySelectorAll("input.tp-skip:checked").forEach((cb) => {
const did = Number(cb.getAttribute("data-template-device-id"));
if (did) skip.push(did);
});
try {
await applyTemplate(state.active.id, {
template_id: tid,
name_overrides: overrides,
skip_devices: skip,
});
const snap = await getSnapshot(state.active.id);
state.frames = snap.frames || [];
state.devices = snap.devices || [];
state.ports = snap.ports || [];
state.ioMarkers = snap.io_markers || [];
state.requirements = snap.connection_requirements || [];
state.cables = snap.cables || [];
state.bundles = snap.bundles || [];
dlg.close();
render();
} catch (ex) {
showError(err, ex.message || "Apply failed");
}
};
}
function renderTemplatePreview(preview, templateIDStr) {
if (!templateIDStr) { preview.innerHTML = ""; return; }
const t = state.setupTemplates.find((x) => String(x.id) === templateIDStr);
if (!t) { preview.innerHTML = ""; return; }
const cableTypeName = new Map(state.cableTypes.map((c) => [c.id, c.name]));
const devByTplID = new Map(t.devices.map((d) => [d.id, d]));
const devsHtml = t.devices.map((d) => {
const dtName = d.device_type?.name ?? `type #${d.device_type_id}`;
const suggested = d.suggested_name ?? dtName;
return `
<li data-template-device-id="${d.id}">
<input type="checkbox" class="tp-skip" data-template-device-id="${d.id}" title="Skip this device" />
<input type="text" class="tp-name inline-input" value="${escapeHtml(suggested)}"
style="width: 140px; display: inline-block;" />
<span class="muted" style="margin-left: 6px;">${escapeHtml(dtName)}</span>
</li>`;
}).join("");
const reqsHtml = t.requirements.map((r) => {
const a = devByTplID.get(r.from_template_device_id);
const b = devByTplID.get(r.to_template_device_id);
const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : "solver picks";
return `<li>${escapeHtml(a?.suggested_name ?? "?")}${escapeHtml(b?.suggested_name ?? "?")} · ${escapeHtml(ct ?? "?")}</li>`;
}).join("");
preview.innerHTML = `
<p>${escapeHtml(t.description)}</p>
<h4>Devices</h4>
<ul>${devsHtml}</ul>
<h4>Requirements</h4>
<ul>${reqsHtml}</ul>
`;
}
// ---------- boot ---------- //
async function boot() {
@@ -1517,6 +1890,8 @@ async function boot() {
bindCloseButtons($("#modal-delete-project"));
bindCloseButtons($("#modal-new-device"));
bindCloseButtons($("#modal-requirement"));
bindCloseButtons($("#modal-solve"));
bindCloseButtons($("#modal-template"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
@@ -1526,6 +1901,8 @@ async function boot() {
if (state.devices.length < 2) { alert("Need at least two devices to add a requirement."); return; }
openRequirementModal(null);
});
$("#btn-solve").addEventListener("click", openSolveModal);
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
$("#project-select").addEventListener("change", (e) => {
const v = /** @type {HTMLSelectElement} */ (e.target).value;

View File

@@ -316,6 +316,76 @@ body {
pointer-events: none;
}
/* Cables on the canvas. Stroke colour comes from the cable_type;
solver-owned cables (auto=1) render with a slightly dashed pattern
so m can tell at a glance which the solver placed. */
.cable-line {
fill: none;
stroke-width: 2;
cursor: pointer;
}
.cable-line.auto { stroke-dasharray: 8 3; }
.cable-line:hover { stroke-width: 4; }
.cable-line.selected { stroke-width: 4; }
/* Solve preview-diff modal */
.modal-wide { width: 560px; }
.sv-body { font-size: 13px; }
.sv-body h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 12px 0 4px;
}
.sv-body ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sv-body li {
padding: 4px 8px;
border-radius: var(--radius);
background: var(--surface-2);
}
.sv-body li.added { border-left: 3px solid #2f9e44; }
.sv-body li.removed { border-left: 3px solid var(--danger); text-decoration: line-through; }
.sv-body li.unmet { border-left: 3px solid #f59f00; }
.sv-body li.unmet .quickfix {
display: inline-block;
margin-left: 8px;
font-size: 11px;
padding: 1px 6px;
background: var(--accent);
color: #fff;
border-radius: 10px;
cursor: pointer;
}
.tp-preview {
font-size: 13px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 8px 12px;
margin: 8px 0;
}
.tp-preview h4 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 6px 0 4px;
}
.tp-preview ul { list-style: none; padding: 0; margin: 0; }
.tp-preview li { padding: 2px 0; }
.tp-preview .skip {
margin-right: 6px;
font-size: 11px;
}
.rubber-band {
fill: rgba(25, 113, 194, 0.08);
stroke: var(--accent);