diff --git a/web/static/index.html b/web/static/index.html
index 3ab32ff..db62865 100644
--- a/web/static/index.html
+++ b/web/static/index.html
@@ -22,9 +22,8 @@
-
+
+
diff --git a/web/static/main.js b/web/static/main.js
index fbd4764..353c796 100644
--- a/web/static/main.js
+++ b/web/static/main.js
@@ -128,6 +128,7 @@ const solveProject = (pid, preview) => api("POST", `/projects/${pid}/solve${pre
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);
+const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {});
// ---------- DOM helpers ---------- //
@@ -2112,6 +2113,40 @@ function renderTemplatePreview(preview, templateIDStr) {
`;
}
+// ---------- export flow ---------- //
+
+let toastTimer = null;
+
+function showToast(kind, html, holdMs = 5000) {
+ const t = $("#toast");
+ t.className = "toast " + (kind || "");
+ t.innerHTML = html;
+ setHidden(t, false);
+ if (toastTimer) clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => { setHidden(t, true); t.innerHTML = ""; }, holdMs);
+}
+
+async function exportCurrentProject() {
+ if (!state.active) { alert("Pick a project first"); return; }
+ const btn = $("#btn-export");
+ btn.disabled = true;
+ showToast("", "Exporting…", 30000);
+ try {
+ const res = await syncExport(state.active.id);
+ const url = res.url ?? "(no url)";
+ const count = res.element_count ?? 0;
+ showToast("ok",
+ `Exported ${count} elements → ${escapeHtml(url)}`,
+ 8000);
+ } catch (e) {
+ // Surface mxdrw unreachability or the upstream error verbatim.
+ const detail = typeof e.details === "object" ? JSON.stringify(e.details) : (e.details ?? "");
+ showToast("error", `Export failed: ${escapeHtml(e.message)}${detail ? ` (${escapeHtml(String(detail))})` : ""}`, 12000);
+ } finally {
+ btn.disabled = false;
+ }
+}
+
// ---------- boot ---------- //
async function boot() {
@@ -2133,6 +2168,7 @@ async function boot() {
});
$("#btn-solve").addEventListener("click", openSolveModal);
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
+ $("#btn-export").addEventListener("click", exportCurrentProject);
$("#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 54c7501..85af292 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -236,6 +236,24 @@ body {
filter: drop-shadow(0 0 4px var(--accent));
}
+/* Header toast — slice 8 export feedback */
+.toast {
+ display: inline-block;
+ margin-left: 12px;
+ font-size: 13px;
+ padding: 4px 10px;
+ border-radius: var(--radius);
+ background: var(--surface-2);
+ color: var(--text);
+ max-width: 420px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.toast.ok { background: #e8f5e9; color: #1b5e20; }
+.toast.error { background: #fdecea; color: #911313; }
+.toast a { color: inherit; text-decoration: underline; }
+
/* IO markers — diamonds. Power-by-convention, so the default fill is
the Power cable_type colour (#e03131). Rotated 45° rect is the
easiest way to draw a diamond that still hit-tests at the rotated