create-project-arch 1.0.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.
Files changed (90) hide show
  1. package/README.md +58 -0
  2. package/dist/cli.js +232 -0
  3. package/dist/cli.test.js +8 -0
  4. package/package.json +29 -0
  5. package/templates/arch-ui/.arch/edges/decision_to_domain.json +4 -0
  6. package/templates/arch-ui/.arch/edges/milestone_to_task.json +4 -0
  7. package/templates/arch-ui/.arch/edges/task_to_decision.json +4 -0
  8. package/templates/arch-ui/.arch/edges/task_to_module.json +4 -0
  9. package/templates/arch-ui/.arch/graph.json +17 -0
  10. package/templates/arch-ui/.arch/nodes/decisions.json +4 -0
  11. package/templates/arch-ui/.arch/nodes/domains.json +4 -0
  12. package/templates/arch-ui/.arch/nodes/milestones.json +4 -0
  13. package/templates/arch-ui/.arch/nodes/modules.json +4 -0
  14. package/templates/arch-ui/.arch/nodes/tasks.json +4 -0
  15. package/templates/arch-ui/app/api/architecture/map/route.ts +13 -0
  16. package/templates/arch-ui/app/api/decisions/route.ts +23 -0
  17. package/templates/arch-ui/app/api/domain-docs/route.ts +89 -0
  18. package/templates/arch-ui/app/api/domains/route.ts +10 -0
  19. package/templates/arch-ui/app/api/graph/route.ts +16 -0
  20. package/templates/arch-ui/app/api/health/route.ts +44 -0
  21. package/templates/arch-ui/app/api/node-files/route.ts +173 -0
  22. package/templates/arch-ui/app/api/phases/route.ts +10 -0
  23. package/templates/arch-ui/app/api/route.ts +22 -0
  24. package/templates/arch-ui/app/api/search/route.ts +56 -0
  25. package/templates/arch-ui/app/api/task-doc/[taskId]/route.ts +60 -0
  26. package/templates/arch-ui/app/api/tasks/route.ts +36 -0
  27. package/templates/arch-ui/app/api/trace/file/route.ts +40 -0
  28. package/templates/arch-ui/app/api/trace/task/[taskId]/route.ts +12 -0
  29. package/templates/arch-ui/app/architecture/page.tsx +5 -0
  30. package/templates/arch-ui/app/globals.css +240 -0
  31. package/templates/arch-ui/app/health/page.tsx +48 -0
  32. package/templates/arch-ui/app/layout.tsx +19 -0
  33. package/templates/arch-ui/app/page.tsx +5 -0
  34. package/templates/arch-ui/app/work/page.tsx +265 -0
  35. package/templates/arch-ui/components/app-shell.tsx +171 -0
  36. package/templates/arch-ui/components/error-boundary.tsx +53 -0
  37. package/templates/arch-ui/components/graph/arch-node.tsx +77 -0
  38. package/templates/arch-ui/components/graph/build-graph-from-dataset.ts +196 -0
  39. package/templates/arch-ui/components/graph/build-initial-graph.ts +245 -0
  40. package/templates/arch-ui/components/graph/graph-context-menu.tsx +84 -0
  41. package/templates/arch-ui/components/graph/graph-doc-panel.tsx +46 -0
  42. package/templates/arch-ui/components/graph/graph-types.ts +82 -0
  43. package/templates/arch-ui/components/graph/use-auto-layout.ts +65 -0
  44. package/templates/arch-ui/components/graph/use-connection-validation.ts +62 -0
  45. package/templates/arch-ui/components/graph/use-flow-persistence.ts +48 -0
  46. package/templates/arch-ui/components/graph-canvas.tsx +670 -0
  47. package/templates/arch-ui/components/health-panel.tsx +49 -0
  48. package/templates/arch-ui/components/inspector-context.tsx +35 -0
  49. package/templates/arch-ui/components/inspector.tsx +895 -0
  50. package/templates/arch-ui/components/markdown-viewer.tsx +74 -0
  51. package/templates/arch-ui/components/sidebar.tsx +531 -0
  52. package/templates/arch-ui/components/topbar.tsx +187 -0
  53. package/templates/arch-ui/components/work-table.tsx +57 -0
  54. package/templates/arch-ui/components/workspace-context.tsx +274 -0
  55. package/templates/arch-ui/eslint.config.js +2 -0
  56. package/templates/arch-ui/global.d.ts +1 -0
  57. package/templates/arch-ui/lib/api.ts +93 -0
  58. package/templates/arch-ui/lib/arch-model.ts +113 -0
  59. package/templates/arch-ui/lib/graph-dataset.ts +756 -0
  60. package/templates/arch-ui/lib/graph-schema.ts +408 -0
  61. package/templates/arch-ui/lib/project-root.ts +52 -0
  62. package/templates/arch-ui/lib/types.ts +116 -0
  63. package/templates/arch-ui/next-env.d.ts +6 -0
  64. package/templates/arch-ui/next.config.js +17 -0
  65. package/templates/arch-ui/package.json +38 -0
  66. package/templates/arch-ui/postcss.config.mjs +6 -0
  67. package/templates/arch-ui/tailwind.config.ts +11 -0
  68. package/templates/arch-ui/tsconfig.json +21 -0
  69. package/templates/ui-package/eslint.config.mjs +4 -0
  70. package/templates/ui-package/package.json +26 -0
  71. package/templates/ui-package/src/accordion.tsx +10 -0
  72. package/templates/ui-package/src/badge.tsx +12 -0
  73. package/templates/ui-package/src/button.tsx +32 -0
  74. package/templates/ui-package/src/card.tsx +22 -0
  75. package/templates/ui-package/src/code.tsx +6 -0
  76. package/templates/ui-package/src/command.tsx +18 -0
  77. package/templates/ui-package/src/dialog.tsx +6 -0
  78. package/templates/ui-package/src/dropdown-menu.tsx +10 -0
  79. package/templates/ui-package/src/input.tsx +6 -0
  80. package/templates/ui-package/src/navigation-menu.tsx +6 -0
  81. package/templates/ui-package/src/scroll-area.tsx +6 -0
  82. package/templates/ui-package/src/select.tsx +6 -0
  83. package/templates/ui-package/src/separator.tsx +6 -0
  84. package/templates/ui-package/src/sheet.tsx +6 -0
  85. package/templates/ui-package/src/skeleton.tsx +6 -0
  86. package/templates/ui-package/src/table.tsx +26 -0
  87. package/templates/ui-package/src/tabs.tsx +14 -0
  88. package/templates/ui-package/src/toggle-group.tsx +10 -0
  89. package/templates/ui-package/src/utils.ts +3 -0
  90. package/templates/ui-package/tsconfig.json +10 -0
