@sleetdrop/dsh-plugin-topology 0.4.0 → 0.5.0

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/NEXT-STEPS.md CHANGED
@@ -17,16 +17,23 @@ unresolved dependencies (red stroke). Color nodes by state instead:
17
17
  This surfaces the most common diagnostic question — "which plugins did not
18
18
  load successfully" — at a glance.
19
19
 
20
- ## Click a node for details
21
-
22
- The snapshot captures each plugin's `inject` (service names) and `source`
23
- (module specifier), but the UI never shows them. Clicking a node could open a
24
- small popover with state, injected services, module path, and degree
25
- centrality. Requires switching the SVG from an `<img>` to inline rendering
26
- with pointer hit-testing — a medium-sized change.
20
+ Worth noting the boundary: when DSH cannot boot at all, this panel cannot
21
+ render either, so pre-boot diagnosis belongs to a CLI tool. State coloring
22
+ targets the other case — DSH runs, but some plugin misbehaved.
27
23
 
28
24
  ## Deferred ideas
29
25
 
30
26
  - Show an instance count (`timer ×3`) instead of the ordinal list. Deferred:
31
27
  the ordinal list doubles as a startup-order hint, and adding the count
32
28
  crowds the label.
29
+ - Transitively affected plugins ("if I remove X, what breaks?"). Deferred:
30
+ direct in/out degree already covers the common question, and transitive
31
+ closure is a query better served by the JSON export than by the picture.
32
+
33
+ ## Done
34
+
35
+ - **Click a node for details** — shipped in `0.5.0` as a node-anchored popover
36
+ (state, module source, drawn in/out degree, npm link) with `deg⁺`/`deg⁻`
37
+ toggles that light one dependency direction at a time. The SVG moved from an
38
+ `<img>` to inline rendering; highlight classes are applied to the Graphviz DOM
39
+ and never baked into the exported SVG.
package/README.md CHANGED
@@ -67,6 +67,26 @@ centered window. Same-named plugin instances merge into one node; each node's
67
67
  label carries the instance creation ordinals in brackets (`timer [1,9,23]`),
68
68
  assigned at startup and meaningful only within that run.
69
69
 
70
+ ### Node inspection
71
+
72
+ The SVG renders inline, so the graph is interactive without baking anything
73
+ into the export — a downloaded SVG still opens clean in any viewer.
74
+
75
+ - **Hover** raises a light ring on the node, nothing else.
76
+ - **Click** opens a card anchored to that node (it tracks the node through pan
77
+ and zoom). The card shows only what needs a running process to know: the
78
+ fiber `state`, the module `source`, and the drawn in/out degree. The name
79
+ links to the npm page for everything that does not (version, description,
80
+ license, repository).
81
+ - **`deg⁺` / `deg⁻`** are pill toggles for the graph-theory out-/in-degree as
82
+ drawn in the collapsed projection. Tapping one lights exactly that
83
+ direction's dependency edges and their far ends while the rest fades; tapping
84
+ it again clears. They are disabled at zero.
85
+
86
+ Degree counts are read from the rendered SVG rather than the raw snapshot, so a
87
+ merged node reports the edges actually drawn after the projection collapses
88
+ same-named instances.
89
+
70
90
  The client injects `remote` (the gateway ClientRemote service) and self-mounts
71
91
  its own `pluginTopology` Remote contribution, so it does not require editing
72
92
  the host assembly's contribution list.
@@ -79,7 +99,7 @@ version on their own schedule, independent of which DSH release it targets.
79
99
  The table below maps each plugin version to the DSH release it was validated
80
100
  against, so pick the plugin version whose target DSH matches your harness.
81
101
 
82
- Current release targets DeepSeek Harness `0.1.7-rc.2`; the `peerDependencies`
102
+ Current release targets DeepSeek Harness `0.2.0-rc.2`; the `peerDependencies`
83
103
  pin the client packages and `@deepseek-ai/cordis@^4.0.4` the snapshot reads
84
104
  through. The 0.1.2 rc line removed the old `dsh-client-runtime` browser runtime:
85
105
  the client now runs on the Cordis `Context` augmented by the shell baseline
