feat(dashboard): right-click improvement modal + UI Feedback page

- improvement-modal.js: global contextmenu handler that opens a modal
  showing page/widget context; submits suggestions as TD items with
  debt_type="dashboard-improvement" to POST /technical-debt/
- _footer.md: shared Observable footer that auto-initialises the modal
  on every dashboard page
- ui-feedback.md: review/approval page — lists open suggestions with
  resolve / won't-fix / in-progress action buttons; archived items shown
  below; live-polled
- observablehq.config.js: "UI Feedback" added under Workstreams group

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 23:47:59 +01:00
parent fcf0515874
commit b558610de6
4 changed files with 554 additions and 0 deletions

View File

@@ -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" },
],

6
dashboard/src/_footer.md Normal file
View File

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

View File

@@ -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:
* <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".
*/
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");
}
});
});
}

View File

@@ -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`<div class="kpi-infobox">
<div class="kpi-infobox-title">UI Feedback</div>
<div class="kpi-row">
<span class="kpi-row-label">open</span>
<div class="kpi-row-right"><div class="kpi-row-value">${_open.length}</div></div>
</div>
<div class="kpi-row">
<span class="kpi-row-label">resolved</span>
<div class="kpi-row-right"><div class="kpi-row-value">${_resolved.length}</div></div>
</div>
<div class="kpi-row">
<span class="kpi-row-label">won't fix</span>
<div class="kpi-row-right"><div class="kpi-row-value">${_wontFix.length}</div></div>
</div>
<div class="kpi-row" style="border-top:1px solid var(--theme-foreground-faint,#eee)">
<span class="kpi-row-label">total</span>
<div class="kpi-row-right"><div class="kpi-row-value">${data.length}</div></div>
</div>
</div>`;
const _liveEl = html`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;
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`<p class="dim">No open suggestions. Right-click any dashboard widget to submit one.</p>`);
} else {
display(html`<div class="fb-list">${_openItems.map(t => {
const card = html`<div class="fb-card fb-status-${t.status}">
<div class="fb-card-header">
${t.td_id ? html`<span class="td-ref">${t.td_id}</span>` : ""}
<span class="td-badge td-badge-${t.status}">${t.status.replace("_", " ")}</span>
<span class="fb-location">${t.location ?? ""}</span>
<div class="fb-actions">
<button class="fb-btn fb-btn-resolve"
onclick=${async () => { await _setStatus(t.id, "resolved"); card.remove(); }}>
✓ resolve
</button>
<button class="fb-btn fb-btn-wontfix"
onclick=${async () => { await _setStatus(t.id, "wont_fix"); card.remove(); }}>
✕ won't fix
</button>
${t.status === "open" ? html`<button class="fb-btn fb-btn-inprogress"
onclick=${async () => { await _setStatus(t.id, "in_progress"); card.style.opacity = "0.7"; }}>
→ in progress
</button>` : ""}
</div>
</div>
<div class="fb-suggestion">${t.description}</div>
</div>`;
return card;
})}</div>`);
}
```
## Resolved / Won't Fix
```js
const _doneItems = data.filter(t => t.status === "resolved" || t.status === "wont_fix");
if (_doneItems.length === 0) {
display(html`<p class="dim">Nothing resolved yet.</p>`);
} else {
display(html`<div class="fb-list fb-done">${_doneItems.map(t => html`
<div class="fb-card fb-status-${t.status}">
<div class="fb-card-header">
${t.td_id ? html`<span class="td-ref">${t.td_id}</span>` : ""}
<span class="td-badge td-badge-${t.status}">${t.status.replace("_", " ")}</span>
<span class="fb-location">${t.location ?? ""}</span>
</div>
<div class="fb-suggestion">${t.description}</div>
</div>
`)}</div>`);
}
```
<style>
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
.kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; border-top: 1px solid var(--theme-foreground-faint, #eee); }
.kpi-row:first-of-type { border-top: none; }
.kpi-row-label { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); }
.kpi-row-right { text-align: right; }
.kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
.fb-list { display: flex; flex-direction: column; gap: 0.55rem; }
.fb-done { opacity: 0.7; }
.fb-card {
border-left: 3px solid #6366f1;
border-radius: 0 8px 8px 0;
background: var(--theme-background-alt);
padding: 0.7rem 1rem;
}
.fb-status-resolved { border-left-color: #16a34a; }
.fb-status-wont_fix { border-left-color: #94a3b8; }
.fb-status-in_progress { border-left-color: #d97706; }
.fb-card-header {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem;
margin-bottom: 0.45rem;
}
.fb-location {
font-size: 0.75rem; color: var(--theme-foreground-faint);
font-style: italic; flex: 1;
}
.fb-suggestion {
font-size: 0.88rem; line-height: 1.55;
color: var(--theme-foreground);
white-space: pre-wrap;
}
.fb-actions { display: flex; gap: 0.35rem; margin-left: auto; }
.fb-btn {
padding: 0.2rem 0.65rem; border-radius: 5px; font-size: 0.75rem;
font-family: inherit; cursor: pointer; font-weight: 600; border: 1px solid transparent;
transition: background 0.1s, opacity 0.1s;
}
.fb-btn-resolve { background: #dcfce7; color: #166534; border-color: #bbf7d0; }
.fb-btn-resolve:hover { background: #bbf7d0; }
.fb-btn-wontfix { background: #f1f5f9; color: #475569; border-color: #cbd5e1; }
.fb-btn-wontfix:hover { background: #e2e8f0; }
.fb-btn-inprogress { background: #fef3c7; color: #92400e; border-color: #fde68a; }
.fb-btn-inprogress:hover { background: #fde68a; }
/* reuse td-badge styles */
.td-ref { font-family: monospace; font-size: 0.72rem; color: var(--theme-foreground-muted); background: var(--theme-background); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 4px; padding: 0.05rem 0.35rem; }
.td-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
.td-badge-open { background: #dbeafe; color: #1e40af; }
.td-badge-in_progress { background: #fef3c7; color: #92400e; }
.td-badge-resolved { background: #dcfce7; color: #166534; }
.td-badge-wont_fix { background: #f3f4f6; color: #9ca3af; }
.dim { color: gray; font-style: italic; }
</style>