@specverse/engines 4.2.2 → 4.3.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 (54) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +43 -22
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +29 -0
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  8. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  9. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  10. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  11. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  12. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  13. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  14. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  15. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  16. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  17. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  18. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  19. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  22. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  23. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  24. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  25. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  26. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  27. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  28. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  29. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  30. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  31. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  32. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  33. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  34. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  35. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  36. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  37. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  38. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  39. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  40. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  41. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  42. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  43. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  44. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  45. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  46. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  47. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  48. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  49. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  50. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  51. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  52. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  53. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  54. package/package.json +2 -2
@@ -1,41 +1,105 @@
1
1
  /**
2
2
  * App.tsx generator for ReactAppStarter
3
3
  *
4
- * Generates a simple App shell that routes between the emitted view
5
- * components. Not using react-router the starter output is
6
- * deliberately low-magic. Users can add routing when they need
7
- * URL-level view selection; for now, internal state drives it.
4
+ * Two-surface shell:
5
+ * 1. Business views declared in the .specly spec (user's app).
6
+ * 2. Dev GUI — 4-tab maintenance surface over the same backend.
8
7
  *
9
- * Output:
10
- * - sidebar nav listing every (model × view-type)
11
- * - a main area rendering the selected view
12
- * - QueryClientProvider at the root
8
+ * The 4 tabs:
9
+ * - Views: declared views + auto-inferred CRUD + operation views.
10
+ * BUSINESS group = views with an entry in spec.views.
11
+ * DEV group = inferred CRUD + operation views.
12
+ * Declaring a view in the spec graduates it from Dev → Business.
13
+ * - Models: raw CRUD for any model (R2 — placeholder for now).
14
+ * - Services: directory of services + their operations (R3).
15
+ * - Events: WebSocket event stream (R4).
16
+ *
17
+ * Not using react-router — the starter output is deliberately low-
18
+ * magic. Users can add URL-level routing when they need it.
13
19
  */
14
20
 
15
21
  import type { ExpandedSpec } from './views-generator.js';
22
+ import { collectOperationViews, type OperationViewDescriptor } from './operation-view-generator.js';
16
23
 
17
24
  export interface AppGeneratorContext {
18
25
  spec: ExpandedSpec;
19
26
  manifest?: unknown;
20
27
  }
21
28
 
