graphlin 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +2 -1
- package/plugin.json +1 -1
- package/runtime/daemon/dashboard-info.mjs +175 -0
- package/runtime/daemon/server.mjs +9 -1
- package/runtime/web/app.js +327 -29
- package/runtime/web/index.html +88 -57
- package/runtime/web/style.css +106 -127
- package/scripts/control.mjs +1 -1
package/runtime/web/app.js
CHANGED
|
@@ -157,6 +157,23 @@ export function liveNodeChanges(previous, next, eligible) {
|
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
export function filterDiagram(graph, query = '', kinds = null) {
|
|
161
|
+
if (!query && kinds === null) return graph;
|
|
162
|
+
const needle = query.toLowerCase();
|
|
163
|
+
const nodes = graph.nodes.filter(node => (kinds === null || kinds.has(node.kind)) && node.label.toLowerCase().includes(needle));
|
|
164
|
+
const visible = new Set(nodes.map(node => node.id));
|
|
165
|
+
return { ...graph, nodes, edges: graph.edges.filter(edge => visible.has(edge.source) && visible.has(edge.target)) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isSearchTypingTarget(target) {
|
|
169
|
+
for (let element = target; element; element = element.parentElement) {
|
|
170
|
+
if (['input', 'textarea', 'select', 'dialog'].includes(element.tagName?.toLowerCase()) ||
|
|
171
|
+
element.isContentEditable || ['textbox', 'combobox', 'searchbox'].includes(element.getAttribute?.('role')) ||
|
|
172
|
+
['true', '', 'plaintext-only'].includes(element.getAttribute?.('contenteditable'))) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
160
177
|
function nodeTitleWidth(shapeName) {
|
|
161
178
|
return { queue: 132, component: 142, parallelogram: 144, diamond: 140 }[shapeName] || 158;
|
|
162
179
|
}
|
|
@@ -816,6 +833,101 @@ export function friendlyProjectName(projectRoot) {
|
|
|
816
833
|
return safeText(projectRoot, 4096).replace(/\/+$/, '').split('/').at(-1)?.slice(0, 120) || 'Local project';
|
|
817
834
|
}
|
|
818
835
|
|
|
836
|
+
export function startDashboardInfo({ load = signal => request('/api/about', { signal }) } = {}) {
|
|
837
|
+
const $ = id => document.getElementById(id);
|
|
838
|
+
let closed = false, controller = null, timer = null, pending = null, command = '';
|
|
839
|
+
function render(info) {
|
|
840
|
+
if (!record(info) || typeof info.projectRoot !== 'string' ||
|
|
841
|
+
!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/.test(info.version ?? '')) {
|
|
842
|
+
throw new Error('invalid_dashboard_info');
|
|
843
|
+
}
|
|
844
|
+
$('project-path').textContent = safeText(info.projectRoot, 4096) || 'Path unavailable';
|
|
845
|
+
$('project-path').title = $('project-path').textContent;
|
|
846
|
+
$('graphlin-version').textContent = info.version;
|
|
847
|
+
$('version-update-steps').textContent = info.mode === 'demo'
|
|
848
|
+
? 'Press Ctrl+C in the demo terminal, then run this command to restart the updated offline demo.'
|
|
849
|
+
: 'Press Ctrl+C in the viewer terminal, then run this command. Start a new Claude Code or Codex session after setup finishes.';
|
|
850
|
+
const branch = info.branch;
|
|
851
|
+
$('project-branch').textContent = branch?.status === 'branch'
|
|
852
|
+
? safeText(branch.name, 1024) || 'Branch unavailable'
|
|
853
|
+
: branch?.status === 'detached' ? `Detached HEAD${branch.commit ? ` · ${safeText(branch.commit, 12)}` : ''}`
|
|
854
|
+
: branch?.status === 'not_git' ? 'Not a Git repository' : 'Branch unavailable';
|
|
855
|
+
$('project-branch').title = $('project-branch').textContent;
|
|
856
|
+
const latest = typeof info.update?.latest === 'string' &&
|
|
857
|
+
/^\d+\.\d+\.\d+$/.test(info.update.latest) ? info.update.latest : null;
|
|
858
|
+
const available = info.update?.status === 'available' && latest;
|
|
859
|
+
$('version-update-indicator').hidden = !available;
|
|
860
|
+
$('version-update-indicator').textContent = available ? `${latest} available` : 'Update available';
|
|
861
|
+
$('version-update-indicator').setAttribute('aria-label', available
|
|
862
|
+
? `Graphlin ${latest} is available. View update instructions.` : 'View update instructions');
|
|
863
|
+
$('version-update-status').textContent = available ? `Graphlin ${latest} is available`
|
|
864
|
+
: info.update?.status === 'current' ? 'No newer release found'
|
|
865
|
+
: 'Update check unavailable';
|
|
866
|
+
const nextCommand = info.update?.command;
|
|
867
|
+
command = available && typeof nextCommand === 'string' && nextCommand.trim() &&
|
|
868
|
+
nextCommand.length <= 8192 && !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(nextCommand)
|
|
869
|
+
? nextCommand : '';
|
|
870
|
+
$('version-update-guide').hidden = !command;
|
|
871
|
+
if ($('version-update-command').textContent !== command) {
|
|
872
|
+
$('version-update-command').textContent = command;
|
|
873
|
+
$('version-update-copy-status').textContent = '';
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
function refresh() {
|
|
877
|
+
if (closed) return Promise.resolve();
|
|
878
|
+
if (pending) return pending;
|
|
879
|
+
clearTimeout(timer);
|
|
880
|
+
controller = new AbortController();
|
|
881
|
+
pending = (async () => {
|
|
882
|
+
try {
|
|
883
|
+
await Promise.resolve();
|
|
884
|
+
if (closed) return;
|
|
885
|
+
const info = await load(controller.signal);
|
|
886
|
+
if (!closed) render(info);
|
|
887
|
+
} catch {
|
|
888
|
+
if (!closed) {
|
|
889
|
+
$('project-branch').textContent = 'Branch unavailable';
|
|
890
|
+
if ($('project-path').textContent === 'Checking…') $('project-path').textContent = 'Path unavailable';
|
|
891
|
+
if ($('graphlin-version').textContent === 'Checking…') $('graphlin-version').textContent = 'Version unavailable';
|
|
892
|
+
$('version-update-status').textContent = 'Update check unavailable';
|
|
893
|
+
$('version-update-indicator').hidden = true;
|
|
894
|
+
$('version-update-guide').hidden = true;
|
|
895
|
+
$('version-update-command').textContent = '';
|
|
896
|
+
command = '';
|
|
897
|
+
}
|
|
898
|
+
} finally {
|
|
899
|
+
pending = null;
|
|
900
|
+
controller = null;
|
|
901
|
+
if (!closed) timer = setTimeout(() => { void refresh(); }, 30000);
|
|
902
|
+
}
|
|
903
|
+
})();
|
|
904
|
+
return pending;
|
|
905
|
+
}
|
|
906
|
+
const onCopy = async () => {
|
|
907
|
+
if (closed || !command) return;
|
|
908
|
+
const copied = command;
|
|
909
|
+
try {
|
|
910
|
+
await window.navigator.clipboard.writeText(copied);
|
|
911
|
+
if (!closed && command === copied) $('version-update-copy-status').textContent = 'Copied';
|
|
912
|
+
} catch {
|
|
913
|
+
if (!closed && command === copied) {
|
|
914
|
+
$('version-update-copy-status').textContent = 'Select the command and copy it manually.';
|
|
915
|
+
$('version-update-command').focus();
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
$('version-update-copy').addEventListener('click', onCopy);
|
|
920
|
+
return {
|
|
921
|
+
refresh,
|
|
922
|
+
close() {
|
|
923
|
+
closed = true;
|
|
924
|
+
controller?.abort();
|
|
925
|
+
clearTimeout(timer);
|
|
926
|
+
$('version-update-copy').removeEventListener('click', onCopy);
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
819
931
|
export const ORIENTATION_PROMPT = 'Orient yourself in this project: read its main files and explain how the components connect.';
|
|
820
932
|
|
|
821
933
|
// Observations are not an installation or trust audit. Activity (including
|
|
@@ -1450,12 +1562,14 @@ export function startViewer() {
|
|
|
1450
1562
|
connection: 'connecting', busy: false, exporting: false, epoch: 0, connectEpoch: 0,
|
|
1451
1563
|
viewport: null, fitBounds: null, zoom: 1, followFit: true, lastGraphSignature: '', inspectorSignature: '',
|
|
1452
1564
|
nodeElements: new Map(), edgeElements: new Map(), activityElements: new Map(),
|
|
1453
|
-
views: new Map(), viewKey: null, view: null, displayGraph: null,
|
|
1454
|
-
|
|
1565
|
+
views: new Map(), viewKey: null, view: null, displayGraph: null, searchQuery: '',
|
|
1566
|
+
nodeTypes: null, nodeTypeButtons: new Map(),
|
|
1567
|
+
effects: new Map(), liveReady: false, motionReady: false, movement: null, closed: false, projectName: '',
|
|
1455
1568
|
};
|
|
1456
1569
|
const motionPreference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
1457
1570
|
const sketches = createSketchCache();
|
|
1458
1571
|
const detailSketches = createSketchCache(sketchDetails);
|
|
1572
|
+
const dashboardInfo = startDashboardInfo();
|
|
1459
1573
|
const connectionDialog = startConnectionDialog({ onInfo: info => {
|
|
1460
1574
|
state.projectName = friendlyProjectName(info.projectRoot);
|
|
1461
1575
|
renderStatus();
|
|
@@ -1524,7 +1638,7 @@ export function startViewer() {
|
|
|
1524
1638
|
$('onboarding-action').dataset.action = progress.next.action;
|
|
1525
1639
|
// Preserve a manual text selection while live snapshots arrive.
|
|
1526
1640
|
if ($('orientation-prompt').textContent !== ORIENTATION_PROMPT) $('orientation-prompt').textContent = ORIENTATION_PROMPT;
|
|
1527
|
-
$('orientation').hidden = Boolean(state.replayFrame || state.snapshot?.mode === 'demo' || state.snapshot?.mode === 'replay');
|
|
1641
|
+
$('orientation').hidden = Boolean(state.searchQuery || state.nodeTypes !== null || state.replayFrame || state.snapshot?.mode === 'demo' || state.snapshot?.mode === 'replay');
|
|
1528
1642
|
}
|
|
1529
1643
|
function currentGraph() { return state.replayFrame?.graph || state.snapshot?.graph; }
|
|
1530
1644
|
function applyTheme() {
|
|
@@ -1537,6 +1651,7 @@ export function startViewer() {
|
|
|
1537
1651
|
if (state.viewKey !== key) {
|
|
1538
1652
|
finishPan();
|
|
1539
1653
|
clearMotion();
|
|
1654
|
+
state.liveReady = false;
|
|
1540
1655
|
state.motionReady = false;
|
|
1541
1656
|
let view = state.views.get(key);
|
|
1542
1657
|
if (!view) view = createPresentation();
|
|
@@ -1580,14 +1695,14 @@ export function startViewer() {
|
|
|
1580
1695
|
state.effects.set(id, effect);
|
|
1581
1696
|
target.addEventListener('animationend', finish);
|
|
1582
1697
|
}
|
|
1583
|
-
function cancelMovement() {
|
|
1698
|
+
function cancelMovement({ fit = true } = {}) {
|
|
1584
1699
|
const movement = state.movement;
|
|
1585
1700
|
if (!movement) return;
|
|
1586
1701
|
state.movement = null;
|
|
1587
1702
|
window.cancelAnimationFrame?.(movement.frame);
|
|
1588
1703
|
clearTimeout(movement.timer);
|
|
1589
1704
|
if (state.displayGraph) paintGeometry(state.displayGraph);
|
|
1590
|
-
if (movement.fitAfter) fitCamera(movement.fitAfter);
|
|
1705
|
+
if (fit && movement.fitAfter) fitCamera(movement.fitAfter);
|
|
1591
1706
|
}
|
|
1592
1707
|
function clearMotion() {
|
|
1593
1708
|
cancelMovement();
|
|
@@ -1595,10 +1710,11 @@ export function startViewer() {
|
|
|
1595
1710
|
}
|
|
1596
1711
|
function resetMotionBaseline() {
|
|
1597
1712
|
finishPan();
|
|
1713
|
+
state.liveReady = false;
|
|
1598
1714
|
state.motionReady = false;
|
|
1599
1715
|
clearMotion();
|
|
1600
1716
|
}
|
|
1601
|
-
function animateChanges(changes, before) {
|
|
1717
|
+
function animateChanges(changes, before, focusNodeId) {
|
|
1602
1718
|
// Re-addition always cancels a removal, even when motion is suppressed.
|
|
1603
1719
|
for (const node of currentGraph().nodes) if (state.effects.get(node.id)?.element) finishEffect(node.id);
|
|
1604
1720
|
if (!motionAllowed()) return;
|
|
@@ -1627,7 +1743,7 @@ export function startViewer() {
|
|
|
1627
1743
|
removals.push(decoration);
|
|
1628
1744
|
}
|
|
1629
1745
|
if (removals.length) {
|
|
1630
|
-
fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
|
|
1746
|
+
if (!focusNodeId) fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
|
|
1631
1747
|
$('empty-canvas').hidden = true;
|
|
1632
1748
|
$('effects-layer').append(...removals);
|
|
1633
1749
|
}
|
|
@@ -1677,15 +1793,25 @@ export function startViewer() {
|
|
|
1677
1793
|
const eligible = streamed && state.motionReady && !switched && !state.replayFrame &&
|
|
1678
1794
|
snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay' && motionAllowed();
|
|
1679
1795
|
const changes = liveNodeChanges(state.snapshot?.graph, snapshot.graph, eligible);
|
|
1796
|
+
// A live baseline is independent of animation preferences. Reconnects and
|
|
1797
|
+
// initial/session snapshots establish it without focusing an old arrival.
|
|
1798
|
+
const live = streamed && state.liveReady && !switched && !state.replayFrame &&
|
|
1799
|
+
snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay';
|
|
1800
|
+
const visible = new Set(filterDiagram(snapshot.graph, state.searchQuery, state.nodeTypes).nodes.map(node => node.id));
|
|
1801
|
+
const focusNodeId = liveNodeChanges(state.snapshot?.graph, snapshot.graph, live).added.filter(id => visible.has(id)).at(-1);
|
|
1680
1802
|
const before = state.displayGraph;
|
|
1681
|
-
cancelMovement();
|
|
1682
|
-
if (switched)
|
|
1803
|
+
cancelMovement({ fit: !focusNodeId });
|
|
1804
|
+
if (switched) {
|
|
1805
|
+
resetView();
|
|
1806
|
+
state.nodeTypes = null;
|
|
1807
|
+
}
|
|
1683
1808
|
state.snapshot = snapshot;
|
|
1684
1809
|
state.epoch += 1;
|
|
1685
1810
|
state.frames = historyFrames(snapshot);
|
|
1686
1811
|
state.replayFrame = reconcileReplayFrame(state.frames, state.replayFrame);
|
|
1687
|
-
render();
|
|
1688
|
-
animateChanges(changes, before);
|
|
1812
|
+
render({ focusNodeId });
|
|
1813
|
+
animateChanges(changes, before, focusNodeId);
|
|
1814
|
+
state.liveReady = streamed && !state.replayFrame && snapshot.mode !== 'replay';
|
|
1689
1815
|
state.motionReady = streamed && !state.replayFrame && snapshot.mode !== 'replay' && motionAllowed();
|
|
1690
1816
|
$('updated-at').textContent = `Snapshot received ${formatTime(Date.now())}`;
|
|
1691
1817
|
}
|
|
@@ -1812,14 +1938,18 @@ export function startViewer() {
|
|
|
1812
1938
|
}
|
|
1813
1939
|
}
|
|
1814
1940
|
function revealInspector() {
|
|
1941
|
+
revealDetailsSection($('inspector-body')?.parentElement);
|
|
1942
|
+
}
|
|
1943
|
+
function revealDetailsSection(panel) {
|
|
1944
|
+
setWorkspacePanel('details', true);
|
|
1815
1945
|
const container = $('live-sidebar');
|
|
1816
|
-
const panel = $('inspector-body')?.parentElement;
|
|
1817
1946
|
if (!container?.scrollTo || !container.getBoundingClientRect || !panel?.getBoundingClientRect ||
|
|
1818
1947
|
!container.contains(panel)) return;
|
|
1819
1948
|
const region = container.getBoundingClientRect();
|
|
1820
1949
|
const evidence = panel.getBoundingClientRect();
|
|
1821
1950
|
if (!Number.isFinite(region.top) || !Number.isFinite(evidence.top) || !(region.height > 0)) return;
|
|
1822
|
-
const
|
|
1951
|
+
const headingHeight = $('details-heading')?.getBoundingClientRect?.().height || 0;
|
|
1952
|
+
const offset = evidence.top - region.top - (container.clientTop || 0) - headingHeight;
|
|
1823
1953
|
if (Math.abs(offset) < 1) return;
|
|
1824
1954
|
container.scrollTo({
|
|
1825
1955
|
top: Math.max(0, (container.scrollTop || 0) + offset),
|
|
@@ -1869,6 +1999,23 @@ export function startViewer() {
|
|
|
1869
1999
|
cancelMovement();
|
|
1870
2000
|
fitCamera(graphBounds(graph));
|
|
1871
2001
|
}
|
|
2002
|
+
function focusNode(node, bounds) {
|
|
2003
|
+
finishPan();
|
|
2004
|
+
const zoom = Math.max(.5, state.zoom);
|
|
2005
|
+
const scale = state.zoom / zoom;
|
|
2006
|
+
const width = state.viewport.width * scale;
|
|
2007
|
+
const height = state.viewport.height * scale;
|
|
2008
|
+
state.viewport = {
|
|
2009
|
+
x: node.x + NODE_WIDTH / 2 - width / 2,
|
|
2010
|
+
y: node.y + NODE_HEIGHT / 2 - height / 2,
|
|
2011
|
+
width, height,
|
|
2012
|
+
};
|
|
2013
|
+
state.zoom = zoom;
|
|
2014
|
+
state.fitBounds = bounds;
|
|
2015
|
+
// Removal cleanup must not replace arrival focus with a later fit-all.
|
|
2016
|
+
state.followFit = false;
|
|
2017
|
+
setViewBox();
|
|
2018
|
+
}
|
|
1872
2019
|
function zoom(factor, anchor = { x: .5, y: .5 }) {
|
|
1873
2020
|
if (!state.viewport || !state.fitBounds) return;
|
|
1874
2021
|
cancelMovement();
|
|
@@ -1885,20 +2032,49 @@ export function startViewer() {
|
|
|
1885
2032
|
state.followFit = false;
|
|
1886
2033
|
setViewBox();
|
|
1887
2034
|
}
|
|
1888
|
-
function
|
|
2035
|
+
function renderNodeTypeFilters(canonical) {
|
|
2036
|
+
const container = $('node-type-filters');
|
|
2037
|
+
if (!container) return;
|
|
2038
|
+
// Discover kinds from the current canonical canvas, never search results.
|
|
2039
|
+
const kinds = [...new Set(canonical.nodes.map(node => node.kind))].sort();
|
|
2040
|
+
for (const [kind, button] of state.nodeTypeButtons) {
|
|
2041
|
+
if (!kinds.includes(kind)) {
|
|
2042
|
+
button.remove();
|
|
2043
|
+
state.nodeTypeButtons.delete(kind);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
kinds.forEach((kind, index) => {
|
|
2047
|
+
let button = state.nodeTypeButtons.get(kind);
|
|
2048
|
+
if (!button) {
|
|
2049
|
+
button = html('button', upperFirst(kind), 'node-type-filter');
|
|
2050
|
+
button.setAttribute('type', 'button');
|
|
2051
|
+
button.dataset.kind = kind;
|
|
2052
|
+
state.nodeTypeButtons.set(kind, button);
|
|
2053
|
+
}
|
|
2054
|
+
button.setAttribute('aria-pressed', String(state.nodeTypes === null || state.nodeTypes.has(kind)));
|
|
2055
|
+
if (container.children[index] !== button) container.insertBefore(button, container.children[index] || null);
|
|
2056
|
+
});
|
|
2057
|
+
$('node-types-all')?.setAttribute('aria-pressed', String(state.nodeTypes === null));
|
|
2058
|
+
$('node-types-none')?.setAttribute('aria-pressed', String(state.nodeTypes?.size === 0));
|
|
2059
|
+
}
|
|
2060
|
+
function renderGraph({ forceFit = false, arrange = false, focusNodeId } = {}) {
|
|
1889
2061
|
const canonical = currentGraph();
|
|
1890
2062
|
if (!canonical) return;
|
|
1891
2063
|
const view = presentation();
|
|
1892
2064
|
applyTheme();
|
|
1893
|
-
|
|
2065
|
+
renderNodeTypeFilters(canonical);
|
|
2066
|
+
// Lay out only visible nodes so hidden components leave no empty slots.
|
|
2067
|
+
// Evidence, exports and live arrival detection retain the canonical graph.
|
|
2068
|
+
const graph = projectPresentation(filterDiagram(canonical, state.searchQuery, state.nodeTypes), view, { arrange });
|
|
1894
2069
|
state.displayGraph = graph;
|
|
1895
2070
|
const routes = graphEdgeRoutes(graph);
|
|
1896
2071
|
const bounds = graphBounds(graph, routes);
|
|
1897
2072
|
const signature = cameraGraphSignature(graph, view.algorithm);
|
|
1898
|
-
//
|
|
1899
|
-
//
|
|
1900
|
-
|
|
1901
|
-
if (
|
|
2073
|
+
// Focus the final projected position before inserting newcomers. Other
|
|
2074
|
+
// diagram changes retain fit-all; metadata-only updates keep the camera.
|
|
2075
|
+
const newest = graph.nodes.find(node => node.id === focusNodeId);
|
|
2076
|
+
if (newest && state.viewport && !forceFit) focusNode(newest, bounds);
|
|
2077
|
+
else if (forceFit || !state.viewport || signature !== state.lastGraphSignature) fitCamera(bounds);
|
|
1902
2078
|
state.lastGraphSignature = signature;
|
|
1903
2079
|
$('layout').value = view.algorithm;
|
|
1904
2080
|
$('auto-arrange').checked = view.auto;
|
|
@@ -1966,6 +2142,7 @@ export function startViewer() {
|
|
|
1966
2142
|
center,
|
|
1967
2143
|
svgElement('rect', { class: 'selection-ring', x: -7, y: -7, width: NODE_WIDTH + 14, height: NODE_HEIGHT + 14, rx: 14 }),
|
|
1968
2144
|
);
|
|
2145
|
+
group.setAttribute('transform', `translate(${node.x} ${node.y})`);
|
|
1969
2146
|
state.nodeElements.set(node.id, group);
|
|
1970
2147
|
$('node-layer').append(group);
|
|
1971
2148
|
}
|
|
@@ -2009,6 +2186,9 @@ export function startViewer() {
|
|
|
2009
2186
|
$('diagram-title').textContent = `${state.replayFrame ? 'Historical' : 'Live'} architecture, revision ${graph.revision}`;
|
|
2010
2187
|
$('diagram-desc').textContent = `${graph.nodes.length} components and ${graph.edges.length} relationships. Code interpretation does not establish runtime connectivity. Use Tab and Enter to inspect a component or relationship. With the diagram focused, use plus and minus to zoom, arrow keys to pan, and 0 to fit.`;
|
|
2011
2188
|
$('graph-count').textContent = `${graph.nodes.length} components · ${graph.edges.length} relationships`;
|
|
2189
|
+
$('diagram-search-status').textContent = state.searchQuery || state.nodeTypes !== null
|
|
2190
|
+
? `${graph.nodes.length} of ${canonical.nodes.length} components shown` : '';
|
|
2191
|
+
$('diagram-search-clear').hidden = !state.searchQuery;
|
|
2012
2192
|
$('empty-canvas').hidden = graph.nodes.length > 0 || removalBounds().length > 0;
|
|
2013
2193
|
const classifier = state.snapshot.paused ? 'paused' : state.snapshot.status.classifier;
|
|
2014
2194
|
const emptyMessages = {
|
|
@@ -2018,7 +2198,13 @@ export function startViewer() {
|
|
|
2018
2198
|
unavailable: ['Waiting for classification.', 'The classifier is unavailable. Safe activity continues below; supported architecture will appear when classification recovers.'],
|
|
2019
2199
|
timeout: ['Evidence needs another moment.', 'Classification exceeded its deadline. Activity still appears below, and no unsupported components are added.'],
|
|
2020
2200
|
};
|
|
2021
|
-
const message = state.
|
|
2201
|
+
const message = state.nodeTypes?.size === 0
|
|
2202
|
+
? ['No component types selected.', 'Choose a type or All types to show components.']
|
|
2203
|
+
: state.searchQuery
|
|
2204
|
+
? ['No matching components.', `No selected components match “${state.searchQuery}”. Try another search or press Esc to clear the search.`]
|
|
2205
|
+
: state.nodeTypes !== null && canonical.nodes.length
|
|
2206
|
+
? ['No matching components.', 'Choose another type or All types to show components.']
|
|
2207
|
+
: state.replayFrame
|
|
2022
2208
|
? ['No components in this revision.', 'Move through the recent revisions or return to Live to follow the current map.']
|
|
2023
2209
|
: emptyMessages[classifier] || ['Your architecture starts here.', 'Work in a connected agent session. Components appear when approved evidence supports them; activity can arrive first.'];
|
|
2024
2210
|
$('empty-title').textContent = message[0];
|
|
@@ -2087,8 +2273,7 @@ export function startViewer() {
|
|
|
2087
2273
|
finishPan();
|
|
2088
2274
|
cancelMovement();
|
|
2089
2275
|
const before = state.displayGraph;
|
|
2090
|
-
|
|
2091
|
-
renderGraph({ forceFit: true });
|
|
2276
|
+
renderGraph({ forceFit: true, arrange: true });
|
|
2092
2277
|
moveLayout(before, state.displayGraph);
|
|
2093
2278
|
announce(`Arranged using ${LAYOUT_NAMES[state.view.algorithm]}. Evidence and selection are unchanged.`);
|
|
2094
2279
|
}
|
|
@@ -2231,6 +2416,7 @@ export function startViewer() {
|
|
|
2231
2416
|
button.addEventListener('click', () => {
|
|
2232
2417
|
const row = state.activityElements.get(event.id);
|
|
2233
2418
|
if (row) {
|
|
2419
|
+
setWorkspacePanel('activity', true);
|
|
2234
2420
|
row.querySelector('button').focus();
|
|
2235
2421
|
row.scrollIntoView({ block: 'nearest' });
|
|
2236
2422
|
}
|
|
@@ -2293,6 +2479,7 @@ export function startViewer() {
|
|
|
2293
2479
|
updateSelection();
|
|
2294
2480
|
renderInspector();
|
|
2295
2481
|
renderActivity();
|
|
2482
|
+
revealInspector();
|
|
2296
2483
|
announce(`Related evidence for ${(node || edge).label} is shown in the inspector.`);
|
|
2297
2484
|
} else toast(state.replayFrame ? 'No evidence link for this event in the displayed revision. Return to Live to inspect current links.' : 'This captured event has no architecture evidence link. Tool activity alone does not establish an architectural claim.');
|
|
2298
2485
|
});
|
|
@@ -2305,10 +2492,10 @@ export function startViewer() {
|
|
|
2305
2492
|
$('activity-empty').hidden = events.length > 0;
|
|
2306
2493
|
$('activity-list').hidden = events.length === 0;
|
|
2307
2494
|
}
|
|
2308
|
-
function render() {
|
|
2495
|
+
function render(graphOptions) {
|
|
2309
2496
|
renderStatus();
|
|
2310
2497
|
renderOnboarding();
|
|
2311
|
-
renderGraph();
|
|
2498
|
+
renderGraph(graphOptions);
|
|
2312
2499
|
renderInspector();
|
|
2313
2500
|
renderHistory();
|
|
2314
2501
|
renderActivity();
|
|
@@ -2323,6 +2510,7 @@ export function startViewer() {
|
|
|
2323
2510
|
function replayAt(index) {
|
|
2324
2511
|
const frame = state.frames[index];
|
|
2325
2512
|
if (!frame) return;
|
|
2513
|
+
setWorkspacePanel('history', true);
|
|
2326
2514
|
resetMotionBaseline();
|
|
2327
2515
|
state.replayFrame = frame;
|
|
2328
2516
|
render();
|
|
@@ -2384,6 +2572,7 @@ export function startViewer() {
|
|
|
2384
2572
|
connection('reconnecting');
|
|
2385
2573
|
error('The live connection was lost. Displaying the last received snapshot while the viewer reconnects.');
|
|
2386
2574
|
});
|
|
2575
|
+
void dashboardInfo.refresh();
|
|
2387
2576
|
// Optional authenticated metadata must not hold up the event stream or
|
|
2388
2577
|
// turn an older server's missing endpoint into a connection failure.
|
|
2389
2578
|
const controller = new AbortController();
|
|
@@ -2392,6 +2581,8 @@ export function startViewer() {
|
|
|
2392
2581
|
const info = normalizeConnectionInfo(await request('/api/connection-info', { signal: controller.signal }));
|
|
2393
2582
|
if (!state.closed && attempt === state.connectEpoch) {
|
|
2394
2583
|
state.projectName = friendlyProjectName(info.projectRoot);
|
|
2584
|
+
$('project-path').textContent = info.projectRoot;
|
|
2585
|
+
$('project-path').title = info.projectRoot;
|
|
2395
2586
|
renderStatus();
|
|
2396
2587
|
}
|
|
2397
2588
|
} catch { /* The project ID remains a usable fallback. */ }
|
|
@@ -2446,15 +2637,110 @@ export function startViewer() {
|
|
|
2446
2637
|
$('orientation-prompt').focus();
|
|
2447
2638
|
}
|
|
2448
2639
|
};
|
|
2449
|
-
const
|
|
2450
|
-
|
|
2451
|
-
$('
|
|
2452
|
-
$('activity-
|
|
2453
|
-
|
|
2640
|
+
const workspacePanels = {
|
|
2641
|
+
details: { panel: $('live-sidebar'), toggle: $('details-toggle') },
|
|
2642
|
+
history: { panel: $('history-panel'), toggle: $('history-toggle') },
|
|
2643
|
+
activity: { panel: $('activity-panel'), toggle: $('activity-toggle') },
|
|
2644
|
+
};
|
|
2645
|
+
function setWorkspacePanel(name, open) {
|
|
2646
|
+
const { panel, toggle } = workspacePanels[name];
|
|
2647
|
+
if (!open && panel.contains(document.activeElement)) toggle.focus();
|
|
2648
|
+
panel.hidden = !open;
|
|
2649
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
2650
|
+
if (name === 'details') $('workspace-body').dataset.detailsOpen = String(open);
|
|
2651
|
+
if (name === 'activity') $('activity-content').hidden = !open;
|
|
2652
|
+
}
|
|
2653
|
+
const onToggleDetails = () => setWorkspacePanel('details', $('live-sidebar').hidden);
|
|
2654
|
+
const onToggleHistory = () => setWorkspacePanel('history', $('history-panel').hidden);
|
|
2655
|
+
const onToggleActivity = () => setWorkspacePanel('activity', $('activity-panel').hidden);
|
|
2656
|
+
const onCloseDetails = () => {
|
|
2657
|
+
setWorkspacePanel('details', false);
|
|
2658
|
+
$('details-toggle').focus();
|
|
2659
|
+
};
|
|
2660
|
+
const onViewUpdate = () => {
|
|
2661
|
+
$('version-update-guide').open = !$('version-update-guide').hidden;
|
|
2662
|
+
revealDetailsSection($('version-update-status'));
|
|
2454
2663
|
};
|
|
2664
|
+
const panelKeyHandlers = new Map();
|
|
2665
|
+
for (const [name, { panel, toggle }] of Object.entries(workspacePanels)) {
|
|
2666
|
+
setWorkspacePanel(name, false);
|
|
2667
|
+
const onKeyDown = event => {
|
|
2668
|
+
if (event.key !== 'Escape' || event.defaultPrevented || event.target?.tagName?.toLowerCase() === 'select') return;
|
|
2669
|
+
event.preventDefault();
|
|
2670
|
+
setWorkspacePanel(name, false);
|
|
2671
|
+
toggle.focus();
|
|
2672
|
+
};
|
|
2673
|
+
panel.addEventListener('keydown', onKeyDown);
|
|
2674
|
+
panelKeyHandlers.set(panel, onKeyDown);
|
|
2675
|
+
}
|
|
2455
2676
|
$('onboarding-action').addEventListener('click', onOnboardingAction);
|
|
2456
2677
|
$('orientation-copy').addEventListener('click', onCopyOrientation);
|
|
2678
|
+
$('details-toggle').addEventListener('click', onToggleDetails);
|
|
2679
|
+
$('details-close').addEventListener('click', onCloseDetails);
|
|
2680
|
+
$('version-update-indicator').addEventListener('click', onViewUpdate);
|
|
2681
|
+
$('history-toggle').addEventListener('click', onToggleHistory);
|
|
2457
2682
|
$('activity-toggle').addEventListener('click', onToggleActivity);
|
|
2683
|
+
const applyFilters = () => {
|
|
2684
|
+
// Filtering is not a source change: cancel existing decoration/movement
|
|
2685
|
+
// and render directly, without changing the snapshot arrival baseline.
|
|
2686
|
+
finishPan();
|
|
2687
|
+
clearMotion();
|
|
2688
|
+
renderOnboarding();
|
|
2689
|
+
renderGraph({ forceFit: true, arrange: true });
|
|
2690
|
+
};
|
|
2691
|
+
const onSearchInput = () => {
|
|
2692
|
+
if (state.closed || state.searchQuery === $('diagram-search').value) return;
|
|
2693
|
+
state.searchQuery = $('diagram-search').value;
|
|
2694
|
+
applyFilters();
|
|
2695
|
+
};
|
|
2696
|
+
const onNodeTypeClick = event => {
|
|
2697
|
+
if (state.closed || !currentGraph()) return;
|
|
2698
|
+
const container = $('node-type-filters');
|
|
2699
|
+
let button = event.target;
|
|
2700
|
+
while (button && button.parentElement !== container) button = button.parentElement;
|
|
2701
|
+
const kind = button?.dataset.kind;
|
|
2702
|
+
if (!kind || !state.nodeTypeButtons.has(kind)) return;
|
|
2703
|
+
// null means all, including future kinds. Explicit selections retain their
|
|
2704
|
+
// choices when a kind disappears and returns; new kinds stay unselected.
|
|
2705
|
+
if (state.nodeTypes === null) state.nodeTypes = new Set(currentGraph().nodes.map(node => node.kind));
|
|
2706
|
+
if (state.nodeTypes.has(kind)) state.nodeTypes.delete(kind);
|
|
2707
|
+
else state.nodeTypes.add(kind);
|
|
2708
|
+
applyFilters();
|
|
2709
|
+
};
|
|
2710
|
+
const onAllNodeTypes = () => {
|
|
2711
|
+
if (state.closed) return;
|
|
2712
|
+
state.nodeTypes = null;
|
|
2713
|
+
applyFilters();
|
|
2714
|
+
};
|
|
2715
|
+
const onNoNodeTypes = () => {
|
|
2716
|
+
if (state.closed) return;
|
|
2717
|
+
state.nodeTypes = new Set();
|
|
2718
|
+
applyFilters();
|
|
2719
|
+
};
|
|
2720
|
+
const clearSearch = () => {
|
|
2721
|
+
$('diagram-search').value = '';
|
|
2722
|
+
onSearchInput();
|
|
2723
|
+
$('diagram-search').focus({ preventScroll: true });
|
|
2724
|
+
};
|
|
2725
|
+
const onSearchKeyDown = event => {
|
|
2726
|
+
if (state.closed || event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey ||
|
|
2727
|
+
$('diagnostics-dialog').open || $('connection-dialog').open || document.querySelector?.('dialog[open], [role="dialog"][aria-modal="true"]')) return;
|
|
2728
|
+
const target = event.target || document.activeElement;
|
|
2729
|
+
if (target !== $('diagram-search') && isSearchTypingTarget(target)) return;
|
|
2730
|
+
if (event.key === '/' && target !== $('diagram-search')) {
|
|
2731
|
+
event.preventDefault();
|
|
2732
|
+
$('diagram-search').focus({ preventScroll: true });
|
|
2733
|
+
} else if (event.key === 'Escape' && state.searchQuery) {
|
|
2734
|
+
event.preventDefault();
|
|
2735
|
+
clearSearch();
|
|
2736
|
+
}
|
|
2737
|
+
};
|
|
2738
|
+
$('diagram-search').addEventListener('input', onSearchInput);
|
|
2739
|
+
$('diagram-search-clear').addEventListener('click', clearSearch);
|
|
2740
|
+
$('node-type-filters')?.addEventListener('click', onNodeTypeClick);
|
|
2741
|
+
$('node-types-all')?.addEventListener('click', onAllNodeTypes);
|
|
2742
|
+
$('node-types-none')?.addEventListener('click', onNoNodeTypes);
|
|
2743
|
+
window.addEventListener('keydown', onSearchKeyDown);
|
|
2458
2744
|
$('pause').addEventListener('click', () => control(state.snapshot?.paused ? 'resume' : 'pause'));
|
|
2459
2745
|
$('session').addEventListener('change', () => control('session', $('session').value));
|
|
2460
2746
|
$('export').addEventListener('click', exportJSON);
|
|
@@ -2595,9 +2881,21 @@ export function startViewer() {
|
|
|
2595
2881
|
state.closed = true;
|
|
2596
2882
|
projectController?.abort();
|
|
2597
2883
|
projectController = null;
|
|
2884
|
+
dashboardInfo.close();
|
|
2598
2885
|
$('onboarding-action').removeEventListener('click', onOnboardingAction);
|
|
2599
2886
|
$('orientation-copy').removeEventListener('click', onCopyOrientation);
|
|
2887
|
+
$('details-toggle').removeEventListener('click', onToggleDetails);
|
|
2888
|
+
$('details-close').removeEventListener('click', onCloseDetails);
|
|
2889
|
+
$('version-update-indicator').removeEventListener('click', onViewUpdate);
|
|
2890
|
+
$('history-toggle').removeEventListener('click', onToggleHistory);
|
|
2600
2891
|
$('activity-toggle').removeEventListener('click', onToggleActivity);
|
|
2892
|
+
for (const [panel, handler] of panelKeyHandlers) panel.removeEventListener('keydown', handler);
|
|
2893
|
+
$('diagram-search').removeEventListener('input', onSearchInput);
|
|
2894
|
+
$('diagram-search-clear').removeEventListener('click', clearSearch);
|
|
2895
|
+
$('node-type-filters')?.removeEventListener('click', onNodeTypeClick);
|
|
2896
|
+
$('node-types-all')?.removeEventListener('click', onAllNodeTypes);
|
|
2897
|
+
$('node-types-none')?.removeEventListener('click', onNoNodeTypes);
|
|
2898
|
+
window.removeEventListener?.('keydown', onSearchKeyDown);
|
|
2601
2899
|
$('architecture').removeEventListener('wheel', onDiagramWheel);
|
|
2602
2900
|
connectionDialog.dispose();
|
|
2603
2901
|
diagnosticsDialog.dispose();
|