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``;
+
+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`
`;
+ 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`
+
+ `)}
`);
+}
+```
+
+