Files
the-custodian/state-hub/dashboard/src/components/improvement-modal.js
tegwick 3d781246a5 feat(dashboard): extend suggestions to TOC right margin + 1s shift delay
- Shift+click now works on #observablehq-toc links, KPI boxes, and [id] elements
- _inferWidgetName detects TOC context and labels suggestions accordingly
- Click handler adds inToc branch alongside existing inSidebar
- _updateMode: 1-second setTimeout before activating highlight mode
  so normal Shift+typing doesn't flicker the UI; clears immediately on
  Shift release or window blur

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

413 lines
17 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 annotatable elements in main content, left nav, and right TOC */
.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],
.impr-mode-shift #observablehq-sidebar a,
.impr-mode-shift #observablehq-sidebar summary,
.impr-mode-shift #observablehq-toc a,
.impr-mode-shift #observablehq-toc .kpi-infobox,
.impr-mode-shift #observablehq-toc [id] {
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) {
// 0a. Right-margin TOC: link text, KPI box title, or nearest labelled container
if (target.closest("#observablehq-toc")) {
const link = target.closest("a");
if (link) return (link.textContent.trim() || "TOC link") + " (TOC)";
const kpiTitle = target.closest(".kpi-infobox")
?.querySelector(".kpi-infobox-title");
if (kpiTitle) return kpiTitle.textContent.trim() + " (sidebar widget)";
const labelled = target.closest("[id]");
if (labelled) return labelled.id.replace(/-/g, " ") + " (TOC)";
return "Right margin";
}
// 0b. Left sidebar navigation: nav link text or section heading
if (target.closest("#observablehq-sidebar")) {
const link = target.closest("a");
if (link) return link.textContent.trim() || "Nav link";
const summary = target.closest("summary");
if (summary) return summary.textContent.trim() || "Nav section";
return target.textContent.trim() || "Navigation";
}
// 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. Highlighting is delayed 1 s so normal Shift+typing
// doesn't trigger the visual mode change; releasing Shift cancels immediately.
let _shiftTimer = null;
function _updateMode(e) {
if (e.shiftKey) {
if (!_shiftTimer && !document.body.classList.contains("impr-mode-shift")) {
_shiftTimer = setTimeout(() => {
document.body.classList.add("impr-mode-shift");
_shiftTimer = null;
}, 1000);
}
} else {
clearTimeout(_shiftTimer);
_shiftTimer = null;
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", () => {
clearTimeout(_shiftTimer);
_shiftTimer = null;
document.body.classList.remove("impr-mode-shift");
});
document.addEventListener("click", (e) => {
if (!e.shiftKey) return;
const inSidebar = !!e.target.closest("#observablehq-sidebar");
const inToc = !!e.target.closest("#observablehq-toc");
// Block shift-clicks on form controls; allow nav/toc links (preventDefault stops navigation)
if (!inSidebar && !inToc && e.target.matches("input, textarea, select, a, button")) return;
if ((inSidebar || inToc) && e.target.matches("input, textarea, select")) return;
e.preventDefault();
const widgetName = _inferWidgetName(e.target);
const currentPage = document.title
? document.title.replace(" Custodian State Hub", "").trim()
: (location.pathname.replace(/^\//, "") || "Overview");
const pageName = inSidebar ? "Navigation" : currentPage;
// 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",
status: "submitted",
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");
}
});
});
}