@vgai/editor 0.4.0 → 0.4.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 (137) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
  4. package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
  5. package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
  7. package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
  8. package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
  9. package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
  10. package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
  11. package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
  14. package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
  15. package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
  16. package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
  17. package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
  18. package/dist/assets/index-DiUacMXR.css +1 -0
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
  22. package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +27125 -1075
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/dev.ts +1 -1
  33. package/server/editor-server.ts +106 -51
  34. package/server/editor-sse.ts +5 -0
  35. package/server/generative-execution-context.ts +45 -0
  36. package/server/model-import-conversion.ts +345 -0
  37. package/server/packaged.ts +1 -1
  38. package/server/project-hmr-files.ts +3 -5
  39. package/server/project-output-writer.ts +440 -36
  40. package/server/project-tools.ts +529 -0
  41. package/server/server-utils.ts +14 -11
  42. package/src/EditorContext.tsx +4 -4
  43. package/src/action-registry.ts +12 -0
  44. package/src/asset-editor-context.tsx +9 -0
  45. package/src/asset-events.ts +3 -15
  46. package/src/asset-selection.ts +3 -0
  47. package/src/asset-workflow/asset-capabilities.ts +40 -0
  48. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  49. package/src/asset-workflow/asset-library-query.ts +8 -1
  50. package/src/asset-workflow/asset-workflow-quality.ts +2 -0
  51. package/src/asset-workflow/import-contract.ts +2 -0
  52. package/src/asset-workflow/model-inspection.ts +92 -1
  53. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  54. package/src/asset-workflow/project-asset-commands.ts +1 -0
  55. package/src/asset-workflow/project-asset-health.ts +3 -6
  56. package/src/asset-workflow/project-asset-operations.ts +119 -4
  57. package/src/command-listener.ts +37 -9
  58. package/src/components/ApplicationMenus.tsx +13 -15
  59. package/src/components/AssetBrowser.tsx +301 -26
  60. package/src/components/AssetImportDetails.tsx +207 -92
  61. package/src/components/AssetImportJobStatus.tsx +55 -0
  62. package/src/components/CenterDocuments.tsx +28 -1
  63. package/src/components/CommandPalette.tsx +2 -2
  64. package/src/components/DefaultEditorLayout.tsx +4 -0
  65. package/src/components/InspectorToolSection.tsx +12 -10
  66. package/src/components/OnlineAssetBrowser.tsx +197 -11
  67. package/src/components/ToolHost.tsx +8 -8
  68. package/src/components/ViewportOverlay.tsx +13 -26
  69. package/src/components/ViewportPanel.tsx +19 -14
  70. package/src/components/ViewportShadingMenu.tsx +103 -0
  71. package/src/components/WorkspaceDock.tsx +103 -17
  72. package/src/components/asset-documents.tsx +38 -11
  73. package/src/components/asset-editor-persistence.ts +300 -5
  74. package/src/components/asset-selection-section.tsx +83 -9
  75. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  76. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  77. package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
  78. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  79. package/src/components/asset-workflow.css +30 -3
  80. package/src/components/primitives/editor-icons.ts +1 -0
  81. package/src/components/project-tool-documents.tsx +298 -0
  82. package/src/components/status-contributions.tsx +38 -0
  83. package/src/components/tool-documents.tsx +20 -17
  84. package/src/components/tool-schema-form.tsx +258 -0
  85. package/src/components/workspace-dock.css +16 -0
  86. package/src/editor-api.ts +159 -22
  87. package/src/editor-store.ts +6 -5
  88. package/src/editor-viewport.ts +12 -7
  89. package/src/game-viewport-state.ts +28 -0
  90. package/src/model-thumbnail.ts +97 -9
  91. package/src/play-mode.ts +30 -3
  92. package/src/project-tool-discovery.ts +5 -5
  93. package/src/{project-operations.ts → project-tools.ts} +39 -26
  94. package/src/scene-sync.ts +0 -67
  95. package/src/storage/handle-storage.ts +5 -1
  96. package/src/storage/http-storage.ts +7 -2
  97. package/src/storage/mem-storage.ts +7 -1
  98. package/src/storage/types.ts +2 -0
  99. package/src/theme-css.ts +17 -7
  100. package/src/tool-loader.ts +152 -241
  101. package/src/viewport-shading-boundary.ts +15 -0
  102. package/src/widgets/index.ts +2 -1
  103. package/src/workspace-dock-controller.ts +8 -0
  104. package/src/workspace-document-registry.ts +4 -4
  105. package/src/workspace-state-persistence.ts +36 -10
  106. package/template/.claude/skills/editor/SKILL.md +13 -3
  107. package/template/AGENTS.md +100 -83
  108. package/template/package.json +16 -0
  109. package/template/public/scenes/default.vscn.json +1 -2
  110. package/template/scripts/playtest.ts +7 -37
  111. package/template/server/rooms/game-room.ts +2 -2
  112. package/template/src/data/README.md +3 -2
  113. package/template/src/data/tuning.data.json +1 -3
  114. package/template/src/data/tuning.schema.json +0 -12
  115. package/template/src/data/tuning.schema.ts +2 -9
  116. package/template/src/data/tuning.ts +1 -1
  117. package/template/src/scripts/registry.ts +0 -3
  118. package/template/src/tools/README.md +151 -16
  119. package/template/src/tools/autoplay.tool.ts +31 -0
  120. package/template/src/tools/autoplay.ts +15 -0
  121. package/template/src/tools/tuning.document.tsx +107 -0
  122. package/template/src/tools/tuning.tool.ts +24 -0
  123. package/template/src/tools/tuning.ts +9 -0
  124. package/template/src/ui/Button.stories.tsx +1 -1
  125. package/template/tests/acceptance/example.spec.ts +4 -59
  126. package/template/vgai.game.json +1 -1
  127. package/dist/assets/index-bAGhEnnO.css +0 -1
  128. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  129. package/dist/project-scripts.bundle.js +0 -3917
  130. package/server/project-operations.ts +0 -343
  131. package/src/components/project-operation-documents.tsx +0 -226
  132. package/template/src/operations/README.md +0 -88
  133. package/template/src/scripts/components/data-spinner.ts +0 -86
  134. package/template/src/scripts/components/spin-lap-math.ts +0 -27
  135. package/template/src/tools/example.tool.tsx +0 -97
  136. package/template/src/tools/spin.tool.tsx +0 -101
  137. package/template/tests/logic/example.test.ts +0 -31
