@specverse/engines 4.2.2 → 4.3.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 (63) 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/inference/ui-contracts/rules/action-buttons-present.d.ts +8 -5
  8. package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts.map +1 -1
  9. package/dist/inference/ui-contracts/rules/action-buttons-present.js +85 -19
  10. package/dist/inference/ui-contracts/rules/action-buttons-present.js.map +1 -1
  11. package/dist/inference/ui-contracts/test-case-types.d.ts +3 -0
  12. package/dist/inference/ui-contracts/test-case-types.d.ts.map +1 -1
  13. package/dist/inference/ui-contracts/translator.d.ts.map +1 -1
  14. package/dist/inference/ui-contracts/translator.js +4 -0
  15. package/dist/inference/ui-contracts/translator.js.map +1 -1
  16. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  17. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  18. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  19. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  20. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  21. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  22. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  23. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  24. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  25. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  26. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  27. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  28. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  29. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  30. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  31. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  32. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  33. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  34. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  35. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  36. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  37. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  38. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  39. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  40. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  41. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  42. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  43. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  44. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  45. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  46. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  47. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  48. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  49. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  50. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  51. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  52. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  53. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  54. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  55. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  56. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  57. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  58. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  59. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  60. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  61. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  62. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  63. package/package.json +2 -2
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Operation-view emitters for ReactAppStarter
3
+ *
4
+ * Source strings for the three `src/lib/*.tsx` files that back the
5
+ * auto-generated operation views:
6
+ *
7
+ * - FieldInput.tsx (+ EntitySelect) — type-aware parameter inputs.
8
+ * - OperationExecutor.tsx — param form + Execute button + result.
9
+ * - OperationResultView.tsx — formatted result / error pane.
10
+ *
11
+ * Ported from app-demo (frontend-react/src/lib/view-components/
12
+ * FieldInput.tsx, components/runtime/OperationExecutor.tsx,
13
+ * components/runtime/OperationResultView.tsx). Adaptations:
14
+ *
15
+ * - EntitySelect fetch: POST /controllers/{M}Controller/list →
16
+ * GET /api/{resource} (static REST convention).
17
+ * - Response-shape: app-demo wraps entities in data.data?.entities
18
+ * or data.entities; static returns a flat array.
19
+ * - Entity id extraction: entity.data?.id || entity.id →
20
+ * entity.id (no interim IDs in static).
21
+ * - OperationExecutor controller URL:
22
+ * apiExecuteOperation(controllerName, op, params) →
23
+ * POST /api/{resource}/{op} where resource = plural lowercase
24
+ * of (controllerName minus "Controller" suffix).
25
+ *
26
+ * The three files are emitted by views-generator.ts into src/lib/
27
+ * alongside entity-display.ts. They have no @specverse/* runtime
28
+ * dependency; lucide-react is added to the generated package.json.
29
+ */
30
+
31
+ export function emitFieldInput(): string {
32
+ return `/**
33
+ * FieldInput — type-aware input component used by OperationExecutor.
34
+ *
35
+ * Inlined into this project by @specverse/realize (ReactAppStarter).
36
+ * Edit freely. Ported from app-demo's FieldInput.
37
+ *
38
+ * Renders the right input for a parameter type:
39
+ * - UUID ending in Id → entity dropdown via EntitySelect
40
+ * - Boolean → checkbox (or select in compact mode)
41
+ * - Integer/Number/Decimal → number input
42
+ * - DateTime/Date → native picker
43
+ * - Email → email input
44
+ * - Text (multiline) → textarea
45
+ * - default → text input
46
+ */
47
+ import { useState, useEffect } from 'react';
48
+ import { getEntityDisplayName } from './entity-display';
49
+
50
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
51
+
52
+ function pluralize(s: string): string {
53
+ if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
54
+ if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
55
+ return s + 's';
56
+ }
57
+
58
+ function humanizeFieldName(name: string): string {
59
+ return name
60
+ .replace(/([A-Z])/g, ' $1')
61
+ .replace(/^./, c => c.toUpperCase())
62
+ .trim();
63
+ }
64
+
65
+ interface FieldInputProps {
66
+ name: string;
67
+ typeStr: string;
68
+ value: any;
69
+ onChange: (value: any) => void;
70
+ required?: boolean;
71
+ disabled?: boolean;
72
+ /** Compact mode for inline use (smaller padding, no label). */
73
+ compact?: boolean;
74
+ }
75
+
76
+ const inputClass = (compact: boolean) => compact
77
+ ? 'w-full px-2 py-1 text-xs bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-500'
78
+ : 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed';
79
+
80
+ export function FieldInput({ name, typeStr, value, onChange, required, disabled, compact = false }: FieldInputProps) {
81
+ const type = typeStr.toLowerCase();
82
+
83
+ if (type.includes('bool')) {
84
+ return compact ? (
85
+ <select
86
+ value={value ?? ''}
87
+ onChange={e => onChange(e.target.value === 'true')}
88
+ disabled={disabled}
89
+ className={inputClass(compact)}
90
+ >
91
+ <option value="">Select...</option>
92
+ <option value="true">true</option>
93
+ <option value="false">false</option>
94
+ </select>
95
+ ) : (
96
+ <div className="flex items-center pt-2">
97
+ <input
98
+ type="checkbox"
99
+ checked={!!value}
100
+ onChange={e => onChange(e.target.checked)}
101
+ disabled={disabled}
102
+ className="h-4 w-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
103
+ />
104
+ <span className="ml-2 text-sm text-gray-600 dark:text-gray-300">
105
+ {value ? 'Yes' : 'No'}
106
+ </span>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ if (type.includes('int') || type.includes('number') || type.includes('decimal')) {
112
+ return (
113
+ <input
114
+ type="number"
115
+ step={type.includes('decimal') ? '0.01' : '1'}
116
+ value={value ?? ''}
117
+ onChange={e => onChange(type.includes('int') ? parseInt(e.target.value) || 0 : parseFloat(e.target.value) || 0)}
118
+ disabled={disabled}
119
+ required={required}
120
+ className={inputClass(compact)}
121
+ />
122
+ );
123
+ }
124
+
125
+ if (type.includes('datetime')) {
126
+ return (
127
+ <input
128
+ type="datetime-local"
129
+ value={value ? new Date(value).toISOString().slice(0, 16) : ''}
130
+ onChange={e => onChange(e.target.value ? new Date(e.target.value).toISOString() : '')}
131
+ disabled={disabled}
132
+ required={required}
133
+ className={inputClass(compact)}
134
+ />
135
+ );
136
+ }
137
+
138
+ if (type.includes('date')) {
139
+ return (
140
+ <input
141
+ type="date"
142
+ value={value ? new Date(value).toISOString().slice(0, 10) : ''}
143
+ onChange={e => onChange(e.target.value ? new Date(e.target.value).toISOString() : '')}
144
+ disabled={disabled}
145
+ required={required}
146
+ className={inputClass(compact)}
147
+ />
148
+ );
149
+ }
150
+
151
+ if (type.includes('email')) {
152
+ return (
153
+ <input
154
+ type="email"
155
+ value={value ?? ''}
156
+ onChange={e => onChange(e.target.value)}
157
+ disabled={disabled}
158
+ required={required}
159
+ placeholder={humanizeFieldName(name)}
160
+ className={inputClass(compact)}
161
+ />
162
+ );
163
+ }
164
+
165
+ if (type === 'text') {
166
+ return (
167
+ <textarea
168
+ value={value ?? ''}
169
+ onChange={e => onChange(e.target.value)}
170
+ disabled={disabled}
171
+ required={required}
172
+ rows={compact ? 2 : 3}
173
+ placeholder={humanizeFieldName(name)}
174
+ className={\`\${inputClass(compact)} resize-none\`}
175
+ />
176
+ );
177
+ }
178
+
179
+ return (
180
+ <input
181
+ type="text"
182
+ value={value ?? ''}
183
+ onChange={e => onChange(e.target.value)}
184
+ disabled={disabled}
185
+ required={required}
186
+ placeholder={\`\${humanizeFieldName(name)}\${typeStr ? \` (\${typeStr})\` : ''}\`}
187
+ className={inputClass(compact)}
188
+ />
189
+ );
190
+ }
191
+
192
+ /**
193
+ * EntitySelect — dropdown of existing entities, for UUID parameters
194
+ * that reference a model. Fetches \`GET /api/{resource}\` (static REST
195
+ * convention) and renders each entity by its display name.
196
+ */
197
+ interface EntitySelectProps {
198
+ modelName: string;
199
+ value: string;
200
+ onChange: (value: string) => void;
201
+ compact?: boolean;
202
+ /** Filter entities by a foreign key value, e.g. { categoryId: 'abc-123' }.
203
+ * Used for cascading pickers where a second param depends on the first. */
204
+ filterBy?: Record<string, string>;
205
+ }
206
+
207
+ export function EntitySelect({ modelName, value, onChange, compact = false, filterBy }: EntitySelectProps) {
208
+ const [allEntities, setAllEntities] = useState<any[]>([]);
209
+ const resource = pluralize(modelName).toLowerCase();
210
+
211
+ useEffect(() => {
212
+ fetch(\`\${API_BASE}/api/\${resource}\`)
213
+ .then(r => r.json())
214
+ .then(data => setAllEntities(Array.isArray(data) ? data : []))
215
+ .catch(() => setAllEntities([]));
216
+ }, [resource]);
217
+
218
+ const entities = filterBy
219
+ ? allEntities.filter(e => {
220
+ for (const [fk, fkValue] of Object.entries(filterBy)) {
221
+ if (!fkValue) continue;
222
+ if (!(fk in e)) continue;
223
+ if (e[fk] !== fkValue) return false;
224
+ }
225
+ return true;
226
+ })
227
+ : allEntities;
228
+
229
+ return (
230
+ <select
231
+ value={value || ''}
232
+ onChange={e => onChange(e.target.value)}
233
+ className={inputClass(compact)}
234
+ >
235
+ <option value="">
236
+ Select {modelName}...{filterBy && Object.values(filterBy).some(v => v) ? \` (\${entities.length} matching)\` : ''}
237
+ </option>
238
+ {entities.map(entity => {
239
+ const id = entity.id;
240
+ return (
241
+ <option key={id} value={id}>
242
+ {getEntityDisplayName(entity)}{id ? \` (\${String(id).slice(0, 8)}...)\` : ''}
243
+ </option>
244
+ );
245
+ })}
246
+ </select>
247
+ );
248
+ }
249
+ `;
250
+ }
251
+
252
+ export function emitOperationExecutor(): string {
253
+ return `/**
254
+ * OperationExecutor — run a controller command or service operation
255
+ * against the backend. Renders a parameter form, an Execute button,
256
+ * and the result/error beneath.
257
+ *
258
+ * Inlined into this project by @specverse/realize (ReactAppStarter).
259
+ * Edit freely. Ported from app-demo's OperationExecutor.
260
+ *
261
+ * For source='service' ops: POST /api/services/{serviceName}/{operationName}
262
+ * For source='controller' ops: POST /api/{resource}/{operationName}, where
263
+ * resource is the pluralized-lowercase form of the controller's managed
264
+ * model (ControllerName minus 'Controller' suffix).
265
+ */
266
+ import { useState, useMemo } from 'react';
267
+ import { FieldInput, EntitySelect } from './FieldInput';
268
+ import { OperationResultView } from './OperationResultView';
269
+ import { Play } from 'lucide-react';
270
+
271
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
272
+
273
+ function pluralize(s: string): string {
274
+ if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
275
+ if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
276
+ return s + 's';
277
+ }
278
+
279
+ interface OperationExecutorProps {
280
+ /** The service (source='service') or controller (source='controller') name. */
281
+ serviceName: string;
282
+ operationName: string;
283
+ /** Parameter declarations from the spec. Supports both
284
+ * \`{ name: { type: 'String' } }\` and \`{ name: 'String' }\` shapes. */
285
+ parameters?: Record<string, any>;
286
+ /** Optional; currently unused but kept for forward compat with
287
+ * app-demo where requires gates the run. */
288
+ requires?: string[];
289
+ source?: 'service' | 'controller';
290
+ }
291
+
292
+ export function OperationExecutor({
293
+ serviceName,
294
+ operationName,
295
+ parameters,
296
+ requires: _requires,
297
+ source = 'service',
298
+ }: OperationExecutorProps) {
299
+ const [params, setParams] = useState<Record<string, any>>({});
300
+ const [loading, setLoading] = useState(false);
301
+ const [result, setResult] = useState<any>(null);
302
+ const [error, setError] = useState<string | null>(null);
303
+
304
+ const paramDefs = useMemo(() => {
305
+ if (!parameters) return [];
306
+ const entries: Array<[string, any]> = Array.isArray(parameters)
307
+ ? parameters.map((p: any) => [p.name, p])
308
+ : Object.entries(parameters);
309
+ if (entries.length === 0) return [];
310
+
311
+ return entries.map(([name, def]) => {
312
+ const typeStr = typeof def === 'string' ? def : def?.type || '';
313
+ const isUuid = typeStr.toLowerCase().includes('uuid');
314
+ let refModel: string | null = null;
315
+ if (isUuid && name.endsWith('Id')) {
316
+ const base = name.slice(0, -2);
317
+ refModel = base.charAt(0).toUpperCase() + base.slice(1);
318
+ }
319
+ return { name, type: typeStr, isUuid, refModel };
320
+ });
321
+ }, [parameters]);
322
+
323
+ const execute = async () => {
324
+ setLoading(true);
325
+ setError(null);
326
+ setResult(null);
327
+ try {
328
+ let url: string;
329
+ if (source === 'controller') {
330
+ // ControllerName → resource-plural-lowercase (PostController → posts)
331
+ const modelName = serviceName.replace(/Controller$/, '');
332
+ const resource = pluralize(modelName).toLowerCase();
333
+ url = \`\${API_BASE}/api/\${resource}/\${operationName}\`;
334
+ } else {
335
+ url = \`\${API_BASE}/api/services/\${serviceName}/\${operationName}\`;
336
+ }
337
+ const res = await fetch(url, {
338
+ method: 'POST',
339
+ headers: { 'Content-Type': 'application/json' },
340
+ body: JSON.stringify(params),
341
+ });
342
+ const data = await res.json();
343
+ setResult(data);
344
+ if (!res.ok) {
345
+ setError(data?.message || data?.error || \`\${res.status} \${res.statusText}\`);
346
+ } else if (data && data.success === false) {
347
+ setError(data.error?.message || 'Operation failed');
348
+ }
349
+ } catch (err: any) {
350
+ setError(err?.message ?? String(err));
351
+ } finally {
352
+ setLoading(false);
353
+ }
354
+ };
355
+
356
+ return (
357
+ <div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 space-y-2">
358
+ {paramDefs.length > 0 ? (
359
+ paramDefs.map(param => {
360
+ // Cascading filter: if this param's model has a belongsTo to another
361
+ // already-filled param's model, filter by it.
362
+ let filterBy: Record<string, string> | undefined;
363
+ if (param.refModel) {
364
+ for (const other of paramDefs) {
365
+ if (other === param || !other.refModel) continue;
366
+ const fk = other.refModel.charAt(0).toLowerCase() + other.refModel.slice(1) + 'Id';
367
+ if (params[other.name]) {
368
+ filterBy = { ...filterBy, [fk]: params[other.name] };
369
+ }
370
+ }
371
+ }
372
+ return (
373
+ <div key={param.name} className="flex items-center gap-2">
374
+ <label
375
+ className="text-xs text-gray-500 dark:text-gray-400 w-24 flex-shrink-0 truncate"
376
+ title={param.name}
377
+ >
378
+ {param.name}
379
+ </label>
380
+ {param.refModel ? (
381
+ <EntitySelect
382
+ modelName={param.refModel}
383
+ value={params[param.name] || ''}
384
+ onChange={v => setParams(prev => ({ ...prev, [param.name]: v }))}
385
+ filterBy={filterBy}
386
+ compact
387
+ />
388
+ ) : (
389
+ <FieldInput
390
+ name={param.name}
391
+ typeStr={param.type}
392
+ value={params[param.name]}
393
+ onChange={v => setParams(prev => ({ ...prev, [param.name]: v }))}
394
+ compact
395
+ />
396
+ )}
397
+ </div>
398
+ );
399
+ })
400
+ ) : (
401
+ <p className="text-xs text-gray-400 italic">No parameters defined</p>
402
+ )}
403
+
404
+ <button
405
+ onClick={execute}
406
+ disabled={loading}
407
+ className="w-full px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded flex items-center justify-center gap-1 disabled:opacity-50"
408
+ >
409
+ <Play className="w-3 h-3" />
410
+ {loading ? 'Executing...' : \`Execute \${operationName}\`}
411
+ </button>
412
+
413
+ {error && !result && (
414
+ <pre className="p-2 text-xs text-red-400 bg-red-50 dark:bg-red-900/20 rounded overflow-x-auto">
415
+ {error}
416
+ </pre>
417
+ )}
418
+ {result && <OperationResultView result={result} />}
419
+ </div>
420
+ );
421
+ }
422
+ `;
423
+ }
424
+
425
+ export function emitOperationResultView(): string {
426
+ return `/**
427
+ * OperationResultView — formatted display of an operation's result.
428
+ *
429
+ * Inlined into this project by @specverse/realize (ReactAppStarter).
430
+ * Edit freely. Ported from app-demo's OperationResultView.
431
+ *
432
+ * Handles three shapes:
433
+ * - Success with data → JSON pretty-print
434
+ * - Success without data → "Operation succeeded" banner
435
+ * - Error { success: false, error: ... } → error banner + details
436
+ * - Unexpected → raw JSON
437
+ */
438
+ import { CheckCircle, XCircle } from 'lucide-react';
439
+
440
+ interface OperationResultViewProps {
441
+ result: any;
442
+ }
443
+
444
+ function JsonDisplay({ value }: { value: any }) {
445
+ return (
446
+ <pre className="p-3 text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded overflow-x-auto text-gray-900 dark:text-gray-100">
447
+ {JSON.stringify(value, null, 2)}
448
+ </pre>
449
+ );
450
+ }
451
+
452
+ export function OperationResultView({ result }: OperationResultViewProps) {
453
+ // Explicit error envelope: { success: false, error: ... }
454
+ if (result && typeof result === 'object' && result.success === false) {
455
+ const errMsg =
456
+ (typeof result.error === 'object' ? result.error?.message : result.error) ||
457
+ 'Operation failed';
458
+ return (
459
+ <div className="space-y-2">
460
+ <div className="flex items-start gap-2 p-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs">
461
+ <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
462
+ <div>
463
+ <div className="font-medium">Operation failed</div>
464
+ <div className="mt-1">{errMsg}</div>
465
+ </div>
466
+ </div>
467
+ {result.error && typeof result.error === 'object' && <JsonDisplay value={result.error} />}
468
+ </div>
469
+ );
470
+ }
471
+
472
+ // Explicit success envelope: { success: true, data?: ... }
473
+ if (result && typeof result === 'object' && result.success === true) {
474
+ return (
475
+ <div className="space-y-2">
476
+ <div className="flex items-center gap-2 p-2 rounded bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs">
477
+ <CheckCircle className="w-4 h-4 flex-shrink-0" />
478
+ <span className="font-medium">Operation succeeded</span>
479
+ </div>
480
+ {result.data !== undefined && <JsonDisplay value={result.data} />}
481
+ </div>
482
+ );
483
+ }
484
+
485
+ // No envelope — just show whatever came back (most REST endpoints).
486
+ return (
487
+ <div className="space-y-2">
488
+ <div className="flex items-center gap-2 p-2 rounded bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs">
489
+ <CheckCircle className="w-4 h-4 flex-shrink-0" />
490
+ <span className="font-medium">Operation completed</span>
491
+ </div>
492
+ {result !== undefined && result !== null && <JsonDisplay value={result} />}
493
+ </div>
494
+ );
495
+ }
496
+ `;
497
+ }