diff --git a/state-hub/dashboard/src/components/entity-modal.js b/state-hub/dashboard/src/components/entity-modal.js new file mode 100644 index 0000000..c3fe3af --- /dev/null +++ b/state-hub/dashboard/src/components/entity-modal.js @@ -0,0 +1,415 @@ +/** + * entity-modal — click any entity row or card to open a full-detail overlay. + * + * Usage: + * import {openEntityModal} from "./components/entity-modal.js"; + * row.addEventListener("click", () => openEntityModal(entity, "workstream")); + * + * Supported types: "workstream" | "task" | "ep" | "td" + */ + +const _STYLE_ID = "entity-modal-styles"; + +function _ensureStyles() { + if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return; + const s = document.createElement("style"); + s.id = _STYLE_ID; + s.textContent = ` +/* ── Modal backdrop ──────────────────────────────────────────────────────── */ +.entity-modal { + position: fixed; inset: 0; background: rgba(0,0,0,0.45); + z-index: 9100; display: flex; align-items: center; justify-content: center; + animation: _em-fade 0.15s ease; +} +@keyframes _em-fade { from { opacity:0 } to { opacity:1 } } + +/* ── Modal box ────────────────────────────────────────────────────────────── */ +.entity-modal-box { + width: min(700px, 92vw); max-height: 88vh; + background: var(--theme-background, #fff); border-radius: 12px; + box-shadow: 0 16px 56px rgba(0,0,0,0.28); + display: flex; flex-direction: column; + animation: _em-rise 0.15s ease; overflow: hidden; +} +@keyframes _em-rise { + from { transform: translateY(14px); opacity: 0 } + to { transform: translateY(0); opacity: 1 } +} + +/* ── Header ───────────────────────────────────────────────────────────────── */ +.entity-modal-header { + display: flex; align-items: flex-start; gap: 0.75rem; + padding: 0.85rem 1rem 0.75rem; + border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8); + background: var(--theme-background-alt, #f7f7f7); flex-shrink: 0; +} +.entity-modal-title { + flex: 1; font-size: 1rem; font-weight: 700; line-height: 1.35; + color: var(--theme-foreground, #111); word-break: break-word; +} +.entity-modal-close { + background: none; border: 1px solid transparent; cursor: pointer; + font-size: 0.9rem; color: var(--theme-foreground-muted, #888); + padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0; + font-family: inherit; line-height: 1.3; +} +.entity-modal-close:hover { + border-color: var(--theme-foreground-faint, #ccc); + background: var(--theme-background, #fff); color: var(--theme-foreground, #111); +} + +/* ── Body ─────────────────────────────────────────────────────────────────── */ +.entity-modal-body { + overflow-y: auto; padding: 0.85rem 1rem; + display: flex; flex-direction: column; gap: 0.4rem; +} + +/* ── Field rows ───────────────────────────────────────────────────────────── */ +.em-field { + display: grid; grid-template-columns: 130px 1fr; + gap: 0.15rem 0.65rem; font-size: 0.85rem; align-items: baseline; +} +.em-label { + font-size: 0.7rem; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa); + padding-top: 0.14rem; white-space: nowrap; +} +.em-value { color: var(--theme-foreground, #222); line-height: 1.5; word-break: break-word; } + +/* ── Description block ────────────────────────────────────────────────────── */ +.em-desc { + font-size: 0.83rem; color: var(--theme-foreground-muted, #555); + line-height: 1.55; white-space: pre-wrap; word-break: break-word; + background: var(--theme-background-alt, #f9f9f9); + border-radius: 6px; padding: 0.55rem 0.75rem; + border: 1px solid var(--theme-foreground-faint, #eee); + max-width: 100%; +} + +/* ── Divider ──────────────────────────────────────────────────────────────── */ +.em-divider { border: none; border-top: 1px solid var(--theme-foreground-faint, #eee); margin: 0.2rem 0; } + +/* ── Badge (reused from page styles, self-contained) ─────────────────────── */ +.em-badge { + display: inline-block; padding: 0.12rem 0.5rem; border-radius: 10px; + font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; +} + +/* ── Deps list ────────────────────────────────────────────────────────────── */ +.em-deps-list { display: flex; flex-direction: column; gap: 0.12rem; } +.em-dep-item { font-size: 0.82rem; color: var(--theme-foreground-muted, #666); } + +/* ── Entity table (shared across all list pages) ──────────────────────────── */ +.entity-table-wrap { overflow-x: auto; max-width: 100%; } +.entity-table { + width: 100%; border-collapse: collapse; font-size: 0.87rem; + table-layout: fixed; /* honour column widths; never spill outside container */ +} +.entity-table thead th { + text-align: left; padding: 0.4rem 0.65rem; + border-bottom: 2px solid var(--theme-foreground-faint, #ddd); + font-size: 0.73rem; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--theme-foreground-muted, #777); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.entity-table tbody tr { border-bottom: 1px solid var(--theme-foreground-faint, #eee); } +.entity-table tbody tr:last-child { border-bottom: none; } +.entity-table tbody tr:hover { background: var(--theme-background-alt, #f5f5f5); } +.entity-table td { + padding: 0.4rem 0.65rem; vertical-align: middle; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.entity-row { cursor: pointer; } +/* Proportional column widths — other cols share the remainder equally */ +.et-title-col { width: 32%; } +.et-ws-col { width: 14%; } +.et-title-cell { font-weight: 500; } +.et-ws-cell { font-style: italic; } +`; + document.head.append(s); +} + +/* ── Style maps ──────────────────────────────────────────────────────────── */ +const _STATUS_STYLE = { + active: "background:#d4edda;color:#155724", + blocked: "background:#f8d7da;color:#721c24", + completed: "background:#cce5ff;color:#004085", + archived: "background:#e2e3e5;color:#383d41", + open: "background:#dbeafe;color:#1e40af", + in_progress: "background:#fef3c7;color:#92400e", + addressed: "background:#dcfce7;color:#166534", + deferred: "background:#f1f5f9;color:#64748b", + wont_fix: "background:#f3f4f6;color:#9ca3af", + todo: "background:#f1f5f9;color:#475569", + done: "background:#dcfce7;color:#166534", + cancelled: "background:#f3f4f6;color:#9ca3af", + resolved: "background:#dcfce7;color:#166534", + superseded: "background:#e2e3e5;color:#383d41", +}; + +const _PRIORITY_STYLE = { + critical: "background:#fee2e2;color:#991b1b", + high: "background:#ffedd5;color:#9a3412", + medium: "background:#dbeafe;color:#1e40af", + low: "background:#f1f5f9;color:#475569", +}; + +/* ── DOM helpers ─────────────────────────────────────────────────────────── */ +function _badge(text, styleMap) { + const el = document.createElement("span"); + el.className = "em-badge"; + el.style.cssText = styleMap[text] ?? "background:#f1f5f9;color:#555"; + el.textContent = (text ?? "").replace(/_/g, " "); + return el; +} + +function _field(label, valueEl) { + const row = document.createElement("div"); + row.className = "em-field"; + const l = document.createElement("span"); + l.className = "em-label"; + l.textContent = label; + row.append(l, valueEl); + return row; +} + +function _textVal(text) { + const v = document.createElement("span"); + v.className = "em-value"; + v.textContent = text ?? "—"; + return v; +} + +function _descVal(text) { + const v = document.createElement("div"); + v.className = "em-desc"; + v.textContent = text; + return v; +} + +function _divider() { + const hr = document.createElement("hr"); + hr.className = "em-divider"; + return hr; +} + +function _fmtDate(iso) { + if (!iso) return "—"; + try { return new Date(iso).toLocaleString(); } catch { return iso; } +} + +/* ── Body builders per entity type ─────────────────────────────────────── */ +function _buildBody(entity, type) { + const els = []; + const tf = (label, text) => _field(label, _textVal(text)); + const bf = (label, val, styleMap) => { + const v = document.createElement("span"); + v.className = "em-value"; + v.append(_badge(val, styleMap)); + return _field(label, v); + }; + + if (type === "workstream") { + els.push( + bf("Status", entity.status, _STATUS_STYLE), + tf("Domain", entity.domain ?? entity.topic_title ?? "—"), + tf("Owner", entity.owner ?? "—"), + tf("Due", entity.due_date ?? "—"), + ); + if (entity.description) { + els.push(_divider(), _field("Description", _descVal(entity.description))); + } + if (entity.tasks_total !== undefined) { + els.push(_divider(), + tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` + + (entity.tasks_in_progress > 0 ? ` · ${entity.tasks_in_progress} in progress` : "") + + (entity.tasks_blocked > 0 ? ` · ${entity.tasks_blocked} blocked` : "")) + ); + } + if (entity.depends_on?.length) { + const list = document.createElement("div"); list.className = "em-deps-list"; + for (const d of entity.depends_on) { + const span = document.createElement("span"); span.className = "em-dep-item"; + span.textContent = `↳ ${d.workstream_title}`; + list.append(span); + } + els.push(_field("Depends on", list)); + } + if (entity.blocks?.length) { + const list = document.createElement("div"); list.className = "em-deps-list"; + for (const d of entity.blocks) { + const span = document.createElement("span"); span.className = "em-dep-item"; + span.textContent = `⊳ ${d.workstream_title}`; + list.append(span); + } + els.push(_field("Blocks", list)); + } + els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at))); + if (entity.slug) els.push(tf("Slug", entity.slug)); + els.push(tf("ID", entity.id)); + } + + else if (type === "task") { + els.push( + bf("Status", entity.status, _STATUS_STYLE), + bf("Priority", entity.priority, _PRIORITY_STYLE), + tf("Domain", entity.domain ?? "—"), + tf("Workstream", entity.workstream_title ?? "—"), + tf("Assignee", entity.assignee ?? "—"), + tf("Due", entity.due_date ?? "—"), + ); + if (entity.description) { + els.push(_divider(), _field("Description", _descVal(entity.description))); + } + if (entity.blocking_reason) { + const v = document.createElement("span"); + v.className = "em-value"; v.style.color = "#b45309"; + v.textContent = entity.blocking_reason; + els.push(_divider(), _field("Blocking reason", v)); + } + els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at))); + els.push(tf("ID", entity.id)); + } + + else if (type === "ep") { + if (entity.ep_id) els.push(tf("EP ID", entity.ep_id)); + els.push( + bf("Status", entity.status, _STATUS_STYLE), + bf("Priority", entity.priority, _PRIORITY_STYLE), + tf("Type", entity.ep_type ?? "—"), + tf("Domain", entity.domain ?? "—"), + tf("Workstream", entity.workstream_title ?? "—"), + tf("Location", entity.location ?? "—"), + ); + if (entity.description) { + els.push(_divider(), _field("Description", _descVal(entity.description))); + } + els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at))); + els.push(tf("UUID", entity.id)); + } + + else if (type === "td") { + if (entity.td_id) els.push(tf("TD ID", entity.td_id)); + els.push( + bf("Severity", entity.severity, _PRIORITY_STYLE), + bf("Status", entity.status, _STATUS_STYLE), + tf("Type", entity.debt_type ?? "—"), + tf("Domain", entity.domain ?? "—"), + tf("Workstream", entity.workstream_title ?? "—"), + tf("Location", entity.location ?? "—"), + ); + if (entity.description) { + els.push(_divider(), _field("Description", _descVal(entity.description))); + } + els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at))); + els.push(tf("UUID", entity.id)); + } + + return els; +} + +/* ── Public API ──────────────────────────────────────────────────────────── */ + +/** + * Open a detail modal for the given entity. + * @param {object} entity - The entity data object (workstream, task, ep, or td) + * @param {string} type - One of: "workstream" | "task" | "ep" | "td" + */ +export function openEntityModal(entity, type) { + _ensureStyles(); + document.getElementById("_entity-modal-root")?.remove(); + + const title = entity.title ?? "(no title)"; + + const root = document.createElement("div"); + root.id = "_entity-modal-root"; + root.className = "entity-modal"; + root.setAttribute("role", "dialog"); + root.setAttribute("aria-modal", "true"); + root.setAttribute("aria-label", title); + + const box = document.createElement("div"); + box.className = "entity-modal-box"; + + // Header + const header = document.createElement("div"); + header.className = "entity-modal-header"; + const titleEl = document.createElement("div"); + titleEl.className = "entity-modal-title"; + titleEl.textContent = title; + const closeBtn = document.createElement("button"); + closeBtn.className = "entity-modal-close"; + closeBtn.textContent = "✕ close"; + closeBtn.setAttribute("aria-label", "Close detail panel"); + header.append(titleEl, closeBtn); + + // Body + const body = document.createElement("div"); + body.className = "entity-modal-body"; + for (const el of _buildBody(entity, type)) body.append(el); + + box.append(header, body); + root.append(box); + document.body.append(root); + + const close = () => root.remove(); + closeBtn.addEventListener("click", close); + root.addEventListener("click", e => { if (e.target === root) close(); }); + const onKey = e => { + if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); } + }; + document.addEventListener("keydown", onKey); +} + +/** + * Build an interactive entity table element. + * + * @param {Array} rows - Array of entity objects to display + * @param {Array} columns - [{label, key, cls?}] — columns in order + * @param {Function} onRowClick - Called with the full entity when a row is clicked + * @returns {HTMLTableElement} + */ +export function buildEntityTable(rows, columns, onRowClick) { + _ensureStyles(); + + const table = document.createElement("table"); + table.className = "entity-table"; + + // Header + const thead = document.createElement("thead"); + const htr = document.createElement("tr"); + for (const col of columns) { + const th = document.createElement("th"); + th.textContent = col.label; + if (col.cls) th.className = col.cls; + htr.append(th); + } + thead.append(htr); + table.append(thead); + + // Body + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + tr.className = "entity-row"; + tr.addEventListener("click", () => onRowClick(row)); + + for (const col of columns) { + const td = document.createElement("td"); + const raw = col.key ? row[col.key] : null; + const text = col.render ? col.render(row) : (raw ?? "—"); + const textStr = String(text ?? "—"); + td.textContent = textStr; + if (col.cls) td.className = col.cls; + // Native tooltip so full value shows on hover (skip placeholder "—") + if (textStr && textStr !== "—") td.title = textStr; + tr.append(td); + } + tbody.append(tr); + } + table.append(tbody); + const wrap = document.createElement("div"); + wrap.className = "entity-table-wrap"; + wrap.append(table); + return wrap; +} diff --git a/state-hub/dashboard/src/extensions.md b/state-hub/dashboard/src/extensions.md index 119cd22..17c56db 100644 --- a/state-hub/dashboard/src/extensions.md +++ b/state-hub/dashboard/src/extensions.md @@ -86,8 +86,9 @@ const filtered = data.filter(e => # Extension Points ```js -import {injectTocTop} from "./components/toc-sidebar.js"; -import {withDocHelp} from "./components/doc-overlay.js"; +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; +import {openEntityModal} from "./components/entity-modal.js"; // ── KPI sidebar ─────────────────────────────────────────────────────────────── const _open = data.filter(e => e.status === "open" || e.status === "in_progress"); @@ -180,17 +181,19 @@ display(_filtersForm); display(html`
${filtered.length} extension points shown.
`); display(html`${ep.location}No blocked tasks in current filter. ✓
`); } else { display(html`