diff --git a/web/static/index.html b/web/static/index.html
new file mode 100644
index 0000000..dabaaed
--- /dev/null
+++ b/web/static/index.html
@@ -0,0 +1,135 @@
+
+
+
+
+
+ mCables
+
+
+
+
+
+
+
+
+
+
+
+ Pick or create a project to start drawing.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/static/main.js b/web/static/main.js
new file mode 100644
index 0000000..c967036
--- /dev/null
+++ b/web/static/main.js
@@ -0,0 +1,335 @@
+// mCables frontend entry — vanilla ES module, no build step.
+//
+// Slice 1 covers: list/create/delete projects, list/create/edit/delete
+// global cable types, and reflect the active project in ?project=.
+
+/**
+ * @typedef {{ id: number, name: string, drawing_name: string,
+ * description: string, created_at: string, updated_at: string }} Project
+ * @typedef {{ id: number, name: string, color: string,
+ * created_at: string, updated_at: string }} CableType
+ */
+
+const API = "/api";
+
+const state = {
+ /** @type {Project[]} */ projects: [],
+ /** @type {CableType[]} */ cableTypes: [],
+ /** @type {Project | null} */ active: null,
+ /** active cable-type id (used for drawing in later slices) */
+ activeTypeId: null,
+};
+
+// ---------- API client ---------- //
+
+async function api(method, path, body) {
+ const res = await fetch(API + path, {
+ method,
+ headers: body ? { "Content-Type": "application/json" } : undefined,
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ if (res.status === 204) return null;
+ const text = await res.text();
+ const json = text ? JSON.parse(text) : null;
+ if (!res.ok) {
+ const err = new Error(json?.error || res.statusText);
+ err.status = res.status;
+ err.details = json?.details;
+ throw err;
+ }
+ return json;
+}
+
+const listProjects = () => api("GET", "/projects");
+const createProject = (body) => api("POST", "/projects", body);
+const patchProject = (id, body) => api("PATCH", `/projects/${id}`, body);
+const deleteProject = (id, confirm) =>
+ api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
+const getSnapshot = (id) => api("GET", `/projects/${id}`);
+
+const listCableTypes = () => api("GET", "/cable-types");
+const createCableType = (body) => api("POST", "/cable-types", body);
+const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
+const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
+
+// ---------- DOM helpers ---------- //
+
+const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
+
+function setHidden(el, hidden) {
+ if (hidden) el.setAttribute("hidden", "");
+ else el.removeAttribute("hidden");
+}
+
+// ---------- URL state ---------- //
+
+function activeProjectIdFromURL() {
+ const raw = new URLSearchParams(location.search).get("project");
+ const id = raw && Number.parseInt(raw, 10);
+ return Number.isFinite(id) && id > 0 ? id : null;
+}
+
+function setActiveInURL(id) {
+ const url = new URL(location.href);
+ if (id == null) url.searchParams.delete("project");
+ else url.searchParams.set("project", String(id));
+ history.replaceState(null, "", url.toString());
+}
+
+// ---------- render ---------- //
+
+function renderProjectPicker() {
+ const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
+ const current = state.active?.id ?? "";
+ sel.innerHTML = "";
+ const blank = new Option("— pick a project —", "");
+ sel.append(blank);
+ for (const p of state.projects) {
+ const opt = new Option(p.name, String(p.id));
+ if (p.id === current) opt.selected = true;
+ sel.append(opt);
+ }
+ setHidden($("#btn-delete-project"), !state.active);
+}
+
+function renderLegend() {
+ const ul = $("#legend-list");
+ ul.innerHTML = "";
+ for (const t of state.cableTypes) {
+ const li = document.createElement("li");
+ li.className = "legend-row";
+ li.dataset.id = String(t.id);
+ if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true");
+ li.innerHTML = `
+
+
+
+ `;
+ li.querySelector(".legend-name").textContent = t.name;
+ li.addEventListener("click", (e) => {
+ if (e.target instanceof HTMLElement && e.target.classList.contains("legend-edit")) {
+ openCableTypeModal(t);
+ e.stopPropagation();
+ return;
+ }
+ state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
+ renderLegend();
+ });
+ ul.append(li);
+ }
+}
+
+function renderEmptyHint() {
+ const hint = $("#empty-hint");
+ if (!state.active) {
+ hint.textContent = state.projects.length
+ ? "Pick a project from the dropdown to start drawing."
+ : "Create your first project to get started.";
+ setHidden(hint, false);
+ } else {
+ hint.textContent = `${state.active.name} — slice 1: empty canvas. Frames + devices arrive in slice 2.`;
+ setHidden(hint, false);
+ }
+}
+
+function render() {
+ renderProjectPicker();
+ renderLegend();
+ renderEmptyHint();
+}
+
+// ---------- active project ---------- //
+
+async function activateProject(id) {
+ if (id == null) {
+ state.active = null;
+ setActiveInURL(null);
+ render();
+ return;
+ }
+ try {
+ const snap = await getSnapshot(id);
+ state.active = snap.project;
+ // The snapshot also returns the global cable types — refresh from
+ // the source of truth so a stale state.cableTypes can never linger.
+ state.cableTypes = snap.cable_types || [];
+ setActiveInURL(id);
+ render();
+ } catch (err) {
+ if (err.status === 404) {
+ // The id in the URL points to a deleted project — clear it.
+ state.active = null;
+ setActiveInURL(null);
+ render();
+ } else {
+ alert(`Failed to load project: ${err.message}`);
+ }
+ }
+}
+
+// ---------- modals ---------- //
+
+function bindCloseButtons(dialog) {
+ dialog.querySelectorAll("[data-close]").forEach((btn) =>
+ btn.addEventListener("click", () => dialog.close()),
+ );
+}
+
+function showError(el, msg) {
+ if (!msg) { setHidden(el, true); el.textContent = ""; return; }
+ el.textContent = msg;
+ setHidden(el, false);
+}
+
+function openNewProjectModal() {
+ const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
+ const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
+ const err = $("#np-error");
+ form.reset();
+ showError(err, "");
+ dlg.showModal();
+ form.elements.namedItem("name").focus();
+
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ const fd = new FormData(form);
+ const body = {
+ name: String(fd.get("name") || "").trim(),
+ drawing_name: String(fd.get("drawing_name") || "").trim(),
+ description: String(fd.get("description") || ""),
+ };
+ if (!body.drawing_name) delete body.drawing_name;
+ try {
+ const p = await createProject(body);
+ state.projects = await listProjects();
+ dlg.close();
+ await activateProject(p.id);
+ } catch (e) {
+ showError(err, e.message || "Failed to create project");
+ }
+ };
+}
+
+function openCableTypeModal(existing) {
+ const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
+ const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
+ const err = $("#ct-error");
+ const title = $("#ct-title");
+ form.reset();
+ showError(err, "");
+ title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
+
+ if (existing) {
+ form.elements.namedItem("name").value = existing.name;
+ form.elements.namedItem("color").value = existing.color;
+ } else {
+ form.elements.namedItem("color").value = "#1971c2";
+ }
+
+ // Slot in a Delete button when editing an existing type.
+ const actions = form.querySelector(".actions");
+ actions.querySelector(".btn-delete-type")?.remove();
+ if (existing) {
+ const del = document.createElement("button");
+ del.type = "button";
+ del.className = "btn btn-danger btn-delete-type";
+ del.style.marginRight = "auto";
+ del.textContent = "Delete";
+ del.addEventListener("click", async () => {
+ try {
+ await deleteCableType(existing.id);
+ state.cableTypes = await listCableTypes();
+ if (state.activeTypeId === existing.id) state.activeTypeId = null;
+ dlg.close();
+ render();
+ } catch (e) {
+ const n = e.details?.in_use_by_cables;
+ showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
+ }
+ });
+ actions.prepend(del);
+ }
+
+ dlg.showModal();
+ form.elements.namedItem("name").focus();
+
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ const fd = new FormData(form);
+ const body = {
+ name: String(fd.get("name") || "").trim(),
+ color: String(fd.get("color") || "").trim(),
+ };
+ try {
+ if (existing) await patchCableType(existing.id, body);
+ else await createCableType(body);
+ state.cableTypes = await listCableTypes();
+ dlg.close();
+ render();
+ } catch (e) {
+ showError(err, e.message || "Save failed");
+ }
+ };
+}
+
+function openDeleteProjectModal() {
+ if (!state.active) return;
+ const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
+ const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
+ const err = $("#dp-error");
+ const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
+ form.reset();
+ showError(err, "");
+ input.placeholder = state.active.name;
+ dlg.showModal();
+ input.focus();
+
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ const confirm = String(new FormData(form).get("confirm") || "");
+ try {
+ await deleteProject(state.active.id, confirm);
+ state.projects = await listProjects();
+ dlg.close();
+ await activateProject(null);
+ } catch (e) {
+ showError(err, e.message || "Delete failed");
+ }
+ };
+}
+
+// ---------- boot ---------- //
+
+async function boot() {
+ bindCloseButtons($("#modal-new-project"));
+ bindCloseButtons($("#modal-cable-type"));
+ bindCloseButtons($("#modal-delete-project"));
+
+ $("#btn-new-project").addEventListener("click", openNewProjectModal);
+ $("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
+ $("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
+
+ $("#project-select").addEventListener("change", (e) => {
+ const v = /** @type {HTMLSelectElement} */ (e.target).value;
+ activateProject(v ? Number(v) : null);
+ });
+
+ try {
+ [state.projects, state.cableTypes] = await Promise.all([
+ listProjects(),
+ listCableTypes(),
+ ]);
+ } catch (e) {
+ alert(`Failed to load: ${e.message}`);
+ return;
+ }
+
+ const wanted = activeProjectIdFromURL();
+ if (wanted && state.projects.some((p) => p.id === wanted)) {
+ await activateProject(wanted);
+ } else {
+ render();
+ }
+}
+
+boot();
diff --git a/web/static/style.css b/web/static/style.css
new file mode 100644
index 0000000..90586a4
--- /dev/null
+++ b/web/static/style.css
@@ -0,0 +1,248 @@
+:root {
+ --bg: #fafafa;
+ --surface: #ffffff;
+ --surface-2: #f4f4f5;
+ --border: #d4d4d8;
+ --text: #18181b;
+ --text-muted: #71717a;
+ --accent: #1971c2;
+ --danger: #e03131;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
+ --radius: 4px;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+/* ---------- topbar ---------- */
+
+.topbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 16px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+}
+
+.brand {
+ font-weight: 600;
+ font-size: 15px;
+}
+
+.project-picker {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.topbar-spacer { flex: 1; }
+
+/* ---------- layout ---------- */
+
+.layout {
+ display: grid;
+ grid-template-columns: 220px 1fr 280px;
+ flex: 1;
+ min-height: 0;
+}
+
+.sidebar,
+.inspector {
+ background: var(--surface);
+ padding: 12px;
+ overflow-y: auto;
+}
+
+.sidebar { border-right: 1px solid var(--border); }
+.inspector { border-left: 1px solid var(--border); }
+
+.sidebar-heading {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ margin: 0 0 8px 0;
+}
+
+.tool-list,
+.legend-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 8px 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.legend-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 6px;
+ border-radius: var(--radius);
+ cursor: pointer;
+}
+.legend-row:hover { background: var(--surface-2); }
+.legend-row[aria-current="true"] {
+ background: var(--surface-2);
+ outline: 1px solid var(--accent);
+}
+.legend-swatch {
+ width: 14px;
+ height: 14px;
+ border-radius: 3px;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ flex-shrink: 0;
+}
+.legend-name { flex: 1; }
+.legend-edit {
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ color: var(--text-muted);
+ padding: 2px 4px;
+ border-radius: 2px;
+ font-size: 12px;
+}
+.legend-edit:hover { color: var(--text); background: var(--surface-2); }
+
+/* ---------- canvas ---------- */
+
+.canvas-wrap {
+ position: relative;
+ overflow: hidden;
+ background: #f7f7f7;
+ background-image:
+ linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px);
+ background-size: 50px 50px;
+}
+
+#canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.empty-hint {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: var(--text-muted);
+ font-size: 14px;
+ pointer-events: none;
+ background: rgba(255, 255, 255, 0.85);
+ padding: 8px 14px;
+ border-radius: var(--radius);
+}
+
+.muted { color: var(--text-muted); }
+
+/* ---------- buttons ---------- */
+
+.btn {
+ font: inherit;
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 4px 10px;
+ border-radius: var(--radius);
+ cursor: pointer;
+ box-shadow: var(--shadow);
+}
+.btn:hover { background: var(--surface-2); }
+.btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
+
+.btn-tiny { padding: 2px 8px; font-size: 12px; }
+.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
+.btn-primary:hover { background: #155da3; }
+.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
+.btn-danger:hover { background: #b02828; }
+
+/* ---------- dialog ---------- */
+
+.modal {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0;
+ width: 380px;
+ max-width: calc(100vw - 32px);
+ background: var(--surface);
+ box-shadow: 0 10px 30px rgba(0,0,0,0.18);
+}
+.modal::backdrop { background: rgba(0,0,0,0.3); }
+.modal form { padding: 16px; }
+.modal h2 { margin: 0 0 12px 0; font-size: 16px; }
+.modal .banner {
+ background: #fff8e1;
+ border: 1px solid #f5d76e;
+ color: #5b4500;
+ padding: 8px 10px;
+ border-radius: var(--radius);
+ font-size: 13px;
+ margin: 0 0 12px 0;
+}
+.modal .actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 12px;
+}
+.modal .form-error {
+ color: var(--danger);
+ font-size: 13px;
+ margin: 6px 0 0 0;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin: 0 0 10px 0;
+}
+.field span {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.field input,
+.field textarea {
+ font: inherit;
+ padding: 6px 8px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: #fff;
+ width: 100%;
+}
+.field input:focus,
+.field textarea:focus {
+ outline: 2px solid var(--accent);
+ outline-offset: -1px;
+ border-color: var(--accent);
+}
diff --git a/web/web.go b/web/web.go
new file mode 100644
index 0000000..053c5b1
--- /dev/null
+++ b/web/web.go
@@ -0,0 +1,23 @@
+// Package web bundles the frontend (HTML/JS/CSS) into the Go binary
+// via embed.FS so deploying mCables means shipping one file.
+package web
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed all:static
+var assets embed.FS
+
+// Static returns the frontend filesystem rooted at the package's static/
+// dir so callers see index.html at "/".
+func Static() fs.FS {
+ sub, err := fs.Sub(assets, "static")
+ if err != nil {
+ // embed sub-rooting can only fail if "static" doesn't exist,
+ // which is a build-time error. Panic is the right shape.
+ panic(err)
+ }
+ return sub
+}