diff --git a/state-hub/dashboard/src/components/help-tip.js b/state-hub/dashboard/src/components/help-tip.js new file mode 100644 index 0000000..7f37d33 --- /dev/null +++ b/state-hub/dashboard/src/components/help-tip.js @@ -0,0 +1,167 @@ +// ABBR +// +// A custom element that shows a floating card on hover/focus. +// Attributes: +// label — bold title line in the card +// description — body text +// doc — optional URL; adds a "Learn more →" link +// +// The card is appended to document.body (position:fixed) so it escapes +// any overflow:hidden or clipping ancestors (e.g. the TOC sidebar). + +const _STYLE_ID = "helptip-global-style"; +if (!document.getElementById(_STYLE_ID)) { + const s = document.createElement("style"); + s.id = _STYLE_ID; + s.textContent = ` +help-tip { + cursor: help; + display: inline; +} +.helptip-card { + position: fixed; + z-index: 9999; + background: var(--theme-background, #fff); + border: 1px solid var(--theme-foreground-faint, #ddd); + border-radius: 9px; + padding: 0.6rem 0.85rem; + max-width: 270px; + min-width: 130px; + box-shadow: 0 6px 22px rgba(0,0,0,0.13), 0 1px 4px rgba(0,0,0,0.07); + font-size: 0.78rem; + line-height: 1.5; + color: var(--theme-foreground, #333); + opacity: 0; + transition: opacity 0.12s ease; + pointer-events: auto; +} +.helptip-card.helptip-visible { opacity: 1; } +.helptip-card-label { + font-weight: 700; + font-size: 0.8rem; + margin-bottom: 0.3rem; + color: var(--theme-foreground, #222); +} +.helptip-card-desc { + color: var(--theme-foreground-muted, #555); +} +.helptip-card-link { + display: inline-block; + margin-top: 0.45rem; + font-size: 0.72rem; + color: var(--theme-foreground-focus, #3b82f6); + text-decoration: none; +} +.helptip-card-link:hover { text-decoration: underline; } +`; + document.head.appendChild(s); +} + +class HelpTip extends HTMLElement { + #card = null; + #showTimer = null; + #hideTimer = null; + + connectedCallback() { + this.addEventListener("mouseenter", this.#onEnter); + this.addEventListener("mouseleave", this.#onLeave); + this.addEventListener("focusin", this.#onEnter); + this.addEventListener("focusout", this.#onLeave); + } + + disconnectedCallback() { + clearTimeout(this.#showTimer); + clearTimeout(this.#hideTimer); + this.#clearCard(); + this.removeEventListener("mouseenter", this.#onEnter); + this.removeEventListener("mouseleave", this.#onLeave); + this.removeEventListener("focusin", this.#onEnter); + this.removeEventListener("focusout", this.#onLeave); + } + + #onEnter = () => { + clearTimeout(this.#hideTimer); + this.#showTimer = setTimeout(() => this.#showCard(), 80); + }; + + #onLeave = () => { + clearTimeout(this.#showTimer); + this.#hideTimer = setTimeout(() => this.#clearCard(), 200); + }; + + #showCard() { + if (this.#card) return; + + const label = this.getAttribute("label") || ""; + const desc = this.getAttribute("description") || ""; + const doc = this.getAttribute("doc") || ""; + + const card = document.createElement("div"); + card.className = "helptip-card"; + + if (label) { + const h = document.createElement("div"); + h.className = "helptip-card-label"; + h.textContent = label; + card.appendChild(h); + } + if (desc) { + const d = document.createElement("div"); + d.className = "helptip-card-desc"; + d.textContent = desc; + card.appendChild(d); + } + if (doc) { + const a = document.createElement("a"); + a.className = "helptip-card-link"; + a.textContent = "Learn more →"; + a.href = doc; + card.appendChild(a); + } + + // Keep card alive while mouse is over it + card.addEventListener("mouseenter", () => clearTimeout(this.#hideTimer)); + card.addEventListener("mouseleave", this.#onLeave); + + document.body.appendChild(card); + this.#card = card; + + this.#position(card); + requestAnimationFrame(() => card.classList.add("helptip-visible")); + } + + #position(card) { + const rect = this.getBoundingClientRect(); + const cw = card.offsetWidth || 230; + const ch = card.offsetHeight || 80; + const gap = 8; + const vw = window.innerWidth; + const vh = window.innerHeight; + + // Horizontal: align to left of trigger, clamp inside viewport + let left = rect.left; + if (left + cw + gap > vw) left = vw - cw - gap; + if (left < gap) left = gap; + + // Vertical: prefer above; fall back to below + const top = (rect.top - ch - gap >= 0) + ? rect.top - ch - gap + : Math.min(rect.bottom + gap, vh - ch - gap); + + card.style.left = `${left}px`; + card.style.top = `${top}px`; + } + + #clearCard() { + if (this.#card) { + this.#card.remove(); + this.#card = null; + } + } +} + +if (!customElements.get("help-tip")) { + customElements.define("help-tip", HelpTip); +} + +export { HelpTip }; diff --git a/state-hub/dashboard/src/workstreams.md b/state-hub/dashboard/src/workstreams.md index dc1b20c..be7f3d3 100644 --- a/state-hub/dashboard/src/workstreams.md +++ b/state-hub/dashboard/src/workstreams.md @@ -134,6 +134,7 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno ```js import {injectTocTop} from "./components/toc-sidebar.js"; import {withDocHelp} from "./components/doc-overlay.js"; +import "./components/help-tip.js"; // ── Live indicator ──────────────────────────────────────────────────────────── const _liveEl = html`
@@ -158,11 +159,11 @@ function _warnLevel(name, val) { function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; } const _whiMetrics = [ - {name: "DD", val: _DD, fmt: v => v.toFixed(2), tip: "Dependency Density — average number of dependencies per open workstream; high values indicate a tightly coupled graph that is hard to parallelise."}, - {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", tip: "Blocked Ratio — share of open workstreams currently in a blocked state; directly reduces the work that can proceed right now."}, - {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", tip: "Single-Point Risk — share of open workstreams that are depended on by others but have no incoming dependencies themselves; losing one stalls everything downstream."}, - {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", tip: "Parallel Execution Potential — share of open workstreams with zero blocking dependencies that could start or continue immediately."}, - {name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%", tip: "Cross-Domain Dependency Ratio — share of dependency edges that cross domain boundaries; high values mean progress in one domain is gated on another team or project."}, + {name: "DD", val: _DD, fmt: v => v.toFixed(2), label: "Dependency Density", desc: "Average number of dependencies per open workstream; high values indicate a tightly coupled graph that is hard to parallelise."}, + {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", label: "Blocked Ratio", desc: "Share of open workstreams currently in a blocked state; directly reduces the work that can proceed right now."}, + {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", label: "Single-Point Risk", desc: "Share of workstreams depended on by others but with no incoming dependencies themselves; losing one stalls everything downstream."}, + {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", label: "Parallel Execution Potential", desc: "Share of open workstreams with zero blocking dependencies that could start or continue immediately."}, + {name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%", label: "Cross-Domain Dependency Ratio", desc: "Share of dependency edges that cross domain boundaries; high values mean progress in one domain is gated on another team or project."}, ]; const _whiBox = html`
@@ -179,8 +180,8 @@ const _whiBox = html`
${_whiMetrics.map(m => { const lv = _warnLevel(m.name, m.val); return html`
- ${m.name} - ${m.fmt(m.val)} + ${m.name} + ${m.fmt(m.val)}
`; })}
@@ -189,9 +190,12 @@ const _whiBox = html`
by domain
${_domainBreakdown.map(d => html`
- ${d.domain} + ${d.domain} ${(d.whi*100).toFixed(0)}% - ${d.cpi === 1 ? html`` : ""} + ${d.cpi === 1 ? html`` : ""}
`)}
` : ""} `} @@ -323,7 +327,7 @@ if (wsWithDeps.length === 0) { .whi-cycle-alert { background: #fef2f2; color: #dc2626; border-radius: 4px; padding: 0.2rem 0.45rem; font-size: 0.72rem; font-weight: 600; margin-bottom: 0.4rem; } .whi-metrics { border-top: 1px solid var(--theme-foreground-faint, #eee); padding-top: 0.35rem; margin-bottom: 0.35rem; } .whi-metric-row { display: flex; justify-content: space-between; padding: 0.16rem 0; } -.whi-metric-name { font-family: monospace; font-size: 0.72rem; text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } +.whi-metric-name { font-family: monospace; font-size: 0.72rem; } .whi-metric-val { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.78rem; } .whi-domains { border-top: 1px solid var(--theme-foreground-faint, #eee); padding-top: 0.35rem; } .whi-domain-header { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-faint, #aaa); margin-bottom: 0.2rem; }