@@ -101,6 +121,7 @@ releases only and skips the fast-moving `alpha` line.
101
121
  | `0.3.0` | `0.1.5-rc.1` | Dependency refresh for DSH 0.1.5-rc.1; no code changes required. |
102
122
  | `0.3.1` | `0.1.5-rc.3` | Peer refresh to DSH 0.1.5-rc.3 (same 0.1.5 rc line). |
103
123
  | `0.4.0` | `0.1.7-rc.2` | TypertCodec API migration (`schema` → `create`); cordis ^4.0.4. |
124
+ | `0.5.0` | `0.2.0-rc.2` | Node detail popover + direction-scoped dependency highlight; panel colors rebound to the real DSH theme tokens (dark theme); peer refresh to DSH 0.2.0-rc.2 (no code changes required). |
104
125
 
105
126
  ## Known Limitations
106
127
 
@@ -121,8 +142,8 @@ pnpm run typecheck # noEmit check
121
142
  ```
122
143
 
123
144
  The `dsh.client` browser bundle inlines everything except the shell's frozen
124
- platform-module rows (`react`, `@deepseek-ai/cordis`, `dsh-client-store`, and
125
- `dsh-client-ui-slots`), which every 0.1.2-rc.1 harness shell serves.
145
+ platform-module rows (`react`, `react/jsx-runtime`, and
146
+ `@deepseek-ai/dsh-client-store`), which every supported harness shell serves.
126
147
 
127
148
  See [NEXT-STEPS.md](NEXT-STEPS.md) for planned renderer improvements.
128
149
 
@@ -147,8 +168,8 @@ store avoids the npm cache entirely:
147
168
  pnpm publish # same prepublishOnly gate, pnpm store
148
169
  ```
149
170
 
150
- `files` ships `lib/`, `cordis.patch.yml`, and `overlay.example.yml`; npm adds
151
- README and LICENSE automatically.
171
+ `files` ships `lib/`, `cordis.patch.yml`, `overlay.example.yml`, and
172
+ `NEXT-STEPS.md`; npm adds README and LICENSE automatically.
152
173
 
153
174
  ## License
