2026-03-17 23:47:59 +01:00
|
|
|
|
/**
|
2026-03-17 23:56:34 +01:00
|
|
|
|
* improvement-modal — Shift+click any dashboard widget to suggest an improvement.
|
2026-03-17 23:47:59 +01:00
|
|
|
|
*
|
|
|
|
|
|
* Usage (once per page, usually via _footer.md):
|
|
|
|
|
|
* import {initImprovementModal} from "./components/improvement-modal.js";
|
|
|
|
|
|
* initImprovementModal({apiBase: "http://127.0.0.1:8000"});
|
|
|
|
|
|
*
|
|
|
|
|
|
* Widget names can be declared explicitly via data attribute:
|
|
|
|
|
|
* <div data-widget-name="Workstreams by Domain">…</div>
|
|
|
|
|
|
*
|
|
|
|
|
|
* Otherwise the component walks the DOM to infer the nearest section heading.
|
|
|
|
|
|
* Submissions are stored as technical-debt items with debt_type="dashboard-improvement".
|
2026-03-17 23:56:34 +01:00
|
|
|
|
*
|
|
|
|
|
|
* Interaction:
|
|
|
|
|
|
* - Hold Shift → cursor changes to crosshair across the entire page
|
|
|
|
|
|
* - Shift+click any element (except form controls) → opens suggestion modal
|
2026-03-17 23:47:59 +01:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const _STYLE_ID = "improvement-modal-styles";
|
|
|
|
|
|
|
|
|
|
|
|
function _ensureStyles() {
|
|
|
|
|
|
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
|
|
|
|
|
const s = document.createElement("style");
|
|
|
|
|
|
s.id = _STYLE_ID;
|
|
|
|
|
|
s.textContent = `
|
|
|
|
|
|
/* ── Backdrop ──────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.impr-modal {
|
|
|
|
|
|
position: fixed; inset: 0; background: rgba(0,0,0,0.42);
|
|
|
|
|
|
z-index: 9200; display: flex; align-items: center; justify-content: center;
|
|
|
|
|
|
animation: _im-fade 0.15s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes _im-fade { from { opacity:0 } to { opacity:1 } }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Box ────────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.impr-modal-box {
|
|
|
|
|
|
width: min(480px, 92vw); max-height: 90vh;
|
|
|
|
|
|
background: var(--theme-background, #fff); border-radius: 12px;
|
|
|
|
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.30);
|
|
|
|
|
|
display: flex; flex-direction: column;
|
|
|
|
|
|
animation: _im-rise 0.15s ease; overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes _im-rise {
|
|
|
|
|
|
from { transform: translateY(12px); opacity: 0 }
|
|
|
|
|
|
to { transform: translateY(0); opacity: 1 }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Header ─────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.impr-header {
|
|
|
|
|
|
display: flex; align-items: center; gap: 0.55rem;
|
|
|
|
|
|
padding: 0.8rem 1rem 0.75rem;
|
|
|
|
|
|
border-bottom: 1px solid var(--theme-foreground-faint, #e4e4e4);
|
|
|
|
|
|
background: var(--theme-background-alt, #f7f7f7); flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-header-icon { font-size: 1.1rem; flex-shrink: 0; line-height: 1; }
|
|
|
|
|
|
.impr-header-title {
|
|
|
|
|
|
flex: 1; font-size: 0.95rem; font-weight: 700;
|
|
|
|
|
|
color: var(--theme-foreground, #111); margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-header-close {
|
|
|
|
|
|
background: none; border: 1px solid transparent; cursor: pointer;
|
|
|
|
|
|
font-size: 0.82rem; color: var(--theme-foreground-muted, #999);
|
|
|
|
|
|
padding: 0.15rem 0.42rem; border-radius: 6px; flex-shrink: 0;
|
|
|
|
|
|
font-family: inherit; line-height: 1.3;
|
|
|
|
|
|
transition: background 0.1s, border-color 0.1s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-header-close:hover {
|
|
|
|
|
|
border-color: var(--theme-foreground-faint, #ccc);
|
|
|
|
|
|
background: var(--theme-background, #fff);
|
|
|
|
|
|
color: var(--theme-foreground, #111);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Body ───────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.impr-body {
|
|
|
|
|
|
padding: 0.85rem 1rem 0.25rem; overflow-y: auto;
|
|
|
|
|
|
display: flex; flex-direction: column; gap: 0.6rem; flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-field-label {
|
|
|
|
|
|
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa);
|
|
|
|
|
|
margin-bottom: 0.18rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-context-chip {
|
|
|
|
|
|
font-size: 0.82rem; color: var(--theme-foreground-muted, #555);
|
|
|
|
|
|
background: var(--theme-background-alt, #f4f4f4);
|
|
|
|
|
|
border: 1px solid var(--theme-foreground-faint, #e0e0e0);
|
|
|
|
|
|
border-radius: 6px; padding: 0.32rem 0.65rem;
|
|
|
|
|
|
word-break: break-word; line-height: 1.45;
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-textarea {
|
|
|
|
|
|
width: 100%; box-sizing: border-box;
|
|
|
|
|
|
min-height: 106px; resize: vertical;
|
|
|
|
|
|
font-size: 0.87rem; font-family: inherit; line-height: 1.55;
|
|
|
|
|
|
color: var(--theme-foreground, #111);
|
|
|
|
|
|
background: var(--theme-background, #fff);
|
|
|
|
|
|
border: 1px solid var(--theme-foreground-faint, #ccc);
|
|
|
|
|
|
border-radius: 7px; padding: 0.5rem 0.7rem; outline: none;
|
|
|
|
|
|
transition: border-color 0.12s, box-shadow 0.12s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-textarea:focus {
|
|
|
|
|
|
border-color: #6366f1;
|
|
|
|
|
|
box-shadow: 0 0 0 2px rgba(99,102,241,0.18);
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-textarea.impr-error { border-color: #e53e3e; }
|
|
|
|
|
|
.impr-hint {
|
|
|
|
|
|
font-size: 0.71rem; color: var(--theme-foreground-faint, #bbb);
|
|
|
|
|
|
margin-top: 0.15rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Footer ─────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.impr-footer {
|
|
|
|
|
|
display: flex; justify-content: flex-end; gap: 0.45rem;
|
|
|
|
|
|
padding: 0.7rem 1rem 0.8rem; flex-shrink: 0;
|
|
|
|
|
|
border-top: 1px solid var(--theme-foreground-faint, #e4e4e4);
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-btn {
|
|
|
|
|
|
padding: 0.38rem 1rem; border-radius: 7px;
|
|
|
|
|
|
font-size: 0.83rem; cursor: pointer; font-family: inherit;
|
|
|
|
|
|
font-weight: 600; border: 1px solid transparent;
|
|
|
|
|
|
transition: background 0.12s, opacity 0.12s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-btn-cancel {
|
|
|
|
|
|
background: var(--theme-background-alt, #f1f1f1);
|
|
|
|
|
|
border-color: var(--theme-foreground-faint, #ddd);
|
|
|
|
|
|
color: var(--theme-foreground-muted, #666);
|
|
|
|
|
|
}
|
|
|
|
|
|
.impr-btn-cancel:hover { background: var(--theme-foreground-faint, #e6e6e6); }
|
|
|
|
|
|
.impr-btn-submit { background: #6366f1; color: #fff; }
|
|
|
|
|
|
.impr-btn-submit:hover:not(:disabled) { background: #4f46e5; }
|
|
|
|
|
|
.impr-btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Toast ──────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.impr-toast {
|
|
|
|
|
|
position: fixed; bottom: 1.4rem; left: 50%; transform: translateX(-50%);
|
|
|
|
|
|
background: #1e1b4b; color: #e0e7ff; border-radius: 8px;
|
|
|
|
|
|
padding: 0.5rem 1.15rem; font-size: 0.82rem; font-weight: 500;
|
|
|
|
|
|
z-index: 9300; box-shadow: 0 4px 24px rgba(0,0,0,0.28);
|
|
|
|
|
|
white-space: nowrap; pointer-events: none;
|
|
|
|
|
|
animation: _im-tin 0.18s ease, _im-tout 0.28s ease 1.7s forwards;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes _im-tin { from { opacity:0; transform:translateX(-50%) translateY(6px) } to { opacity:1; transform:translateX(-50%) translateY(0) } }
|
|
|
|
|
|
@keyframes _im-tout { from { opacity:1 } to { opacity:0 } }
|
|
|
|
|
|
|
2026-03-18 00:18:56 +01:00
|
|
|
|
/* ── Shift-held mode: cursor + element highlighting ─────────────────────── */
|
|
|
|
|
|
.impr-mode-shift,
|
|
|
|
|
|
.impr-mode-shift * { cursor: copy !important; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Highlight "widget" elements so the user sees what can be annotated */
|
|
|
|
|
|
.impr-mode-shift #observablehq-main figure,
|
|
|
|
|
|
.impr-mode-shift #observablehq-main h2,
|
|
|
|
|
|
.impr-mode-shift #observablehq-main h3,
|
|
|
|
|
|
.impr-mode-shift #observablehq-main h4,
|
|
|
|
|
|
.impr-mode-shift #observablehq-main [data-widget-name] {
|
|
|
|
|
|
outline: 1px dashed rgba(99, 102, 241, 0.45);
|
|
|
|
|
|
background: rgba(99, 102, 241, 0.055) !important;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
transition: background 0.1s, outline 0.1s;
|
|
|
|
|
|
}
|
2026-03-17 23:47:59 +01:00
|
|
|
|
`;
|
|
|
|
|
|
document.head.append(s);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Widget name inference ─────────────────────────────────────────────── */
|
|
|
|
|
|
function _inferWidgetName(target) {
|
|
|
|
|
|
// 1. Explicit data-widget-name on self or ancestor
|
|
|
|
|
|
let el = target;
|
|
|
|
|
|
while (el && el !== document.body) {
|
|
|
|
|
|
if (el.dataset?.widgetName) return el.dataset.widgetName;
|
|
|
|
|
|
el = el.parentElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Direct child heading inside a container (chart cards etc.)
|
|
|
|
|
|
el = target;
|
|
|
|
|
|
const main = document.querySelector("#observablehq-main") ?? document.body;
|
|
|
|
|
|
while (el && el !== main) {
|
|
|
|
|
|
const h = el.querySelector(":scope > h2, :scope > h3, :scope > h4");
|
|
|
|
|
|
if (h) return h.textContent.trim();
|
|
|
|
|
|
el = el.parentElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Nearest preceding sibling or ancestor heading in the main flow
|
|
|
|
|
|
el = target;
|
|
|
|
|
|
while (el && el !== main) {
|
|
|
|
|
|
let sib = el.previousElementSibling;
|
|
|
|
|
|
while (sib) {
|
|
|
|
|
|
if (sib.matches("h2, h3, h4")) return sib.textContent.trim();
|
|
|
|
|
|
const inner = sib.querySelector("h2, h3, h4");
|
|
|
|
|
|
if (inner) return inner.textContent.trim();
|
|
|
|
|
|
sib = sib.previousElementSibling;
|
|
|
|
|
|
}
|
|
|
|
|
|
el = el.parentElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. Page h1 as final fallback
|
|
|
|
|
|
return document.querySelector("#observablehq-main h1, h1")?.textContent?.trim()
|
|
|
|
|
|
?? "Dashboard page";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Toast helper ──────────────────────────────────────────────────────── */
|
|
|
|
|
|
function _toast(msg) {
|
|
|
|
|
|
document.querySelector(".impr-toast")?.remove();
|
|
|
|
|
|
const t = document.createElement("div");
|
|
|
|
|
|
t.className = "impr-toast";
|
|
|
|
|
|
t.textContent = msg;
|
|
|
|
|
|
document.body.append(t);
|
|
|
|
|
|
setTimeout(() => t.remove(), 2100);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Module-level guard — one listener per page load ──────────────────── */
|
|
|
|
|
|
let _initialized = false;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-18 00:18:56 +01:00
|
|
|
|
* Wire Shift+click → improvement modal on the current page.
|
2026-03-17 23:47:59 +01:00
|
|
|
|
* Safe to call multiple times — only the first call takes effect.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {object} opts
|
|
|
|
|
|
* @param {string} opts.apiBase State Hub API base URL (default: "http://127.0.0.1:8000")
|
|
|
|
|
|
* @param {string} opts.domain Domain slug for the TD record (default: "custodian")
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain = "custodian" } = {}) {
|
|
|
|
|
|
if (_initialized) return;
|
|
|
|
|
|
_initialized = true;
|
|
|
|
|
|
_ensureStyles();
|
|
|
|
|
|
|
2026-03-18 00:18:56 +01:00
|
|
|
|
// Track modifier state via keydown, keyup, AND mousemove so mode
|
|
|
|
|
|
// stays in sync even when focus changes between elements.
|
|
|
|
|
|
function _updateMode(e) {
|
|
|
|
|
|
if (e.shiftKey) {
|
|
|
|
|
|
document.body.classList.add("impr-mode-shift");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document.body.classList.remove("impr-mode-shift");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener("keydown", _updateMode);
|
|
|
|
|
|
window.addEventListener("keyup", _updateMode);
|
|
|
|
|
|
window.addEventListener("mousemove", _updateMode);
|
|
|
|
|
|
// Clear on blur in case Shift is held when the window loses focus
|
|
|
|
|
|
window.addEventListener("blur", () => document.body.classList.remove("impr-mode-shift"));
|
2026-03-17 23:56:34 +01:00
|
|
|
|
|
|
|
|
|
|
document.addEventListener("click", (e) => {
|
|
|
|
|
|
if (!e.shiftKey) return;
|
|
|
|
|
|
// Don't intercept shift-clicks on form controls or links
|
2026-03-17 23:47:59 +01:00
|
|
|
|
if (e.target.matches("input, textarea, select, a, button")) return;
|
|
|
|
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const widgetName = _inferWidgetName(e.target);
|
|
|
|
|
|
const pageName = document.title
|
|
|
|
|
|
? document.title.replace(" – Custodian State Hub", "").trim()
|
|
|
|
|
|
: (location.pathname.replace(/^\//, "") || "Overview");
|
|
|
|
|
|
|
|
|
|
|
|
// Remove any open modal
|
|
|
|
|
|
document.getElementById("_impr-root")?.remove();
|
|
|
|
|
|
|
|
|
|
|
|
/* ── DOM construction ────────────────────────────────────────────── */
|
|
|
|
|
|
const root = document.createElement("div");
|
|
|
|
|
|
root.id = "_impr-root";
|
|
|
|
|
|
root.className = "impr-modal";
|
|
|
|
|
|
root.setAttribute("role", "dialog");
|
|
|
|
|
|
root.setAttribute("aria-modal", "true");
|
|
|
|
|
|
root.setAttribute("aria-label", "Request Improvement");
|
|
|
|
|
|
|
|
|
|
|
|
const box = document.createElement("div");
|
|
|
|
|
|
box.className = "impr-modal-box";
|
|
|
|
|
|
|
|
|
|
|
|
// Header
|
|
|
|
|
|
const header = document.createElement("div");
|
|
|
|
|
|
header.className = "impr-header";
|
|
|
|
|
|
const icon = Object.assign(document.createElement("span"), { className: "impr-header-icon", textContent: "💡" });
|
|
|
|
|
|
const title = Object.assign(document.createElement("div"), { className: "impr-header-title", textContent: "Request Improvement" });
|
|
|
|
|
|
const closeBtn = Object.assign(document.createElement("button"), { className: "impr-header-close", textContent: "✕ close" });
|
|
|
|
|
|
header.append(icon, title, closeBtn);
|
|
|
|
|
|
|
|
|
|
|
|
// Body
|
|
|
|
|
|
const body = document.createElement("div");
|
|
|
|
|
|
body.className = "impr-body";
|
|
|
|
|
|
|
|
|
|
|
|
const ctxLabel = Object.assign(document.createElement("div"), { className: "impr-field-label", textContent: "Widget / Section" });
|
|
|
|
|
|
const chip = Object.assign(document.createElement("div"), {
|
|
|
|
|
|
className: "impr-context-chip",
|
|
|
|
|
|
textContent: `${pageName} › ${widgetName}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const sugLabel = Object.assign(document.createElement("div"), { className: "impr-field-label", textContent: "Your suggestion" });
|
|
|
|
|
|
const textarea = document.createElement("textarea");
|
|
|
|
|
|
textarea.className = "impr-textarea";
|
|
|
|
|
|
textarea.placeholder = "Describe what you'd like to improve or change…";
|
|
|
|
|
|
textarea.rows = 5;
|
|
|
|
|
|
|
|
|
|
|
|
const hint = Object.assign(document.createElement("div"), {
|
|
|
|
|
|
className: "impr-hint",
|
2026-03-17 23:56:34 +01:00
|
|
|
|
textContent: "Ctrl + Enter to submit · Escape to cancel",
|
2026-03-17 23:47:59 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
body.append(ctxLabel, chip, sugLabel, textarea, hint);
|
|
|
|
|
|
|
|
|
|
|
|
// Footer
|
|
|
|
|
|
const footer = document.createElement("div");
|
|
|
|
|
|
footer.className = "impr-footer";
|
|
|
|
|
|
const cancelBtn = Object.assign(document.createElement("button"), { className: "impr-btn impr-btn-cancel", textContent: "Cancel" });
|
|
|
|
|
|
const submitBtn = Object.assign(document.createElement("button"), { className: "impr-btn impr-btn-submit", textContent: "Submit suggestion" });
|
|
|
|
|
|
footer.append(cancelBtn, submitBtn);
|
|
|
|
|
|
|
|
|
|
|
|
box.append(header, body, footer);
|
|
|
|
|
|
root.append(box);
|
|
|
|
|
|
document.body.append(root);
|
|
|
|
|
|
|
|
|
|
|
|
// Focus textarea after animation settles
|
|
|
|
|
|
setTimeout(() => textarea.focus(), 80);
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Close behaviour ─────────────────────────────────────────────── */
|
|
|
|
|
|
const close = () => {
|
|
|
|
|
|
root.remove();
|
|
|
|
|
|
document.removeEventListener("keydown", onKey);
|
|
|
|
|
|
};
|
|
|
|
|
|
closeBtn.addEventListener("click", close);
|
|
|
|
|
|
cancelBtn.addEventListener("click", close);
|
|
|
|
|
|
root.addEventListener("click", e => { if (e.target === root) close(); });
|
|
|
|
|
|
|
|
|
|
|
|
const onKey = e => {
|
|
|
|
|
|
if (e.key === "Escape") close();
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") submitBtn.click();
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Submit ──────────────────────────────────────────────────────── */
|
|
|
|
|
|
submitBtn.addEventListener("click", async () => {
|
|
|
|
|
|
const suggestion = textarea.value.trim();
|
|
|
|
|
|
if (!suggestion) {
|
|
|
|
|
|
textarea.classList.add("impr-error");
|
|
|
|
|
|
textarea.focus();
|
|
|
|
|
|
setTimeout(() => textarea.classList.remove("impr-error"), 1200);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
submitBtn.disabled = true;
|
|
|
|
|
|
submitBtn.textContent = "Submitting…";
|
|
|
|
|
|
|
|
|
|
|
|
const location = `${pageName} › ${widgetName}`;
|
|
|
|
|
|
const payload = {
|
2026-03-18 00:36:46 +01:00
|
|
|
|
domain: domain,
|
2026-03-17 23:47:59 +01:00
|
|
|
|
title: `UI: ${widgetName}`,
|
|
|
|
|
|
description: suggestion,
|
|
|
|
|
|
debt_type: "dashboard-improvement",
|
|
|
|
|
|
severity: "low",
|
|
|
|
|
|
location,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await fetch(`${apiBase}/technical-debt/`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
|
close();
|
|
|
|
|
|
_toast("✓ Suggestion saved — check UI Feedback in the nav");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
submitBtn.disabled = false;
|
|
|
|
|
|
submitBtn.textContent = "Submit suggestion";
|
|
|
|
|
|
_toast(`⚠ Submission failed (HTTP ${r.status})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
submitBtn.disabled = false;
|
|
|
|
|
|
submitBtn.textContent = "Submit suggestion";
|
|
|
|
|
|
_toast("⚠ API unreachable — submission failed");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|