@@ -0,0 +1,298 @@
1
+ import { useState, useSyncExternalStore } from 'react';
2
+ import type { EditorAction } from '../action-registry';
3
+ import { Button, bg, border, danger, EditorBadge, fontSize, radius, text } from '../design-system';
4
+ import {
5
+ getProjectTools,
6
+ projectToolsVersion,
7
+ runProjectTool,
8
+ subscribeProjectTools,
9
+ } from '../project-tools';
10
+ import { getDocumentToolContributions } from '../tool-loader';
11
+ import {
12
+ openWorkspaceDocument,
13
+ type WorkspaceDocumentContentProps,
14
+ } from '../workspace-document-registry';
15
+ import { openToolDocument } from './tool-documents';
16
+ import { defaultValueForSchema, ToolSchemaForm } from './tool-schema-form';
17
+
18
+ export const PROJECT_TOOLS_DOCUMENT_ID = 'project-tools';
19
+ export const PROJECT_TOOL_DOCUMENT_PREFIX = 'project-tool:';
20
+
21
+ export interface ProjectToolDocumentStore {
22
+ setActiveViewportTab(tab: 'scene' | 'game'): void;
23
+ }
24
+
25
+ function projectToolDocumentId(name: string): string {
26
+ return `${PROJECT_TOOL_DOCUMENT_PREFIX}${name}`;
27
+ }
28
+
29
+ export function openProjectToolsDocument(store: ProjectToolDocumentStore): void {
30
+ openWorkspaceDocument({
31
+ id: PROJECT_TOOLS_DOCUMENT_ID,
32
+ title: 'Project Tools',
33
+ kind: 'project-tool',
34
+ Content: ProjectToolDocumentContent,
35
+ closeable: true,
36
+ onActivate: () => store.setActiveViewportTab('scene'),
37
+ });
38
+ }
39
+
40
+ export function openProjectToolDocument(
41
+ store: ProjectToolDocumentStore | null,
42
+ name: string,
43
+ ): boolean {
44
+ const tool = getProjectTools().tools.find((entry) => entry.name === name);
45
+ if (!tool) return false;
46
+ const contribution = getDocumentToolContributions().find((item) => item.tool.name === name);
47
+ if (contribution) return openToolDocument(store, contribution.id);
48
+ openWorkspaceDocument({
49
+ id: projectToolDocumentId(name),
50
+ title: tool.summary || tool.name,
51
+ kind: 'project-tool',
52
+ provenance: { sourcePath: tool.sourcePath, origin: tool.name },
53
+ Content: ProjectToolDocumentContent,
54
+ closeable: true,
55
+ ...(store ? { onActivate: () => store.setActiveViewportTab('scene') } : {}),
56
+ });
57
+ return true;
58
+ }
59
+
60
+ export function buildProjectToolActions(store: ProjectToolDocumentStore): EditorAction[] {
61
+ return [
62
+ {
63
+ id: 'project-tools:open',
64
+ label: 'Open Project Tools',
65
+ category: 'action',
66
+ execute: () => openProjectToolsDocument(store),
67
+ },
68
+ ...getProjectTools().tools.map((tool) => ({
69
+ id: `project-tool:${tool.name}`,
70
+ label: `Run: ${tool.summary || tool.name}`,
71
+ category: 'action' as const,
72
+ execute: () => void openProjectToolDocument(store, tool.name),
73
+ })),
74
+ ];
75
+ }
76
+
77
+ function JsonBlock({ value }: { value: unknown }) {
78
+ return <pre style={jsonStyle}>{JSON.stringify(value, null, 2)}</pre>;
79
+ }
80
+
81
+ export function ProjectToolDocumentContent({ documentId }: WorkspaceDocumentContentProps) {
82
+ useSyncExternalStore(subscribeProjectTools, projectToolsVersion);
83
+ const snapshot = getProjectTools();
84
+ const toolName = documentId.startsWith(PROJECT_TOOL_DOCUMENT_PREFIX)
85
+ ? documentId.slice(PROJECT_TOOL_DOCUMENT_PREFIX.length)
86
+ : null;
87
+ const tool = toolName ? snapshot.tools.find((entry) => entry.name === toolName) : null;
88
+ if (!toolName) {
89
+ return (
90
+ <div style={rootStyle} data-testid="project-tools-catalog">
91
+ <h2 style={headingStyle}>Project Tools</h2>
92
+ <p style={{ ...mutedStyle, margin: '0 0 18px', maxWidth: 720, lineHeight: 1.5 }}>
93
+ Explicitly registered in <code>package.json#vgai.tools</code>. Each tool is an ordinary
94
+ project function; a registration may pair it with optional editor contributions.
95
+ </p>
96
+ <div style={{ display: 'grid', gap: 8 }}>
97
+ {snapshot.tools.map((entry) => (
98
+ <Button
99
+ key={entry.name}
100
+ variant="ghost"
101
+ className="vgai-project-tool-card"
102
+ onClick={() => openProjectToolDocument(null, entry.name)}
103
+ data-testid={`project-command-card:${entry.name}`}
104
+ >
105
+ <span style={cardTitleRowStyle}>
106
+ <strong style={{ color: text[1], fontWeight: 600 }}>{entry.summary}</strong>
107
+ <span style={openLabelStyle}>Open</span>
108
+ </span>
109
+ <code style={{ color: text[2], fontSize: fontSize.sm }}>{entry.name}</code>
110
+ <span style={cardMetaRowStyle}>
111
+ <span>
112
+ {entry.host} · {entry.permission.risk} · {entry.sourcePath}
113
+ </span>
114
+ {entry.contributions.length > 0 && (
115
+ <EditorBadge style={badgeStyle}>UI ×{entry.contributions.length}</EditorBadge>
116
+ )}
117
+ </span>
118
+ </Button>
119
+ ))}
120
+ </div>
121
+ {snapshot.tools.length === 0 && snapshot.loadErrors.length === 0 && (
122
+ <div style={mutedStyle}>No project tools registered.</div>
123
+ )}
124
+ {snapshot.loadErrors.map((error) => (
125
+ <div key={`${error.sourcePath}:${error.message}`} style={errorStyle}>
126
+ {error.sourcePath}: {error.message}
127
+ </div>
128
+ ))}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ if (!tool) {
134
+ return <div style={rootStyle}>This tool is no longer available.</div>;
135
+ }
136
+
137
+ return <ProjectToolRunner key={tool.name} tool={tool} />;
138
+ }
139
+
140
+ function ProjectToolRunner({
141
+ tool,
142
+ }: {
143
+ tool: ReturnType<typeof getProjectTools>['tools'][number];
144
+ }) {
145
+ const initial = defaultValueForSchema(tool.inputSchema);
146
+ const [input, setInput] = useState<Record<string, unknown>>(
147
+ initial && typeof initial === 'object' && !Array.isArray(initial)
148
+ ? (initial as Record<string, unknown>)
149
+ : {},
150
+ );
151
+ const [rawText, setRawText] = useState(() => JSON.stringify(input, null, 2));
152
+ const [result, setResult] = useState<unknown>(null);
153
+ const [running, setRunning] = useState(false);
154
+ const [inputError, setInputError] = useState<string | null>(null);
155
+
156
+ const runnable = tool.host === 'node';
157
+ const execute = async () => {
158
+ const confirm =
159
+ tool.permission.risk === 'read' ||
160
+ window.confirm(`${tool.permission.summary}\n\nRun ${tool.name}?`);
161
+ if (!confirm) return;
162
+ setRunning(true);
163
+ try {
164
+ setResult(await runProjectTool(tool.name, input, { confirm: true }));
165
+ } catch (error) {
166
+ setResult({ ok: false, error: { code: 'REQUEST_FAILED', message: String(error) } });
167
+ } finally {
168
+ setRunning(false);
169
+ }
170
+ };
171
+
172
+ return (
173
+ <div style={rootStyle} data-testid={`project-tool:${tool.name}`}>
174
+ <h2 style={headingStyle}>{tool.summary}</h2>
175
+ <code style={{ color: text[2] }}>{tool.name}</code>
176
+ <p style={{ color: text[2] }}>{tool.description}</p>
177
+ <div style={mutedStyle}>
178
+ {tool.host} · {tool.permission.risk} · {tool.sourcePath}
179
+ </div>
180
+ {!runnable && (
181
+ <div style={errorStyle}>
182
+ This runner supports Node-hosted tools. Import the underlying function directly in browser
183
+ or runtime code.
184
+ </div>
185
+ )}
186
+ <section style={{ marginTop: 18, maxWidth: 760 }}>
187
+ <ToolSchemaForm
188
+ schema={tool.inputSchema}
189
+ value={input}
190
+ onChange={(next) => {
191
+ setInput(next);
192
+ setRawText(JSON.stringify(next, null, 2));
193
+ setInputError(null);
194
+ }}
195
+ />
196
+ </section>
197
+ {inputError && <div style={errorStyle}>{inputError}</div>}
198
+ <Button
199
+ variant="primary"
200
+ disabled={!runnable || running || inputError !== null}
201
+ aria-busy={running}
202
+ onClick={() => void execute()}
203
+ style={{ marginTop: 16 }}
204
+ >
205
+ {running ? 'Running…' : 'Run tool'}
206
+ </Button>
207
+ <details style={{ marginTop: 16 }}>
208
+ <summary style={labelStyle}>Advanced JSON</summary>
209
+ <textarea
210
+ className="vgai-input"
211
+ id={`tool-input:${tool.name}`}
212
+ value={rawText}
213
+ onChange={(event) => {
214
+ const nextText = event.target.value;
215
+ setRawText(nextText);
216
+ try {
217
+ const parsed = JSON.parse(nextText) as unknown;
218
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
219
+ throw new Error('Input must be a JSON object.');
220
+ }
221
+ setInput(parsed as Record<string, unknown>);
222
+ setInputError(null);
223
+ } catch (error) {
224
+ setInputError(error instanceof Error ? error.message : String(error));
225
+ }
226
+ }}
227
+ rows={10}
228
+ style={textareaStyle}
229
+ />
230
+ <details>
231
+ <summary style={labelStyle}>Input schema</summary>
232
+ <JsonBlock value={tool.inputSchema} />
233
+ </details>
234
+ </details>
235
+ {result !== null && (
236
+ <section>
237
+ <div style={labelStyle}>Result</div>
238
+ <JsonBlock value={result} />
239
+ </section>
240
+ )}
241
+ </div>
242
+ );
243
+ }
244
+
245
+ const rootStyle: React.CSSProperties = {
246
+ width: '100%',
247
+ maxWidth: 960,
248
+ margin: '0 auto',
249
+ padding: 24,
250
+ boxSizing: 'border-box',
251
+ overflow: 'auto',
252
+ pointerEvents: 'auto',
253
+ color: text[1],
254
+ fontSize: fontSize.base,
255
+ };
256
+ const headingStyle: React.CSSProperties = { margin: '0 0 6px', fontSize: 20 };
257
+ const mutedStyle: React.CSSProperties = { color: text[2], fontSize: fontSize.sm };
258
+ const labelStyle: React.CSSProperties = { display: 'block', margin: '14px 0 6px', color: text[1] };
259
+ const cardTitleRowStyle: React.CSSProperties = {
260
+ display: 'flex',
261
+ width: '100%',
262
+ alignItems: 'center',
263
+ justifyContent: 'space-between',
264
+ gap: 12,
265
+ };
266
+ const openLabelStyle: React.CSSProperties = {
267
+ color: text[2],
268
+ fontSize: fontSize.sm,
269
+ fontWeight: 500,
270
+ };
271
+ const cardMetaRowStyle: React.CSSProperties = {
272
+ display: 'flex',
273
+ width: '100%',
274
+ minWidth: 0,
275
+ alignItems: 'center',
276
+ justifyContent: 'space-between',
277
+ gap: 12,
278
+ color: text[2],
279
+ fontSize: fontSize.sm,
280
+ };
281
+ const badgeStyle: React.CSSProperties = {
282
+ flex: '0 0 auto',
283
+ };
284
+ const errorStyle: React.CSSProperties = { marginTop: 10, color: danger, whiteSpace: 'pre-wrap' };
285
+ const textareaStyle: React.CSSProperties = {
286
+ width: '100%',
287
+ boxSizing: 'border-box',
288
+ resize: 'vertical',
289
+ fontFamily: 'monospace',
290
+ };
291
+ const jsonStyle: React.CSSProperties = {
292
+ overflow: 'auto',
293
+ padding: 10,
294
+ background: bg.inset,
295
+ border: `1px solid ${border[1]}`,
296
+ borderRadius: radius.sm,
297
+ color: text[2],
298
+ };
@@ -25,6 +25,11 @@
25
25
 
26
26
  import type { ConnectionState } from '@engine/adapter';
27
27
  import { useSyncExternalStore } from 'react';
28
+ import {
29
+ assetImportJobs,
30
+ assetImportJobsVersion,
31
+ subscribeAssetImportJobs,
32
+ } from '../asset-workflow/asset-import-jobs';
28
33
  import { getActiveNetworking } from '../authoring/active-systems';
29
34
  import { useAvailabilityTick } from '../availability-tick';
30
35
  import { Button, themeVars } from '../design-system';
@@ -100,6 +105,31 @@ export function ConnectionStatus() {
100
105
  );
101
106
  }