29
+ type ViewType = 'list' | 'detail' | 'form' | 'dashboard';
30
+ const VIEW_TYPES: ViewType[] = ['list', 'detail', 'form', 'dashboard'];
31
+
32
+ function viewSuffix(v: ViewType): string {
33
+ return v.charAt(0).toUpperCase() + v.slice(1) + 'View';
34
+ }
35
+
36
+ /** Per-view metadata the sidebar + routing need. */
37
+ interface ModelViewEntry {
38
+ model: string;
39
+ viewType: ViewType;
40
+ viewName: string; // "CategoryListView"
41
+ componentName: string; // same as viewName for our codegen
42
+ declared: boolean; // true iff spec.views[viewName] exists
43
+ }
44
+
22
45
  export async function generate(context: AppGeneratorContext): Promise<string> {
23
46
  const models = Object.keys(context.spec.models ?? {});
24
- const imports = buildImports(models);
25
- const navEntries = buildNavEntries(models);
26
- const viewSwitch = buildViewSwitch(models);
47
+ const declaredViewNames = new Set(Object.keys(context.spec.views ?? {}));
48
+ const operationViews = collectOperationViews(context.spec);
49
+
50
+ const modelViews: ModelViewEntry[] = [];
51
+ for (const m of models) {
52
+ for (const v of VIEW_TYPES) {
53
+ const viewName = `${m}${viewSuffix(v)}`;
54
+ modelViews.push({
55
+ model: m,
56
+ viewType: v,
57
+ viewName,
58
+ componentName: viewName,
59
+ declared: declaredViewNames.has(viewName),
60
+ });
61
+ }
62
+ }
63
+
64
+ const business = modelViews.filter(e => e.declared);
65
+ // Dev = inferred CRUD + operation views
66
+ const devModelViews = modelViews.filter(e => !e.declared);
67
+
68
+ const imports = buildImports(modelViews, operationViews);
69
+ const navBusiness = buildNavGroup('Business', business.map(e => ({
70
+ label: e.viewName,
71
+ onSelect: `setSelection({ kind: 'model', model: '${e.model}', view: '${e.viewType}' })`,
72
+ activeExpr: `selection.kind === 'model' && selection.model === '${e.model}' && selection.view === '${e.viewType}'`,
73
+ })), business.length > 0 ? undefined : 'No declared views in this spec. Declare one in spec.views to curate its layout.');
74
+ const navDev = buildNavGroup('Dev', [
75
+ ...devModelViews.map(e => ({
76
+ label: e.viewName,
77
+ onSelect: `setSelection({ kind: 'model', model: '${e.model}', view: '${e.viewType}' })`,
78
+ activeExpr: `selection.kind === 'model' && selection.model === '${e.model}' && selection.view === '${e.viewType}'`,
79
+ })),
80
+ ...operationViews.map(op => ({
81
+ label: op.viewName,
82
+ onSelect: `setSelection({ kind: 'op', viewName: '${op.viewName}' })`,
83
+ activeExpr: `selection.kind === 'op' && selection.viewName === '${op.viewName}'`,
84
+ })),
85
+ ]);
86
+
87
+ const viewSwitch = buildViewSwitch(modelViews, operationViews);
88
+ const initialSelection = buildInitialSelection(business, devModelViews, operationViews);
27
89
 
28
90
  return `/**
29
91
  * App.tsx — generated by @specverse/realize (ReactAppStarter)
30
92
  *
31
93
  * Safe to edit. Edits are preserved across regeneration via content
32
- * hashing. This is a minimal shell: a sidebar listing every
33
- * (model, view-type) plus a main area that renders the selection.
34
- * Swap in react-router or your preferred routing library when the
35
- * app needs URL-driven navigation.
94
+ * hashing. Four-tab shell (Views / Models / Services / Events):
95
+ * the Views tab carries the user-facing app (declared + inferred
96
+ * views), the other three are a maintenance / debug GUI over the
97
+ * same backend.
36
98
  */
37
99
  import { useState } from 'react';
38
100
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
101
+ import { useResizableSidebar } from './hooks/useResizableSidebar';
102
+ import { AppEventsProvider, useRecentEvents } from './hooks/useAppEvents';
39
103
  ${imports}
40
104
 
41
105
  const queryClient = new QueryClient({
@@ -44,40 +108,143 @@ const queryClient = new QueryClient({
44
108
  },
45
109
  });
46
110
 
47
- type Selection = {
48
- model: string;
49
- view: 'list' | 'detail' | 'form' | 'dashboard';
50
- entityId?: string | number;
51
- };
111
+ type Tab = 'views' | 'models' | 'services' | 'events';
52
112
 
53
- function Inner() {
54
- const [selection, setSelection] = useState<Selection>(${models.length > 0 ? `{ model: '${models[0]}', view: 'list' }` : `{ model: '', view: 'list' }`});
113
+ type Selection =
114
+ | { kind: 'model'; model: string; view: 'list' | 'detail' | 'form' | 'dashboard'; entityId?: string | number }
115
+ | { kind: 'op'; viewName: string };
55
116
 
56
- const select = (model: string, view: Selection['view']) =>
57
- setSelection({ model, view });
117
+ function Inner() {
118
+ const [tab, setTab] = useState<Tab>('views');
119
+ const [selection, setSelection] = useState<Selection>(${initialSelection});
120
+ const [modelsTabPick, setModelsTabPick] = useState<string>(${models.length > 0 ? `'${models[0]}'` : `''`});
121
+ const { width: sidebarWidth, isResizing, startResizing } = useResizableSidebar({ defaultWidth: 256 });
58
122
 
59
123
  return (
60
- <div className="min-h-screen flex bg-gray-50 dark:bg-gray-950">
61
- <aside className="w-64 shrink-0 border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
62
- <div className="p-4 border-b border-gray-200 dark:border-gray-800">
63
- <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">App</h1>
64
- </div>
65
- <nav className="p-2 space-y-4">
66
- ${navEntries}
124
+ <div className="min-h-screen flex flex-col bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
125
+ <header className="flex items-center gap-4 px-6 py-3 border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
126
+ <h1 className="text-lg font-semibold">App</h1>
127
+ <nav className="flex gap-1">
128
+ <TabButton active={tab === 'views'} onClick={() => setTab('views')}>Views</TabButton>
129
+ <TabButton active={tab === 'models'} onClick={() => setTab('models')}>Models</TabButton>
130
+ <TabButton active={tab === 'services'} onClick={() => setTab('services')}>Services</TabButton>
131
+ <TabButton active={tab === 'events'} onClick={() => setTab('events')}>Events</TabButton>
67
132
  </nav>
68
- </aside>
133
+ </header>
69
134
 
70
- <main className="flex-1 overflow-auto">
135
+ <div className="flex-1 flex overflow-hidden">
136
+ {tab === 'views' && (
137
+ <>
138
+ <aside
139
+ style={{ width: sidebarWidth }}
140
+ className="shrink-0 overflow-auto border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
141
+ >
142
+ ${navBusiness}
143
+ ${navDev}
144
+ </aside>
145
+ <div
146
+ role="separator"
147
+ aria-orientation="vertical"
148
+ onMouseDown={startResizing}
149
+ className={\`w-1 shrink-0 cursor-col-resize hover:bg-blue-500/40 \${isResizing ? 'bg-blue-500/60' : 'bg-transparent'}\`}
150
+ />
151
+ <main className="flex-1 overflow-auto">
71
152
  ${viewSwitch}
72
- </main>
153
+ </main>
154
+ </>
155
+ )}
156
+
157
+ {tab === 'models' && (
158
+ ${buildModelsTab(models)}
159
+ )}
160
+ {tab === 'services' && (
161
+ ${buildServicesTab(operationViews)}
162
+ )}
163
+ {tab === 'events' && <EventsTab />}
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
170
+ return (
171
+ <button
172
+ type="button"
173
+ onClick={onClick}
174
+ className={\`px-3 py-1.5 text-sm font-medium rounded-md transition-colors \${active ? 'bg-blue-500/10 text-blue-600 ring-1 ring-blue-500/40 dark:text-blue-400' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200'}\`}
175
+ >
176
+ {children}
177
+ </button>
178
+ );
179
+ }
180
+
181
+ /**
182
+ * EventsTab — live view of the recent-events buffer populated by
183
+ * AppEventsProvider. Newest at top, clear button resets the buffer
184
+ * (doesn't affect server-side event state).
185
+ */
186
+ function EventsTab() {
187
+ const { recentEvents, clear } = useRecentEvents();
188
+ const reversed = [...recentEvents].reverse();
189
+ return (
190
+ <main className="flex-1 flex flex-col overflow-hidden">
191
+ <div className="flex items-center justify-between px-6 py-3 border-b border-gray-200 dark:border-gray-800">
192
+ <div>
193
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Events</h2>
194
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Live stream of CURED events over the WebSocket. Oldest buffered first.</p>
195
+ </div>
196
+ <button
197
+ type="button"
198
+ onClick={clear}
199
+ disabled={recentEvents.length === 0}
200
+ className="rounded border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-60 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
201
+ >
202
+ Clear buffer ({recentEvents.length})
203
+ </button>
204
+ </div>
205
+ <div className="flex-1 overflow-auto p-6">
206
+ {reversed.length === 0 ? (
207
+ <p className="text-sm text-gray-400 italic">No events yet. Create/update/delete an entity in another tab and it will appear here.</p>
208
+ ) : (
209
+ <ul className="space-y-2">
210
+ {reversed.map((e, idx) => (
211
+ <li key={reversed.length - idx} className="rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
212
+ <div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800">
213
+ <span className="font-mono text-sm text-blue-600 dark:text-blue-400">{e.event}</span>
214
+ <span className="text-xs text-gray-500 dark:text-gray-400">{new Date(e.timestamp).toLocaleTimeString()}</span>
215
+ </div>
216
+ <pre className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300 overflow-x-auto">{JSON.stringify(e.payload, null, 2)}</pre>
217
+ </li>
218
+ ))}
219
+ </ul>
220
+ )}
221
+ </div>
222
+ </main>
223
+ );
224
+ }
225
+
226
+ /** Placeholder used by Models / Services tabs when the spec declares
227
+ * no models or no services. Kept even when the current spec has both
228
+ * populated — users who later remove the declarations re-trigger the
229
+ * fallback branch without the component going missing. */
230
+ function TabPlaceholder({ label, note }: { label: string; note: string }) {
231
+ return (
232
+ <div className="flex-1 flex items-center justify-center p-12">
233
+ <div className="text-center max-w-md">
234
+ <h2 className="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">{label}</h2>
235
+ <p className="text-sm text-gray-500 dark:text-gray-400">{note}</p>
236
+ </div>
73
237
  </div>
74
238
  );
75
239
  }
240
+ void TabPlaceholder; // TS unused-warning suppression — see comment above.
76
241
 
77
242
  export default function App() {
78
243
  return (
79
244
  <QueryClientProvider client={queryClient}>
80
- <Inner />
245
+ <AppEventsProvider>
246
+ <Inner />
247
+ </AppEventsProvider>
81
248
  </QueryClientProvider>
82
249
  );
83
250
  }
@@ -88,54 +255,193 @@ export default function App() {
88
255
  // Helpers
89
256
  // ──────────────────────────────────────────────────────────────────────
90
257
 
91
- function buildImports(models: string[]): string {
92
- return models.flatMap(m => [
93
- `import { ${m}ListView } from './views/${m}ListView';`,
94
- `import { ${m}DetailView } from './views/${m}DetailView';`,
95
- `import { ${m}FormView } from './views/${m}FormView';`,
96
- `import { ${m}DashboardView } from './views/${m}DashboardView';`,
97
- ]).join('\n');
258
+ function buildImports(modelViews: ModelViewEntry[], operationViews: OperationViewDescriptor[]): string {
259
+ const lines: string[] = [];
260
+ for (const e of modelViews) {
261
+ lines.push(`import { ${e.componentName} } from './views/${e.viewName}';`);
262
+ }
263
+ for (const op of operationViews) {
264
+ lines.push(`import { ${op.viewName} } from './views/${op.viewName}';`);
265
+ }
266
+ return lines.join('\n');
98
267
  }
99
268
 
100
- function buildNavEntries(models: string[]): string {
101
- if (models.length === 0) {
102
- return ' <p className="text-sm text-gray-400 px-2">No models in this spec.</p>';
269
+ function buildInitialSelection(
270
+ business: ModelViewEntry[],
271
+ devModelViews: ModelViewEntry[],
272
+ operationViews: OperationViewDescriptor[],
273
+ ): string {
274
+ // Prefer the first business view; fall back to the first dev model
275
+ // view; then operation view; else empty.
276
+ if (business.length > 0) {
277
+ return `{ kind: 'model', model: '${business[0].model}', view: '${business[0].viewType}' }`;
278
+ }
279
+ if (devModelViews.length > 0) {
280
+ return `{ kind: 'model', model: '${devModelViews[0].model}', view: '${devModelViews[0].viewType}' }`;
281
+ }
282
+ if (operationViews.length > 0) {
283
+ return `{ kind: 'op', viewName: '${operationViews[0].viewName}' }`;
103
284
  }
104
- return models.map(m => ` <div>
105
- <div className="px-2 pb-1 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
106
- ${m}
107
- </div>
108
- <button type="button" onClick={() => select('${m}', 'list')} className="${navButtonCls('list')}">List</button>
109
- <button type="button" onClick={() => select('${m}', 'dashboard')} className="${navButtonCls('dashboard')}">Dashboard</button>
110
- <button type="button" onClick={() => select('${m}', 'form')} className="${navButtonCls('form')}">Form</button>
111
- </div>`).join('\n');
285
+ return `{ kind: 'model', model: '', view: 'list' }`;
112
286
  }
113
287
 
114
- function navButtonCls(_view: string): string {
115
- return (
116
- 'block w-full text-left rounded px-2 py-1 text-sm ' +
117
- 'text-gray-700 hover:bg-gray-100 ' +
118
- 'dark:text-gray-300 dark:hover:bg-gray-800'
119
- );
288
+ interface NavEntry {
289
+ label: string;
290
+ /** Full expression string: `setSelection({...})`. */
291
+ onSelect: string;
292
+ /** JS boolean expression that is true when this entry is active. */
293
+ activeExpr: string;
294
+ }
295
+
296
+ function buildNavGroup(heading: string, entries: NavEntry[], emptyNote?: string): string {
297
+ const headingLine = ` <div className="px-4 pb-2 pt-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
298
+ ${heading}
299
+ </div>`;
300
+ const baseCls = 'block w-full text-left rounded-md px-3 py-1.5 text-sm transition-colors';
301
+ const activeCls = 'bg-blue-500/10 text-blue-600 ring-1 ring-blue-500/40 dark:text-blue-400';
302
+ const inactiveCls = 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200';
303
+
304
+ if (entries.length === 0) {
305
+ const note = emptyNote
306
+ ? ` <p className="px-3 py-2 text-xs text-gray-400 italic">${emptyNote}</p>`
307
+ : '';
308
+ return `${headingLine}
309
+ <nav className="px-2 space-y-0.5">
310
+ ${note}
311
+ </nav>`;
312
+ }
313
+ const buttons = entries
314
+ .map(e => ` <button type="button" onClick={() => ${e.onSelect}} className={\`${baseCls} \${${e.activeExpr} ? '${activeCls}' : '${inactiveCls}'}\`}>${e.label}</button>`)
315
+ .join('\n');
316
+ return `${headingLine}
317
+ <nav className="px-2 space-y-0.5">
318
+ ${buttons}
319
+ </nav>`;
320
+ }
321
+
322
+ /**
323
+ * Services tab — directory of services + their operations.
324
+ * Each operation is a button that jumps to the corresponding
325
+ * operation view in the Views tab (no executor duplication — the
326
+ * actual parameter form lives with the view).
327
+ */
328
+ function buildServicesTab(operationViews: OperationViewDescriptor[]): string {
329
+ const services = operationViews.filter(op => op.source === 'service');
330
+ if (services.length === 0) {
331
+ return ` <TabPlaceholder label="Services" note="No services declared in this spec. Add a 'services:' block to main.specly to populate this directory." />`;
332
+ }
333
+ // Group operations by serviceName.
334
+ const byService = new Map<string, OperationViewDescriptor[]>();
335
+ for (const op of services) {
336
+ if (!byService.has(op.serviceName)) byService.set(op.serviceName, []);
337
+ byService.get(op.serviceName)!.push(op);
338
+ }
339
+
340
+ const serviceCards = [...byService.entries()]
341
+ .map(([svcName, ops]) => {
342
+ const opButtons = ops
343
+ .map(op => {
344
+ const paramsLabel = op.parameters
345
+ ? Object.keys(op.parameters as any).length === 0
346
+ ? 'no params'
347
+ : Object.keys(op.parameters as any).join(', ')
348
+ : 'no params';
349
+ const returns = op.returns ? ` → ${escapeJsx(op.returns)}` : '';
350
+ return ` <button type="button" onClick={() => { setTab('views'); setSelection({ kind: 'op', viewName: '${op.viewName}' }); }} className="block w-full text-left rounded-md border border-gray-200 px-4 py-3 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 transition-colors">
351
+ <div className="font-mono text-sm text-blue-600 dark:text-blue-400">${op.operationName}</div>
352
+ <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
353
+ ${paramsLabel}${returns}
354
+ </div>
355
+ </button>`;
356
+ })
357
+ .join('\n');
358
+ return ` <div className="mb-6">
359
+ <h3 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">${svcName}</h3>
360
+ <div className="space-y-2">
361
+ ${opButtons}
362
+ </div>
363
+ </div>`;
364
+ })
365
+ .join('\n');
366
+
367
+ return ` <main className="flex-1 overflow-auto p-6">
368
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Services</h2>
369
+ ${serviceCards}
370
+ </main>`;
120
371
  }
