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,48 @@
1
+ "use client";
2
+
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/card";
4
+ import { useEffect, useState } from "react";
5
+ import { HealthPanel } from "../../components/health-panel";
6
+ import { useInspector } from "../../components/inspector-context";
7
+ import { getHealth } from "../../lib/api";
8
+ import { CheckData } from "../../lib/types";
9
+
10
+ export default function HealthPage() {
11
+ const [data, setData] = useState<CheckData | null>(null);
12
+ const { setSelection } = useInspector();
13
+
14
+ useEffect(() => {
15
+ void getHealth().then(setData);
16
+ }, []);
17
+
18
+ return (
19
+ <div className="grid gap-3">
20
+ <Card>
21
+ <CardHeader>
22
+ <CardTitle>Architecture Health</CardTitle>
23
+ </CardHeader>
24
+ <CardContent>
25
+ {data ? (
26
+ <HealthPanel
27
+ health={data}
28
+ onInspect={(label, status) =>
29
+ setSelection({
30
+ type: "health",
31
+ title: label,
32
+ metadata: [
33
+ { label: "Status", value: status },
34
+ { label: "Errors", value: String(data.errors.length) },
35
+ { label: "Warnings", value: String(data.warnings.length) },
36
+ ],
37
+ links: [...data.errors, ...data.warnings],
38
+ })
39
+ }
40
+ />
41
+ ) : (
42
+ <p>Loading health checks...</p>
43
+ )}
44
+ </CardContent>
45
+ </Card>
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from "react";
2
+ import "./globals.css";
3
+ import { AppShell } from "../components/app-shell";
4
+
5
+ export const metadata = {
6
+ title: "Architecture Control Surface",
7
+ description: "Architecture graph and traceability UI",
8
+ };
9
+ export const dynamic = "force-dynamic";
10
+
11
+ export default function RootLayout({ children }: { children: ReactNode }) {
12
+ return (
13
+ <html lang="en">
14
+ <body className="h-dvh overflow-hidden">
15
+ <AppShell>{children}</AppShell>
16
+ </body>
17
+ </html>
18
+ );
19
+ }
@@ -0,0 +1,5 @@
1
+ import { redirect } from "next/navigation";
2
+
3
+ export default function Page() {
4
+ redirect("/work?view=architecture");
5
+ }
@@ -0,0 +1,265 @@
1
+ "use client";
2
+
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/card";
4
+ import { Input } from "@repo/ui/input";
5
+ import { Select } from "@repo/ui/select";
6
+ import { Tabs, TabsList, TabsTrigger } from "@repo/ui/tabs";
7
+ import { useRouter, useSearchParams } from "next/navigation";
8
+ import { useEffect, useMemo, useState } from "react";
9
+ import { GraphCanvas } from "../../components/graph-canvas";
10
+ import { GraphEdgeFilter } from "../../components/graph/graph-types";
11
+ import { useInspector } from "../../components/inspector-context";
12
+ import { WorkTable } from "../../components/work-table";
13
+ import { useWorkspace } from "../../components/workspace-context";
14
+ import { getGraphDataset, getPhases, getTasks } from "../../lib/api";
15
+ import { GraphDatasetResponse, GraphValidationData, TaskNode } from "../../lib/types";
16
+
17
+ const defaultFilters = ["domains", "modules", "tasks", "decisions"] as Array<
18
+ "domains" | "modules" | "tasks" | "decisions"
19
+ >;
20
+
21
+ export default function WorkPage() {
22
+ const [phases, setPhases] = useState<Array<{ id: string; active: boolean }>>([]);
23
+ const [tasks, setTasks] = useState<TaskNode[]>([]);
24
+ const [graphData, setGraphData] = useState<GraphDatasetResponse["dataset"] | null>(null);
25
+ const [graphValidation, setGraphValidation] = useState<GraphValidationData | null>(null);
26
+ const [loadErrors, setLoadErrors] = useState<string[]>([]);
27
+ const [phase, setPhase] = useState("all");
28
+ const [query, setQuery] = useState("");
29
+ const { setSelection } = useInspector();
30
+ const { filters, setHopDepth } = useWorkspace();
31
+ const searchParams = useSearchParams();
32
+ const router = useRouter();
33
+
34
+ useEffect(() => {
35
+ let cancelled = false;
36
+
37
+ void Promise.allSettled([getPhases(), getTasks(), getGraphDataset()]).then((results) => {
38
+ if (cancelled) return;
39
+
40
+ const [phaseResult, taskResult, graphResult] = results;
41
+ const errors: string[] = [];
42
+
43
+ if (phaseResult.status === "fulfilled") {
44
+ setPhases(phaseResult.value);
45
+ } else {
46
+ setPhases([]);
47
+ errors.push(`Phases unavailable: ${phaseResult.reason instanceof Error ? phaseResult.reason.message : "Unknown error"}`);
48
+ }
49
+
50
+ if (taskResult.status === "fulfilled") {
51
+ setTasks(taskResult.value.tasks);
52
+ } else {
53
+ setTasks([]);
54
+ errors.push(`Tasks unavailable: ${taskResult.reason instanceof Error ? taskResult.reason.message : "Unknown error"}`);
55
+ }
56
+
57
+ if (graphResult.status === "fulfilled") {
58
+ setGraphData(graphResult.value.dataset);
59
+ setGraphValidation(graphResult.value.validation);
60
+ if (!graphResult.value.validation.valid) {
61
+ errors.push(
62
+ ...graphResult.value.validation.errors.map((issue) => `Graph schema error: ${issue.message}`),
63
+ );
64
+ }
65
+ } else {
66
+ setGraphData(null);
67
+ setGraphValidation(null);
68
+ errors.push(
69
+ `Graph unavailable: ${
70
+ graphResult.reason instanceof Error ? graphResult.reason.message : "Unknown error"
71
+ }`,
72
+ );
73
+ }
74
+
75
+ setLoadErrors(errors);
76
+ });
77
+
78
+ return () => {
79
+ cancelled = true;
80
+ };
81
+ }, []);
82
+
83
+ const viewParam = searchParams.get("view");
84
+ const view = viewParam === "project" ? "project" : viewParam === "architecture" ? "architecture" : "tasks";
85
+
86
+ const milestones = useMemo(() => {
87
+ const milestoneSet = new Set<string>();
88
+ for (const task of tasks) {
89
+ if (phase !== "all" && !task.milestone.startsWith(`${phase}/`)) continue;
90
+ milestoneSet.add(task.milestone);
91
+ }
92
+ return [...milestoneSet];
93
+ }, [tasks, phase]);
94
+
95
+ const filteredTasks = useMemo(() => {
96
+ return tasks.filter((task) => {
97
+ const inPhase = phase === "all" || task.milestone.startsWith(`${phase}/`);
98
+ if (!inPhase) return false;
99
+ if (!query) return true;
100
+ const q = query.toLowerCase();
101
+ return task.id.toLowerCase().includes(q) || task.title.toLowerCase().includes(q);
102
+ });
103
+ }, [phase, query, tasks]);
104
+
105
+ const activeFilters = useMemo(
106
+ () => defaultFilters.filter((filter) => filters.nodeTypes[filter]),
107
+ [filters.nodeTypes],
108
+ );
109
+ const activeEdgeFilters = useMemo(
110
+ () =>
111
+ (Object.entries(filters.edgeTypes) as Array<[keyof typeof filters.edgeTypes, boolean]>)
112
+ .filter(([, enabled]) => enabled)
113
+ .map(([filter]) => filter as GraphEdgeFilter),
114
+ [filters.edgeTypes],
115
+ );
116
+ const activeAuthorityFilters = useMemo(
117
+ () =>
118
+ (Object.entries(filters.authorityTypes) as Array<
119
+ [keyof typeof filters.authorityTypes, boolean]
120
+ >)
121
+ .filter(([, enabled]) => enabled)
122
+ .map(([filter]) => filter),
123
+ [filters.authorityTypes],
124
+ );
125
+
126
+ return (
127
+ <div className="grid gap-3">
128
+ {loadErrors.length > 0 ? (
129
+ <div className="rounded-xl border border-amber-700 bg-amber-950/60 p-3 text-sm text-amber-200">
130
+ {loadErrors.map((message) => (
131
+ <p key={message}>{message}</p>
132
+ ))}
133
+ </div>
134
+ ) : null}
135
+ <Card>
136
+ <CardHeader>
137
+ <CardTitle>Work Views</CardTitle>
138
+ </CardHeader>
139
+ <CardContent>
140
+ <Tabs>
141
+ <TabsList>
142
+ <TabsTrigger
143
+ className={view === "architecture" ? "border border-blue-700 bg-slate-800 text-slate-100" : ""}
144
+ onClick={() => router.push("/work?view=architecture")}
145
+ >
146
+ Architecture
147
+ </TabsTrigger>
148
+ <TabsTrigger
149
+ className={view === "tasks" ? "border border-blue-700 bg-slate-800 text-slate-100" : ""}
150
+ onClick={() => router.push("/work?view=tasks")}
151
+ >
152
+ Tasks
153
+ </TabsTrigger>
154
+ <TabsTrigger
155
+ className={view === "project" ? "border border-blue-700 bg-slate-800 text-slate-100" : ""}
156
+ onClick={() => router.push("/work?view=project")}
157
+ >
158
+ Project
159
+ </TabsTrigger>
160
+ </TabsList>
161
+ </Tabs>
162
+ </CardContent>
163
+ </Card>
164
+
165
+ {graphValidation && graphValidation.errors.length > 0 ? (
166
+ <div className="rounded-xl border border-red-700 bg-red-950/60 p-3 text-sm text-red-200">
167
+ <p className="font-medium">Graph activation blocked by schema validation errors.</p>
168
+ {graphValidation.errors.map((issue, index) => (
169
+ <p key={`${issue.ruleId}:${index}`}>[{issue.ruleId}] {issue.message}</p>
170
+ ))}
171
+ </div>
172
+ ) : graphData ? (
173
+ <GraphCanvas
174
+ data={graphData}
175
+ viewMode={view === "project" ? "project" : view === "architecture" ? "architecture-map" : "tasks"}
176
+ enabledFilters={activeFilters}
177
+ enabledEdgeFilters={activeEdgeFilters}
178
+ enabledAuthorityFilters={activeAuthorityFilters}
179
+ hopDepth={filters.hopDepth}
180
+ onHopDepthChange={setHopDepth}
181
+ showExternalDependencies={filters.showExternalDependencies}
182
+ hideCompletedTasks={filters.hideCompletedTasks}
183
+ onNodeSelect={(node) =>
184
+ setSelection({
185
+ type: node.type as "domain" | "decision" | "phase" | "milestone" | "task" | "file" | "health",
186
+ title: node.title,
187
+ id: node.id,
188
+ metadata: node.metadata,
189
+ markdown: node.markdown,
190
+ })
191
+ }
192
+ />
193
+ ) : (
194
+ <div className="rounded-xl border border-slate-700 bg-slate-900/90 p-3">
195
+ Loading graph...
196
+ </div>
197
+ )}
198
+ {graphValidation && graphValidation.warnings.length > 0 ? (
199
+ <div className="rounded-xl border border-amber-700 bg-amber-950/60 p-3 text-sm text-amber-200">
200
+ <p className="font-medium">Graph warnings</p>
201
+ {graphValidation.warnings.map((issue, index) => (
202
+ <p key={`${issue.ruleId}:${index}`}>[{issue.ruleId}] {issue.message}</p>
203
+ ))}
204
+ </div>
205
+ ) : null}
206
+
207
+ {view !== "architecture" ? (
208
+ <Card>
209
+ <CardHeader>
210
+ <CardTitle>Roadmap Work</CardTitle>
211
+ </CardHeader>
212
+ <CardContent className="grid gap-2">
213
+ <div className="flex flex-wrap gap-2">
214
+ <Select value={phase} onChange={(event) => setPhase(event.target.value)}>
215
+ <option value="all">All phases</option>
216
+ {phases.map((item) => (
217
+ <option key={item.id} value={item.id}>
218
+ {item.id} {item.active ? "(active)" : ""}
219
+ </option>
220
+ ))}
221
+ </Select>
222
+ <Input
223
+ placeholder="Search task..."
224
+ value={query}
225
+ onChange={(event) => setQuery(event.target.value)}
226
+ />
227
+ </div>
228
+
229
+ <div className="grid gap-1 text-slate-400">
230
+ {milestones.map((milestone) => (
231
+ <span key={milestone}>{milestone}</span>
232
+ ))}
233
+ </div>
234
+ </CardContent>
235
+ </Card>
236
+ ) : null}
237
+
238
+ {view !== "architecture" ? (
239
+ <Card>
240
+ <CardHeader>
241
+ <CardTitle>Tasks</CardTitle>
242
+ </CardHeader>
243
+ <CardContent>
244
+ <WorkTable
245
+ tasks={filteredTasks}
246
+ onSelectTask={(task) =>
247
+ setSelection({
248
+ type: "task",
249
+ title: task.title,
250
+ id: task.id,
251
+ metadata: [
252
+ { label: "Phase", value: task.milestone.split("/")[0] ?? "unknown" },
253
+ { label: "Milestone", value: task.milestone },
254
+ { label: "Status", value: task.lane },
255
+ { label: "Domain", value: task.domain ?? "foundation" },
256
+ ],
257
+ })
258
+ }
259
+ />
260
+ </CardContent>
261
+ </Card>
262
+ ) : null}
263
+ </div>
264
+ );
265
+ }
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { MouseEvent as ReactMouseEvent, ReactNode, useEffect, useMemo, useState } from "react";
4
+ import { ErrorBoundary } from "./error-boundary";
5
+ import { InspectorPanel } from "./inspector";
6
+ import { InspectorProvider } from "./inspector-context";
7
+ import { Sidebar } from "./sidebar";
8
+ import { Topbar } from "./topbar";
9
+ import { useWorkspace, WorkspaceProvider } from "./workspace-context";
10
+
11
+ function AppShellContent({ children }: { children: ReactNode }) {
12
+ const collapsedLeftWidth = 56;
13
+ const collapsedRightWidth = 120;
14
+ const minMainWidth = 420;
15
+ const minExpandedLeftWidth = 220;
16
+ const minExpandedRightWidth = 320;
17
+ const resizerWidth = 4;
18
+ const {
19
+ leftCollapsed,
20
+ rightCollapsed,
21
+ leftWidth,
22
+ rightWidth,
23
+ setLeftCollapsed,
24
+ setRightCollapsed,
25
+ setLeftWidth,
26
+ setRightWidth,
27
+ resetLayout,
28
+ } = useWorkspace();
29
+ const [viewportWidth, setViewportWidth] = useState(0);
30
+
31
+ useEffect(() => {
32
+ if (typeof window === "undefined") return;
33
+ const update = () => setViewportWidth(window.innerWidth);
34
+ update();
35
+ window.addEventListener("resize", update);
36
+ return () => window.removeEventListener("resize", update);
37
+ }, []);
38
+
39
+ useEffect(() => {
40
+ function onKeyDown(event: KeyboardEvent) {
41
+ if (!(event.metaKey || event.ctrlKey)) return;
42
+ const key = event.key.toLowerCase();
43
+ if (key === "b") {
44
+ event.preventDefault();
45
+ setLeftCollapsed(!leftCollapsed);
46
+ return;
47
+ }
48
+ if (key === "i") {
49
+ event.preventDefault();
50
+ setRightCollapsed(!rightCollapsed);
51
+ return;
52
+ }
53
+ if (key === "0") {
54
+ event.preventDefault();
55
+ resetLayout();
56
+ }
57
+ }
58
+ window.addEventListener("keydown", onKeyDown);
59
+ return () => window.removeEventListener("keydown", onKeyDown);
60
+ }, [
61
+ leftCollapsed,
62
+ resetLayout,
63
+ rightCollapsed,
64
+ setLeftCollapsed,
65
+ setRightCollapsed,
66
+ ]);
67
+
68
+ const shellStyle = useMemo(() => {
69
+ const leftResizer = leftCollapsed ? 0 : resizerWidth;
70
+ const rightResizer = rightCollapsed ? 0 : resizerWidth;
71
+ let effectiveLeft = leftCollapsed ? collapsedLeftWidth : leftWidth;
72
+ let effectiveRight = rightCollapsed ? collapsedRightWidth : rightWidth;
73
+
74
+ if (viewportWidth > 0) {
75
+ const maxSideBudget = Math.max(0, viewportWidth - minMainWidth - leftResizer - rightResizer);
76
+ let overflow = effectiveLeft + effectiveRight - maxSideBudget;
77
+
78
+ if (overflow > 0 && !rightCollapsed) {
79
+ const reducible = Math.max(0, effectiveRight - minExpandedRightWidth);
80
+ const reduction = Math.min(reducible, overflow);
81
+ effectiveRight -= reduction;
82
+ overflow -= reduction;
83
+ }
84
+
85
+ if (overflow > 0 && !leftCollapsed) {
86
+ const reducible = Math.max(0, effectiveLeft - minExpandedLeftWidth);
87
+ const reduction = Math.min(reducible, overflow);
88
+ effectiveLeft -= reduction;
89
+ }
90
+ }
91
+
92
+ return {
93
+ gridTemplateColumns: `${effectiveLeft}px ${leftResizer}px minmax(0, 1fr) ${rightResizer}px ${effectiveRight}px`,
94
+ };
95
+ }, [leftCollapsed, leftWidth, rightCollapsed, rightWidth, viewportWidth]);
96
+
97
+ function startResize(side: "left" | "right", event: ReactMouseEvent<HTMLDivElement>) {
98
+ event.preventDefault();
99
+ const startX = event.clientX;
100
+ const startWidth = side === "left" ? leftWidth : rightWidth;
101
+ function onMove(moveEvent: MouseEvent) {
102
+ const delta = moveEvent.clientX - startX;
103
+ if (side === "left") {
104
+ setLeftWidth(startWidth + delta);
105
+ } else {
106
+ setRightWidth(startWidth - delta);
107
+ }
108
+ }
109
+ function onUp() {
110
+ window.removeEventListener("mousemove", onMove);
111
+ window.removeEventListener("mouseup", onUp);
112
+ }
113
+ window.addEventListener("mousemove", onMove);
114
+ window.addEventListener("mouseup", onUp);
115
+ }
116
+
117
+ return (
118
+ <div className="grid h-dvh min-h-0 overflow-hidden" style={shellStyle}>
119
+ <Sidebar />
120
+
121
+ <div
122
+ className={
123
+ leftCollapsed
124
+ ? "w-0 pointer-events-none"
125
+ : "w-1 cursor-col-resize bg-transparent hover:bg-slate-600"
126
+ }
127
+ onMouseDown={(event) => {
128
+ if (!leftCollapsed) startResize("left", event);
129
+ }}
130
+ role="separator"
131
+ aria-orientation="vertical"
132
+ aria-label="Resize sidebar"
133
+ />
134
+
135
+ <div className="grid min-h-0 min-w-0 grid-rows-[64px_1fr]">
136
+ <Topbar projectName="testProjectV2" />
137
+ <main className="min-h-0 overflow-auto p-4">
138
+ <ErrorBoundary>{children}</ErrorBoundary>
139
+ </main>
140
+ </div>
141
+
142
+ <div
143
+ className={
144
+ rightCollapsed
145
+ ? "w-0 pointer-events-none"
146
+ : "w-1 cursor-col-resize bg-transparent hover:bg-slate-600"
147
+ }
148
+ onMouseDown={(event) => {
149
+ if (!rightCollapsed) startResize("right", event);
150
+ }}
151
+ role="separator"
152
+ aria-orientation="vertical"
153
+ aria-label="Resize inspector"
154
+ />
155
+
156
+ <ErrorBoundary>
157
+ <InspectorPanel />
158
+ </ErrorBoundary>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ export function AppShell({ children }: { children: ReactNode }) {
164
+ return (
165
+ <WorkspaceProvider>
166
+ <InspectorProvider>
167
+ <AppShellContent>{children}</AppShellContent>
168
+ </InspectorProvider>
169
+ </WorkspaceProvider>
170
+ );
171
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { Component, type ReactNode } from "react";
4
+
5
+ type ErrorBoundaryProps = {
6
+ children: ReactNode;
7
+ fallback?: ReactNode;
8
+ };
9
+
10
+ type ErrorBoundaryState = {
11
+ hasError: boolean;
12
+ error: Error | null;
13
+ };
14
+
15
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
16
+ constructor(props: ErrorBoundaryProps) {
17
+ super(props);
18
+ this.state = { hasError: false, error: null };
19
+ }
20
+
21
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
22
+ return { hasError: true, error };
23
+ }
24
+
25
+ override render() {
26
+ if (this.state.hasError) {
27
+ if (this.props.fallback) {
28
+ return this.props.fallback;
29
+ }
30
+ return (
31
+ <div className="rounded-xl border border-slate-600 bg-slate-900/90 p-6 text-center">
32
+ <h3 className="mb-2">Something went wrong</h3>
33
+ <p className="mb-3 text-slate-400">
34
+ A component encountered an error. This does not affect other panels.
35
+ </p>
36
+ <pre className="inline-block max-w-[80%] whitespace-pre-wrap rounded-md border border-slate-600 bg-slate-950 px-2 py-1 font-mono text-xs">
37
+ {this.state.error?.message ?? "Unknown error"}
38
+ </pre>
39
+ <div className="mt-4">
40
+ <button
41
+ className="rounded-lg border border-slate-600 px-3 py-2 text-sm"
42
+ type="button"
43
+ onClick={() => this.setState({ hasError: false, error: null })}
44
+ >
45
+ Try Again
46
+ </button>
47
+ </div>
48
+ </div>
49
+ );
50
+ }
51
+ return this.props.children;
52
+ }
53
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { Badge } from "@repo/ui/badge";
4
+ import { Handle, NodeProps, NodeToolbar, Position, useStore } from "reactflow";
5
+ import type { ArchNodeData } from "./graph-types";
6
+
7
+ export function ArchNode({ data, selected }: NodeProps<ArchNodeData>) {
8
+ const zoom = useStore((state) => state.transform[2]);
9
+ const detailLevel = zoom < 0.85 ? 0 : zoom < 1.1 ? 1 : 2;
10
+ const titleStyle =
11
+ detailLevel === 0
12
+ ? {
13
+ whiteSpace: "nowrap" as const,
14
+ overflow: "hidden",
15
+ textOverflow: "ellipsis",
16
+ }
17
+ : detailLevel === 1
18
+ ? {
19
+ display: "-webkit-box",
20
+ WebkitBoxOrient: "vertical" as const,
21
+ WebkitLineClamp: 2,
22
+ overflow: "hidden",
23
+ lineHeight: "1.2",
24
+ }
25
+ : {
26
+ whiteSpace: "normal" as const,
27
+ lineHeight: "1.25",
28
+ };
29
+ const toneClass =
30
+ data.tone === "domain"
31
+ ? "bg-blue-900/90"
32
+ : data.tone === "decision"
33
+ ? "bg-violet-900/90"
34
+ : data.tone === "phase"
35
+ ? "bg-green-900/90"
36
+ : data.tone === "task"
37
+ ? "bg-amber-900/90"
38
+ : "bg-slate-700";
39
+
40
+ const metadataPreviewCount = detailLevel === 0 ? 1 : detailLevel === 1 ? 2 : 4;
41
+
42
+ return (
43
+ <div className={`w-[280px] rounded-xl border border-slate-600 px-2.5 py-2 text-slate-100 shadow ${toneClass}`}>
44
+ <NodeToolbar isVisible={selected} position={Position.Top}>
45
+ <div className="flex items-center gap-2 rounded-md border border-slate-600 bg-slate-950 px-2 py-1">
46
+ <Badge variant="secondary">{data.kind}</Badge>
47
+ <span className="text-xs text-slate-400">drag, connect</span>
48
+ </div>
49
+ </NodeToolbar>
50
+ <Handle type="target" position={Position.Left} className="!h-2 !w-2 !border !border-slate-900 !bg-slate-200" />
51
+ <div className="mb-1 text-[13px] font-semibold" style={titleStyle}>
52
+ {data.label}
53
+ </div>
54
+ {detailLevel > 0 && data.subtitle ? (
55
+ <div className="mb-1.5 text-xs leading-tight text-slate-300">{data.subtitle}</div>
56
+ ) : null}
57
+ {data.metadata.length > 0 ? (
58
+ <div
59
+ className="text-[11px] leading-tight text-slate-400"
60
+ style={{
61
+ whiteSpace: detailLevel === 2 ? ("normal" as const) : ("nowrap" as const),
62
+ overflow: "hidden",
63
+ textOverflow: "ellipsis",
64
+ }}
65
+ >
66
+ {data.metadata
67
+ .slice(0, metadataPreviewCount)
68
+ .map((item) => `${item.label}: ${item.value}`)
69
+ .join(" · ")}
70
+ </div>
71
+ ) : null}
72
+ <Handle type="source" position={Position.Right} className="!h-2 !w-2 !border !border-slate-900 !bg-slate-200" />
73
+ </div>
74
+ );
75
+ }
76
+
77
+ export const nodeTypes = { archNode: ArchNode };