102
107
 
108
+ /** Project-wide import status remains visible when the Library document closes. */
109
+ export function AssetImportStatus() {
110
+ useSyncExternalStore(subscribeAssetImportJobs, assetImportJobsVersion);
111
+ const jobs = assetImportJobs();
112
+ if (jobs.length === 0) return null;
113
+ const running = jobs.filter((job) => job.status === 'running').length;
114
+ const failed = jobs.filter((job) => job.status === 'failed').length;
115
+ const completed = jobs.filter(
116
+ (job) => job.status === 'imported' || job.status === 'skipped',
117
+ ).length;
118
+ const label = running
119
+ ? `${running} importing`
120
+ : failed
121
+ ? `${failed} import failed`
122
+ : `${completed} imported`;
123
+ return (
124
+ <span
125
+ data-testid="status-asset-imports"
126
+ style={{ color: failed ? themeVars.semantic.danger : themeVars.content.muted }}
127
+ >
128
+ {label}
129
+ </span>
130
+ );
131
+ }
132
+
103
133
  /** Right-aligned concise selection summary (§4.1 "Board selected"). */
104
134
  export function SelectionStatus() {
105
135
  const store = useEditorStore();
@@ -155,6 +185,14 @@ export function ensureCoreStatusContributionsRegistered(): void {
155
185
  Content: ConnectionStatus,
156
186
  }),
