diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 1074879..566e45e 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -19,6 +19,7 @@ export default { { name: "Tasks", path: "/tasks" }, { name: "Interventions", path: "/interventions" }, { name: "Debt", path: "/techdept" }, + { name: "UI Feedback", path: "/ui-feedback" }, { name: "Extends", path: "/extensions" }, { name: "Dependencies", path: "/dependencies" }, ], diff --git a/dashboard/src/_footer.md b/dashboard/src/_footer.md new file mode 100644 index 0000000..a02a17c --- /dev/null +++ b/dashboard/src/_footer.md @@ -0,0 +1,6 @@ +```js +// Right-click improvement modal — initialised once, active on every page +import {initImprovementModal} from "./components/improvement-modal.js"; +import {API} from "./components/config.js"; +initImprovementModal({apiBase: API, domain: "custodian"}); +``` diff --git a/dashboard/src/components/improvement-modal.js b/dashboard/src/components/improvement-modal.js new file mode 100644 index 0000000..591e45e --- /dev/null +++ b/dashboard/src/components/improvement-modal.js @@ -0,0 +1,336 @@ +/** + * improvement-modal — right-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". + */ + +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 } } + +/* ── Right-click hint cursor on interactive elements ────────────────────── */ +.impr-hint-cursor { cursor: context-menu; } +`; + 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 right-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(); + + document.addEventListener("contextmenu", (e) => { + // Don't intercept native right-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", + }); + + 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_slug: 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"); + } + }); + }); +} diff --git a/dashboard/src/ui-feedback.md b/dashboard/src/ui-feedback.md new file mode 100644 index 0000000..ba9fe95 --- /dev/null +++ b/dashboard/src/ui-feedback.md @@ -0,0 +1,211 @@ +--- +title: UI Feedback +--- + +```js +import {API, POLL} from "./components/config.js"; +``` + +```js +const feedbackState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const r = await fetch(`${API}/technical-debt/?debt_type=dashboard-improvement`); + ok = r.ok; + if (ok) { + const items = await r.json(); + data = items + .filter(t => t.debt_type === "dashboard-improvement") + .sort((a, b) => { + const st = {open: 0, in_progress: 1, deferred: 2, resolved: 3, wont_fix: 4}; + return (st[a.status] ?? 9) - (st[b.status] ?? 9); + }); + } + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = feedbackState.data ?? []; +const _ok = feedbackState.ok ?? false; +const _ts = feedbackState.ts; +``` + +# UI Feedback + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +const _open = data.filter(t => t.status === "open" || t.status === "in_progress"); +const _resolved = data.filter(t => t.status === "resolved"); +const _wontFix = data.filter(t => t.status === "wont_fix"); + +const _kpiBox = html`
+
UI Feedback
+
+ open +
${_open.length}
+
+
+ resolved +
${_resolved.length}
+
+
+ won't fix +
${_wontFix.length}
+
+
+ total +
${data.length}
+
+
`; + +const _liveEl = html`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : html`Offline — run: make api`} +
`; +withDocHelp(_liveEl, "/docs/live-data"); + +injectTocTop("fb-kpi-box", _kpiBox); +injectTocTop("live-indicator", _liveEl); +``` + +> Right-click any widget or section on any dashboard page to submit a suggestion. +> Items appear here for review and can be resolved or dismissed. + +## Open Suggestions + +```js +async function _setStatus(td_id, status) { + try { + const r = await fetch(`${API}/technical-debt/${td_id}`, { + method: "PATCH", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({status}), + }); + if (!r.ok) alert(`Failed to update status (HTTP ${r.status})`); + } catch { + alert("API unreachable"); + } +} +``` + +```js +const _openItems = data.filter(t => t.status === "open" || t.status === "in_progress"); + +if (_openItems.length === 0) { + display(html`

No open suggestions. Right-click any dashboard widget to submit one.

`); +} else { + display(html`
${_openItems.map(t => { + const card = html`
+
+ ${t.td_id ? html`${t.td_id}` : ""} + ${t.status.replace("_", " ")} + ${t.location ?? ""} +
+ + + ${t.status === "open" ? html`` : ""} +
+
+
${t.description}
+
`; + return card; + })}
`); +} +``` + +## Resolved / Won't Fix + +```js +const _doneItems = data.filter(t => t.status === "resolved" || t.status === "wont_fix"); + +if (_doneItems.length === 0) { + display(html`

Nothing resolved yet.

`); +} else { + display(html`
${_doneItems.map(t => html` +
+
+ ${t.td_id ? html`${t.td_id}` : ""} + ${t.status.replace("_", " ")} + ${t.location ?? ""} +
+
${t.description}
+
+ `)}
`); +} +``` + +