@nwire/studio 0.9.1

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/components.json +19 -0
  4. package/index.html +12 -0
  5. package/package.json +66 -0
  6. package/src/App.vue +305 -0
  7. package/src/components/EmptyState.stories.ts +53 -0
  8. package/src/components/EmptyState.vue +28 -0
  9. package/src/components/ErrorBoundary.vue +60 -0
  10. package/src/components/FilterInput.stories.ts +32 -0
  11. package/src/components/FilterInput.vue +33 -0
  12. package/src/components/JsonView.stories.ts +38 -0
  13. package/src/components/JsonView.vue +34 -0
  14. package/src/components/KindBadge.stories.ts +72 -0
  15. package/src/components/KindBadge.vue +59 -0
  16. package/src/components/ListRow.stories.ts +56 -0
  17. package/src/components/ListRow.vue +48 -0
  18. package/src/components/MasterDetail.stories.ts +74 -0
  19. package/src/components/MasterDetail.vue +35 -0
  20. package/src/components/MonacoViewer.vue +143 -0
  21. package/src/components/PageHeader.stories.ts +45 -0
  22. package/src/components/PageHeader.vue +46 -0
  23. package/src/components/SchemaNode.vue +208 -0
  24. package/src/components/SchemaTree.vue +65 -0
  25. package/src/components/SourceDrawer.vue +136 -0
  26. package/src/components/SourcePill.vue +103 -0
  27. package/src/components/__tests__/EmptyState.test.ts +28 -0
  28. package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
  29. package/src/components/__tests__/FilterInput.test.ts +38 -0
  30. package/src/components/__tests__/JsonView.test.ts +33 -0
  31. package/src/components/__tests__/KindBadge.test.ts +39 -0
  32. package/src/components/__tests__/ListRow.test.ts +39 -0
  33. package/src/components/__tests__/MasterDetail.test.ts +40 -0
  34. package/src/components/__tests__/PageHeader.test.ts +42 -0
  35. package/src/components/index.ts +17 -0
  36. package/src/components/ui/badge/Badge.vue +17 -0
  37. package/src/components/ui/badge/index.ts +25 -0
  38. package/src/components/ui/button/Button.vue +28 -0
  39. package/src/components/ui/button/index.ts +34 -0
  40. package/src/components/ui/card/Card.vue +14 -0
  41. package/src/components/ui/card/CardContent.vue +14 -0
  42. package/src/components/ui/card/CardDescription.vue +14 -0
  43. package/src/components/ui/card/CardFooter.vue +14 -0
  44. package/src/components/ui/card/CardHeader.vue +14 -0
  45. package/src/components/ui/card/CardTitle.vue +14 -0
  46. package/src/components/ui/card/index.ts +6 -0
  47. package/src/components/ui/dialog/Dialog.vue +15 -0
  48. package/src/components/ui/dialog/DialogClose.vue +12 -0
  49. package/src/components/ui/dialog/DialogContent.vue +47 -0
  50. package/src/components/ui/dialog/DialogDescription.vue +22 -0
  51. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  52. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  53. package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
  54. package/src/components/ui/dialog/DialogTitle.vue +22 -0
  55. package/src/components/ui/dialog/DialogTrigger.vue +12 -0
  56. package/src/components/ui/dialog/index.ts +9 -0
  57. package/src/components/ui/input/Input.vue +32 -0
  58. package/src/components/ui/input/index.ts +1 -0
  59. package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
  60. package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
  61. package/src/components/ui/scroll-area/index.ts +2 -0
  62. package/src/components/ui/separator/Separator.vue +27 -0
  63. package/src/components/ui/separator/index.ts +1 -0
  64. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  65. package/src/components/ui/skeleton/index.ts +1 -0
  66. package/src/components/ui/tabs/Tabs.vue +15 -0
  67. package/src/components/ui/tabs/TabsContent.vue +25 -0
  68. package/src/components/ui/tabs/TabsList.vue +25 -0
  69. package/src/components/ui/tabs/TabsTrigger.vue +29 -0
  70. package/src/components/ui/tabs/index.ts +4 -0
  71. package/src/components/ui/tooltip/Tooltip.vue +15 -0
  72. package/src/components/ui/tooltip/TooltipContent.vue +40 -0
  73. package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
  74. package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
  75. package/src/components/ui/tooltip/index.ts +4 -0
  76. package/src/composables/useCopy.ts +31 -0
  77. package/src/lib/__tests__/normalize-cache.test.ts +104 -0
  78. package/src/lib/cache.ts +334 -0
  79. package/src/lib/normalize-cache.ts +92 -0
  80. package/src/lib/project-catalog.ts +125 -0
  81. package/src/lib/utils.ts +6 -0
  82. package/src/main.ts +112 -0
  83. package/src/pages/Actions.vue +180 -0
  84. package/src/pages/Commands.vue +262 -0
  85. package/src/pages/Dispatch.vue +431 -0
  86. package/src/pages/Events.vue +166 -0
  87. package/src/pages/Home.stories.ts +47 -0
  88. package/src/pages/Home.vue +485 -0
  89. package/src/pages/Hooks.vue +297 -0
  90. package/src/pages/Live.vue +249 -0
  91. package/src/pages/Modules.vue +174 -0
  92. package/src/pages/Overview.vue +159 -0
  93. package/src/pages/Plugins.stories.ts +44 -0
  94. package/src/pages/Plugins.vue +403 -0
  95. package/src/pages/Projects.vue +272 -0
  96. package/src/pages/Run.vue +479 -0
  97. package/src/pages/Topology.vue +164 -0
  98. package/src/pages/Trace.vue +511 -0
  99. package/src/pages/TraceNode.vue +166 -0
  100. package/src/pages/Workflows.vue +191 -0
  101. package/src/pages/__tests__/Actions.test.ts +98 -0
  102. package/src/pages/__tests__/Home.test.ts +98 -0
  103. package/src/pages/__tests__/Hooks.test.ts +119 -0
  104. package/src/pages/__tests__/Plugins.test.ts +80 -0
  105. package/src/style.css +40 -0
  106. package/tsconfig.json +20 -0
  107. package/vite.config.ts +892 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Gefter / 200apps Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # @nwire/studio