157
187
  );
188
+ track(
189
+ registerWorkspaceStatus({
190
+ id: 'asset-imports',
191
+ align: 'left',
192
+ order: 40,
193
+ Content: AssetImportStatus,
194
+ }),
195
+ );
158
196
  // `build-progress` (build-documents.tsx, server mode) registers with
159
197
  // order 0/default and a later sequence, so it lands after `save-status`
160
198
  // among the order-0 items — visually beside the session state it extends.
@@ -24,7 +24,11 @@ import { themeVars } from '../design-system';
24
24
 
25
25
  import { useSyncExternalStore } from 'react';
26
26
  import type { EditorAction } from '../action-registry';
27
- import { getDocumentTools, getGlobalTools, subscribeGlobalTools } from '../tool-loader';
27
+ import {
28
+ getDocumentToolContributions,
29
+ getGlobalToolContributions,
30
+ subscribeToolContributions,
31
+ } from '../tool-loader';
28
32
  import {
29
33
  openWorkspaceDocument,
30
34
  type WorkspaceDocumentContentProps,
@@ -46,18 +50,18 @@ export function toolDocumentId(toolId: string): string {
46
50
  * tool. Returns `false` (opens nothing) when no discovered document tool
47
51
  * carries that id — no fabricated documents for deleted/renamed tools.
48
52
  */
49
- export function openToolDocument(store: ToolDocumentStore, toolId: string): boolean {
50
- const tool = getDocumentTools().find((t) => t.id === toolId);
51
- if (!tool) return false;
53
+ export function openToolDocument(store: ToolDocumentStore | null, toolId: string): boolean {
54
+ const contribution = getDocumentToolContributions().find((item) => item.id === toolId);
55
+ if (!contribution) return false;
52
56
  openWorkspaceDocument({
53
57
  id: toolDocumentId(toolId),
54
58
  // §8: the tool's own declared title (`Tuning`), never a tech bucket.
55
- title: tool.title,
56
- kind: 'tool',
57
- provenance: { sourcePath: tool.file },
59
+ title: contribution.title,
60
+ kind: 'tool-contribution',
61
+ provenance: { sourcePath: contribution.file, origin: contribution.tool.name },
58
62
  Content: ToolDocumentContent,
59
63
  closeable: true,
60
- onActivate: () => store.setActiveViewportTab('scene'),
64
+ ...(store ? { onActivate: () => store.setActiveViewportTab('scene') } : {}),
61
65
  });
62
66
  return true;
63
67
  }
@@ -65,7 +69,7 @@ export function openToolDocument(store: ToolDocumentStore, toolId: string): bool
65
69
  /** `Open Tool: <title>` palette actions — one per discovered document tool
66
70
  * (the B12 discovery gesture). */
67
71
  export function buildToolActions(store: ToolDocumentStore): EditorAction[] {
68
- return getDocumentTools().map((t) => ({
72
+ return getDocumentToolContributions().map((t) => ({
69
73
  id: `tool-doc:${t.id}`,
70
74
  label: `Open Tool: ${t.title}`,
71
75
  category: 'action' as const,
@@ -76,23 +80,22 @@ export function buildToolActions(store: ToolDocumentStore): EditorAction[] {
76
80
  /** Content host: resolves the LIVE tool by id on every render (HMR re-key,
77
81
  * honest deleted-tool state). */
78
82
  export function ToolDocumentContent({ documentId }: WorkspaceDocumentContentProps) {
79
- useSyncExternalStore(subscribeGlobalTools, getGlobalTools);
83
+ useSyncExternalStore(subscribeToolContributions, getGlobalToolContributions);
80
84
  const toolId = documentId.startsWith('tool:') ? documentId.slice('tool:'.length) : documentId;
81
- const tool = getDocumentTools().find((t) => t.id === toolId);
82
- if (!tool) {
85
+ const contribution = getDocumentToolContributions().find((item) => item.id === toolId);
86
+ if (!contribution) {
83
87
  return (
84
88
  <div
85
89
  style={{ padding: 12, fontSize: 12, color: themeVars.content.muted, pointerEvents: 'auto' }}
86
90
  >
87
- This tool is no longer available (its `src/tools/` file was deleted or renamed) — close this
88
- document.
91
+ This tool contribution is no longer registered — close this document.
89
92
  </div>
90
93
  );
91
94
  }
92
95
  return (
93
96
  <div
94
- key={`${tool.id}:${tool.version}`}
95
- data-testid={`tool-document:${tool.id}`}
97
+ key={`${contribution.id}:${contribution.version}`}
98
+ data-testid={`tool-document:${contribution.id}`}
96
99
  style={{
97
100
  flex: 1,
98
101
  minHeight: 0,
@@ -104,7 +107,7 @@ export function ToolDocumentContent({ documentId }: WorkspaceDocumentContentProp
104
107
  padding: 12,
105
108
  }}
106
109
  >
107
- <ToolHost tool={tool} />
110
+ <ToolHost contribution={contribution} />
108
111
  </div>
109
112
  );
110
113
  }
@@ -0,0 +1,258 @@
1
+ import type { CSSProperties } from 'react';
2
+ import { Checkbox, fontSize, Select, TextInput, text } from '../design-system';
3
+
4
+ type JsonSchema = Record<string, unknown>;
5
+
6
+ function schemaRecord(value: unknown): JsonSchema {
7
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonSchema) : {};
8
+ }
9
+
10
+ function schemaType(schema: JsonSchema): string | undefined {
11
+ const type = schema['type'];
12
+ if (typeof type === 'string') return type;
13
+ if (Array.isArray(type)) return type.find((item): item is string => item !== 'null');
14
+ return undefined;
15
+ }
16
+
17
+ export function defaultValueForSchema(value: unknown): unknown {
18
+ const schema = schemaRecord(value);
19
+ if ('default' in schema) return structuredClone(schema['default']);
20
+ const type = schemaType(schema);
21
+ if (type === 'object' || schema['properties']) {
22
+ const result: Record<string, unknown> = {};
23
+ for (const [key, child] of Object.entries(schemaRecord(schema['properties']))) {
24
+ const childSchema = schemaRecord(child);
25
+ if (
26
+ 'default' in childSchema ||
27
+ (Array.isArray(schema['required']) && schema['required'].includes(key))
28
+ ) {
29
+ result[key] = defaultValueForSchema(childSchema);
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+ if (type === 'array') return [];
35
+ if (type === 'boolean') return false;
36
+ if (type === 'number' || type === 'integer') return Number(schema['minimum'] ?? 0);
37
+ return '';
38
+ }
39
+
40
+ function humanize(value: string): string {
41
+ return value
42
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
43
+ .replace(/[-_]/g, ' ')
44
+ .replace(/^./, (letter) => letter.toUpperCase());
45
+ }
46
+
47
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this is the deliberately small JSON-Schema widget dispatch; each primitive type remains native HTML.
48
+ function PrimitiveField({
49
+ name,
50
+ schema,
51
+ value,
52
+ required,
53
+ onChange,
54
+ }: {
55
+ name: string;
56
+ schema: JsonSchema;
57
+ value: unknown;
58
+ required: boolean;
59
+ onChange(value: unknown): void;
60
+ }) {
61
+ const type = schemaType(schema);
62
+ const description = typeof schema['description'] === 'string' ? schema['description'] : null;
63
+ const choices = Array.isArray(schema['enum']) ? schema['enum'] : null;
64
+ const id = `tool-field:${name}`;
65
+
66
+ if (type === 'boolean') {
67
+ return (
68
+ <label style={{ ...fieldStyle, gridTemplateColumns: '18px minmax(0, 1fr)' }}>
69
+ <Checkbox
70
+ id={id}
71
+ checked={value === true}
72
+ onChange={(event) => onChange(event.target.checked)}
73
+ />
74
+ <span>
75
+ <span style={fieldLabelStyle}>{humanize(name)}</span>
76
+ {description && <span style={descriptionStyle}>{description}</span>}
77
+ </span>
78
+ </label>
79
+ );
80
+ }
81
+
82
+ const label = (
83
+ <span style={fieldLabelStyle}>
84
+ {humanize(name)} {!required && <span style={optionalStyle}>optional</span>}
85
+ </span>
86
+ );
87
+
88
+ if (choices) {
89
+ return (
90
+ <label style={fieldStyle} htmlFor={id}>
91
+ {label}
92
+ <Select
93
+ id={id}
94
+ value={String(value ?? '')}
95
+ onChange={(event) => onChange(event.target.value)}
96
+ style={controlLayoutStyle}
97
+ >
98
+ {!required && <option value="">Not set</option>}
99
+ {choices.map((choice) => (
100
+ <option key={String(choice)} value={String(choice)}>
101
+ {String(choice)}
102
+ </option>
103
+ ))}
104
+ </Select>
105
+ {description && <span style={descriptionStyle}>{description}</span>}
106
+ </label>
107
+ );
108
+ }
109
+
110
+ if (type === 'number' || type === 'integer') {
111
+ return (
112
+ <label style={fieldStyle} htmlFor={id}>
113
+ {label}
114
+ <TextInput
115
+ id={id}
116
+ type="number"
117
+ value={typeof value === 'number' ? value : ''}
118
+ min={typeof schema['minimum'] === 'number' ? schema['minimum'] : undefined}
119
+ max={typeof schema['maximum'] === 'number' ? schema['maximum'] : undefined}
120
+ step={type === 'integer' ? 1 : 'any'}
121
+ onChange={(event) =>
122
+ onChange(event.target.value === '' ? undefined : Number(event.target.value))
123
+ }
124
+ style={controlLayoutStyle}
125
+ />
126
+ {description && <span style={descriptionStyle}>{description}</span>}
127
+ </label>
128
+ );
129
+ }
130
+
131
+ if (type === 'array' && schemaType(schemaRecord(schema['items'])) === 'string') {
132
+ return (
133
+ <label style={fieldStyle} htmlFor={id}>
134
+ {label}
135
+ <TextInput
136
+ id={id}
137
+ value={Array.isArray(value) ? value.join(', ') : ''}
138
+ onChange={(event) =>
139
+ onChange(
140
+ event.target.value
141
+ .split(',')
142
+ .map((item) => item.trim())
143
+ .filter(Boolean),
144
+ )
145
+ }
146
+ placeholder="Comma-separated values"
147
+ style={controlLayoutStyle}
148
+ />
149
+ {description && <span style={descriptionStyle}>{description}</span>}
150
+ </label>
151
+ );
152
+ }
153
+
154
+ return (
155
+ <label style={fieldStyle} htmlFor={id}>
156
+ {label}
157
+ <TextInput
158
+ id={id}
159
+ value={typeof value === 'string' ? value : ''}
160
+ onChange={(event) => onChange(event.target.value || (required ? '' : undefined))}
161
+ style={controlLayoutStyle}
162
+ />
163
+ {description && <span style={descriptionStyle}>{description}</span>}
164
+ </label>
165
+ );
166
+ }
167
+
168
+ function ObjectFields({
169
+ schema,
170
+ value,
171
+ onChange,
172
+ path = '',
173
+ }: {
174
+ schema: JsonSchema;
175
+ value: Record<string, unknown>;
176
+ onChange(value: Record<string, unknown>): void;
177
+ path?: string;
178
+ }) {
179
+ const properties = schemaRecord(schema['properties']);
180
+ const required = new Set(Array.isArray(schema['required']) ? schema['required'] : []);
181
+ return (
182
+ <div style={{ display: 'grid', gap: 14 }}>
183
+ {Object.entries(properties).map(([key, child]) => {
184
+ const childSchema = schemaRecord(child);
185
+ const childType = schemaType(childSchema);
186
+ const update = (next: unknown) => {
187
+ const copy = { ...value };
188
+ if (next === undefined) delete copy[key];
189
+ else copy[key] = next;
190
+ onChange(copy);
191
+ };
192
+ if (childType === 'object' || childSchema['properties']) {
193
+ return (
194
+ <fieldset key={key} style={fieldsetStyle}>
195
+ <legend style={legendStyle}>{humanize(key)}</legend>
196
+ {typeof childSchema['description'] === 'string' && (
197
+ <p style={{ ...descriptionStyle, marginTop: 0 }}>{childSchema['description']}</p>
198
+ )}
199
+ <ObjectFields
200
+ schema={childSchema}
201
+ value={schemaRecord(value[key])}
202
+ onChange={update}
203
+ path={path ? `${path}.${key}` : key}
204
+ />
205
+ </fieldset>
206
+ );
207
+ }
208
+ return (
209
+ <PrimitiveField
210
+ key={key}
211
+ name={path ? `${path}.${key}` : key}
212
+ schema={childSchema}
213
+ value={value[key]}
214
+ required={required.has(key)}
215
+ onChange={update}
216
+ />
217
+ );
218
+ })}
219
+ </div>
220
+ );
221
+ }
222
+
223
+ export function ToolSchemaForm({
224
+ schema: schemaValue,
225
+ value,
226
+ onChange,
227
+ }: {
228
+ schema: unknown;
229
+ value: Record<string, unknown>;
230
+ onChange(value: Record<string, unknown>): void;
231
+ }) {
232
+ const schema = schemaRecord(schemaValue);
233
+ if (!schema['properties']) {
234
+ return <div style={descriptionStyle}>This schema has no form-compatible fields.</div>;
235
+ }
236
+ return <ObjectFields schema={schema} value={value} onChange={onChange} />;
237
+ }
238
+
239
+ const fieldStyle: CSSProperties = { display: 'grid', gap: 5 };
240
+ const fieldLabelStyle: CSSProperties = { color: text[1], fontWeight: 600, fontSize: fontSize.sm };
241
+ const optionalStyle: CSSProperties = { color: text[3], fontWeight: 400, marginLeft: 5 };
242
+ const descriptionStyle: CSSProperties = {
243
+ display: 'block',
244
+ color: text[2],
245
+ fontSize: fontSize.sm,
246
+ lineHeight: 1.4,
247
+ };
248
+ const controlLayoutStyle: CSSProperties = {
249
+ boxSizing: 'border-box',
250
+ width: '100%',
251
+ };
252
+ const fieldsetStyle: CSSProperties = {
253
+ margin: 0,
254
+ padding: 14,
255
+ border: '1px solid var(--vgai-boundary-default)',
256
+ borderRadius: 'var(--vgai-radius-md)',
257
+ };
258
+ const legendStyle: CSSProperties = { padding: '0 6px', color: text[1], fontWeight: 600 };