@@ -0,0 +1,196 @@
1
+ import type { Edge, Node } from "reactflow";
2
+ import type {
3
+ GraphDataset,
4
+ GraphEdgeType,
5
+ GraphNodeType,
6
+ GraphViewMode,
7
+ } from "../../lib/graph-schema";
8
+ import type {
9
+ ArchEdgeData,
10
+ ArchNodeData,
11
+ GraphEdgeFilter,
12
+ GraphKind,
13
+ GraphTone,
14
+ } from "./graph-types";
15
+
16
+ function mapNodeTypeToKind(type: GraphNodeType): GraphKind {
17
+ switch (type) {
18
+ case "arch_folder":
19
+ case "domain_doc":
20
+ case "architecture_doc":
21
+ return "domain";
22
+ case "roadmap_folder":
23
+ return "phase";
24
+ case "architecture_model":
25
+ return "decision";
26
+ case "roadmap_epic":
27
+ return "phase";
28
+ case "roadmap_story":
29
+ return "milestone";
30
+ case "roadmap_task":
31
+ return "task";
32
+ case "project_folder":
33
+ case "app":
34
+ case "package":
35
+ case "module":
36
+ case "component":
37
+ default:
38
+ return "file";
39
+ }
40
+ }
41
+
42
+ function mapNodeTypeToTone(type: GraphNodeType): GraphTone {
43
+ switch (type) {
44
+ case "arch_folder":
45
+ case "domain_doc":
46
+ case "architecture_doc":
47
+ return "domain";
48
+ case "architecture_model":
49
+ return "decision";
50
+ case "roadmap_folder":
51
+ case "roadmap_epic":
52
+ case "roadmap_story":
53
+ return "phase";
54
+ case "roadmap_task":
55
+ return "task";
56
+ default:
57
+ return "file";
58
+ }
59
+ }
60
+
61
+ function mapEdgeTypeToFilter(type: GraphEdgeType): GraphEdgeFilter {
62
+ switch (type) {
63
+ case "references":
64
+ case "documents":
65
+ return "data-flow";
66
+ case "implements":
67
+ return "blocking";
68
+ default:
69
+ return "dependency";
70
+ }
71
+ }
72
+
73
+ const typeColumn: Record<GraphNodeType, number> = {
74
+ arch_folder: 0,
75
+ domain_doc: 1,
76
+ architecture_doc: 2,
77
+ architecture_model: 3,
78
+ roadmap_folder: 0,
79
+ roadmap_epic: 1,
80
+ roadmap_story: 2,
81
+ roadmap_task: 3,
82
+ project_folder: 0,
83
+ app: 1,
84
+ package: 2,
85
+ module: 3,
86
+ component: 4,
87
+ };
88
+
89
+ function estimateNodeHeight(node: GraphDataset["nodes"][number]): number {
90
+ // Keep estimates stable and deterministic so structure remains predictable.
91
+ const base = 92;
92
+ const titleLines = Math.max(1, Math.ceil(node.title.length / 26));
93
+ const subtitleLines = node.description ? Math.max(1, Math.ceil(node.description.length / 36)) : 0;
94
+ const metadataLines = Math.min(3, Math.max(1, Object.keys(node.metadata).length));
95
+ return base + titleLines * 16 + subtitleLines * 14 + metadataLines * 12;
96
+ }
97
+
98
+ function estimateNodeWidth(node: GraphDataset["nodes"][number]): number {
99
+ const base = 220;
100
+ const titleWidth = node.title.length * 7.2;
101
+ const subtitleWidth = (node.description ?? "").length * 6.4;
102
+ const metadataStrings = Object.entries(node.metadata).map(([key, value]) => {
103
+ const normalized = Array.isArray(value) ? value.join(", ") : String(value);
104
+ return `${key}: ${normalized}`;
105
+ });
106
+ const metadataWidth = metadataStrings.reduce((max, value) => Math.max(max, value.length * 6.8), 0);
107
+ const estimated = base + Math.max(titleWidth, subtitleWidth, metadataWidth) * 0.58;
108
+ return Math.max(240, Math.min(estimated, 760));
109
+ }
110
+
111
+ export function buildGraphFromDataset(
112
+ dataset: GraphDataset,
113
+ _viewMode: GraphViewMode,
114
+ ): { nodes: Node<ArchNodeData>[]; edges: Edge<ArchEdgeData>[] } {
115
+ const indexByType = new Map<GraphNodeType, number>();
116
+ const columnCursorY = new Map<number, number>();
117
+ const columnMaxWidth = new Map<number, number>();
118
+ const columnX = new Map<number, number>();
119
+ const VERTICAL_GAP = 34;
120
+ const MIN_HORIZONTAL_GAP = 110;
121
+ const START_Y = 40;
122
+ const START_X = 120;
123
+
124
+ dataset.nodes.forEach((node) => {
125
+ const column = typeColumn[node.type];
126
+ const width = estimateNodeWidth(node);
127
+ const currentMax = columnMaxWidth.get(column) ?? 0;
128
+ if (width > currentMax) columnMaxWidth.set(column, width);
129
+ });
130
+
131
+ const columns = [...new Set(dataset.nodes.map((node) => typeColumn[node.type]))].sort(
132
+ (a, b) => a - b,
133
+ );
134
+ let cursorX = START_X;
135
+ columns.forEach((column, index) => {
136
+ columnX.set(column, cursorX);
137
+ const currentWidth = columnMaxWidth.get(column) ?? 260;
138
+ const nextColumn = columns[index + 1];
139
+ const nextWidth = nextColumn === undefined ? currentWidth : (columnMaxWidth.get(nextColumn) ?? 260);
140
+ const adaptiveGap = Math.max(
141
+ MIN_HORIZONTAL_GAP,
142
+ Math.round(Math.max(currentWidth, nextWidth) * 0.24),
143
+ );
144
+ cursorX += currentWidth + adaptiveGap;
145
+ });
146
+
147
+ const nodes: Node<ArchNodeData>[] = dataset.nodes.map((node) => {
148
+ const count = indexByType.get(node.type) ?? 0;
149
+ indexByType.set(node.type, count + 1);
150
+ const column = typeColumn[node.type];
151
+ const currentY = columnCursorY.get(column) ?? START_Y;
152
+ const height = estimateNodeHeight(node);
153
+ columnCursorY.set(column, currentY + height + VERTICAL_GAP);
154
+
155
+ return {
156
+ id: node.id,
157
+ type: "archNode",
158
+ position: {
159
+ x: columnX.get(column) ?? START_X + column * 320,
160
+ y: currentY,
161
+ },
162
+ data: {
163
+ kind: mapNodeTypeToKind(node.type),
164
+ tone: mapNodeTypeToTone(node.type),
165
+ label: node.title,
166
+ subtitle: node.description,
167
+ canonicalType: node.type,
168
+ metadata: [
169
+ { label: "Node Type", value: node.type },
170
+ { label: "Source", value: node.source.path },
171
+ { label: "Scope", value: node.source.scope },
172
+ ...Object.entries(node.metadata).map(([key, value]) => ({
173
+ label: key,
174
+ value: Array.isArray(value) ? value.join(", ") : String(value),
175
+ })),
176
+ ],
177
+ },
178
+ };
179
+ });
180
+
181
+ const nodeIds = new Set(nodes.map((node) => node.id));
182
+ const edges: Edge<ArchEdgeData>[] = dataset.edges
183
+ .filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target))
184
+ .map((edge) => ({
185
+ id: edge.id,
186
+ source: edge.source,
187
+ target: edge.target,
188
+ type: "smoothstep",
189
+ data: {
190
+ edgeType: mapEdgeTypeToFilter(edge.type),
191
+ authority: edge.authority,
192
+ },
193
+ }));
194
+
195
+ return { nodes, edges };
196
+ }
@@ -0,0 +1,245 @@
1
+ import type { Edge, Node } from "reactflow";
2
+ import type { ArchitectureMapData } from "../../lib/types";
3
+ import type {
4
+ ArchEdgeData,
5
+ ArchNodeData,
6
+ GraphFilter,
7
+ GraphKind,
8
+ GraphTone,
9
+ } from "./graph-types";
10
+
11
+ function createGraphNode(
12
+ kind: GraphKind,
13
+ id: string,
14
+ x: number,
15
+ y: number,
16
+ label: string,
17
+ tone: GraphTone,
18
+ subtitle?: string,
19
+ metadata: Array<{ label: string; value: string }> = [],
20
+ ): Node<ArchNodeData> {
21
+ return {
22
+ id: `${kind}:${id}`,
23
+ type: "archNode",
24
+ position: { x, y },
25
+ data: { kind, tone, label, subtitle, metadata },
26
+ };
27
+ }
28
+
29
+ export function buildInitialGraph(
30
+ data: ArchitectureMapData,
31
+ enabledFilters: GraphFilter[],
32
+ hideCompletedTasks = false,
33
+ ): { nodes: Node<ArchNodeData>[]; edges: Edge[] } {
34
+ const includeDomains = enabledFilters.includes("domains");
35
+ const includeModules = enabledFilters.includes("modules");
36
+ const includeTasks = enabledFilters.includes("tasks");
37
+ const includeDecisions = enabledFilters.includes("decisions");
38
+
39
+ const milestoneTaskCount = new Map<string, number>();
40
+ data.edges.milestoneToTask.forEach((edge) => {
41
+ milestoneTaskCount.set(edge.milestone, (milestoneTaskCount.get(edge.milestone) ?? 0) + 1);
42
+ });
43
+
44
+ const decisionTaskCount = new Map<string, number>();
45
+ data.edges.taskToDecision.forEach((edge) => {
46
+ decisionTaskCount.set(edge.decision, (decisionTaskCount.get(edge.decision) ?? 0) + 1);
47
+ });
48
+
49
+ const moduleTaskCount = new Map<string, number>();
50
+ data.edges.taskToModule.forEach((edge) => {
51
+ moduleTaskCount.set(edge.module, (moduleTaskCount.get(edge.module) ?? 0) + 1);
52
+ });
53
+
54
+ const domainDecisionCount = new Map<string, number>();
55
+ data.edges.decisionToDomain.forEach((edge) => {
56
+ domainDecisionCount.set(edge.domain, (domainDecisionCount.get(edge.domain) ?? 0) + 1);
57
+ });
58
+
59
+ const nodes: Node<ArchNodeData>[] = [];
60
+ const edges: Edge<ArchEdgeData>[] = [];
61
+
62
+ if (includeDomains) {
63
+ data.nodes.domains.forEach((domain, index) => {
64
+ nodes.push(
65
+ createGraphNode(
66
+ "domain",
67
+ domain.name,
68
+ 40,
69
+ 40 + index * 140,
70
+ domain.name,
71
+ "domain",
72
+ domain.description,
73
+ [
74
+ { label: "Description", value: domain.description ?? "n/a" },
75
+ { label: "Decisions", value: String(domainDecisionCount.get(domain.name) ?? 0) },
76
+ { label: "Graph Node", value: `domain:${domain.name}` },
77
+ ],
78
+ ),
79
+ );
80
+ });
81
+ }
82
+
83
+ if (includeDecisions) {
84
+ data.nodes.decisions.forEach((decision, index) => {
85
+ nodes.push(
86
+ createGraphNode(
87
+ "decision",
88
+ decision.id,
89
+ 280,
90
+ 40 + index * 140,
91
+ decision.id,
92
+ "decision",
93
+ decision.title,
94
+ [
95
+ { label: "Title", value: decision.title ?? decision.id },
96
+ { label: "Status", value: decision.status ?? "open" },
97
+ { label: "Linked Tasks", value: String(decisionTaskCount.get(decision.id) ?? 0) },
98
+ { label: "Graph Node", value: `decision:${decision.id}` },
99
+ ],
100
+ ),
101
+ );
102
+ });
103
+ }
104
+
105
+ const phases = [...new Set(data.nodes.milestones.map((item) => item.phaseId))];
106
+ phases.forEach((phase, index) => {
107
+ const phaseMilestones = data.nodes.milestones.filter(
108
+ (milestone) => milestone.phaseId === phase,
109
+ ).length;
110
+ nodes.push(
111
+ createGraphNode("phase", phase, 520, 40 + index * 140, phase, "phase", undefined, [
112
+ { label: "Milestones", value: String(phaseMilestones) },
113
+ { label: "Graph Node", value: `phase:${phase}` },
114
+ ]),
115
+ );
116
+ });
117
+
118
+ data.nodes.milestones.forEach((milestone, index) => {
119
+ nodes.push(
120
+ createGraphNode(
121
+ "milestone",
122
+ milestone.id,
123
+ 760,
124
+ 40 + index * 140,
125
+ milestone.id,
126
+ "phase",
127
+ milestone.phaseId,
128
+ [
129
+ { label: "Phase", value: milestone.phaseId },
130
+ { label: "Milestone", value: milestone.milestoneId },
131
+ { label: "Tasks", value: String(milestoneTaskCount.get(milestone.id) ?? 0) },
132
+ { label: "Graph Node", value: `milestone:${milestone.id}` },
133
+ ],
134
+ ),
135
+ );
136
+ edges.push({
137
+ id: `phase-milestone:${milestone.phaseId}-${milestone.id}`,
138
+ source: `phase:${milestone.phaseId}`,
139
+ target: `milestone:${milestone.id}`,
140
+ type: "smoothstep",
141
+ data: { edgeType: "blocking", authority: "authoritative" },
142
+ });
143
+ });
144
+
145
+ const visibleTasks = data.nodes.tasks.filter(
146
+ (task) => !(hideCompletedTasks && task.lane === "complete"),
147
+ );
148
+
149
+ if (includeTasks) {
150
+ visibleTasks.forEach((task, index) => {
151
+ nodes.push(
152
+ createGraphNode("task", task.id, 1020, 40 + index * 120, task.title, "task", task.id, [
153
+ { label: "ID", value: task.id },
154
+ { label: "Milestone", value: task.milestone },
155
+ { label: "Lane", value: task.lane },
156
+ { label: "Status", value: task.status },
157
+ { label: "Domain", value: task.domain ?? "unassigned" },
158
+ { label: "Graph Node", value: `task:${task.id}` },
159
+ ]),
160
+ );
161
+ });
162
+ }
163
+
164
+ if (includeModules) {
165
+ data.nodes.modules.forEach((moduleRef, index) => {
166
+ nodes.push(
167
+ createGraphNode(
168
+ "file",
169
+ moduleRef.name,
170
+ 1280,
171
+ 40 + index * 100,
172
+ moduleRef.name,
173
+ "file",
174
+ moduleRef.type,
175
+ [
176
+ { label: "Name", value: moduleRef.name },
177
+ { label: "Type", value: moduleRef.type ?? "module" },
178
+ { label: "Description", value: moduleRef.description ?? "n/a" },
179
+ { label: "Linked Tasks", value: String(moduleTaskCount.get(moduleRef.name) ?? 0) },
180
+ { label: "Graph Node", value: `file:${moduleRef.name}` },
181
+ ],
182
+ ),
183
+ );
184
+ });
185
+ }
186
+
187
+ if (includeTasks) {
188
+ data.edges.milestoneToTask.forEach((edge) => {
189
+ if (!visibleTasks.some((task) => task.id === edge.task)) return;
190
+ edges.push({
191
+ id: `milestone-task:${edge.milestone}-${edge.task}`,
192
+ source: `milestone:${edge.milestone}`,
193
+ target: `task:${edge.task}`,
194
+ type: "smoothstep",
195
+ data: { edgeType: "blocking", authority: "authoritative" },
196
+ });
197
+ });
198
+ }
199
+
200
+ if (includeDecisions && includeDomains) {
201
+ data.edges.decisionToDomain.forEach((edge) => {
202
+ edges.push({
203
+ id: `decision-domain:${edge.decision}-${edge.domain}`,
204
+ source: `decision:${edge.decision}`,
205
+ target: `domain:${edge.domain}`,
206
+ type: "smoothstep",
207
+ data: { edgeType: "data-flow", authority: "authoritative" },
208
+ });
209
+ });
210
+ }
211
+
212
+ if (includeTasks && includeModules) {
213
+ data.edges.taskToModule.forEach((edge) => {
214
+ if (!visibleTasks.some((task) => task.id === edge.task)) return;
215
+ edges.push({
216
+ id: `task-module:${edge.task}-${edge.module}`,
217
+ source: `task:${edge.task}`,
218
+ target: `file:${edge.module}`,
219
+ type: "smoothstep",
220
+ data: { edgeType: "dependency", authority: "authoritative" },
221
+ });
222
+ });
223
+ }
224
+
225
+ if (includeTasks && includeDecisions) {
226
+ data.edges.taskToDecision.forEach((edge) => {
227
+ if (!visibleTasks.some((task) => task.id === edge.task)) return;
228
+ edges.push({
229
+ id: `task-decision:${edge.task}-${edge.decision}`,
230
+ source: `task:${edge.task}`,
231
+ target: `decision:${edge.decision}`,
232
+ type: "smoothstep",
233
+ data: { edgeType: "blocking", authority: "authoritative" },
234
+ });
235
+ });
236
+ }
237
+
238
+ edges.forEach((edge) => {
239
+ if (!edge.data) {
240
+ edge.data = { edgeType: "dependency", authority: "authoritative" };
241
+ }
242
+ });
243
+
244
+ return { nodes, edges };
245
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import type { Node, ReactFlowInstance } from "reactflow";
4
+ import type { ArchNodeData, ContextMenuState, InspectorNode } from "./graph-types";
5
+ import { buildNodeMarkdown, parseNodeId } from "./graph-types";
6
+
7
+ type GraphContextMenuProps = {
8
+ contextMenu: ContextMenuState;
9
+ node: Node<ArchNodeData>;
10
+ flowInstance: ReactFlowInstance<ArchNodeData> | null;
11
+ onInspect: (node: InspectorNode) => void;
12
+ onHideNode: (nodeId: string) => void;
13
+ onShowAll: () => void;
14
+ onClose: () => void;
15
+ };
16
+
17
+ export function GraphContextMenu({
18
+ contextMenu,
19
+ node,
20
+ flowInstance,
21
+ onInspect,
22
+ onHideNode,
23
+ onShowAll,
24
+ onClose,
25
+ }: GraphContextMenuProps) {
26
+ function inspect() {
27
+ const parsed = parseNodeId(node.id);
28
+ onInspect({
29
+ type: parsed.kind,
30
+ id: parsed.id,
31
+ title: node.data.label,
32
+ metadata: node.data.metadata,
33
+ markdown: buildNodeMarkdown(node),
34
+ });
35
+ onClose();
36
+ }
37
+
38
+ return (
39
+ <div
40
+ className="fixed z-[100] grid min-w-40 gap-1 rounded-lg border border-slate-600 bg-slate-950 p-1.5"
41
+ style={{ top: contextMenu.y, left: contextMenu.x }}
42
+ >
43
+ <button
44
+ type="button"
45
+ className="cursor-pointer rounded-md border border-transparent px-2 py-1.5 text-left text-sm hover:border-slate-600 hover:bg-slate-800"
46
+ onClick={inspect}
47
+ >
48
+ Inspect node
49
+ </button>
50
+ <button
51
+ type="button"
52
+ className="cursor-pointer rounded-md border border-transparent px-2 py-1.5 text-left text-sm hover:border-slate-600 hover:bg-slate-800"
53
+ onClick={() => {
54
+ if (flowInstance) {
55
+ flowInstance.fitView({ nodes: [{ id: node.id }], duration: 300 });
56
+ }
57
+ onClose();
58
+ }}
59
+ >
60
+ Center node
61
+ </button>
62
+ <button
63
+ type="button"
64
+ className="cursor-pointer rounded-md border border-transparent px-2 py-1.5 text-left text-sm hover:border-slate-600 hover:bg-slate-800"
65
+ onClick={() => {
66
+ onHideNode(node.id);
67
+ onClose();
68
+ }}
69
+ >
70
+ Hide node
71
+ </button>
72
+ <button
73
+ type="button"
74
+ className="cursor-pointer rounded-md border border-transparent px-2 py-1.5 text-left text-sm hover:border-slate-600 hover:bg-slate-800"
75
+ onClick={() => {
76
+ onShowAll();
77
+ onClose();
78
+ }}
79
+ >
80
+ Show all nodes
81
+ </button>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { Panel } from "reactflow";
4
+ import type { Node } from "reactflow";
5
+ import type { ArchNodeData, InspectorNode } from "./graph-types";
6
+ import { buildNodeMarkdown, parseNodeId } from "./graph-types";
7
+ import { MarkdownViewer } from "../markdown-viewer";
8
+
9
+ type GraphDocPanelProps = {
10
+ node: Node<ArchNodeData>;
11
+ markdown: string;
12
+ onInspect: (node: InspectorNode) => void;
13
+ onClose: () => void;
14
+ };
15
+
16
+ export function GraphDocPanel({ node, markdown, onInspect, onClose }: GraphDocPanelProps) {
17
+ function openDetail() {
18
+ const parsed = parseNodeId(node.id);
19
+ onInspect({
20
+ type: parsed.kind,
21
+ id: parsed.id,
22
+ title: node.data.label,
23
+ metadata: node.data.metadata,
24
+ markdown: buildNodeMarkdown(node),
25
+ });
26
+ }
27
+
28
+ return (
29
+ <Panel position="top-right">
30
+ <div className="graph-doc-panel">
31
+ <div className="row-between">
32
+ <strong>Node Notes</strong>
33
+ <div className="row-wrap">
34
+ <button className="ui-btn ui-btn-outline" type="button" onClick={openDetail}>
35
+ Open Detail
36
+ </button>
37
+ <button className="ui-btn ui-btn-ghost" type="button" onClick={onClose}>
38
+ Close
39
+ </button>
40
+ </div>
41
+ </div>
42
+ <MarkdownViewer markdown={markdown} />
43
+ </div>
44
+ </Panel>
45
+ );
46
+ }
@@ -0,0 +1,82 @@
1
+ import type { Node } from "reactflow";
2
+
3
+ export type GraphFilter = "domains" | "modules" | "tasks" | "decisions";
4
+ export type GraphEdgeFilter = "dependency" | "data-flow" | "blocking";
5
+ export type GraphEdgeAuthority = "authoritative" | "manual" | "inferred";
6
+ export type GraphCanvasViewMode = "architecture-map" | "tasks" | "project";
7
+ export type GraphKind = "domain" | "decision" | "phase" | "milestone" | "task" | "file";
8
+ export type GraphTone = "domain" | "decision" | "phase" | "task" | "file";
9
+
10
+ export type InspectorNode = {
11
+ type: string;
12
+ id: string;
13
+ title: string;
14
+ metadata: Array<{ label: string; value: string }>;
15
+ markdown?: string;
16
+ };
17
+
18
+ export type ArchNodeData = {
19
+ kind: GraphKind;
20
+ tone: GraphTone;
21
+ label: string;
22
+ subtitle?: string;
23
+ canonicalType?: string;
24
+ metadata: Array<{ label: string; value: string }>;
25
+ };
26
+
27
+ export type ArchEdgeData = {
28
+ edgeType: GraphEdgeFilter;
29
+ authority: GraphEdgeAuthority;
30
+ };
31
+
32
+ export type ContextMenuState = {
33
+ nodeId: string;
34
+ x: number;
35
+ y: number;
36
+ };
37
+
38
+ export const NODE_WIDTH = 220;
39
+ export const NODE_HEIGHT = 100;
40
+
41
+ export const toneColor: Record<GraphTone, string> = {
42
+ domain: "#3b82f6",
43
+ decision: "#7c3aed",
44
+ phase: "#22c55e",
45
+ task: "#f59e0b",
46
+ file: "#6b7280",
47
+ };
48
+
49
+ export function parseNodeId(nodeId: string): { kind: GraphKind; id: string } {
50
+ const [scope = "file", type = "file", ...rest] = nodeId.split(":");
51
+ if (scope === "roadmap" && type === "task") return { kind: "task", id: rest.join(":") };
52
+ if (scope === "roadmap" && type === "story") return { kind: "milestone", id: rest.join(":") };
53
+ if (scope === "roadmap" && type === "epic") return { kind: "phase", id: rest.join(":") };
54
+ if (scope === "arch" && type === "domain") return { kind: "domain", id: rest.join(":") };
55
+ if (scope === "arch" && type === "doc") return { kind: "domain", id: rest.join(":") };
56
+ if (scope === "arch" && type === "model") return { kind: "decision", id: rest.join(":") };
57
+ if (scope === "project") return { kind: "file", id: rest.join(":") };
58
+ const kind = (scope as GraphKind) || "file";
59
+ return { kind, id: [type, ...rest].filter(Boolean).join(":") };
60
+ }
61
+
62
+ export function buildNodeMarkdown(node: Node<ArchNodeData>): string {
63
+ const { kind, label, subtitle, metadata } = node.data;
64
+ const identity = parseNodeId(node.id);
65
+ const metadataMarkdown =
66
+ metadata.length > 0
67
+ ? metadata.map((item) => `- **${item.label}**: ${item.value}`).join("\n")
68
+ : "- No metadata available";
69
+
70
+ return [
71
+ `## ${label}`,
72
+ "",
73
+ `- **Type**: ${kind}`,
74
+ `- **ID**: \`${identity.id}\``,
75
+ subtitle ? `- **Subtitle**: ${subtitle}` : "",
76
+ "",
77
+ "### Metadata",
78
+ metadataMarkdown,
79
+ ]
80
+ .filter(Boolean)
81
+ .join("\n");
82
+ }