154
175
 
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { PluginNode, TopologyAnalysis } from '../types.ts';
3
+ import type { PluginTopologyLocaleKey } from './locales.ts';
4
+ /** The currently selected node's detail data, derived from the analysis. */
5
+ export interface NodeDetail {
6
+ readonly node: PluginNode;
7
+ readonly dependsOnCount: number;
8
+ readonly dependedByCount: number;
9
+ readonly unresolvedCount: number;
10
+ }
11
+ export interface NodeDetailPanelProps {
12
+ readonly detail: NodeDetail;
13
+ readonly analysis: TopologyAnalysis;
14
+ /** Which dependency direction is currently highlighted on the graph, if any. */
15
+ readonly highlightDirection: 'depends-on' | 'depended-by' | null;
16
+ readonly onHighlight: (direction: 'depends-on' | 'depended-by' | null) => void;
17
+ readonly onClose: () => void;
18
+ readonly t: (key: PluginTopologyLocaleKey) => string;
19
+ }
20
+ /**
21
+ * Side panel showing runtime-only information about the selected plugin node.
22
+ * Displays name (linked to npm), state badge, source path, dependency counts
23
+ * (clickable to highlight edges on the graph), and unresolved dependency count.
24
+ */
25
+ export declare function NodeDetailPanel({ detail, highlightDirection, onHighlight, onClose, t, }: NodeDetailPanelProps): ReactNode;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { IconCloseOutline16 } from "./icons.js";
3
+ import css from './NodeDetailPanel.module.css';
4
+ /** Build an npm registry URL from a source specifier, or null for local paths. */
5
+ function npmUrl(source) {
6
+ if (source === undefined)
7
+ return null;
8
+ // npm package names: @scope/name or bare-name (no path separators beyond scope)
9
+ if (source.startsWith('/') || source.startsWith('.') || source.includes(':\\'))
10
+ return null;
11
+ return `https://www.npmjs.com/package/${encodeURIComponent(source)}`;
12
+ }
13
+ function stateBadgeClass(state) {
14
+ if (state === 'ACTIVE')
15
+ return `${css.stateBadge} ${css.stateActive}`;
16
+ if (state === 'FAILED')
17
+ return `${css.stateBadge} ${css.stateFailed}`;
18
+ return `${css.stateBadge} ${css.stateOther}`;
19
+ }
20
+ /**
21
+ * Side panel showing runtime-only information about the selected plugin node.
22
+ * Displays name (linked to npm), state badge, source path, dependency counts
23
+ * (clickable to highlight edges on the graph), and unresolved dependency count.
24
+ */
25
+ export function NodeDetailPanel({ detail, highlightDirection, onHighlight, onClose, t, }) {
26
+ const { node, dependsOnCount, dependedByCount, unresolvedCount } = detail;
27
+ const url = npmUrl(node.source);
28
+ const toggleHighlight = (direction) => {
29
+ onHighlight(highlightDirection === direction ? null : direction);
30
+ };
31
+ return (_jsxs("aside", { className: css.panel, "aria-label": t('detailClose'), children: [_jsxs("div", { className: css.header, children: [url !== null ? (_jsx("a", { className: css.nameLink, href: url, target: "_blank", rel: "noopener noreferrer", children: node.label })) : (_jsx("span", { className: css.nameLink, style: { color: 'var(--dsw-text-primary, #0f172a)' }, children: node.label })), _jsx("button", { type: "button", className: css.closeButton, onClick: onClose, "aria-label": t('detailClose'), children: _jsx(IconCloseOutline16, { size: 14 }) })] }), _jsx("span", { className: stateBadgeClass(node.state), children: node.state }), node.source !== undefined && (_jsxs("div", { className: css.row, children: [_jsx("span", { className: css.rowLabel, children: t('detailSource') }), _jsx("span", { className: `${css.rowValue} ${css.sourceValue}`, children: node.source })] })), _jsxs("div", { className: css.row, children: [_jsx("span", { className: css.rowLabel, children: t('detailDependsOn') }), _jsx("button", { type: "button", className: highlightDirection === 'depends-on' ? `${css.countButton} ${css.countButtonActive}` : css.countButton, onClick: () => { toggleHighlight('depends-on'); }, disabled: dependsOnCount === 0, children: dependsOnCount })] }), _jsxs("div", { className: css.row, children: [_jsx("span", { className: css.rowLabel, children: t('detailDependedBy') }), _jsx("button", { type: "button", className: highlightDirection === 'depended-by' ? `${css.countButton} ${css.countButtonActive}` : css.countButton, onClick: () => { toggleHighlight('depended-by'); }, disabled: dependedByCount === 0, children: dependedByCount })] }), unresolvedCount > 0 && (_jsxs("div", { className: css.row, children: [_jsx("span", { className: css.rowLabel, children: t('detailUnresolved') }), _jsx("span", { className: `${css.rowValue} ${css.unresolvedValue}`, children: unresolvedCount })] }))] }));
32
+ }
@@ -0,0 +1,39 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { HighlightDirection } from './TopologyGraphView.tsx';
3
+ import type { TopologyAnalysis } from '../types.ts';
4
+ import type { PluginTopologyLocaleKey } from './locales.ts';
5
+ export interface PopoverData {
6
+ /** Stable element id of the selected node in the SVG. */
7
+ readonly key: string;
8
+ /** DOT node id (`p:3` / `m:0`). */
9
+ readonly nodeId: string;
10
+ readonly label: string;
11
+ /** Tap point in graph coordinates; the durable anchor across pan and zoom. */
12
+ readonly anchorX: number;
13
+ readonly anchorY: number;
14
+ /** Viewport-relative x derived from the live transform. */
15
+ readonly x: number;
16
+ /** Viewport-relative y derived from the live transform. */
17
+ readonly y: number;
18
+ /** Outgoing direct dependencies (deg⁺) as drawn. */
19
+ readonly outCount: number;
20
+ /** Incoming direct dependents (deg⁻) as drawn. */
21
+ readonly inCount: number;
22
+ }
23
+ export interface NodePopoverProps {
24
+ readonly popover: PopoverData;
25
+ readonly analysis: TopologyAnalysis;
26
+ /** Currently highlighted direction, or null when only the node is selected. */
27
+ readonly direction: HighlightDirection | null;
28
+ /** Toggle one direction's edge highlight on or off. */
29
+ readonly onToggleDirection: (direction: HighlightDirection) => void;
30
+ readonly onClose: () => void;
31
+ readonly t: (key: PluginTopologyLocaleKey) => string;
32
+ }
33
+ /**
34
+ * Floating detail card anchored to a clicked graph node. It shows only facts
35
+ * that need the running process to know (state, load source, drawn in/out
36
+ * degree) and links the name out to npm for everything else. The deg⁺/deg⁻
37
+ * pills drive the graph highlight, so the panel itself stays a one-screen read.
38
+ */
39
+ export declare function NodePopover({ popover, analysis, direction, onToggleDirection, onClose, t, }: NodePopoverProps): ReactNode;
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from 'react';
3
+ import { IconCloseOutline16 } from "./icons.js";
4
+ import css from './NodePopover.module.css';
5
+ /** Gap between the anchor and the card's top-left corner, in px. */
6
+ const OFFSET = 8;
7
+ /** Build an npm registry URL from a source specifier, or null for local paths. */
8
+ function npmUrl(source) {
9
+ if (source === undefined)
10
+ return null;
11
+ if (source.startsWith('/') || source.startsWith('.') || source.includes(':\\'))
12
+ return null;
13
+ return `https://www.npmjs.com/package/${encodeURIComponent(source)}`;
14
+ }
15
+ function stateBadgeClass(state) {
16
+ if (state === 'ACTIVE')
17
+ return `${css.stateBadge} ${css.stateActive}`;
18
+ if (state === 'FAILED')
19
+ return `${css.stateBadge} ${css.stateFailed}`;
20
+ return `${css.stateBadge} ${css.stateOther}`;
21
+ }
22
+ /** The worst lifecycle state in a group, so a broken instance is never hidden. */
23
+ function worstState(states) {
24
+ if (states.includes('FAILED'))
25
+ return 'FAILED';
26
+ if (states.includes('PENDING'))
27
+ return 'PENDING';
28
+ return states[0] ?? 'ACTIVE';
29
+ }
30
+ /**
31
+ * Resolve the runtime facts the panel shows. Single instances map straight to a
32
+ * snapshot node; merged render nodes (`m:N`, several same-named instances drawn
33
+ * as one) are recovered from the ordinals in the rendered label.
34
+ */
35
+ function resolveNode(nodeId, svgLabel, analysis) {
36
+ const direct = analysis.collapsed.nodes.find(node => node.id === nodeId);
37
+ if (direct !== undefined) {
38
+ return {
39
+ node: direct,
40
+ unresolved: analysis.graph.unresolved.filter(dep => dep.plugin === nodeId).length,
41
+ };
42
+ }
43
+ const bracket = svgLabel.match(/\[([^\]]*)\]\s*$/);
44
+ const baseName = svgLabel.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
45
+ const ordinals = bracket === null
46
+ ? []
47
+ : bracket[1].split(',').map(part => part.trim()).filter(part => part !== '' && part !== '…');
48
+ let members = analysis.collapsed.nodes.filter(node => node.label === baseName);
49
+ if (ordinals.length > 0) {
50
+ const wanted = new Set(ordinals.map(ordinal => `p:${ordinal}`));
51
+ const matched = members.filter(node => wanted.has(node.id));
52
+ if (matched.length > 0)
53
+ members = matched;
54
+ }
55
+ const source = members.find(member => member.source !== undefined)?.source;
56
+ const node = {
57
+ id: nodeId,
58
+ kind: 'plugin',
59
+ label: baseName === '' ? svgLabel : baseName,
60
+ state: worstState(members.map(member => member.state)),
61
+ parent: null,
62
+ inject: [],
63
+ ...(source === undefined ? {} : { source }),
64
+ };
65
+ const unresolved = members.reduce((total, member) => total + analysis.graph.unresolved.filter(dep => dep.plugin === member.id).length, 0);
66
+ return { node, unresolved };
67
+ }
68
+ /**
69
+ * Floating detail card anchored to a clicked graph node. It shows only facts
70
+ * that need the running process to know (state, load source, drawn in/out
71
+ * degree) and links the name out to npm for everything else. The deg⁺/deg⁻
72
+ * pills drive the graph highlight, so the panel itself stays a one-screen read.
73
+ */
74
+ export function NodePopover({ popover, analysis, direction, onToggleDirection, onClose, t, }) {
75
+ const ref = useRef(null);
76
+ const { node, unresolved } = resolveNode(popover.nodeId, popover.label, analysis);
77
+ const url = npmUrl(node.source);
78
+ // Track the anchor on every pan/zoom. The card sits just off the tap point,
79
+ // clamped inside the viewport so it stays readable and slides along the edge
80
+ // once its node scrolls out instead of being left behind.
81
+ useEffect(() => {
82
+ const el = ref.current;
83
+ if (el === null)
84
+ return;
85
+ const parent = el.parentElement;
86
+ if (parent === null)
87
+ return;
88
+ const bounds = parent.getBoundingClientRect();
89
+ const own = el.getBoundingClientRect();
90
+ const maxLeft = Math.max(OFFSET, bounds.width - own.width - OFFSET);
91
+ const maxTop = Math.max(OFFSET, bounds.height - own.height - OFFSET);
92
+ el.style.left = `${Math.min(Math.max(OFFSET, popover.x + OFFSET), maxLeft)}px`;
93
+ el.style.top = `${Math.min(Math.max(OFFSET, popover.y + OFFSET), maxTop)}px`;
94
+ }, [popover.x, popover.y]);
95
+ return (_jsxs("div", { ref: ref, className: css.popover, style: { left: popover.x + OFFSET, top: popover.y + OFFSET }, role: "dialog", "aria-label": node.label, children: [_jsxs("div", { className: css.header, children: [url !== null ? (_jsx("a", { className: css.nameLink, href: url, target: "_blank", rel: "noopener noreferrer", children: node.label })) : (_jsx("span", { className: css.namePlain, children: node.label })), _jsx("button", { type: "button", className: css.closeButton, onClick: onClose, "aria-label": t('popoverClose'), children: _jsx(IconCloseOutline16, { size: 12 }) })] }), _jsxs("div", { className: css.metaRow, children: [_jsx("span", { className: stateBadgeClass(node.state), children: node.state }), unresolved > 0 && (_jsxs("span", { className: css.unresolvedBadge, children: [t('popoverUnresolved'), " ", unresolved] }))] }), node.source !== undefined && (_jsxs("div", { className: css.row, children: [_jsx("span", { className: css.rowLabel, children: t('popoverSource') }), _jsx("span", { className: `${css.rowValue} ${css.sourceValue}`, children: node.source })] })), _jsxs("div", { className: css.depPills, children: [_jsxs("button", { type: "button", className: direction === 'out' ? `${css.depPill} ${css.depPillActive}` : css.depPill, "aria-pressed": direction === 'out', "aria-label": t('popoverOutgoing'), title: t('popoverOutgoing'), disabled: popover.outCount === 0, onClick: () => { onToggleDirection('out'); }, children: [_jsx("span", { className: css.degMark, children: "deg\u207A" }), _jsx("span", { className: css.degValue, children: popover.outCount })] }), _jsxs("button", { type: "button", className: direction === 'in' ? `${css.depPill} ${css.depPillActive}` : css.depPill, "aria-pressed": direction === 'in', "aria-label": t('popoverIncoming'), title: t('popoverIncoming'), disabled: popover.inCount === 0, onClick: () => { onToggleDirection('in'); }, children: [_jsx("span", { className: css.degMark, children: "deg\u207B" }), _jsx("span", { className: css.degValue, children: popover.inCount })] })] })] }));
96
+ }
@@ -1,5 +1,35 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { TopologyTransform } from './stores.ts';
3
+ /** Dependency direction highlighted from the selected node. */
4
+ export type HighlightDirection = 'out' | 'in';
5
+ /** A node resolved from the composed SVG DOM. */
6
+ export interface GraphNodeRef {
7
+ /** Stable element id (`node-p:3` / `iso-node-m:0`), unique across the composed SVG. */
8
+ readonly key: string;
9
+ /** DOT node id from the `<title>` text (`p:3` / `m:0`), used to match edge endpoints. */
10
+ readonly dotId: string;
11
+ /** Display label (`ApprovalService [60,96]`). */
12
+ readonly label: string;
13
+ }
14
+ /** Data passed when a graph node is tapped. */
15
+ export interface NodeClickInfo {
16
+ /** Stable element id, echoed back as the selection key. */
17
+ readonly key: string;
18
+ /** DOT node id (`p:3` / `m:0`). */
19
+ readonly nodeId: string;
20
+ /** Display label as rendered, including the instance ordinals. */
21
+ readonly label: string;
22
+ /**
23
+ * Tap point in graph coordinates. Anchoring here rather than in viewport px
24
+ * keeps the popover attached to its node across pan and zoom.
25
+ */
26
+ readonly anchorX: number;
27
+ readonly anchorY: number;
28
+ /** Outgoing direct dependencies (deg⁺) as drawn in the current projection. */
29
+ readonly outCount: number;
30
+ /** Incoming direct dependents (deg⁻) as drawn in the current projection. */
31
+ readonly inCount: number;
32
+ }
3
33
  /** Zoom/pan props plus the localized control labels and the shared transform. */
