/** * 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 = { proposed: "background:#fef3c7;color:#92400e", ready: "background:#e0f2fe;color:#075985", active: "background:#d4edda;color:#155724", blocked: "background:#f8d7da;color:#721c24", backlog: "background:#f1f5f9;color:#64748b", finished: "background:#cce5ff;color:#004085", completed: "background:#cce5ff;color:#004085", archived: "background:#e2e3e5;color:#383d41", open: "background:#dbeafe;color:#1e40af", in_progress: "background:#fef3c7;color:#92400e", wait: "background:#fef3c7;color:#92400e", progress: "background:#ede9fe;color:#5b21b6", 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", cancel: "background:#f3f4f6;color:#9ca3af", 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_progress > 0 ? ` · ${entity.tasks_progress} progress` : "") + (entity.tasks_wait > 0 ? ` · ${entity.tasks_wait} wait` : "")) ); } 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 rendered = col.render ? col.render(row) : (raw ?? "—"); let textStr = ""; if (rendered instanceof Node) { td.append(rendered); textStr = td.textContent ?? ""; } else { textStr = String(rendered ?? "—"); td.textContent = textStr; } if (col.cls) td.className = col.cls; // Native tooltip so full value shows on hover (skip placeholder "—") if (!(rendered instanceof Node) && 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; }