121
372
 
122
- function buildViewSwitch(models: string[]): string {
373
+ function escapeJsx(s: string): string {
374
+ return s.replace(/[<>&]/g, ch => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]!));
375
+ }
376
+
377
+ /**
378
+ * Models tab — an admin-style CRUD surface over every model in the spec.
379
+ * Sub-sidebar listing each model; main area renders that model's
380
+ * {Model}FormView, which already contains the create/update form plus
381
+ * the embedded existing-entities list. Reuses existing components; no
382
+ * new rendering path required.
383
+ */
384
+ function buildModelsTab(models: string[]): string {
123
385
  if (models.length === 0) {
124
- return ' <p className="p-6 text-sm text-gray-400">No models — add one to your .specly file and run <code>spv realize</code>.</p>';
386
+ return ` <TabPlaceholder label="Models" note="No models in this spec — add one to start." />`;
125
387
  }
126
- const branches = models.flatMap(m => [
127
- ` {selection.model === '${m}' && selection.view === 'list' && (
128
- <${m}ListView onSelect={item => setSelection({ model: '${m}', view: 'detail', entityId: (item as any).id })} onCreate={() => setSelection({ model: '${m}', view: 'form' })} />
388
+ const modelButtons = models
389
+ .map(m =>
390
+ ` <button type="button" onClick={() => setModelsTabPick('${m}')} className={\`block w-full text-left rounded-md px-3 py-1.5 text-sm transition-colors \${modelsTabPick === '${m}' ? 'bg-blue-500/10 text-blue-600 ring-1 ring-blue-500/40 dark:text-blue-400' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200'}\`}>${m}</button>`
391
+ )
392
+ .join('\n');
393
+ const switchBranches = models
394
+ .map(m => ` {modelsTabPick === '${m}' && <${m}FormView />}`)
395
+ .join('\n');
396
+
397
+ return ` <>
398
+ <aside className="w-56 shrink-0 overflow-auto border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
399
+ <div className="px-4 pt-5 pb-3">
400
+ <div className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Models</div>
401
+ </div>
402
+ <nav className="px-2 space-y-0.5">
403
+ ${modelButtons}
404
+ </nav>
405
+ </aside>
406
+ <main className="flex-1 overflow-auto">
407
+ ${switchBranches}
408
+ </main>
409
+ </>`;
410
+ }
411
+
412
+ function buildViewSwitch(modelViews: ModelViewEntry[], operationViews: OperationViewDescriptor[]): string {
413
+ const models = [...new Set(modelViews.map(e => e.model))];
414
+ if (models.length === 0 && operationViews.length === 0) {
415
+ return ' <p className="p-6 text-sm text-gray-400">No models or operations — add to your .specly file and run <code>spv realize</code>.</p>';
416
+ }
417
+ const navHandler =
418
+ `(navModel: string, navId: string | number) => setSelection({ kind: 'model', model: navModel, view: 'detail', entityId: navId })`;
419
+ const branches: string[] = [];
420
+
421
+ for (const m of models) {
422
+ branches.push(
423
+ ` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'list' && (
424
+ <${m}ListView onSelect={item => setSelection({ kind: 'model', model: '${m}', view: 'detail', entityId: (item as any).id })} onCreate={() => setSelection({ kind: 'model', model: '${m}', view: 'form' })} onNavigate={${navHandler}} />
129
425
  )}`,
130
- ` {selection.model === '${m}' && selection.view === 'detail' && selection.entityId !== undefined && (
131
- <${m}DetailView entityId={selection.entityId} onEdit={item => setSelection({ model: '${m}', view: 'form', entityId: (item as any).id })} onBack={() => setSelection({ model: '${m}', view: 'list' })} onDeleted={() => setSelection({ model: '${m}', view: 'list' })} />
426
+ ` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'detail' && (
427
+ <${m}DetailView entityId={selection.entityId} onEntityChange={id => setSelection({ kind: 'model', model: '${m}', view: 'detail', entityId: id })} onNavigate={${navHandler}} />
132
428
  )}`,
133
- ` {selection.model === '${m}' && selection.view === 'form' && (
134
- <${m}FormView mode={selection.entityId ? 'update' : 'create'} entityId={selection.entityId} onSuccess={() => setSelection({ model: '${m}', view: 'list' })} onCancel={() => setSelection({ model: '${m}', view: 'list' })} />
429
+ ` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'form' && (
430
+ <${m}FormView mode={selection.entityId ? 'update' : 'create'} entityId={selection.entityId} onSuccess={() => setSelection({ kind: 'model', model: '${m}', view: 'list' })} onDeleted={() => setSelection({ kind: 'model', model: '${m}', view: 'list' })} onSelectEntity={item => setSelection({ kind: 'model', model: '${m}', view: 'form', entityId: (item as any).id })} />
135
431
  )}`,
136
- ` {selection.model === '${m}' && selection.view === 'dashboard' && (
137
- <${m}DashboardView onSelect={item => setSelection({ model: '${m}', view: 'detail', entityId: (item as any).id })} />
432
+ ` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'dashboard' && (
433
+ <${m}DashboardView onSelect={item => setSelection({ kind: 'model', model: '${m}', view: 'detail', entityId: (item as any).id })} />
138
434
  )}`,
139
- ]);
435
+ );
436
+ }
437
+
438
+ for (const op of operationViews) {
439
+ branches.push(
440
+ ` {selection.kind === 'op' && selection.viewName === '${op.viewName}' && (
441
+ <${op.viewName} />
442
+ )}`,
443
+ );
444
+ }
445
+
140
446
  return branches.join('\n');
141
447
  }
@@ -80,3 +80,69 @@ export function buildFKMap(model: ModelSpec): Map<string, BelongsToRel> {
80
80
  }
81
81
  return map;
82
82
  }
83
+
84
+ /**
85
+ * A hasMany relationship — the "other side" of a belongsTo. E.g.
86
+ * Category has `items: hasMany Item`, which means we can filter
87
+ * Items by `item.categoryId === category.id` to get a Category's
88
+ * items. Used by the detail composer to render tabbed related lists.
89
+ */
90
+ export interface HasManyRel {
91
+ /** Relationship name on the parent — e.g. "items", "assignedTasks". */
92
+ name: string;
93
+ /** Target (child) model name — e.g. "Item", "Task". */
94
+ target: string;
95
+ /**
96
+ * FK column on the child that points back to this parent. Typically
97
+ * \`${parentName.toLowerCase()}Id\` — e.g. for \`Item.belongsTo Category\`,
98
+ * Item's FK is \`categoryId\`. Factory B filters by this at render time.
99
+ */
100
+ foreignKey: string;
101
+ }
102
+
103
+ export function extractHasManyTargets(
104
+ model: ModelSpec,
105
+ modelSchemas?: Record<string, ModelSpec>,
106
+ ): HasManyRel[] {
107
+ const rels = (model.relationships ?? {}) as Record<string, unknown>;
108
+ const out: HasManyRel[] = [];
109
+ const parentLower = model.name.charAt(0).toLowerCase() + model.name.slice(1);
110
+
111
+ for (const [name, rawDef] of Object.entries(rels)) {
112
+ const parsed = parseHasMany(rawDef);
113
+ if (!parsed) continue;
114
+
115
+ // Determine the FK on the target that points back to this model.
116
+ // Prefer an explicit belongsTo on the target; fall back to the
117
+ // conventional \`${parentLower}Id\`.
118
+ let foreignKey = `${parentLower}Id`;
119
+ if (modelSchemas && modelSchemas[parsed]) {
120
+ const targetRels = (modelSchemas[parsed].relationships ?? {}) as Record<string, unknown>;
121
+ for (const [targetRelName, targetRelDef] of Object.entries(targetRels)) {
122
+ const backTarget = parseBelongsTo(targetRelDef);
123
+ if (backTarget === model.name) {
124
+ foreignKey = `${targetRelName}Id`;
125
+ break;
126
+ }
127
+ }
128
+ }
129
+
130
+ out.push({ name, target: parsed, foreignKey });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ function parseHasMany(def: unknown): string | null {
136
+ if (typeof def === 'string') {
137
+ const parts = def.trim().split(/\s+/);
138
+ if (parts[0] === 'hasMany' && parts[1]) return parts[1];
139
+ return null;
140
+ }
141
+ if (def && typeof def === 'object') {
142
+ const o = def as { type?: string; target?: string; to?: string; model?: string; targetModel?: string };
143
+ if (o.type === 'hasMany') {
144
+ return o.target || o.to || o.model || o.targetModel || null;
145
+ }
146
+ }
147
+ return null;
148
+ }