4
34
  export interface TopologyGraphViewProps {
5
35
  svg: string;
@@ -16,12 +46,25 @@ export interface TopologyGraphViewProps {
16
46
  resetViewLabel: string;
17
47
  /** Accessible name for the clickable zoom-level toggle. */
18
48
  zoomLevelLabel: string;
49
+ /** Called when a graph node is tapped; receives id, label, counts, and viewport coords. */
50
+ onNodeClick?: (info: NodeClickInfo) => void;
51
+ /** Called when empty space is tapped (to close the popover and drop the highlight). */
52
+ onEmptyClick?: () => void;
53
+ /** Element id of the selected node, or null when nothing is selected. */
54
+ selectedKey?: string | null;
55
+ /** DOT id of the selected node, used to match edge endpoints. */
56
+ selectedDotId?: string | null;
57
+ /** Active dependency direction to highlight; null means selection only. */
58
+ direction?: HighlightDirection | null;
19
59
  }
20
60
  /**
21
- * Pan/zoom SVG viewer: fit-and-center on load, wheel zooms toward the cursor,
22
- * drag pans, double-click zooms toward the cursor, and a compact floating pill
23
- * (fit / zoom-out / zoom-level / zoom-in) collapses to just the fit control at
24
- * rest. The transform is written back to the parent store on every change so it
25
- * survives panel close/reopen.
61
+ * Pan/zoom SVG viewer with drill-down interaction. Fit-and-center on load,
62
+ * wheel zooms toward the cursor, drag pans, double-click zooms toward the
63
+ * cursor, and a compact floating pill (fit / zoom-out / zoom-level / zoom-in)
64
+ * collapses to just the fit control at rest.
65
+ *
66
+ * Hovering a node raises a ring; tapping it fires onNodeClick with viewport
67
+ * coordinates and the drawn deg⁺/deg⁻ counts. Tapping empty space fires
68
+ * onEmptyClick. `direction` lights only the chosen dependency direction.
26
69
  */
27
- export declare function TopologyGraphView({ svg, alt, height, transform, onTransformChange, zoomInLabel, zoomOutLabel, resetViewLabel, zoomLevelLabel, }: TopologyGraphViewProps): ReactNode;
70
+ export declare function TopologyGraphView({ svg, alt, height, transform, onTransformChange, zoomInLabel, zoomOutLabel, resetViewLabel, zoomLevelLabel, onNodeClick, onEmptyClick, selectedKey, selectedDotId, direction, }: TopologyGraphViewProps): ReactNode;