/** * improvement-modal — Shift+click any dashboard widget to suggest an improvement. * * 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: *
* * Otherwise the component walks the DOM to infer the nearest section heading. * Submissions are stored as technical-debt items with debt_type="dashboard-improvement". * * Interaction: * - Hold Shift → cursor changes to crosshair across the entire page * - Shift+click any element (except form controls) → opens suggestion modal */ 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 } } /* ── 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; } `; 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; /** * Wire Shift+click → improvement modal on the current page. * 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(); // 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")); document.addEventListener("click", (e) => { if (!e.shiftKey) return; // Don't intercept shift-clicks on form controls or links 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", textContent: "Ctrl + Enter to submit · Escape to cancel", }); 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 = { domain: domain, 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"); } }); }); }