Files
state-hub/dashboard/src/components/improvement-modal.js
tegwick 6cd9f75d7e fix(dashboard): domain field name in TD payload; rename Improvements → Suggestions
- improvement-modal.js: API expects `domain` not `domain_slug` (422 fix)
- todo.md: section heading and KPI label renamed to "Suggestions"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:36:46 +01:00

370 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:
* <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".
*
* 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");
}
});
});
}