generated from coulomb/repo-seed
feat(dashboard): replace title tooltips with <help-tip> web component
New custom element (src/components/help-tip.js): - Floating card appears on hover/focus, appended to document.body (position:fixed) so it escapes overflow:hidden in the TOC sidebar - Attributes: label (bold), description (body), doc (optional "Learn more →" link) - Mouse-over-card grace period so the link stays reachable - Correct viewport clamping (horizontal + prefer-above/fallback-below) workstreams.md: - WHI metric abbreviations (DD/BR/SPR/PEP/CDDR) now use <help-tip> with full name, one-sentence description, and doc link - Domain breakdown labels show domain-scoped stats (open count, blocked%, runnable%) and a doc link - Cycle ⚠ icon upgraded to <help-tip> with explanation - Removed dotted underline; cursor:help comes from the element CSS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
167
dashboard/src/components/help-tip.js
Normal file
167
dashboard/src/components/help-tip.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// <help-tip label="Full Name" description="One sentence." doc="/path">ABBR</help-tip>
|
||||||
|
//
|
||||||
|
// 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 };
|
||||||
@@ -134,6 +134,7 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno
|
|||||||
```js
|
```js
|
||||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||||
import {withDocHelp} from "./components/doc-overlay.js";
|
import {withDocHelp} from "./components/doc-overlay.js";
|
||||||
|
import "./components/help-tip.js";
|
||||||
|
|
||||||
// ── Live indicator ────────────────────────────────────────────────────────────
|
// ── Live indicator ────────────────────────────────────────────────────────────
|
||||||
const _liveEl = html`<div class="live-indicator">
|
const _liveEl = html`<div class="live-indicator">
|
||||||
@@ -158,11 +159,11 @@ function _warnLevel(name, val) {
|
|||||||
function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; }
|
function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; }
|
||||||
|
|
||||||
const _whiMetrics = [
|
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: "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)+"%", tip: "Blocked Ratio — share of open workstreams currently in a blocked state; directly reduces the work that can proceed right now."},
|
{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)+"%", 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: "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)+"%", tip: "Parallel Execution Potential — share of open workstreams with zero blocking dependencies that could start or continue immediately."},
|
{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)+"%", 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: "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`<div class="kpi-infobox whi-box">
|
const _whiBox = html`<div class="kpi-infobox whi-box">
|
||||||
@@ -179,8 +180,8 @@ const _whiBox = html`<div class="kpi-infobox whi-box">
|
|||||||
${_whiMetrics.map(m => {
|
${_whiMetrics.map(m => {
|
||||||
const lv = _warnLevel(m.name, m.val);
|
const lv = _warnLevel(m.name, m.val);
|
||||||
return html`<div class="whi-metric-row">
|
return html`<div class="whi-metric-row">
|
||||||
<span class="whi-metric-name" style="color:${_warnColor(lv)}" title="${m.tip}">${m.name}</span>
|
<help-tip class="whi-metric-name" style="color:${_warnColor(lv)}" label="${m.label}" description="${m.desc}" doc="/docs/workstream-health-index">${m.name}</help-tip>
|
||||||
<span class="whi-metric-val" style="color:${_warnColor(lv)}">${m.fmt(m.val)}</span>
|
<span class="whi-metric-val" style="color:${_warnColor(lv)}">${m.fmt(m.val)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,9 +190,12 @@ const _whiBox = html`<div class="kpi-infobox whi-box">
|
|||||||
<div class="whi-domain-header">by domain</div>
|
<div class="whi-domain-header">by domain</div>
|
||||||
${_domainBreakdown.map(d => html`<div class="whi-domain-row">
|
${_domainBreakdown.map(d => html`<div class="whi-domain-row">
|
||||||
<span class="whi-domain-dot" style="background:${_whiColor(d.whi)}"></span>
|
<span class="whi-domain-dot" style="background:${_whiColor(d.whi)}"></span>
|
||||||
<span class="whi-domain-name">${d.domain}</span>
|
<help-tip class="whi-domain-name"
|
||||||
|
label="${d.domain.replaceAll('_', ' ')}"
|
||||||
|
description="Domain-scoped WHI (intra-domain edges only). Open: ${d.openCount} · Blocked: ${(d.br*100).toFixed(0)}% · Runnable: ${(d.pep*100).toFixed(0)}%"
|
||||||
|
doc="/docs/workstream-health-index">${d.domain}</help-tip>
|
||||||
<span class="whi-domain-score" style="color:${_whiColor(d.whi)}">${(d.whi*100).toFixed(0)}%</span>
|
<span class="whi-domain-score" style="color:${_whiColor(d.whi)}">${(d.whi*100).toFixed(0)}%</span>
|
||||||
${d.cpi === 1 ? html`<span style="color:#d97706;font-size:0.7rem" title="cycle detected">⚠</span>` : ""}
|
${d.cpi === 1 ? html`<help-tip style="color:#d97706;font-size:0.7rem" label="Dependency Cycle" description="A circular dependency exists within this domain — workstreams are waiting on each other and cannot all proceed." doc="/docs/workstream-health-index">⚠</help-tip>` : ""}
|
||||||
</div>`)}
|
</div>`)}
|
||||||
</div>` : ""}
|
</div>` : ""}
|
||||||
`}
|
`}
|
||||||
@@ -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-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-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-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-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-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; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user