From c681b01aff64fa066d7918da340619ef1b05801d Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 16 May 2026 01:07:20 +0200 Subject: [PATCH] feat(ui): Solve flow + setup-templates apply + cable rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 port" → "+ Add port to 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. --- web/static/index.html | 33 +++- web/static/main.js | 379 +++++++++++++++++++++++++++++++++++++++++- web/static/style.css | 70 ++++++++ 3 files changed, 480 insertions(+), 2 deletions(-) diff --git a/web/static/index.html b/web/static/index.html index 6077886..d098a6c 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -20,7 +20,9 @@
- + + @@ -175,6 +177,35 @@ + + +
+

Solve preview

+
+
+ + +
+
+
+ + + +
+

Apply setup template

+ +
+ +
+ + +
+
+
+
diff --git a/web/static/main.js b/web/static/main.js index 208331a..f24387a 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -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 = `

Nothing selected.

`; } } +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 = ` +

Cable ${c.auto ? "(solver)" : "(manual)"}

+
+
type
+
from
+
to
+
driver
+
+
+ ${c.auto ? `` : ""} + +
+ `; + 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 = `

Computing…

`; + dlg.showModal(); + solveProject(state.active.id, true) + .then((preview) => renderSolvePreview(body, preview)) + .catch((e) => { body.innerHTML = `

${escapeHtml(e.message)}

`; }); + + $("#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 `
  • + ${escapeHtml(a)} ↔ ${escapeHtml(b)} · ${escapeHtml(cableTypeName.get(c.type_id) ?? "?")}
  • `; + }).join(""); + const remsHtml = (preview.cables_removed || []).map((id) => `
  • cable #${id}
  • `).join(""); + const bunsHtml = (preview.bundles_added || []).map((b) => `
  • bundle: ${escapeHtml(b.name)}
  • `).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 = `+ Add ${escapeHtml(u.cable_type)} port to ${escapeHtml(sideName)} and re-solve`; + } else if ((u.reason || "").startsWith("ambiguous") && r) { + action = `Specify cable type…`; + } 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 = `+ Add port to ${escapeHtml(sideName)} and re-solve`; + } + return `
  • ⚠️ ${reqDesc} · ${escapeHtml(u.reason)}${action}
  • `; + }).join(""); + + body.innerHTML = ` + ${addsHtml ? `

    Cables to add

      ${addsHtml}
    ` : ""} + ${remsHtml ? `

    Cables to remove

      ${remsHtml}
    ` : ""} + ${bunsHtml ? `

    Bundles to add

      ${bunsHtml}
    ` : ""} + ${unmetsHtml ? `

    Unsatisfied

      ${unmetsHtml}
    ` : ""} + ${(addsHtml || remsHtml || bunsHtml || unmetsHtml) ? "" : `

    No changes — already solved.

    `} + `; + + 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 ` +
  • + + + ${escapeHtml(dtName)} +
  • `; + }).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 `
  • ${escapeHtml(a?.suggested_name ?? "?")} ↔ ${escapeHtml(b?.suggested_name ?? "?")} · ${escapeHtml(ct ?? "?")}
  • `; + }).join(""); + preview.innerHTML = ` +

    ${escapeHtml(t.description)}

    +

    Devices

    +
      ${devsHtml}
    +

    Requirements

    +
      ${reqsHtml}
    + `; +} + // ---------- 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; diff --git a/web/static/style.css b/web/static/style.css index cac8b20..2b9661d 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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);