{
incomingCount?: number;
outgoingCount?: number;
tags?: string[];
+ dense?: boolean;
}
export type CustomFlowNode = Node
;
@@ -129,11 +130,38 @@ function CustomNodeComponent({
const name = data.label ?? "unnamed";
const truncatedName =
- name.length > 24 ? name.slice(0, 22) + "..." : name;
+ name.length > 60 ? name.slice(0, 58) + "..." : name;
+
+ // Dense row variant for children of large expanded containers.
+ if (data.dense) {
+ return (
+ data.onNodeClick?.(id)}
+ title={data.summary}
+ >
+
+
+
+
+ {name}
+
+
+ {data.nodeType}
+
+
+
+
+ );
+ }
return (
data.onNodeClick?.(id)}
>
{/* Left color bar */}
@@ -168,11 +196,11 @@ function CustomNodeComponent({
-
+
{truncatedName}
-
diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx
index 2a5037a1..fcad43eb 100644
--- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx
+++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx
@@ -23,6 +23,7 @@ import type { PortalFlowNode } from "./PortalNode";
import ContainerNode from "./ContainerNode";
import type { ContainerFlowNode, ContainerNodeData } from "./ContainerNode";
import Breadcrumb from "./Breadcrumb";
+import LayerIndexView from "./LayerIndexView";
import { useDashboardStore } from "../store";
import type {
GraphEdge,
@@ -33,6 +34,9 @@ import type {
import { useTheme } from "../themes/index.ts";
import {
NODE_WIDTH,
+ DENSE_THRESHOLD,
+ DENSE_NODE_WIDTH,
+ DENSE_NODE_HEIGHT,
NODE_HEIGHT,
LAYER_CLUSTER_WIDTH,
LAYER_CLUSTER_HEIGHT,
@@ -48,6 +52,7 @@ import {
aggregateContainerEdges,
aggregateLayerEdges,
computePortals,
+ findExternalNeighborFiles,
findCrossLayerFileNodes,
} from "../utils/edgeAggregation";
import { deriveContainers } from "../utils/containers";
@@ -179,6 +184,24 @@ function TourFitView() {
}
/** Centers the graph on the selected node (e.g. from search). */
+function ContainerDrillFitView() {
+ const activeContainerId = useDashboardStore((s) => s.activeContainerId);
+ const { fitView } = useReactFlow();
+ const prevRef = useRef
(null);
+
+ useEffect(() => {
+ if (activeContainerId !== prevRef.current) {
+ prevRef.current = activeContainerId;
+ const timer = setTimeout(() => {
+ fitView({ duration: 400, padding: 0.2 });
+ }, 350);
+ return () => clearTimeout(timer);
+ }
+ }, [activeContainerId, fitView]);
+
+ return null;
+}
+
function SelectedNodeFitView() {
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const { fitView } = useReactFlow();
@@ -266,17 +289,35 @@ function useOverviewGraph() {
// Aggregate edges between layers
const aggregated = aggregateLayerEdges(graph);
- const flowEdges: Edge[] = aggregated.map((agg, i) => ({
- id: `le-${i}`,
- source: agg.sourceLayerId,
- target: agg.targetLayerId,
- label: `${agg.count}`,
+ // With pinned tiers, edges flowing AGAINST the vertical order (lower
+ // tier → higher tier) would leave the lower card's bottom handle, loop
+ // around and enter the upper card's top handle. The overview shows no
+ // arrowheads, so flip such edges visually: exit the upper card's
+ // bottom, enter the lower card's top.
+ const tierOf = new Map();
+ for (const layer of graph.layers) {
+ if (typeof layer.tier === "number") tierOf.set(layer.id, layer.tier);
+ }
+ const flowEdges: Edge[] = aggregated.map((agg, i) => {
+ let src = agg.sourceLayerId;
+ let tgt = agg.targetLayerId;
+ const ts = tierOf.get(src);
+ const tt = tierOf.get(tgt);
+ if (ts !== undefined && tt !== undefined && ts > tt) {
+ [src, tgt] = [tgt, src];
+ }
+ return {
+ id: `le-${i}`,
+ source: src,
+ target: tgt,
+ label: `${agg.count}`,
style: {
stroke: "rgba(212,165,116,0.4)",
strokeWidth: Math.min(1 + Math.log2(agg.count + 1), 5),
},
labelStyle: { fill: "#a39787", fontSize: 11, fontWeight: 600 },
- }));
+ };
+ });
const dims = new Map();
for (const n of clusterNodes) {
@@ -301,7 +342,31 @@ function useOverviewGraph() {
let cancelled = false;
const { clusterNodes, flowEdges, dims } = built;
const baseNodes = clusterNodes as unknown as Node[];
- const elkInput = nodesToElkInput(baseNodes, flowEdges, dims);
+ // Tier pinning: when layers carry a tier, use ELK partitioning so the
+ // vertical order follows the declared architecture.
+ const tiers = new Map();
+ for (const layer of graph?.layers ?? []) {
+ if (typeof layer.tier === "number") tiers.set(layer.id, layer.tier);
+ }
+ let layoutOverride: Record | undefined;
+ let childOptions: Map> | undefined;
+ if (tiers.size > 0) {
+ const maxTier = Math.max(...Array.from(tiers.values()));
+ layoutOverride = {
+ "elk.partitioning.activate": "true",
+ // Post-compaction moves nodes across layer boundaries and silently
+ // violates partitions (a tier-2 node ends up in the top row) —
+ // disable it whenever partitioning is active.
+ "elk.layered.compaction.postCompaction.strategy": "NONE",
+ };
+ childOptions = new Map(
+ baseNodes.map((n) => [
+ n.id,
+ { "elk.partitioning.partition": String(tiers.get(n.id) ?? maxTier + 1) },
+ ]),
+ );
+ }
+ const elkInput = nodesToElkInput(baseNodes, flowEdges, dims, layoutOverride, childOptions);
setLayoutStatus("computing");
applyElkLayout(elkInput, { strict: import.meta.env.DEV })
.then(({ positioned, issues }) => {
@@ -340,6 +405,7 @@ interface LayerDetailTopology {
containers: DerivedContainer[];
nodeToContainer: Map;
intraContainer: GraphEdge[];
+ portalCrossFiles: Map;
}
const EMPTY_TOPOLOGY: LayerDetailTopology = {
@@ -352,6 +418,7 @@ const EMPTY_TOPOLOGY: LayerDetailTopology = {
containers: [],
nodeToContainer: new Map(),
intraContainer: [],
+ portalCrossFiles: new Map(),
};
/**
@@ -366,6 +433,7 @@ function useLayerDetailTopology(): LayerDetailTopology & {
layoutStatus: "computing" | "ready";
} {
const graph = useDashboardStore((s) => s.graph);
+ const activeContainerId = useDashboardStore((s) => s.activeContainerId);
const nodesById = useDashboardStore((s) => s.nodesById);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const selectNode = useDashboardStore((s) => s.selectNode);
@@ -389,10 +457,11 @@ function useLayerDetailTopology(): LayerDetailTopology & {
// Stable across renders so ContainerNode's memo() actually short-circuits.
// Reading toggleContainer via getState() avoids subscribing this hook to
// expandedContainers — Stage 1 must not relayout on expand.
- const handleContainerToggle = useCallback(
- (id: string) => useDashboardStore.getState().toggleContainer(id),
- [],
- );
+ const handleContainerToggle = useCallback((id: string, name?: string) => {
+ // Containers are a navigation level (project → layer → feature):
+ // clicking drills in instead of expanding dozens of cards in place.
+ useDashboardStore.getState().drillIntoContainer(id, name ?? id.replace(/^container:/, ""));
+ }, []);
// ── Structural build (synchronous): filtering + containers + nodes/edges
// pre-layout. Re-runs whenever the inputs that drive container derivation
@@ -474,10 +543,33 @@ function useLayerDetailTopology(): LayerDetailTopology & {
}
// Derive containers + bucket edges
- const { containers, ungrouped } = deriveContainers(
- filteredGraphNodes,
- filteredGraphEdges,
- );
+ // Small layers read better as plain full cards with real edges than as
+ // a couple of half-empty folder chips — skip auto-grouping (data-defined
+ // layer.containers still win).
+ const skipAutoGrouping =
+ !activeLayer.containers?.length && filteredGraphNodes.length <= 10;
+ let { containers, ungrouped } = skipAutoGrouping
+ ? { containers: [], ungrouped: filteredGraphNodes.map((n) => n.id) }
+ : deriveContainers(
+ filteredGraphNodes,
+ filteredGraphEdges,
+ activeLayer.containers,
+ );
+ // Container drill-in view: only the active container's children, all
+ // ungrouped (full cards), no sibling containers on the canvas.
+ if (activeContainerId) {
+ const active = containers.find((c) => c.id === activeContainerId);
+ if (active) {
+ const childSet = new Set(active.nodeIds);
+ filteredGraphNodes = filteredGraphNodes.filter((n) => childSet.has(n.id));
+ filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
+ filteredGraphEdges = filteredGraphEdges.filter(
+ (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
+ );
+ containers = [];
+ ungrouped = [...childSet];
+ }
+ }
const ungroupedSet = new Set(ungrouped);
const nodeToContainer = new Map();
for (const c of containers) {
@@ -497,20 +589,17 @@ function useLayerDetailTopology(): LayerDetailTopology & {
// Caps prevent first-paint sprawl: at 100 children sqrt() yields
// ~3360px which renders as a huge empty box pre-expansion. Stage 2
// sets the actual size once it's measured, and Task 15 re-flows.
- const STAGE1_MAX_CONTAINER_WIDTH = 800;
- const STAGE1_MAX_CONTAINER_HEIGHT = 600;
const sizeMemory = useDashboardStore.getState().containerSizeMemory;
+ // Containers render as compact navigation chips (click = drill in).
const containerWidth = (c: DerivedContainer) => {
const memo = sizeMemory.get(c.id)?.width;
if (memo) return memo;
- const estimate = Math.sqrt(c.nodeIds.length) * NODE_WIDTH * 1.2;
- return Math.min(STAGE1_MAX_CONTAINER_WIDTH, Math.max(NODE_WIDTH, estimate));
+ return 230;
};
const containerHeight = (c: DerivedContainer) => {
const memo = sizeMemory.get(c.id)?.height;
if (memo) return memo;
- const estimate = Math.sqrt(c.nodeIds.length) * NODE_HEIGHT * 1.2;
- return Math.min(STAGE1_MAX_CONTAINER_HEIGHT, Math.max(NODE_HEIGHT, estimate));
+ return 64;
};
// Build container flow nodes (children NOT rendered yet — Task 12)
@@ -575,7 +664,10 @@ function useLayerDetailTopology(): LayerDetailTopology & {
id: `agg-${i}`,
source: agg.sourceContainerId,
target: agg.targetContainerId,
- label: String(agg.count),
+ label:
+ agg.count === 1 && agg.edgeTypes.length === 1
+ ? agg.edgeTypes[0]
+ : String(agg.count),
style: baseStyle,
labelStyle: {
fill: diffMode ? "rgba(163,151,135,0.3)" : "#a39787",
@@ -588,6 +680,7 @@ function useLayerDetailTopology(): LayerDetailTopology & {
const portals = computePortals(graph, activeLayerId);
const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i]));
+ const nodeNameById = new Map(graph.nodes.map((n) => [n.id, n.name ?? n.id]));
const portalNodes: PortalFlowNode[] = portals.map((portal) => ({
id: `portal:${portal.layerId}`,
type: "portal" as const,
@@ -597,14 +690,24 @@ function useLayerDetailTopology(): LayerDetailTopology & {
targetLayerName: portal.layerName,
connectionCount: portal.connectionCount,
layerColorIndex: layerIndexMap.get(portal.layerId) ?? 0,
+ externalFileNames: [
+ ...findExternalNeighborFiles(graph, activeLayerId, portal.layerId),
+ ]
+ .map((id) => nodeNameById.get(id) ?? id)
+ .sort(),
onNavigate: drillIntoLayer,
},
}));
const portalEdges: Edge[] = [];
+ const portalCrossFiles = new Map();
let portalEdgeIdx = aggEdges.length;
for (const portal of portals) {
const crossFiles = findCrossLayerFileNodes(graph, activeLayerId, portal.layerId);
+ portalCrossFiles.set(
+ `portal:${portal.layerId}`,
+ [...crossFiles].filter((id) => filteredNodeIds.has(id)),
+ );
// Dedupe by atom — multiple files in the same container hitting the
// same portal collapse to one Stage 1 edge. Task 12 will re-route to
// the actual file ids when the source container expands.
@@ -618,7 +721,7 @@ function useLayerDetailTopology(): LayerDetailTopology & {
id: `e-${portalEdgeIdx++}`,
source: atomId,
target: `portal:${portal.layerId}`,
- style: { stroke: "rgba(212,165,116,0.2)", strokeWidth: 1, strokeDasharray: "4 4" },
+ style: { stroke: "rgba(212,165,116,0.55)", strokeWidth: 1.6, strokeDasharray: "5 4" },
animated: false,
});
}
@@ -636,11 +739,13 @@ function useLayerDetailTopology(): LayerDetailTopology & {
aggEdges,
portalNodes,
portalEdges,
+ portalCrossFiles,
};
}, [
graph,
nodesById,
activeLayerId,
+ activeContainerId,
persona,
diffMode,
changedNodeIds,
@@ -681,6 +786,7 @@ function useLayerDetailTopology(): LayerDetailTopology & {
aggEdges,
portalNodes,
portalEdges,
+ portalCrossFiles,
} = built;
// Build Stage 1 ELK input: containers as opaque atoms + ungrouped files
@@ -707,11 +813,17 @@ function useLayerDetailTopology(): LayerDetailTopology & {
width: NODE_WIDTH,
height: NODE_HEIGHT,
})),
- ...portalNodes.map((pn) => ({
- id: pn.id,
- width: PORTAL_NODE_WIDTH,
- height: PORTAL_NODE_HEIGHT,
- })),
+ ...portalNodes.map((pn) => {
+ // Portal cards grew a member-name list — account for it in the
+ // layout estimate or ELK packs them at 80px and cards overlap.
+ const names = (pn.data.externalFileNames ?? []).length;
+ const listLines = Math.min(names, 6) + (names > 6 ? 1 : 0);
+ return {
+ id: pn.id,
+ width: PORTAL_NODE_WIDTH,
+ height: PORTAL_NODE_HEIGHT + (listLines > 0 ? 6 + listLines * 15 : 0),
+ };
+ }),
];
const stage1Edges: ElkEdge[] = [
@@ -758,6 +870,7 @@ function useLayerDetailTopology(): LayerDetailTopology & {
containers,
nodeToContainer,
intraContainer,
+ portalCrossFiles,
});
setLayoutStatus("ready");
})
@@ -806,6 +919,36 @@ function useLayerDetailTopology(): LayerDetailTopology & {
const childEdges = stage2Intra.filter(
(e) => childIds.has(e.source) && childIds.has(e.target),
);
+
+ // Dense path: large containers skip ELK and pack compact rows into
+ // a name-sorted grid — dozens of full cards produce an unusable
+ // multi-thousand-pixel canvas.
+ if (c.nodeIds.length > DENSE_THRESHOLD) {
+ const GAP_X = 14;
+ const GAP_Y = 10;
+ const sorted = [...c.nodeIds].sort((a, b) => {
+ const an = a.split("/").pop() ?? a;
+ const bn = b.split("/").pop() ?? b;
+ return an.localeCompare(bn);
+ });
+ const cols = Math.max(2, Math.round(0.62 * Math.sqrt(sorted.length)));
+ const childPositions = new Map();
+ sorted.forEach((id, i) => {
+ const col = i % cols;
+ const row = Math.floor(i / cols);
+ childPositions.set(id, {
+ x: 20 + col * (DENSE_NODE_WIDTH + GAP_X),
+ y: 56 + row * (DENSE_NODE_HEIGHT + GAP_Y),
+ });
+ });
+ const rows = Math.ceil(sorted.length / cols);
+ const actualSize = {
+ width: 40 + cols * (DENSE_NODE_WIDTH + GAP_X) - GAP_X,
+ height: 76 + rows * (DENSE_NODE_HEIGHT + GAP_Y) - GAP_Y,
+ };
+ return { containerId, childPositions, actualSize, deviated: true };
+ }
+
const stage2Children: ElkChild[] = c.nodeIds.map((id) => ({
id,
width: NODE_WIDTH,
@@ -997,6 +1140,9 @@ function useLayerDetailGraph() {
affectedNodeIds,
onNodeClick: handleNodeSelect,
});
+ if (container.nodeIds.length > DENSE_THRESHOLD) {
+ (base.data as Record).dense = true;
+ }
out.push({
...base,
parentId: containerId,
@@ -1258,10 +1404,39 @@ function useLayerDetailGraph() {
expandedContainers,
]);
+ // Re-route portal edges: when a container is expanded, replace its single
+ // container→portal edge with per-file edges from the actual cross-layer
+ // files inside it (e.g. service.ts → "REST API" portal).
+ const portalEdgesFinal = useMemo(() => {
+ if (expandedContainers.size === 0) return topo.portalEdges;
+ const out: Edge[] = [];
+ const seen = new Set();
+ for (const pe of topo.portalEdges) {
+ const srcAtom = String(pe.source);
+ if (!expandedContainers.has(srcAtom)) {
+ out.push(pe);
+ continue;
+ }
+ const portalId = String(pe.target);
+ const files = topo.portalCrossFiles.get(portalId) ?? [];
+ let emitted = false;
+ for (const f of files) {
+ if (topo.nodeToContainer.get(f) !== srcAtom) continue;
+ const key = `${f}|${portalId}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ ...pe, id: `portal-file-${key}`, source: f });
+ emitted = true;
+ }
+ if (!emitted) out.push(pe);
+ }
+ return out;
+ }, [topo.portalEdges, topo.portalCrossFiles, topo.nodeToContainer, expandedContainers]);
+
const edges = useMemo(() => {
- // Compose: Stage 1 / inflated edges, plus portal edges (Stage 1 sources
- // them off container atoms — re-sourcing on expand is deferred).
- const base = [...expandedEdges, ...topo.portalEdges];
+ // Compose: Stage 1 / inflated edges, plus portal edges (re-routed to
+ // actual files for expanded containers).
+ const base = [...expandedEdges, ...portalEdgesFinal];
if (!selectedNodeId) return base;
// Apply selection-based edge styling on top of topology edges
@@ -1276,7 +1451,7 @@ function useLayerDetailGraph() {
// Fade unrelated edges
return { ...edge, animated: false, style: { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }, labelStyle: { fill: "rgba(163,151,135,0.2)", fontSize: 10 } };
});
- }, [expandedEdges, topo.portalEdges, selectedNodeId]);
+ }, [expandedEdges, portalEdgesFinal, selectedNodeId]);
// Expose container topology so the parent component can wire auto-expand
// triggers (focus, tour, zoom) without having to re-derive containers.
@@ -1300,6 +1475,7 @@ function GraphViewInner() {
const graph = useDashboardStore((s) => s.graph);
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
+ const activeContainerIdNav = useDashboardStore((s) => s.activeContainerId);
const selectNode = useDashboardStore((s) => s.selectNode);
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
@@ -1460,10 +1636,8 @@ function GraphViewInner() {
if (vp.zoom <= 1.0) return;
// Only fire when zoom actually increased — pan and zoom-out are no-ops.
if (prev !== null && vp.zoom <= prev) return;
- const expanded = useDashboardStore.getState().expandedContainers;
- for (const cid of containerIds) {
- if (!expanded.has(cid)) expandContainer(cid);
- }
+ // Zoom-driven auto-expand disabled: containers open only by
+ // explicit click (drill-in), focus or tour navigation.
}, 200);
},
[containerIds, getViewport, expandContainer],
@@ -1505,6 +1679,21 @@ function GraphViewInner() {
);
}
+ const indexLayer =
+ navigationLevel === "layer-detail" && !activeContainerIdNav
+ ? graph.layers.find((l) => l.id === activeLayerId)
+ : undefined;
+ const indexMode = (indexLayer?.containers?.length ?? 0) > 8;
+
+ if (indexMode) {
+ return (
+
+
+
+
+ );
+ }
+
return (
@@ -1549,6 +1738,7 @@ function GraphViewInner() {
/>
+
{(layoutStatus === "computing" || tourFitPending) && (
s.graph);
+ const activeLayerId = useDashboardStore((s) => s.activeLayerId);
+ const nodesById = useDashboardStore((s) => s.nodesById);
+ const drillIntoContainer = useDashboardStore((s) => s.drillIntoContainer);
+ const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
+ const selectNode = useDashboardStore((s) => s.selectNode);
+ const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
+
+ if (!graph || !activeLayerId) return null;
+ const layer = graph.layers.find((l) => l.id === activeLayerId);
+ if (!layer?.containers?.length) return null;
+
+ const claimed = new Set(layer.containers.flatMap((c) => c.nodeIds));
+ const ungrouped = layer.nodeIds.filter((id) => !claimed.has(id));
+ const portals = computePortals(graph, activeLayerId);
+ const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i]));
+ // Sections: containers may declare a `group` (e.g. "services", "models").
+ // Section order = first appearance in data; ungrouped go to "features".
+ const sections: { title: string; items: typeof layer.containers }[] = [];
+ const sectionIdx = new Map
();
+ for (const c of layer.containers) {
+ const title = c.group ?? "features";
+ if (!sectionIdx.has(title)) {
+ sectionIdx.set(title, sections.length);
+ sections.push({ title, items: [] });
+ }
+ sections[sectionIdx.get(title)!].items.push(c);
+ }
+ for (const sec of sections) {
+ sec.items = [...sec.items].sort((a, b) => a.name.localeCompare(b.name));
+ }
+ const showSectionTitles = sections.length > 1;
+
+ const baseName = (id: string) =>
+ nodesById.get(id)?.name ?? id.split("/").pop() ?? id;
+
+ return (
+
+ {ungrouped.length > 0 && (
+ <>
+
+ Layer level
+
+
+ {ungrouped.map((id) => {
+ const n = nodesById.get(id);
+ if (!n) return null;
+ const sel = selectedNodeId === id;
+ return (
+
selectNode(id)}
+ className={`cursor-pointer rounded-lg bg-elevated border px-4 py-3 max-w-[440px] transition-colors ${
+ sel ? "border-gold" : "border-gold/40 hover:border-gold"
+ }`}
+ >
+
{n.name}
+
+ {n.summary}
+
+
+ );
+ })}
+
+ >
+ )}
+
+ {sections.map((sec) => (
+
+
+ {showSectionTitles ? sec.title : "Features"} · {sec.items.length}
+
+
+ {sec.items.map((c) => (
+
drillIntoContainer(c.id ?? `container:${slug(c.name)}`, c.name)}
+ className="cursor-pointer rounded-lg bg-elevated border border-border-subtle hover:border-gold/60 transition-colors px-4 py-3"
+ >
+
+
+ {c.name}
+
+
+ {c.nodeIds.length}
+
+
+
+ {c.nodeIds.slice(0, 3).map((id) => (
+
+ · {baseName(id)}
+
+ ))}
+ {c.nodeIds.length > 3 && (
+
+ +{c.nodeIds.length - 3} more
+
+ )}
+
+
+ ))}
+
+
+ ))}
+
+ {portals.length > 0 && (
+ <>
+
+ Connected layers
+
+
+ {portals.map((p) => {
+ const color = getLayerColor(layerIndexMap.get(p.layerId) ?? 0);
+ return (
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+}
diff --git a/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx
index 04f94212..05911fbf 100644
--- a/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx
+++ b/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx
@@ -8,6 +8,7 @@ export interface PortalNodeData extends Record {
targetLayerName: string;
connectionCount: number;
layerColorIndex: number;
+ externalFileNames?: string[];
onNavigate: (layerId: string) => void;
}
@@ -50,6 +51,24 @@ function PortalNode({
{data.connectionCount} connection{data.connectionCount !== 1 ? "s" : ""}
+ {data.externalFileNames && data.externalFileNames.length > 0 && (
+
+ {data.externalFileNames.slice(0, 6).map((n) => (
+
+ · {n}
+
+ ))}
+ {data.externalFileNames.length > 6 && (
+
+ +{data.externalFileNames.length - 6} more
+
+ )}
+
+ )}
void;
goBackNode: () => void;
drillIntoLayer: (layerId: string) => void;
+ drillIntoContainer: (containerId: string, name: string) => void;
+ exitContainer: () => void;
navigateToOverview: () => void;
setFocusNode: (nodeId: string | null) => void;
setSearchQuery: (query: string) => void;
@@ -299,6 +304,8 @@ export const useDashboardStore = create()((set, get) => ({
navigationLevel: "overview",
activeLayerId: null,
+ activeContainerId: null,
+ activeContainerName: null,
codeViewerOpen: false,
codeViewerNodeId: null,
codeViewerExpanded: false,
@@ -471,10 +478,30 @@ export const useDashboardStore = create()((set, get) => ({
}
},
+ drillIntoContainer: (containerId, name) =>
+ set({
+ activeContainerId: containerId,
+ activeContainerName: name,
+ selectedNodeId: null,
+ focusNodeId: null,
+ expandedContainers: new Set(),
+ pendingFocusContainer: null,
+ }),
+
+ exitContainer: () =>
+ set({
+ activeContainerId: null,
+ activeContainerName: null,
+ selectedNodeId: null,
+ focusNodeId: null,
+ }),
+
drillIntoLayer: (layerId) =>
set({
navigationLevel: "layer-detail",
activeLayerId: layerId,
+ activeContainerId: null,
+ activeContainerName: null,
selectedNodeId: null,
focusNodeId: null,
codeViewerOpen: false,
@@ -493,6 +520,8 @@ export const useDashboardStore = create()((set, get) => ({
set({
navigationLevel: "overview",
activeLayerId: null,
+ activeContainerId: null,
+ activeContainerName: null,
selectedNodeId: null,
focusNodeId: null,
codeViewerOpen: false,
diff --git a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts
index b5d6d407..92d9b061 100644
--- a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts
+++ b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts
@@ -107,10 +107,36 @@ describe("deriveContainers — community fallback", () => {
}
}
const { containers } = deriveContainers(nodes, edges);
+ // A single folder covering the whole set is now kept as ONE folder
+ // container (named after the folder) instead of being split into
+ // anonymous Louvain communities.
+ expect(containers.length).toBe(1);
+ expect(containers[0].strategy).toBe("folder");
+ expect(containers[0].name).toBe("services");
+ });
+
+ it("names community clusters by member files when no folder signal exists", () => {
+ // Flat paths (no directories) force the community fallback.
+ const nodes = Array.from({ length: 10 }, (_, i) =>
+ node(`n${i}`, `n${i}.go`),
+ );
+ const edges: GraphEdge[] = [];
+ for (const i of [0, 1, 2, 3, 4]) {
+ for (const j of [0, 1, 2, 3, 4]) {
+ if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge);
+ }
+ }
+ for (const i of [5, 6, 7, 8, 9]) {
+ for (const j of [5, 6, 7, 8, 9]) {
+ if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge);
+ }
+ }
+ const { containers } = deriveContainers(nodes, edges);
expect(containers.length).toBeGreaterThanOrEqual(2);
for (const c of containers) {
expect(c.strategy).toBe("community");
- expect(c.name).toMatch(/^Cluster [A-Z]$/);
+ // Member-derived label, e.g. "n0 · n1 · n2 +2" — not "Cluster A".
+ expect(c.name).toMatch(/·|\+/);
}
});
diff --git a/understand-anything-plugin/packages/dashboard/src/utils/containers.ts b/understand-anything-plugin/packages/dashboard/src/utils/containers.ts
index dd885219..4ebdb752 100644
--- a/understand-anything-plugin/packages/dashboard/src/utils/containers.ts
+++ b/understand-anything-plugin/packages/dashboard/src/utils/containers.ts
@@ -79,6 +79,13 @@ function shouldFallbackToCommunity(
rooted: string[],
totalNodes: number,
): boolean {
+ // A single folder covering the whole set is a meaningful unit (e.g. a
+ // Redux slice folder like src/store/meetingTypes) — keep it as ONE named
+ // container instead of splitting into anonymous Louvain communities.
+ if (groups.size === 1 && rooted.length === 0) return false;
+ // A single folder covering the whole set is a meaningful unit — keep it
+ // as ONE named container instead of splitting into Louvain communities.
+ if (groups.size === 1 && rooted.length === 0) return false;
const bucketCount = groups.size + (rooted.length > 0 ? 1 : 0);
if (bucketCount < MIN_BUCKET_COUNT) return true;
for (const ids of groups.values()) {
@@ -88,14 +95,49 @@ function shouldFallbackToCommunity(
return false;
}
+export interface PredefinedGroup {
+ id?: string;
+ name: string;
+ nodeIds: string[];
+}
+
+export function slug(name: string): string {
+ return name.toLowerCase().replace(/[^a-z0-9\u0400-\u04ff]+/gi, "-").replace(/^-+|-+$/g, "");
+}
+
export function deriveContainers(
nodes: GraphNode[],
edges: GraphEdge[],
+ predefined?: PredefinedGroup[],
): DeriveResult {
if (nodes.length === 0) {
return { containers: [], ungrouped: [] };
}
+ // Manual grouping from the graph data (layer.containers) wins over
+ // folder/community derivation. Nodes not claimed by any group stay
+ // ungrouped — they render as standalone cards one level above.
+ if (predefined && predefined.length > 0) {
+ const present = new Set(nodes.map((n) => n.id));
+ const claimed = new Set();
+ const containers: DerivedContainer[] = [];
+ for (const g of predefined) {
+ const ids = g.nodeIds.filter((id) => present.has(id) && !claimed.has(id));
+ if (ids.length === 0) continue; // singleton FEATURE containers are allowed
+ for (const id of ids) claimed.add(id);
+ containers.push({
+ id: g.id ?? `container:${slug(g.name)}`,
+ name: g.name,
+ nodeIds: ids,
+ strategy: "folder",
+ });
+ }
+ if (containers.length > 0) {
+ const ungrouped = nodes.map((n) => n.id).filter((id) => !claimed.has(id));
+ return { containers, ungrouped };
+ }
+ }
+
const { groups, rooted } = groupByFolder(nodes);
const useCommunity = shouldFallbackToCommunity(groups, rooted, nodes.length);
@@ -113,11 +155,33 @@ export function deriveContainers(
byCommunity.set(cid, arr);
}
const sorted = [...byCommunity.entries()].sort((a, b) => a[0] - b[0]);
+ // Name community clusters by their member files instead of "Cluster A/B/C".
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
+ const FILE_LEVEL = new Set([
+ "file", "config", "document", "service", "pipeline",
+ "table", "schema", "resource", "endpoint",
+ ]);
+ const labelFor = (ids: string[]): string => {
+ let members = ids
+ .map((id) => nodeById.get(id))
+ .filter((n): n is GraphNode => Boolean(n));
+ const fileMembers = members.filter((m) => FILE_LEVEL.has(m.type));
+ if (fileMembers.length > 0) members = fileMembers;
+ const bases = [
+ ...new Set(
+ members.map((m) =>
+ (m.name ?? m.id).replace(/\.(tsx?|jsx?|java|py|go|rb|cs|kt)$/i, ""),
+ ),
+ ),
+ ];
+ const head = bases.slice(0, 3).join(" · ");
+ return bases.length > 3 ? `${head} +${bases.length - 3}` : head;
+ };
containers = sorted.map(([cid, ids], i) => ({
id: `container:cluster-${cid}`,
- // A-Z for the first 26, then numeric. Avoids `String.fromCharCode(65+i)`
- // wrapping into `[`, `\`, `]` ... once the cluster count exceeds 26.
- name: i < 26 ? `Cluster ${String.fromCharCode(65 + i)}` : `Cluster ${i + 1}`,
+ name:
+ labelFor(ids) ||
+ (i < 26 ? `Cluster ${String.fromCharCode(65 + i)}` : `Cluster ${i + 1}`),
nodeIds: ids,
strategy: "community" as const,
}));
diff --git a/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts
index 3e47a7e6..48076236 100644
Binary files a/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts and b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts differ
diff --git a/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts
index 8bc9f862..a4668c26 100644
--- a/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts
+++ b/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts
@@ -6,6 +6,7 @@ export interface ElkChild {
id: string;
width?: number;
height?: number;
+ layoutOptions?: Record;
/** Set by ELK after layout; absent on input. Downstream consumers must default. */
x?: number;
y?: number;
diff --git a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts
index b35328c5..5ba347a9 100644
--- a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts
+++ b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts
@@ -12,8 +12,11 @@ import type { SimulationNodeDatum, SimulationLinkDatum } from "d3-force";
import type { Node, Edge } from "@xyflow/react";
import type { ElkInput } from "./elk-layout";
-export const NODE_WIDTH = 280;
-export const NODE_HEIGHT = 120;
+export const NODE_WIDTH = 330;
+export const DENSE_THRESHOLD = 12;
+export const DENSE_NODE_WIDTH = 250;
+export const DENSE_NODE_HEIGHT = 44;
+export const NODE_HEIGHT = 150;
export const LAYER_CLUSTER_WIDTH = 320;
export const LAYER_CLUSTER_HEIGHT = 180;
export const PORTAL_NODE_WIDTH = 240;
@@ -208,16 +211,19 @@ export function nodesToElkInput(
edges: Edge[],
dims: Map,
layoutOptionsOverride?: Record,
+ childOptions?: Map>,
): ElkInput {
return {
id: "root",
layoutOptions: { ...ELK_DEFAULT_LAYOUT_OPTIONS, ...layoutOptionsOverride },
children: nodes.map((n) => {
const d = dims.get(n.id);
+ const opts = childOptions?.get(n.id);
return {
id: n.id,
width: d?.width ?? NODE_WIDTH,
height: d?.height ?? NODE_HEIGHT,
+ ...(opts ? { layoutOptions: opts } : {}),
};
}),
edges: edges.map((e, i) => ({
diff --git a/understand-anything-plugin/pnpm-lock.yaml b/understand-anything-plugin/pnpm-lock.yaml
index 16b6eed8..ea7bd259 100644
--- a/understand-anything-plugin/pnpm-lock.yaml
+++ b/understand-anything-plugin/pnpm-lock.yaml
@@ -11,6 +11,12 @@ importers:
'@understand-anything/core':
specifier: workspace:*
version: link:packages/core
+ graphology:
+ specifier: ~0.26.0
+ version: 0.26.0(graphology-types@0.24.8)
+ graphology-communities-louvain:
+ specifier: ^2.0.2
+ version: 2.0.2(graphology-types@0.24.8)
devDependencies:
'@types/node':
specifier: ^22.0.0
@@ -133,7 +139,7 @@ importers:
devDependencies:
'@tailwindcss/vite':
specifier: ^4.0.0
- version: 4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
+ version: 4.2.2(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
'@types/d3-force':
specifier: ^3.0.10
version: 3.0.10
@@ -145,7 +151,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.3.0
- version: 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
+ version: 4.7.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
@@ -156,8 +162,8 @@ importers:
specifier: ^5.7.0
version: 5.9.3
vite:
- specifier: ^6.0.0
- version: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
+ specifier: ^6.4.2
+ version: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
vitest:
specifier: ^3.1.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
@@ -482,66 +488,79 @@ packages:
resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.0':
resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.0':
resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.0':
resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.0':
resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.0':
resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.0':
resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.0':
resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.0':
resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.0':
resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.0':
resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==}
@@ -611,24 +630,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@@ -1084,6 +1107,11 @@ packages:
peerDependencies:
graphology-types: '>=0.24.0'
+ graphology@0.26.0:
+ resolution: {integrity: sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==}
+ peerDependencies:
+ graphology-types: '>=0.24.0'
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -1207,24 +1235,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -1759,6 +1791,46 @@ packages:
yaml:
optional: true
+ vite@6.4.3:
+ resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -2231,12 +2303,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
- '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
+ '@tailwindcss/vite@4.2.2(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
dependencies:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2
- vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
+ vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
'@types/babel__core@7.20.5':
dependencies:
@@ -2333,7 +2405,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
+ '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -2341,7 +2413,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
- vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
+ vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -2692,6 +2764,11 @@ snapshots:
graphology-types: 0.24.8
obliterator: 2.0.5
+ graphology@0.26.0(graphology-types@0.24.8):
+ dependencies:
+ events: 3.3.0
+ graphology-types: 0.24.8
+
has-flag@4.0.0: {}
hast-util-to-jsx-runtime@2.3.6:
@@ -3444,7 +3521,7 @@ snapshots:
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
+ vite: 6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -3465,7 +3542,7 @@ snapshots:
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
+ vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -3510,6 +3587,36 @@ snapshots:
lightningcss: 1.32.0
yaml: 2.8.3
+ vite@6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.8
+ rollup: 4.60.0
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 22.19.15
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+ yaml: 2.8.3
+
+ vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.8
+ rollup: 4.60.0
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 25.5.0
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+ yaml: 2.8.3
+
vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3