Add graph explorer bubble help

This commit is contained in:
2026-05-19 00:51:38 +02:00
parent 98a747dd5a
commit 4f4f28a6d5
3 changed files with 143 additions and 27 deletions

View File

@@ -67,6 +67,42 @@ def graph_explorer_page() -> str:
}
button.primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
button:disabled { color: #98a2b3; cursor: default; }
.field-label {
display: inline-flex;
align-items: center;
gap: 5px;
}
.help-tip {
width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
border-radius: 50%;
color: var(--muted);
font-size: 11px;
line-height: 1;
background: #f8fafc;
}
.help-tip:hover, .help-tip:focus-visible {
border-color: var(--accent);
color: var(--accent);
outline: none;
}
.help-popup {
position: fixed;
z-index: 30;
display: none;
max-width: 300px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(23, 32, 51, .16);
color: var(--text);
padding: 10px;
pointer-events: none;
}
.help-popup strong { display: block; margin-bottom: 4px; }
.help-popup p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.35; }
.filter-menu {
position: relative;
min-width: 0;
@@ -122,7 +158,10 @@ def graph_explorer_page() -> str:
z-index: 3;
display: flex;
align-items: end;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
max-width: min(460px, calc(100% - 24px));
padding: 6px;
border: 1px solid var(--line);
border-radius: 8px;
@@ -220,48 +259,76 @@ def graph_explorer_page() -> str:
}
.popup strong { display: block; margin-bottom: 4px; }
.notice { color: var(--warn); padding: 14px; }
.canvas-notice {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
padding: 24px;
text-align: center;
}
@media (max-width: 900px) {
.shell { min-height: 760px; }
.toolbar { grid-template-columns: 1fr 1fr; }
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
.side { border-left: 0; border-top: 1px solid var(--line); }
.map-controls { top: 8px; right: 8px; max-width: calc(100% - 16px); }
}
</style>
</head>
<body>
<section class="shell">
<div class="toolbar">
<label>Search <input id="search" autocomplete="off"></label>
<label>Mode <select id="mode-select"></select></label>
<label>Layout <select id="layout-select">
<div class="field">
<span class="field-label">Search <button type="button" class="help-tip" aria-label="Search help" data-help-title="Search" data-help="Search matches labels, ids, repos, node types, edge types, and descriptions. It hides non-matching nodes and edges without redrawing the layout.">?</button></span>
<input id="search" autocomplete="off" aria-label="Search graph">
</div>
<div class="field">
<span class="field-label">Mode <button type="button" class="help-tip" aria-label="Mode help" data-help-title="Mode" data-help="Modes are predefined map views. Some modes use the selected item as context; changing modes changes which graph entities are visible, but keeps the layout controls separate.">?</button></span>
<select id="mode-select" aria-label="Graph mode"></select>
</div>
<div class="field">
<span class="field-label">Layout <button type="button" class="help-tip" aria-label="Layout help" data-help-title="Layout" data-help="Layout redraws the map arrangement. Cose uses relationship strength and repo affinity; circle, grid, concentric, and breadthfirst are simpler alternate arrangements.">?</button></span>
<select id="layout-select" aria-label="Graph layout">
<option value="cose">Cose</option>
<option value="concentric">Concentric</option>
<option value="circle">Circle</option>
<option value="grid">Grid</option>
<option value="breadthfirst">Breadthfirst</option>
</select></label>
<div class="field"><span>Node Types</span>
</select>
</div>
<div class="field"><span class="field-label">Node Types <button type="button" class="help-tip" aria-label="Node types help" data-help-title="Node Types" data-help="Nodes are the entities drawn on the map: repositories, services, deployments, servers, capabilities, interfaces, dependencies, bindings, and libraries. This filter hides unchecked node types while preserving the current layout.">?</button></span>
<details id="node-type-menu" class="filter-menu">
<summary id="node-type-summary">All node types</summary>
<div id="node-type-filter" class="check-list"></div>
</details>
</div>
<div class="field"><span>Edge Types</span>
<div class="field"><span class="field-label">Edge Types <button type="button" class="help-tip" aria-label="Edge types help" data-help-title="Edge Types" data-help="Edges are relationships between nodes, such as provides, consumes, runs_on, or binds. This filter hides unchecked relationship types while preserving the current layout.">?</button></span>
<details id="edge-type-menu" class="filter-menu">
<summary id="edge-type-summary">All edge types</summary>
<div id="edge-type-filter" class="check-list"></div>
</details>
</div>
<label>Review <select id="review-filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select></label>
<label>Unresolved <select id="unresolved-filter"><option value="">Any</option><option value="true">Only</option></select></label>
<label>Profile <select id="profile-select" disabled><option>Unsaved exploration</option></select></label>
<div class="field">
<span class="field-label">Review <button type="button" class="help-tip" aria-label="Review help" data-help-title="Review" data-help="Review state applies only to entities that carry review metadata. Accepted means trusted data; candidate means registered or inferred data that still needs context.">?</button></span>
<select id="review-filter" aria-label="Review state filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select>
</div>
<div class="field">
<span class="field-label">Unresolved <button type="button" class="help-tip" aria-label="Unresolved help" data-help-title="Unresolved" data-help="Unresolved marks missing provider bindings or registered repositories without accepted graph snapshots. It only applies to node and edge types that can express those gaps.">?</button></span>
<select id="unresolved-filter" aria-label="Unresolved filter"><option value="">Any</option><option value="true">Only</option></select>
</div>
<div class="field">
<span class="field-label">Profile <button type="button" class="help-tip" aria-label="Saved views help" data-help-title="Saved Views" data-help="Saved views store the current search, mode, layout, label density, filters, and manual visibility overrides in this browser. Copy State creates a shareable URL for the current view.">?</button></span>
<select id="profile-select" aria-label="Saved view profile" disabled><option>Unsaved exploration</option></select>
</div>
<span id="counts" class="meta"></span>
</div>
<main class="main">
<div class="canvas-wrap">
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph"></div>
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph" aria-live="polite"><p class="canvas-notice">Loading graph...</p></div>
<div class="map-controls" aria-label="Map navigation controls">
<label class="map-control-field">Labels
<label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span>
<select id="label-select" title="Control node label density">
<option value="auto">Auto</option>
<option value="key">Key</option>
@@ -269,14 +336,15 @@ def graph_explorer_page() -> str:
<option value="none">None</option>
</select>
</label>
<button type="button" data-action="fit" title="Fit graph to view">Fit</button>
<button type="button" data-action="focus" title="Focus selected neighborhood">Focus</button>
<button type="button" data-action="clear" class="primary" title="Reset map controls">Reset</button>
<button type="button" data-action="fit" title="Fit graph to view" data-help-title="Fit" data-help="Fit pans and zooms the current visible graph into the map viewport. It does not change filters, labels, or layout.">Fit</button>
<button type="button" data-action="focus" title="Focus selected neighborhood" data-help-title="Focus" data-help="Focus narrows the map to the selected entity and its nearby context. It changes visibility, not the underlying registry data.">Focus</button>
<button type="button" data-action="clear" class="primary" title="Reset map controls" data-help-title="Reset" data-help="Reset clears search, mode, node and edge type filters, label density, unresolved/review filters, focus, and manual overrides.">Reset</button>
</div>
<div id="selection-anchor" class="selection-anchor" aria-hidden="true">
<span id="selection-anchor-label" class="selection-anchor-label"></span>
</div>
<div id="popup" class="popup"></div>
<div id="help-popup" class="help-popup" role="tooltip"></div>
</div>
<aside class="side">
<section class="section">
@@ -291,24 +359,27 @@ def graph_explorer_page() -> str:
</section>
<section class="section">
<div class="button-row">
<button type="button" data-override="show">Show</button>
<button type="button" data-override="blur">Blur</button>
<button type="button" data-override="hide">Hide</button>
<button type="button" data-action="reset-overrides">Clear Overrides</button>
<button type="button" data-override="show" data-help-title="Show" data-help="Show pins the selected entity visible even when other filters would hide it. It does not redraw the layout.">Show</button>
<button type="button" data-override="blur" data-help-title="Blur" data-help="Blur keeps the selected entity in place but reduces emphasis. It is useful for context that should stay visible without competing for attention.">Blur</button>
<button type="button" data-override="hide" data-help-title="Hide" data-help="Hide makes the selected entity invisible while preserving layout context. Remove is reserved for the upcoming rule panel and will redraw the graph without the entity.">Hide</button>
<button type="button" data-action="reset-overrides" data-help-title="Clear Overrides" data-help="Clear Overrides removes manual Show, Blur, and Hide choices. Filters, mode, layout, and label density stay as they are.">Clear Overrides</button>
</div>
</section>
<section class="section">
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
<label>Profile Name <input id="profile-name" autocomplete="off" disabled></label>
<div class="field">
<span class="field-label">Profile Name <button type="button" class="help-tip" aria-label="Profile name help" data-help-title="Profile Name" data-help="Profile Name labels a saved browser-local map view. It does not rename graph entities or registry data.">?</button></span>
<input id="profile-name" autocomplete="off" aria-label="Profile name" disabled>
</div>
<div class="button-row">
<button type="button" data-profile-action="save" disabled>Save</button>
<button type="button" data-profile-action="duplicate" disabled>Duplicate</button>
<button type="button" data-profile-action="delete" disabled>Delete</button>
<button type="button" data-profile-action="copy" disabled>Copy State</button>
<button type="button" data-profile-action="save" data-help-title="Save" data-help="Save stores the current browser-local view, including filters, layout, label density, and manual overrides." disabled>Save</button>
<button type="button" data-profile-action="duplicate" data-help-title="Duplicate" data-help="Duplicate creates a new saved view from the currently loaded profile and current map state." disabled>Duplicate</button>
<button type="button" data-profile-action="delete" data-help-title="Delete" data-help="Delete removes the selected browser-local saved view. It does not delete graph data." disabled>Delete</button>
<button type="button" data-profile-action="copy" data-help-title="Copy State" data-help="Copy State copies a URL that can restore this map state, including filters and manual overrides." disabled>Copy State</button>
</div>
</section>
<section class="section">
<p id="hidden-summary" class="meta">Hidden 0</p>
<p class="meta"><span id="hidden-summary">Hidden 0</span> <button type="button" class="help-tip" aria-label="Hidden entities help" data-help-title="Hidden Entities" data-help="Hidden counts entities currently not shown because of filters, mode, or manual Hide overrides. Hidden keeps layout context; Remove will redraw without the entity in the rule panel.">?</button></p>
<div id="legend"></div>
</section>
</aside>
@@ -321,6 +392,7 @@ def graph_explorer_page() -> str:
const graphUrl = "/exports/graph-explorer";
const canvas = document.getElementById("graph-canvas");
const popup = document.getElementById("popup");
const helpPopup = document.getElementById("help-popup");
const selectionAnchor = document.getElementById("selection-anchor");
const selectionAnchorLabel = document.getElementById("selection-anchor-label");
const searchInput = document.getElementById("search");
@@ -365,6 +437,26 @@ def graph_explorer_page() -> str:
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const showHelp = (target) => {
if (!helpPopup || !target?.dataset?.help) return;
const bounds = target.getBoundingClientRect();
const title = target.dataset.helpTitle || target.textContent || "Help";
helpPopup.innerHTML = `<strong>${escapeHtml(title)}</strong><p>${escapeHtml(target.dataset.help)}</p>`;
helpPopup.style.display = "block";
const width = Math.min(helpPopup.offsetWidth || 300, 300);
const left = Math.min(Math.max(12, bounds.left), window.innerWidth - width - 12);
const top = bounds.bottom + 8 < window.innerHeight - 80
? bounds.bottom + 8
: Math.max(12, bounds.top - helpPopup.offsetHeight - 8);
helpPopup.style.left = `${left}px`;
helpPopup.style.top = `${top}px`;
};
const hideHelp = () => {
if (!helpPopup) return;
helpPopup.style.display = "none";
};
const elementText = (data) => [
data.id, data.stableKey, data.label, data.name, data.description,
data.repo, data.domain, data.kind, data.layer, data.edgeType
@@ -960,6 +1052,13 @@ def graph_explorer_page() -> str:
...element,
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
}));
if (elements.length === 0) {
canvas.innerHTML = "<p class='canvas-notice'>No graph entities were returned by the registry.</p>";
counts.textContent = "0 nodes / 0 edges";
hiddenSummary.textContent = "Hidden 0";
return;
}
canvas.innerHTML = "";
cy = cytoscape({
container: canvas,
elements,
@@ -1158,12 +1257,21 @@ def graph_explorer_page() -> str:
}
});
});
document.querySelectorAll("[data-help]").forEach((target) => {
target.addEventListener("mouseenter", () => showHelp(target));
target.addEventListener("focus", () => showHelp(target));
target.addEventListener("mouseleave", hideHelp);
target.addEventListener("blur", hideHelp);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") hideHelp();
});
if (!window.cytoscape) {
canvas.innerHTML = "<p class='notice'>Cytoscape.js could not be loaded.</p>";
return;
}
boot().catch((error) => {
canvas.innerHTML = `<p class='notice'>${escapeHtml(error.message)}</p>`;
canvas.innerHTML = `<p class='canvas-notice'>Could not load graph explorer data: ${escapeHtml(error.message)}</p>`;
});
})();
</script>