2
+
3
+ > Vue 3 dev-time control plane for any Nwire app — static topology + live traces + a kernel-backed Run page.
4
+
5
+ A Vite + Vue 3 + Tailwind 4 + VueFlow SPA that reads `.nwire/manifest.json`
6
+ (emitted by `@nwire/scan`) and `/__nwire/*` middleware on a running wire
7
+ to render the system as it's actually wired. Designed to ship as the
8
+ default dev surface — not a separate dashboard you have to spin up.
9
+
10
+ ## Install
11
+
12
+ Usually launched via the CLI rather than installed standalone.
13
+
14
+ ```bash
15
+ nwire studio
16
+ # → http://localhost:7777
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ From any Nwire repo with a `.nwire/` cache and (optionally) a running app:
22
+
23
+ ```bash
24
+ nwire cache # rebuild .nwire/*.json from source
25
+ nwire studio # open browser to :7777
26
+ ```
27
+
28
+ Studio reads the cache + live runtime over `/__nwire/*` middleware
29
+ exposed by the Vite dev server (manifest, telemetry stream, run
30
+ supervisor, dispatch).
31
+
32
+ ## Pages
33
+
34
+ | Page | Reads | Renders |
35
+ | --------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
36
+ | Overview | `manifest.json` | Per-app summary, counts, links. |
37
+ | Topology | `graph.json` + manifest | VueFlow graph of modules + cross-domain event edges. |
38
+ | Modules | `modules.json` | Per-domain card with badges (public/private, persona, journeyStep). |
39
+ | Actions | `actions.json` | Action table with SLO + retry + emits. |
40
+ | Events | `events.json` | Event catalog grouped by audience/outcome. |
41
+ | Resolvers | `resolvers.json` + `routes.json` | Resolver bindings with their HTTP mount tuples. |
42
+ | Workflows | `workflows.json` | Reactions + sagas with state diagrams. |
43
+ | Hooks | `hooks.json` + live `.tap()` | Every `hook()` in the registry with chain + listener counts, click-to-open chips, live step taps. |
44
+ | Plugins | `plugins.json` | Plugins + modules-compiled-as-plugins (distinguished by `kind`). |
45
+ | Commands | `commands.json` | Operator `defineCommand` entries with run buttons. |
46
+ | Live | `/__nwire/events/stream` (SSE) | Tail every domain event as it fires. |
47
+ | Trace | `/__nwire/telemetry/stream` (SSE) | Causation tree for one `correlationId`; Play Trace replays telemetry across canvas with amber-glow per sticky. |
48
+ | Dispatch | `/__nwire/dispatch` | Form-driven action invocation against a live wire. |
49
+ | Run | `/__nwire/run/*` (kernel supervisor) | 3-column picker / processes / live stdout — boots wires from the browser. |
50
+
51
+ ## Surface (programmatic)
52
+
53
+ | Export / route | Role |
54
+ | -------------------------- | --------------------------------------------------------------- |
55
+ | `nwire studio` CLI command | Boots the Studio Vite dev server. |
56
+ | `/__nwire/manifest` | Serves the disk cache. |
57
+ | `/__nwire/run/*` | Start/stop/list processes; SSE for live stdout (kernel-driven). |
58
+ | `/__nwire/telemetry/*` | `recent` + `stream` over the runtime's `onTelemetry` stream. |
59
+ | `/__nwire/events/*` | Domain-event tail. |
60
+ | Reusable shadcn-vue lib | Buttons, badges, tables, ErrorBoundary used across pages. |
61
+
62
+ ## Related
63
+
64
+ - `@nwire/scan` — produces every JSON file Studio reads at boot.
65
+ - `@nwire/cli` — `nwire studio` is its entry point.
66
+ - `@nwire/runner` — supplies the `RunnerSupervisor` that backs the Run page.
67
+ - `@nwire/hooks` — Studio's Hooks page is a thin reflection of `listHooks()` + `.tap()`.
68
+ - `@nwire/mcp` — the same `/__nwire/*` surface AI clients reach via MCP.
69
+
70
+ ## Status
71
+
72
+ v0.x — pages are stable; cache schema is treated as the contract. A separate cloud product (`@nwire/studio-cloud`) layers history, multi-deploy diffing, AI debug, and team affordances on top — shipped separately.
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://shadcn-vue.com/schema.json",
3
+ "style": "default",
4
+ "typescript": true,
5
+ "tailwind": {
6
+ "config": "",
7
+ "css": "src/style.css",
8
+ "baseColor": "zinc",
9
+ "cssVariables": true
10
+ },
11
+ "aliases": {
12
+ "components": "@/components",
13
+ "composables": "@/composables",
14
+ "utils": "@/lib/utils",
15
+ "ui": "@/components/ui",
16
+ "lib": "@/lib"
17
+ },
18
+ "iconLibrary": "lucide"
19
+ }
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Nwire Studio</title>
7
+ </head>
8
+ <body class="bg-zinc-950 text-zinc-100 antialiased">
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@nwire/studio",
3
+ "version": "0.9.1",
4
+ "description": "Nwire Studio — visual companion for the framework. Vue 3 + Vite + shadcn-vue + VueFlow. System topology, action runner, actor browser, projection viewer, DLQ inspector.",
5
+ "keywords": [
6
+ "devtools",
7
+ "nwire",
8
+ "shadcn",
9
+ "studio",
10
+ "vue",
11
+ "vueflow"
12
+ ],
13
+ "license": "MIT",
14
+ "files": [
15
+ "src",
16
+ "index.html",
17
+ "vite.config.ts",
18
+ "components.json",
19
+ "tsconfig.json",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "type": "module",
24
+ "dependencies": {
25
+ "@tailwindcss/vite": "^4.0.0",
26
+ "@vitejs/plugin-vue": "^5.2.1",
27
+ "@vue-flow/background": "^1.3.2",
28
+ "@vue-flow/controls": "^1.1.2",
29
+ "@vue-flow/core": "^1.42.5",
30
+ "@vue-flow/minimap": "^1.5.3",
31
+ "@vueuse/core": "^11.3.0",
32
+ "class-variance-authority": "^0.7.1",
33
+ "clsx": "^2.1.1",
34
+ "elkjs": "^0.11.1",
35
+ "lucide-vue-next": "^0.469.0",
36
+ "monaco-editor": "^0.55.0",
37
+ "reka-ui": "^2.5.1",
38
+ "tailwind-merge": "^2.6.0",
39
+ "tailwindcss": "^4.0.0",
40
+ "vite": "npm:rolldown-vite@latest",
41
+ "vue": "^3.5.13",
42
+ "vue-router": "^4.5.0",
43
+ "@nwire/kernel": "0.9.1"
44
+ },
45
+ "devDependencies": {
46
+ "@playwright/test": "^1.60.0",
47
+ "@storybook/vue3-vite": "^10.4.0",
48
+ "@types/node": "^22.19.9",
49
+ "@vue/test-utils": "^2.4.10",
50
+ "happy-dom": "^20.9.0",
51
+ "storybook": "^10.4.0",
52
+ "typescript": "^5.9.3",
53
+ "vitest": "^4.1.6",
54
+ "vue-tsc": "^2.2.0"
55
+ },
56
+ "scripts": {
57
+ "dev": "vite",
58
+ "build": "vue-tsc -b && vite build",
59
+ "preview": "vite preview",
60
+ "typecheck": "vue-tsc --noEmit",
61
+ "storybook": "storybook dev -p 6006",
62
+ "storybook:build": "storybook build",
63
+ "test:unit": "vitest run",
64
+ "test:e2e": "playwright test"
65
+ }
66
+ }
package/src/App.vue ADDED
@@ -0,0 +1,305 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onBeforeUnmount, ref, watch, computed } from "vue";
3
+ import { RouterLink, RouterView, useRoute } from "vue-router";
4
+ import {
5
+ upsertCurrent,
6
+ loadCatalog,
7
+ getActiveProjectCwd,
8
+ setActiveProjectCwd,
9
+ type ProjectSnapshot,
10
+ } from "@/lib/project-catalog";
11
+ import {
12
+ Home,
13
+ Network,
14
+ Boxes,
15
+ Zap,
16
+ Radio,
17
+ GitBranch,
18
+ Activity,
19
+ RefreshCw,
20
+ Send,
21
+ Waves,
22
+ Play,
23
+ Workflow,
24
+ Anchor,
25
+ Puzzle,
26
+ FolderOpen,
27
+ } from "lucide-vue-next";
28
+ import { useCache } from "@/lib/cache";
29
+ import { ErrorBoundary } from "@/components";
30
+ import { Button } from "@/components/ui/button";
31
+
32
+ const route = useRoute();
33
+ const { cache, loading, error, missingFields, reload } = useCache();
34
+
35
+ // Project identity — multi-project (Shape A). The active project is
36
+ // stored in localStorage; the server uses `?project=<cwd>` (injected via
37
+ // the fetch shim in main.ts) to scope every endpoint. We need to register
38
+ // every catalog cwd with the server's allowlist before any further fetch
39
+ // fires, so the boot sequence runs sequentially.
40
+ const project = ref<{ name: string; cwd: string } | null>(null);
41
+ const catalog = ref<Record<string, ProjectSnapshot>>({});
42
+
43
+ async function bootProjects() {
44
+ // Server-side registration happens in main.ts before mount (so the
45
+ // cache fetch has an accepted cwd). Here we just resolve the active
46
+ // project's metadata + load the catalog into reactive state.
47
+ catalog.value = loadCatalog();
48
+ const stored = getActiveProjectCwd();
49
+ try {
50
+ const res = await fetch("/__nwire/project");
51
+ if (res.ok) {
52
+ const body = (await res.json()) as { name: string; cwd: string };
53
+ project.value = body;
54
+ // First-time mount with no active selection — pin it now so the
55
+ // catalog page + future requests have something to point at.
56
+ if (!stored) setActiveProjectCwd(body.cwd);
57
+ }
58
+ } catch {
59
+ // header just shows "Nwire Studio" if the fetch fails
60
+ }
61
+ persistSnapshot();
62
+ }
63
+
64
+ const catalogList = computed<ProjectSnapshot[]>(() =>
65
+ Object.values(catalog.value).sort((a, b) => b.lastVisited.localeCompare(a.lastVisited)),
66
+ );
67
+
68
+ function switchProject(cwd: string) {
69
+ setActiveProjectCwd(cwd);
70
+ // Hard reload — every page's data depends on the active project, and
71
+ // SSE streams are tied to the old one. A fresh page guarantees a clean
72
+ // pivot without untangling every page's reactive subscriptions.
73
+ window.location.reload();
74
+ }
75
+
76
+ // ─── Project switcher dropdown — click-driven + close-on-outside ────
77
+ const switcherOpen = ref(false);
78
+ const switcherRef = ref<HTMLElement | null>(null);
79
+
80
+ function toggleSwitcher(): void {
81
+ switcherOpen.value = !switcherOpen.value;
82
+ }
83
+
84
+ function onDocumentClick(event: MouseEvent): void {
85
+ if (!switcherOpen.value) return;
86
+ const target = event.target as Node | null;
87
+ if (target && switcherRef.value && !switcherRef.value.contains(target)) {
88
+ switcherOpen.value = false;
89
+ }
90
+ }
91
+
92
+ function onEscape(event: KeyboardEvent): void {
93
+ if (event.key === "Escape") switcherOpen.value = false;
94
+ }
95
+
96
+ onMounted(() => {
97
+ void bootProjects();
98
+ document.addEventListener("click", onDocumentClick);
99
+ document.addEventListener("keydown", onEscape);
100
+ });
101
+
102
+ onBeforeUnmount(() => {
103
+ document.removeEventListener("click", onDocumentClick);
104
+ document.removeEventListener("keydown", onEscape);
105
+ });
106
+
107
+ // Whenever the manifest finishes loading (initial fetch + reloads), update
108
+ // this project's catalog snapshot so /projects shows fresh composition
109
+ // counts the next time Studio opens. Project metadata might land before
110
+ // the cache does, so we watch both.
111
+ watch([project, cache], persistSnapshot);
112
+
113
+ function persistSnapshot() {
114
+ if (!project.value) return;
115
+ const c = cache.value;
116
+ upsertCurrent({
117
+ cwd: project.value.cwd,
118
+ name: project.value.name,
119
+ lastVisited: new Date().toISOString(),
120
+ composition: c
121
+ ? {
122
+ apps: c.apps.length,
123
+ modules: c.modules.length,
124
+ actions: c.actions.length,
125
+ events: c.events.length,
126
+ resolvers: c.resolvers?.length ?? 0,
127
+ workflows: c.workflows?.length ?? 0,
128
+ }
129
+ : undefined,
130
+ });
131
+ }
132
+
133
+ async function rebuildCache() {
134
+ // Studio's runner-plugin exposes the CLI surface; "cache" rebuilds .nwire/.
135
+ await fetch("/__nwire/run/exec", {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({ command: "cache" }),
139
+ });
140
+ // Give the supervisor a beat to write the new manifest, then refresh.
141
+ setTimeout(() => void reload(), 800);
142
+ }
143
+
144
+ // Studio nav — grouped by what a working dev actually does.
145
+ // Daily work first (Trace + Try + Processes are the high-frequency tools);
146
+ // Explorer for browsing the surface; Architecture for the big picture.
147
+ const navGroups = [
148
+ {
149
+ label: "Daily",
150
+ items: [
151
+ { to: "/projects", label: "Projects", icon: FolderOpen }, // catalog of every project ever opened
152
+ { to: "/", label: "Home", icon: Home },
153
+ { to: "/trace", label: "Trace", icon: Workflow }, // causal tree, one correlationId
154
+ { to: "/live", label: "Stream", icon: Waves }, // raw live event firehose
155
+ { to: "/dispatch", label: "Try", icon: Send }, // form-driven action dispatch
156
+ { to: "/run", label: "Processes", icon: Play }, // start/stop wires + logs
157
+ ],
158
+ },
159
+ {
160
+ label: "Explorer",
161
+ items: [
162
+ { to: "/modules", label: "Modules", icon: Boxes },
163
+ { to: "/actions", label: "Actions", icon: Zap },
164
+ { to: "/events", label: "Events", icon: Radio },
165
+ { to: "/workflows", label: "Workflows", icon: GitBranch },
166
+ { to: "/plugins", label: "Plugins", icon: Puzzle },
167
+ { to: "/hooks", label: "Hooks", icon: Anchor },
168
+ ],
169
+ },
170
+ {
171
+ label: "Architecture",
172
+ items: [{ to: "/topology", label: "Topology", icon: Network }],
173
+ },
174
+ ];
175
+ </script>
176
+
177
+ <template>
178
+ <div class="flex h-full">
179
+ <aside class="w-56 border-r border-zinc-800 bg-zinc-950 flex flex-col">
180
+ <div class="px-4 py-4 border-b border-zinc-800">
181
+ <div class="flex items-center gap-2">
182
+ <Activity class="w-5 h-5 text-emerald-400" />
183
+ <span class="font-semibold tracking-tight">Nwire Studio</span>
184
+ </div>
185
+ <div v-if="project" ref="switcherRef" class="mt-1 relative">
186
+ <button
187
+ class="w-full flex items-center justify-between text-xs text-zinc-400 hover:text-zinc-100 transition-colors text-left rounded px-1 -mx-1"
188
+ :class="{ 'bg-zinc-900 text-zinc-100': switcherOpen }"
189
+ :title="project.cwd"
190
+ :aria-haspopup="catalogList.length > 1"
191
+ :aria-expanded="switcherOpen"
192
+ data-testid="project-switcher-toggle"
193
+ @click.stop="toggleSwitcher"
194
+ >
195
+ <span class="truncate">{{ project.name }}</span>
196
+ <span
197
+ class="text-zinc-600 text-[10px] ml-1 transition-transform"
198
+ :class="{ 'rotate-180': switcherOpen }"
199
+ >▾</span
200
+ >
201
+ </button>
202
+ <!-- Click-driven switcher: opens on toggle, closes on outside-click
203
+ or Escape. The /projects page is the rich version; this is the
204
+ always-available shortcut. -->
205
+ <div
206
+ v-if="switcherOpen && catalogList.length > 1"
207
+ class="absolute z-20 left-0 mt-1 w-64 bg-zinc-900 border border-zinc-800 rounded-md shadow-xl py-1"
208
+ data-testid="project-switcher-menu"
209
+ >
210
+ <button
211
+ v-for="p in catalogList"
212
+ :key="p.cwd"
213
+ class="w-full text-left px-3 py-1.5 text-xs hover:bg-zinc-800 flex items-center justify-between gap-2"
214
+ :class="{ 'text-emerald-400': p.cwd === project.cwd }"
215
+ @click="switchProject(p.cwd)"
216
+ >
217
+ <span class="truncate">{{ p.name }}</span>
218
+ <span class="text-[10px] text-zinc-600 font-mono truncate" :title="p.cwd">
219
+ {{ p.cwd.split("/").slice(-2).join("/") }}
220
+ </span>
221
+ </button>
222
+ <div class="border-t border-zinc-800 mt-1 pt-1">
223
+ <RouterLink
224
+ to="/projects"
225
+ class="block px-3 py-1.5 text-[11px] text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
226
+ @click="switcherOpen = false"
227
+ >
228
+ All projects →
229
+ </RouterLink>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ <div class="text-[10px] text-zinc-600 mt-0.5">v0 · OSS</div>
234
+ </div>
235
+ <nav class="flex-1 px-2 py-3 space-y-3 overflow-y-auto">
236
+ <div v-for="group in navGroups" :key="group.label">
237
+ <div class="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-600">
238
+ {{ group.label }}
239
+ </div>
240
+ <div class="space-y-0.5">
241
+ <RouterLink
242
+ v-for="item in group.items"
243
+ :key="item.to"
244
+ :to="item.to"
245
+ class="flex items-center gap-2 px-2 py-1.5 rounded text-sm text-zinc-400 hover:bg-zinc-900 hover:text-zinc-100 transition-colors"
246
+ :class="{
247
+ 'bg-zinc-900 text-zinc-100': route.path === item.to,
248
+ }"
249
+ >
250
+ <component :is="item.icon" class="w-4 h-4" />
251
+ {{ item.label }}
252
+ </RouterLink>
253
+ </div>
254
+ </div>
255
+ </nav>
256
+ <div class="border-t border-zinc-800 px-4 py-3 text-xs text-zinc-500 space-y-1">
257
+ <div v-if="cache" class="flex items-center justify-between">
258
+ <span>{{ cache.apps.length }} apps · {{ cache.modules.length }} BCs</span>
259
+ <button
260
+ class="text-zinc-400 hover:text-zinc-100 transition-colors"
261
+ title="Reload manifest"
262
+ @click="reload"
263
+ >
264
+ <RefreshCw class="w-3 h-3" />
265
+ </button>
266
+ </div>
267
+ <div v-if="cache" class="text-zinc-600 text-[10px]">
268
+ built {{ new Date(cache.generatedAt).toLocaleString() }}
269
+ </div>
270
+ </div>
271
+ </aside>
272
+
273
+ <main class="flex-1 overflow-auto">
274
+ <div
275
+ v-if="error"
276
+ class="m-6 p-4 rounded bg-red-950/50 border border-red-900 text-red-200"
277
+ data-testid="cache-error"
278
+ >
279
+ <div class="font-medium mb-1">Cache load error</div>
280
+ <div class="text-sm">{{ error }}</div>
281
+ <Button variant="secondary" size="sm" class="mt-3" @click="rebuildCache">
282
+ Rebuild cache
283
+ </Button>
284
+ </div>
285
+ <div v-else-if="loading && !cache" class="p-6 text-zinc-400">Loading manifest…</div>
286
+ <template v-else>
287
+ <div
288
+ v-if="missingFields.length > 0"
289
+ class="m-4 p-3 rounded bg-amber-950/30 border border-amber-900 text-amber-200 text-xs flex items-center justify-between gap-3"
290
+ data-testid="stale-cache-banner"
291
+ >
292
+ <div>
293
+ <span class="font-medium">Manifest is missing fields</span> ({{
294
+ missingFields.join(", ")
295
+ }}). Likely built with an older scanner — rebuild for the full picture.
296
+ </div>
297
+ <Button variant="secondary" size="sm" @click="rebuildCache">Rebuild</Button>
298
+ </div>
299
+ <ErrorBoundary>
300
+ <RouterView />
301
+ </ErrorBoundary>
302
+ </template>
303
+ </main>
304
+ </div>
305
+ </template>
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { Network, GitBranch, Database } from "lucide-vue-next";
3
+ import EmptyState from "./EmptyState.vue";
4
+
5
+ const meta: Meta<typeof EmptyState> = {
6
+ title: "Components/EmptyState",
7
+ component: EmptyState,
8
+ tags: ["autodocs"],
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof EmptyState>;
13
+
14
+ export const TitleOnly: Story = {
15
+ args: { title: "No routes in cache" },
16
+ };
17
+
18
+ export const WithHint: Story = {
19
+ args: {
20
+ title: "No routes in cache",
21
+ hint: "Routes are wired in app code via httpInterface().wire(...). Run `nwire cache` after adding one.",
22
+ icon: Network,
23
+ },
24
+ };
25
+
26
+ export const Workflows: Story = {
27
+ args: {
28
+ title: "No workflows in cache",
29
+ hint: "Workflows are declared via defineWorkflow(name, ({ on, send }) => ...).",
30
+ icon: GitBranch,
31
+ },
32
+ };
33
+
34
+ export const WithAction: Story = {
35
+ args: {
36
+ title: "Cache is empty",
37
+ hint: "Run nwire cache to populate the manifest.",
38
+ icon: Database,
39
+ },
40
+ render: (args) => ({
41
+ components: { EmptyState },
42
+ setup: () => ({ args }),
43
+ template: `
44
+ <EmptyState v-bind="args">
45
+ <template #actions>
46
+ <button class="text-xs px-3 py-1.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100">
47
+ Rebuild cache
48
+ </button>
49
+ </template>
50
+ </EmptyState>
51
+ `,
52
+ }),
53
+ };
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Empty state — what to show when a list/detail panel has no content.
4
+ * Pass an icon + title + optional hint. Action slot is for "Build cache"-type
5
+ * recovery buttons.
6
+ */
7
+ import type { FunctionalComponent } from "vue";
8
+
9
+ defineProps<{
10
+ title: string;
11
+ hint?: string;
12
+ icon?: FunctionalComponent;
13
+ }>();
14
+ </script>
15
+
16
+ <template>
17
+ <div
18
+ class="flex flex-col items-center justify-center h-full p-12 text-center"
19
+ data-testid="empty-state"
20
+ >
21
+ <component :is="icon" v-if="icon" class="w-10 h-10 text-zinc-700 mb-4" />
22
+ <h2 class="text-sm font-medium text-zinc-400">{{ title }}</h2>
23
+ <p v-if="hint" class="text-xs text-zinc-600 mt-2 max-w-md">{{ hint }}</p>
24
+ <div class="mt-4">
25
+ <slot name="actions" />
26
+ </div>
27
+ </div>
28
+ </template>
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Vue 3 error boundary — catches runtime errors raised by descendant
4
+ * components and renders a fallback instead of letting the whole app
5
+ * unmount.
6
+ *
7
+ * <ErrorBoundary>
8
+ * <RouterView />
9
+ * </ErrorBoundary>
10
+ *
11
+ * `onErrorCaptured` returning `false` stops the error from propagating
12
+ * to the global handler. We also call `console.error` so devtools still
13
+ * surface the stack.
14
+ */
15
+ import { onErrorCaptured, ref } from "vue";
16
+ import { AlertTriangle, RotateCw } from "lucide-vue-next";
17
+
18
+ const err = ref<Error | null>(null);
19
+
20
+ onErrorCaptured((e) => {
21
+ err.value = e instanceof Error ? e : new Error(String(e));
22
+ // eslint-disable-next-line no-console
23
+ console.error("[Studio] page error captured by boundary:", err.value);
24
+ return false; // stop propagation; we own the surface
25
+ });
26
+
27
+ function reset() {
28
+ err.value = null;
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <div v-if="err" class="p-6" data-testid="error-boundary">
34
+ <div class="rounded-lg border border-rose-900 bg-rose-950/30 p-4 max-w-2xl space-y-3">
35
+ <div class="flex items-center gap-2 text-rose-300">
36
+ <AlertTriangle class="w-5 h-5 shrink-0" />
37
+ <h2 class="font-medium">This page hit a runtime error</h2>
38
+ </div>
39
+ <p class="text-sm text-zinc-300 font-mono">{{ err.message }}</p>
40
+ <details v-if="err.stack" class="text-xs text-zinc-500">
41
+ <summary class="cursor-pointer">stack</summary>
42
+ <pre class="mt-2 whitespace-pre-wrap">{{ err.stack }}</pre>
43
+ </details>
44
+ <div class="flex items-center gap-2 pt-2">
45
+ <button
46
+ class="text-xs px-3 py-1.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 flex items-center gap-1.5"
47
+ @click="reset"
48
+ >
49
+ <RotateCw class="w-3 h-3" />
50
+ Try again
51
+ </button>
52
+ <span class="text-xs text-zinc-500">
53
+ A stale <code>.nwire/manifest.json</code> is the most common cause — rerun
54
+ <code>nwire cache</code>.
55
+ </span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ <slot v-else />
60
+ </template>
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { ref } from "vue";
3
+ import FilterInput from "./FilterInput.vue";
4
+
5
+ const meta: Meta<typeof FilterInput> = {
6
+ title: "Components/FilterInput",
7
+ component: FilterInput,
8
+ tags: ["autodocs"],
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof FilterInput>;
13
+
14
+ export const Empty: Story = {
15
+ args: { modelValue: "", placeholder: "filter…" },
16
+ render: (args) => ({
17
+ components: { FilterInput },
18
+ setup() {
19
+ const value = ref(args.modelValue);
20
+ return { args, value };
21
+ },
22
+ template: `<FilterInput v-bind="args" v-model="value" /><p class="mt-3 text-xs text-zinc-500">value: <code>{{ value }}</code></p>`,
23
+ }),
24
+ };
25
+
26
+ export const WithValue: Story = {
27
+ args: { modelValue: "moderation", placeholder: "filter by name, module, app…" },
28
+ };
29
+
30
+ export const Compact: Story = {
31
+ args: { modelValue: "", placeholder: "filter routes…", compact: true },
32
+ };