View File

@@ -210,11 +210,19 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert 'id="node-type-filter"' in page
assert 'id="edge-type-filter"' in page
assert 'id="selection-anchor"' in page
assert 'id="help-popup"' in page
assert "updateSelectionAnchor" in page
assert "updateLabelVisibility" in page
assert "showHelp" in page
assert "label-hidden" in page
assert "Loading graph..." in page
assert "No graph entities were returned by the registry." in page
assert "Could not load graph explorer data" in page
assert "Node Types" in page
assert "Edge Types" in page
assert 'data-help-title="Node Types"' in page
assert 'data-help-title="Saved Views"' in page
assert "Remove will redraw" in page
assert "Registered only" in page
assert 'id="layer-filter"' not in page
assert '"border-width": 4' not in page

View File

@@ -123,7 +123,7 @@ Acceptance notes:
```task
id: RAIL-FAB-WP-0009-T03
status: in_progress
status: done
priority: high
state_hub_task_id: "3e60397c-c833-4bd7-ba1b-b754b203dade"
```
@@ -176,7 +176,7 @@ Acceptance notes:
```task
id: RAIL-FAB-WP-0009-T05
status: todo
status: done
priority: medium
state_hub_task_id: "67a9cbc9-ebaa-4cb1-bec9-46bf250faf41"
```