@vgai/editor 0.2.0 → 0.4.0-canary.20260715.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 (430) hide show
  1. package/README.md +24 -0
  2. package/dist/assets/axe-CyiTVRi6.js +30 -0
  3. package/dist/assets/browserAll-fGZ_P1P8.js +1 -0
  4. package/dist/assets/create-runtime-BzSHBTYw.js +1 -0
  5. package/dist/assets/game-DQ7YHNS8.js +1 -0
  6. package/dist/assets/game-DX6Obomf.js +1 -0
  7. package/dist/assets/game-DoUJj8vY.js +1 -0
  8. package/dist/assets/{game-xsOho_wg.js → game-HqybI_X7.js} +3 -3
  9. package/dist/assets/game-NUAnyPkS.js +1 -0
  10. package/dist/assets/game-ck_dNIE6.js +1 -0
  11. package/dist/assets/{game-pt_1lQLv.js → game-hIf5LD4E.js} +1 -1
  12. package/dist/assets/game-loop-o2CcBMFY.js +1 -0
  13. package/dist/assets/{game-source-CvidN7AB.js → game-source-C8HM7gbP.js} +1 -1
  14. package/dist/assets/{game-BI111o5f.js → game-uR_lv3Mp.js} +3 -3
  15. package/dist/assets/index-B-HA1uu3.js +15412 -0
  16. package/dist/assets/{index-Bp5cecD6.js → index-CSBAZwMx.js} +2 -2
  17. package/dist/assets/{index-DL7RcQ0n.js → index-CoSRCKPS.js} +1 -1
  18. package/dist/assets/{index-Cs8xwv3e.js → index-D93bBuUl.js} +1 -1
  19. package/dist/assets/index-bu01fqch.css +1 -0
  20. package/dist/assets/ingest-siblings-CCkkE2h7.js +1 -0
  21. package/dist/assets/mount-manifest-B2ECssU2.js +1 -0
  22. package/dist/assets/react-18-Bmj05S8J.js +1 -0
  23. package/dist/assets/webworkerAll-BC6gMOLZ.js +1 -0
  24. package/dist/assets/world-documents-DXPcAEA0.js +1 -0
  25. package/dist/index.html +15 -13
  26. package/dist/ingest/racing-game/vgai.game.json +5 -4
  27. package/dist/ingest/simcity/vgai.game.json +5 -4
  28. package/dist/ingest/tanks/vgai.game.json +5 -4
  29. package/dist/project-scripts.bundle.js +3917 -0
  30. package/dist/scenes/untitled.vscn.json +2 -0
  31. package/dist-server/packaged.mjs +7718 -3436
  32. package/package.json +31 -4
  33. package/scripts/probe-reporter.ts +349 -0
  34. package/scripts/run-probe.ts +2260 -0
  35. package/server/asset-catalog-audit.ts +268 -0
  36. package/server/asset-catalog-v2.ts +146 -0
  37. package/server/asset-library-routes.ts +276 -58
  38. package/server/canonical-path.ts +1 -1
  39. package/server/dev.ts +148 -44
  40. package/server/editor-server.ts +888 -61
  41. package/server/editor-sse.ts +115 -4
  42. package/server/harness-chat-service.ts +796 -0
  43. package/server/local-asset-catalog.ts +157 -22
  44. package/server/packaged.ts +71 -21
  45. package/server/process-shutdown.ts +136 -0
  46. package/server/prod.ts +9 -6
  47. package/server/project-hmr-files.ts +41 -0
  48. package/server/project-operations.ts +343 -0
  49. package/server/project-optimize-deps-entries.ts +4 -4
  50. package/server/project-output-writer.ts +156 -0
  51. package/server/project-validation.ts +140 -0
  52. package/server/project-view.ts +8 -50
  53. package/server/server-utils.ts +75 -10
  54. package/server/session-registry.ts +12 -0
  55. package/src/EditorContext.tsx +107 -3
  56. package/src/action-registry.ts +115 -17
  57. package/src/adapter-resolver.ts +213 -478
  58. package/src/asset-events.ts +64 -1
  59. package/src/asset-preview.ts +906 -0
  60. package/src/asset-selection.ts +121 -0
  61. package/src/asset-workflow/asset-capabilities.ts +259 -0
  62. package/src/asset-workflow/asset-editor-errors.ts +46 -0
  63. package/src/asset-workflow/asset-import-pipeline.ts +176 -0
  64. package/src/asset-workflow/asset-library-query.ts +107 -0
  65. package/src/asset-workflow/asset-types.ts +192 -0
  66. package/src/asset-workflow/asset-workflow-quality.ts +65 -0
  67. package/src/asset-workflow/import-contract.ts +113 -0
  68. package/src/asset-workflow/model-conversion.ts +126 -0
  69. package/src/asset-workflow/model-inspection.ts +185 -0
  70. package/src/asset-workflow/project-asset-commands.ts +23 -0
  71. package/src/asset-workflow/project-asset-health.ts +153 -0
  72. package/src/asset-workflow/project-asset-operations.ts +119 -0
  73. package/src/asset-workflow/thumbnail-system.ts +172 -0
  74. package/src/authoring/active-systems.ts +15 -0
  75. package/src/authoring/boundary-authoring-adapter.ts +16 -5
  76. package/src/authoring/composite-authoring-adapter.ts +305 -124
  77. package/src/authoring/design-time-layers.ts +300 -117
  78. package/src/authoring/edit-mode-authoring.ts +278 -293
  79. package/src/authoring/embed-only-adapter.ts +4 -2
  80. package/src/authoring/first-party-hierarchy-menu.ts +7 -2
  81. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
  82. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
  83. package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
  84. package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
  85. package/src/authoring/first-party-inspector/register.tsx +13 -2
  86. package/src/authoring/ingest-authoring-adapter.ts +142 -45
  87. package/src/authoring/mount-failure-report.ts +5 -5
  88. package/src/authoring/provenance.ts +41 -0
  89. package/src/authoring/react-dom-authoring-adapter.ts +92 -92
  90. package/src/authoring/react-inspector-section.tsx +489 -51
  91. package/src/authoring/react-world-authoring-adapter.ts +541 -306
  92. package/src/authoring/story-overlay.ts +1 -1
  93. package/src/authoring/story-types.ts +11 -0
  94. package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
  95. package/src/authoring/viewport-tool-context.ts +78 -0
  96. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  97. package/src/authoring/world-hidden-viewport.ts +186 -0
  98. package/src/authoring/world-pan-state.ts +33 -17
  99. package/src/authoring/world-session-state.ts +1 -1
  100. package/src/authoring/world2d-authoring-adapter.ts +109 -52
  101. package/src/availability-tick.ts +56 -0
  102. package/src/browser-play-entry.ts +5 -5
  103. package/src/browser-project-scripts.ts +1 -0
  104. package/src/build-identity.ts +15 -0
  105. package/src/command-listener.ts +893 -19
  106. package/src/components/AlignToolbar.tsx +74 -63
  107. package/src/components/AppRoot.tsx +193 -26
  108. package/src/components/ApplicationMenus.tsx +272 -0
  109. package/src/components/AssetBrowser.tsx +1236 -296
  110. package/src/components/AssetEditorShell.tsx +141 -0
  111. package/src/components/AssetImportDetails.tsx +218 -0
  112. package/src/components/AssetLibraryPanel.tsx +14 -0
  113. package/src/components/CameraInfo.tsx +11 -20
  114. package/src/components/CenterDocuments.tsx +585 -0
  115. package/src/components/CommandPalette.tsx +75 -30
  116. package/src/components/ConsolePanel.css +55 -0
  117. package/src/components/ConsolePanel.tsx +57 -112
  118. package/src/components/DebugConsolePanel.tsx +461 -0
  119. package/src/components/DebuggerPanel.tsx +165 -0
  120. package/src/components/DefaultEditorLayout.tsx +167 -231
  121. package/src/components/GameHierarchy.tsx +873 -290
  122. package/src/components/GamePerformanceOverlay.tsx +46 -0
  123. package/src/components/HarnessChatPanel.tsx +2352 -0
  124. package/src/components/HistoryPanel.tsx +226 -0
  125. package/src/components/Inspector.tsx +153 -99
  126. package/src/components/InspectorToolSection.tsx +6 -4
  127. package/src/components/MountFailureBanner.tsx +31 -48
  128. package/src/components/NetworkSection.tsx +25 -14
  129. package/src/components/OnlineAssetBrowser.tsx +1116 -344
  130. package/src/components/OverlayReportBanner.tsx +38 -64
  131. package/src/components/PerformancePanel.tsx +206 -0
  132. package/src/components/PlayBar.tsx +93 -263
  133. package/src/components/PrefabSaveDialog.tsx +55 -127
  134. package/src/components/ProjectHeader.css +60 -0
  135. package/src/components/ProjectHeader.tsx +170 -105
  136. package/src/components/ProjectScreen.tsx +246 -184
  137. package/src/components/ReactCanvasControls.tsx +185 -0
  138. package/src/components/ResolutionPicker.tsx +85 -0
  139. package/src/components/RootActivateDialog.tsx +39 -68
  140. package/src/components/SaveDialog.tsx +55 -127
  141. package/src/components/SaveStatus.tsx +11 -29
  142. package/src/components/StartupLoadingScreen.tsx +96 -0
  143. package/src/components/StateWatchPanel.tsx +302 -0
  144. package/src/components/StatsOverlay.tsx +12 -5
  145. package/src/components/StatusBar.tsx +64 -0
  146. package/src/components/ThemeManagerDialog.css +89 -0
  147. package/src/components/ThemeManagerDialog.tsx +371 -0
  148. package/src/components/ToolHost.tsx +26 -15
  149. package/src/components/Toolbar.tsx +117 -214
  150. package/src/components/VgaiVoxelLogo.tsx +89 -0
  151. package/src/components/ViewportOverlay.tsx +97 -172
  152. package/src/components/ViewportPanel.tsx +130 -31
  153. package/src/components/WorkspaceDock.tsx +891 -0
  154. package/src/components/WorldSelectionOverlay.tsx +51 -63
  155. package/src/components/WorldTextEditor.tsx +9 -5
  156. package/src/components/asset-documents.tsx +390 -0
  157. package/src/components/asset-editor-persistence.ts +44 -0
  158. package/src/components/asset-selection-section.tsx +338 -0
  159. package/src/components/asset-viewers/AudioViewer.tsx +73 -21
  160. package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
  161. package/src/components/asset-viewers/ImageViewer.tsx +43 -11
  162. package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
  163. package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
  164. package/src/components/asset-viewers/ModelViewer.tsx +805 -121
  165. package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
  166. package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
  167. package/src/components/asset-workflow.css +620 -0
  168. package/src/components/build-documents.tsx +696 -0
  169. package/src/components/core-utilities.tsx +75 -0
  170. package/src/components/data-documents.tsx +364 -0
  171. package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
  172. package/src/components/debugger-panel-state.ts +57 -0
  173. package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
  174. package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
  175. package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
  176. package/src/components/inspector-widgets/FontPicker.tsx +13 -21
  177. package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
  178. package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
  179. package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
  180. package/src/components/inspector-widgets/shared.tsx +90 -49
  181. package/src/components/inspectors/AnimationSection.tsx +39 -40
  182. package/src/components/inspectors/AudioSection.tsx +28 -39
  183. package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
  184. package/src/components/inspectors/CameraSection.tsx +14 -23
  185. package/src/components/inspectors/ComponentsSection.tsx +39 -43
  186. package/src/components/inspectors/JointsSection.tsx +34 -43
  187. package/src/components/inspectors/LightSection.tsx +90 -35
  188. package/src/components/inspectors/MaterialSection.tsx +137 -174
  189. package/src/components/inspectors/MeshSection.tsx +9 -17
  190. package/src/components/inspectors/NavigationSection.tsx +8 -15
  191. package/src/components/inspectors/ParticleSection.tsx +91 -76
  192. package/src/components/inspectors/PhysicsSection.tsx +42 -46
  193. package/src/components/inspectors/PostProcessingSection.tsx +28 -36
  194. package/src/components/inspectors/RenderingSection.tsx +12 -20
  195. package/src/components/inspectors/ShadowSection.tsx +7 -8
  196. package/src/components/inspectors/SplineSection.tsx +31 -42
  197. package/src/components/inspectors/TransformSection.tsx +14 -22
  198. package/src/components/patterns/Dialog.tsx +95 -0
  199. package/src/components/patterns/Fields.tsx +44 -0
  200. package/src/components/patterns/List.tsx +25 -0
  201. package/src/components/patterns/StateSurface.tsx +40 -0
  202. package/src/components/patterns/Surfaces.tsx +115 -0
  203. package/src/components/patterns/Tabs.tsx +80 -0
  204. package/src/components/patterns/Toolbar.tsx +51 -0
  205. package/src/components/patterns/Tree.tsx +72 -0
  206. package/src/components/primitives/Button.tsx +46 -0
  207. package/src/components/primitives/ColorInput.tsx +11 -47
  208. package/src/components/primitives/EditorIcon.tsx +99 -0
  209. package/src/components/primitives/FormControls.tsx +71 -0
  210. package/src/components/primitives/JsonInput.tsx +15 -2
  211. package/src/components/primitives/Layout.tsx +100 -0
  212. package/src/components/primitives/Menu.tsx +105 -0
  213. package/src/components/primitives/NumberInput.tsx +8 -35
  214. package/src/components/primitives/Panel.tsx +49 -44
  215. package/src/components/primitives/SectionHeader.tsx +10 -28
  216. package/src/components/primitives/Text.tsx +46 -0
  217. package/src/components/primitives/Tooltip.tsx +20 -12
  218. package/src/components/primitives/Vec3Input.tsx +3 -0
  219. package/src/components/primitives/banner-tones.ts +33 -0
  220. package/src/components/primitives/editor-icons.ts +102 -0
  221. package/src/components/primitives/panel-header-styles.ts +30 -0
  222. package/src/components/project-operation-documents.tsx +226 -0
  223. package/src/components/status-contributions.tsx +175 -0
  224. package/src/components/story-documents-register.ts +15 -0
  225. package/src/components/story-documents.tsx +1185 -0
  226. package/src/components/tool-documents.tsx +110 -0
  227. package/src/components/workspace-dock.css +293 -0
  228. package/src/components/workspace-history.ts +33 -0
  229. package/src/components/world-documents.tsx +145 -0
  230. package/src/components/xstate-documents.tsx +209 -0
  231. package/src/composite-screenshot.ts +248 -0
  232. package/src/design-system/index.ts +70 -0
  233. package/src/design-system-stories/Foundation.stories.tsx +183 -0
  234. package/src/design-system-stories/Patterns.stories.tsx +251 -0
  235. package/src/design-system-stories/Workspace.stories.tsx +538 -0
  236. package/src/doctor/report.ts +57 -13
  237. package/src/editor-api.ts +295 -31
  238. package/src/editor-compatibility.ts +200 -0
  239. package/src/editor-console.ts +35 -17
  240. package/src/editor-hotkeys.ts +85 -33
  241. package/src/editor-store.ts +538 -233
  242. package/src/editor-viewport.ts +160 -34
  243. package/src/entity-factory.ts +14 -3
  244. package/src/gizmo-registry.ts +21 -5
  245. package/src/harness-chat-editor-context.ts +37 -0
  246. package/src/harness-chat-indicator.ts +68 -0
  247. package/src/harness-chat-layout.ts +53 -0
  248. package/src/harness-chat-lifecycle.ts +170 -0
  249. package/src/harness-chat-reconnect.ts +85 -0
  250. package/src/harness-chat-types.ts +409 -0
  251. package/src/hierarchy-drop.ts +50 -0
  252. package/src/hierarchy-expansion-state.ts +100 -0
  253. package/src/hierarchy-menu-registry.ts +1 -1
  254. package/src/hierarchy-projection.ts +76 -0
  255. package/src/history/editor-session.ts +21 -0
  256. package/src/history/history-commands.ts +147 -0
  257. package/src/history/history-service.ts +904 -0
  258. package/src/history/json-history-resource.ts +165 -0
  259. package/src/history/persistence-coordinator.ts +35 -0
  260. package/src/history/project-file-history.ts +266 -0
  261. package/src/history/project-root-history-backends.ts +53 -0
  262. package/src/history/resource-registry.ts +209 -0
  263. package/src/history/snapshot-store.ts +108 -0
  264. package/src/history/source-history-backend.ts +374 -0
  265. package/src/history/types.ts +100 -0
  266. package/src/hmr-registration-group.ts +67 -0
  267. package/src/hmr-stable-react-context.ts +23 -0
  268. package/src/hosted-example.ts +12 -2
  269. package/src/hotkeys.ts +89 -18
  270. package/src/ingest/discovery-react.ts +9 -10
  271. package/src/ingest/discovery.ts +4 -4
  272. package/src/ingest/discovery2d.ts +5 -5
  273. package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
  274. package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
  275. package/src/ingest/games/games-fps/vgai.game.json +3 -3
  276. package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
  277. package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
  278. package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
  279. package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
  280. package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
  281. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
  282. package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
  283. package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
  284. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
  285. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
  286. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
  287. package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
  288. package/src/ingest/types.ts +1 -1
  289. package/src/ingest-mode.ts +47 -52
  290. package/src/ingest-siblings.ts +23 -33
  291. package/src/initial-scene.ts +1 -2
  292. package/src/main.tsx +16 -8
  293. package/src/manifest-project.ts +10 -12
  294. package/src/model-thumbnail.ts +261 -26
  295. package/src/module-mode.ts +44 -50
  296. package/src/play-log-events.ts +26 -0
  297. package/src/play-mode.ts +495 -401
  298. package/src/play-scene-adoption.ts +23 -0
  299. package/src/playground/main.ts +68 -24
  300. package/src/playwright-shim.ts +473 -0
  301. package/src/project-manager.ts +90 -10
  302. package/src/project-operations.ts +112 -0
  303. package/src/project-story-discovery.ts +25 -0
  304. package/src/project-tool-discovery.ts +43 -0
  305. package/src/scene-serializer.ts +0 -6
  306. package/src/scene-sync.ts +7 -2
  307. package/src/script-loader.ts +3 -3
  308. package/src/skeleton-helper.ts +57 -0
  309. package/src/storage/seed.ts +68 -8
  310. package/src/stories/StoryPreviewMount.tsx +265 -0
  311. package/src/stories/action-stories.fixture.tsx +44 -0
  312. package/src/stories/button-stories.fixture.tsx +50 -0
  313. package/src/stories/compose-project-stories.ts +182 -0
  314. package/src/stories/loader-stories.fixture.tsx +53 -0
  315. package/src/stories/story-discovery.ts +130 -0
  316. package/src/stories/story-registry.ts +146 -0
  317. package/src/theme-css.ts +1049 -0
  318. package/src/theme-library.ts +238 -0
  319. package/src/theme-preference.ts +130 -0
  320. package/src/theme.ts +527 -0
  321. package/src/tool-loader.ts +80 -24
  322. package/src/ui-source/inspect.ts +27 -4
  323. package/src/ui-source/source-write-backend.ts +83 -22
  324. package/src/widgets/index.ts +9 -18
  325. package/src/workspace-aux-commands.ts +11 -0
  326. package/src/workspace-dock-controller.ts +64 -0
  327. package/src/workspace-document-ids.ts +5 -0
  328. package/src/workspace-document-registry.ts +367 -0
  329. package/src/workspace-state-persistence.ts +455 -0
  330. package/src/workspace-status-registry.ts +110 -0
  331. package/src/workspace-utility-commands.ts +49 -0
  332. package/src/workspace-utility-registry.ts +161 -0
  333. package/src/world-document-routing.ts +16 -0
  334. package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
  335. package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
  336. package/src/xstate-inspect/use-live-actor-state.ts +39 -0
  337. package/src/xstate-inspect/xstate-graph.ts +265 -0
  338. package/src/xstate-inspect/xstate-layout.ts +76 -0
  339. package/src/z-index.ts +26 -0
  340. package/template/.claude/skills/editor/SKILL.md +129 -90
  341. package/template/AGENTS.md +1110 -0
  342. package/template/CLAUDE.md +1 -512
  343. package/template/IDIOMS.md +50 -0
  344. package/template/check-idioms.ts +706 -0
  345. package/template/manifest-entry-modules-plugin.ts +53 -0
  346. package/template/package.json +13 -4
  347. package/template/public/scenes/default.scn2d.json +6 -0
  348. package/template/public/scenes/default.vscn.json +7 -1
  349. package/template/public/scenes/untitled.vscn.json +10 -0
  350. package/template/scripts/playtest.ts +40 -0
  351. package/template/server/loopback.ts +75 -0
  352. package/template/server/rooms/arena-room.ts +69 -1
  353. package/template/server/rooms/game-room.ts +68 -1
  354. package/template/src/data/README.md +9 -4
  355. package/template/src/data/data-ref.ts +35 -0
  356. package/template/src/data/tuning.schema.ts +1 -1
  357. package/template/src/main.ts +41 -62
  358. package/template/src/manifest-entry-modules.d.ts +3 -0
  359. package/template/src/operations/README.md +88 -0
  360. package/template/src/runtime/pixi-adapter.ts +24 -0
  361. package/template/src/scripts/components/data-spinner.ts +43 -2
  362. package/template/src/scripts/components/spin-lap-math.ts +27 -0
  363. package/template/src/tools/README.md +5 -3
  364. package/template/src/tools/example.tool.tsx +13 -7
  365. package/template/src/ui/Button.stories.tsx +66 -0
  366. package/template/src/ui/RebindPanel.tsx +2 -6
  367. package/template/src/ui/game.tsx +17 -0
  368. package/template/tests/acceptance/example.spec.ts +64 -0
  369. package/template/tests/logic/example.test.ts +31 -0
  370. package/template/tests/logic/room.test.ts +100 -0
  371. package/template/tests/tsconfig.json +30 -0
  372. package/template/validate-manifest.ts +5 -5
  373. package/template/validate-scenes.ts +50 -18
  374. package/template/vgai.game.json +20 -13
  375. package/template/vite.config.ts +77 -16
  376. package/vite-plugin-ui-oid.ts +261 -17
  377. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
  378. package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
  379. package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
  380. package/dist/assets/browserAll-CEkWYlFk.js +0 -1
  381. package/dist/assets/create-runtime-DYIWr3va.js +0 -1
  382. package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
  383. package/dist/assets/game-8SDZPPgH.js +0 -1
  384. package/dist/assets/game-CevorxyV.js +0 -1
  385. package/dist/assets/game-Di7XlywD.js +0 -1
  386. package/dist/assets/game-DjnCaw8R.js +0 -1
  387. package/dist/assets/game-Q9A0aXSV.js +0 -1
  388. package/dist/assets/index-CPVV6pjw.js +0 -1
  389. package/dist/assets/index-CpOnYyeD.js +0 -213
  390. package/dist/assets/index-Dr8H_6Rl.js +0 -1
  391. package/dist/assets/index-_bCaipUk.js +0 -13650
  392. package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
  393. package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
  394. package/dist/assets/scene-query-BivAHhJu.js +0 -1
  395. package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
  396. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
  397. package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
  398. package/src/authoring/scene-ui-layer.ts +0 -485
  399. package/src/authoring/ui-authoring-adapter.ts +0 -325
  400. package/src/authoring/ui-hierarchy-menu.ts +0 -40
  401. package/src/authoring/ui-inspector-sections.tsx +0 -156
  402. package/src/authoring/undo-timeline.ts +0 -145
  403. package/src/components/BottomPanel.tsx +0 -326
  404. package/src/components/BuildPanel.tsx +0 -455
  405. package/src/components/FolderTree.tsx +0 -179
  406. package/src/components/RightPanel.tsx +0 -166
  407. package/src/components/WorldContextMenu.tsx +0 -257
  408. package/src/components/asset-viewers/AssetViewer.tsx +0 -38
  409. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
  410. package/src/scene-ui-mode/active-scene-ui.ts +0 -58
  411. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
  412. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
  413. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
  414. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
  415. package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
  416. package/src/ui-editor/inspector.tsx +0 -563
  417. package/src/ui-editor/node-ops.ts +0 -266
  418. package/src/ui-editor/overlay.tsx +0 -380
  419. package/src/ui-editor/palette.tsx +0 -114
  420. package/src/ui-editor/react-store.ts +0 -78
  421. package/src/ui-editor/timeline-panel.tsx +0 -443
  422. package/src/ui-editor/ui-edit-store.ts +0 -552
  423. package/src/ui-editor/ui-prefab.ts +0 -95
  424. package/template/project.json +0 -9
  425. package/template/src/scripts/main.ts +0 -94
  426. package/template/src/ui/DefaultHud.tsx +0 -40
  427. package/template/src/ui/game-state.tsx +0 -37
  428. package/template/src/ui/mount-default-hud.tsx +0 -19
  429. package/template/src/ui/mount-ui.ts +0 -15
  430. package/vite-plugin-react-world-provider.ts +0 -81
@@ -1,6 +1,20 @@
1
- import { faCheck, faSpinner } from '@fortawesome/free-solid-svg-icons';
2
- import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3
- import { useCallback, useEffect, useRef, useState } from 'react';
1
+ import { faCheck, faCloud, faSpinner } from '@fortawesome/free-solid-svg-icons';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ type AssetLibrarySort,
5
+ assetLibraryStateKey,
6
+ onlineAssetKey,
7
+ queryAssetLibrary,
8
+ } from '../asset-workflow/asset-library-query';
9
+ import {
10
+ Button,
11
+ Checkbox,
12
+ EditorIcon,
13
+ Select,
14
+ StateSurface,
15
+ TextInput,
16
+ text,
17
+ } from '../design-system';
4
18
  import { useEditorStore } from '../EditorContext';
5
19
  import {
6
20
  getAssetLibraryCategories,
@@ -10,12 +24,14 @@ import {
10
24
  onAssetDownloaded,
11
25
  searchOnlineAssets,
12
26
  } from '../editor-api';
13
-
14
- // ---------------------------------------------------------------------------
15
- // Constants
16
- // ---------------------------------------------------------------------------
27
+ import { getCurrentProject } from '../project-manager';
28
+ import { openOnlineAssetDocument } from './asset-documents';
29
+ import { OnlineAssetDetail } from './asset-viewers/OnlineAssetDetail';
30
+ import './asset-workflow.css';
17
31
 
18
32
  type Source = 'local' | 'polyhaven' | 'ambientcg';
33
+ type Density = 'compact' | 'standard' | 'large';
34
+ type Sort = AssetLibrarySort;
19
35
 
20
36
  interface SourceOption {
21
37
  value: Source;
@@ -28,10 +44,10 @@ const SOURCES: SourceOption[] = [
28
44
  value: 'local',
29
45
  label: 'Full Catalog',
30
46
  types: [
31
- { value: 'all', label: 'All' },
47
+ { value: 'all', label: 'All assets' },
32
48
  { value: 'models', label: 'Models' },
33
49
  { value: 'animations', label: 'Animations' },
34
- { value: 'sources', label: 'Source Files' },
50
+ { value: 'sources', label: 'Source files' },
35
51
  ],
36
52
  },
37
53
  {
@@ -53,396 +69,1152 @@ const SOURCES: SourceOption[] = [
53
69
  },
54
70
  ];
55
71
 
56
- const PAGE_SIZE = 48;
57
-
58
- // ---------------------------------------------------------------------------
59
- // Styles
60
- // ---------------------------------------------------------------------------
61
-
62
- const selectStyle: React.CSSProperties = {
63
- background: '#1e1e1e',
64
- border: '1px solid #333',
65
- borderRadius: 3,
66
- color: '#c5c8ce',
67
- fontSize: 11,
68
- padding: '3px 6px',
69
- outline: 'none',
70
- cursor: 'pointer',
71
- };
72
+ const PAGE_SIZE = 96;
73
+ interface PersistedLibraryState {
74
+ source: Source;
75
+ assetType: string;
76
+ query: string;
77
+ density: Density;
78
+ sort: Sort;
79
+ favorites: string[];
80
+ savedSearches: string[];
81
+ recent: string[];
82
+ sourceWidth: number;
83
+ detailsWidth: number;
84
+ sourceCollapsed: boolean;
85
+ detailsCollapsed: boolean;
86
+ }
72
87
 
73
- const inputStyle: React.CSSProperties = {
74
- background: '#1e1e1e',
75
- border: '1px solid #333',
76
- borderRadius: 3,
77
- color: '#c5c8ce',
78
- fontSize: 11,
79
- padding: '3px 6px',
80
- outline: 'none',
81
- flex: 1,
82
- minWidth: 80,
88
+ const DEFAULT_STATE: PersistedLibraryState = {
89
+ source: 'local',
90
+ assetType: 'all',
91
+ query: '',
92
+ density: 'standard',
93
+ sort: 'relevance',
94
+ favorites: [],
95
+ savedSearches: [],
96
+ recent: [],
97
+ sourceWidth: 210,
98
+ detailsWidth: 360,
99
+ sourceCollapsed: false,
100
+ detailsCollapsed: false,
83
101
  };
84
102
 
85
- // ---------------------------------------------------------------------------
86
- // Component
87
- // ---------------------------------------------------------------------------
103
+ function readState(): PersistedLibraryState {
104
+ try {
105
+ const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
106
+ const parsed = JSON.parse(
107
+ localStorage.getItem(assetLibraryStateKey(projectId)) ?? '{}',
108
+ ) as Partial<PersistedLibraryState>;
109
+ return { ...DEFAULT_STATE, ...parsed };
110
+ } catch {
111
+ return DEFAULT_STATE;
112
+ }
113
+ }
114
+
115
+ function toOnlineInfo(asset: OnlineAsset) {
116
+ return {
117
+ source: asset.source,
118
+ id: asset.id,
119
+ name: asset.name,
120
+ thumbnailUrl: asset.thumbnailUrl,
121
+ type: asset.type,
122
+ tags: asset.tags,
123
+ ...(asset.license ? { license: asset.license } : {}),
124
+ ...(asset.author ? { author: asset.author } : {}),
125
+ ...(asset.sourceUrl ? { sourceUrl: asset.sourceUrl } : {}),
126
+ };
127
+ }
128
+
129
+ function SourcePane(props: {
130
+ source: Source;
131
+ assetType: string;
132
+ category: string;
133
+ categoryQuery: string;
134
+ categories: readonly string[];
135
+ savedSearches: readonly string[];
136
+ favoritesOnly: boolean;
137
+ animatedOnly: boolean;
138
+ runtimeReadyOnly: boolean;
139
+ downloaded: 'all' | 'downloaded' | 'not-downloaded';
140
+ license: string;
141
+ delivery: string;
142
+ health: string;
143
+ licenseCounts: ReadonlyMap<string, number>;
144
+ deliveryCounts: ReadonlyMap<string, number>;
145
+ healthCounts: ReadonlyMap<string, number>;
146
+ onSource(source: Source): void;
147
+ onType(type: string): void;
148
+ onCategory(category: string): void;
149
+ onCategoryQuery(query: string): void;
150
+ onSavedSearch(query: string): void;
151
+ onFavoritesOnly(value: boolean): void;
152
+ onAnimatedOnly(value: boolean): void;
153
+ onRuntimeReadyOnly(value: boolean): void;
154
+ onDownloaded(value: 'all' | 'downloaded' | 'not-downloaded'): void;
155
+ onLicense(value: string): void;
156
+ onDelivery(value: string): void;
157
+ onHealth(value: string): void;
158
+ }) {
159
+ const sourceConfig = SOURCES.find((entry) => entry.value === props.source)!;
160
+ const visibleCategories = props.categories
161
+ .filter((value) => value.toLowerCase().includes(props.categoryQuery.toLowerCase()))
162
+ .slice(0, 80);
163
+ return (
164
+ <aside className="vgai-asset-library__sources" aria-label="Library sources and filters">
165
+ <div className="vgai-asset-facet-title">Sources</div>
166
+ {SOURCES.map((entry) => (
167
+ <Button
168
+ className="vgai-asset-facet-button"
169
+ type="button"
170
+ key={entry.value}
171
+ aria-pressed={props.source === entry.value}
172
+ onClick={() => props.onSource(entry.value)}
173
+ >
174
+ {entry.label}
175
+ </Button>
176
+ ))}
177
+
178
+ <div className="vgai-asset-facet-title">Type</div>
179
+ {sourceConfig.types.map((entry) => (
180
+ <Button
181
+ className="vgai-asset-facet-button"
182
+ type="button"
183
+ key={entry.value}
184
+ aria-pressed={props.assetType === entry.value}
185
+ onClick={() => props.onType(entry.value)}
186
+ >
187
+ {entry.label}
188
+ </Button>
189
+ ))}
190
+
191
+ <div className="vgai-asset-facet-title">Saved views</div>
192
+ <Button
193
+ className="vgai-asset-facet-button"
194
+ type="button"
195
+ aria-pressed={props.favoritesOnly}
196
+ onClick={() => props.onFavoritesOnly(!props.favoritesOnly)}
197
+ >
198
+ Favorites
199
+ </Button>
200
+ {props.savedSearches.map((query) => (
201
+ <Button
202
+ className="vgai-asset-facet-button"
203
+ type="button"
204
+ key={query}
205
+ onClick={() => props.onSavedSearch(query)}
206
+ >
207
+ {query}
208
+ </Button>
209
+ ))}
210
+
211
+ <div className="vgai-asset-facet-title">Readiness</div>
212
+ <label className="vgai-asset-facet-check">
213
+ <Checkbox
214
+ checked={props.runtimeReadyOnly}
215
+ onChange={(event) => props.onRuntimeReadyOnly(event.target.checked)}
216
+ />
217
+ Runtime-ready
218
+ </label>
219
+ <label className="vgai-asset-facet-check">
220
+ <Checkbox
221
+ checked={props.animatedOnly}
222
+ onChange={(event) => props.onAnimatedOnly(event.target.checked)}
223
+ />
224
+ Animated
225
+ </label>
226
+ <label className="vgai-asset-facet-check">
227
+ Project status
228
+ <Select
229
+ className="vgai-select"
230
+ value={props.downloaded}
231
+ onChange={(event) =>
232
+ props.onDownloaded(event.target.value as 'all' | 'downloaded' | 'not-downloaded')
233
+ }
234
+ >
235
+ <option value="all">All</option>
236
+ <option value="downloaded">In project</option>
237
+ <option value="not-downloaded">Not in project</option>
238
+ </Select>
239
+ </label>
240
+ <label className="vgai-asset-facet-check">
241
+ License
242
+ <Select
243
+ className="vgai-select"
244
+ value={props.license}
245
+ onChange={(event) => props.onLicense(event.target.value)}
246
+ >
247
+ <option value="">All licenses</option>
248
+ {[...props.licenseCounts].map(([value, count]) => (
249
+ <option key={value} value={value}>
250
+ {value} ({count})
251
+ </option>
252
+ ))}
253
+ </Select>
254
+ </label>
255
+ <label className="vgai-asset-facet-check">
256
+ Delivery
257
+ <Select
258
+ className="vgai-select"
259
+ value={props.delivery}
260
+ onChange={(event) => props.onDelivery(event.target.value)}
261
+ >
262
+ <option value="">All deliveries</option>
263
+ {[...props.deliveryCounts].map(([value, count]) => (
264
+ <option key={value} value={value}>
265
+ {value} ({count})
266
+ </option>
267
+ ))}
268
+ </Select>
269
+ </label>
270
+ <label className="vgai-asset-facet-check">
271
+ Health
272
+ <Select
273
+ className="vgai-select"
274
+ value={props.health}
275
+ onChange={(event) => props.onHealth(event.target.value)}
276
+ >
277
+ <option value="">All health states</option>
278
+ {[...props.healthCounts].map(([value, count]) => (
279
+ <option key={value} value={value}>
280
+ {value} ({count})
281
+ </option>
282
+ ))}
283
+ </Select>
284
+ </label>
285
+
286
+ {props.categories.length > 0 && (
287
+ <>
288
+ <div className="vgai-asset-facet-title">Pack / category</div>
289
+ <TextInput
290
+ className="vgai-input"
291
+ aria-label="Filter categories"
292
+ placeholder="Filter categories"
293
+ value={props.categoryQuery}
294
+ onChange={(event) => props.onCategoryQuery(event.target.value)}
295
+ style={{ width: '100%', boxSizing: 'border-box', marginBottom: 4 }}
296
+ />
297
+ <Button
298
+ className="vgai-asset-facet-button"
299
+ type="button"
300
+ aria-pressed={!props.category}
301
+ onClick={() => props.onCategory('')}
302
+ >
303
+ All categories
304
+ </Button>
305
+ {visibleCategories.map((value) => (
306
+ <Button
307
+ className="vgai-asset-facet-button"
308
+ type="button"
309
+ key={value}
310
+ aria-pressed={props.category === value}
311
+ onClick={() => props.onCategory(value)}
312
+ >
313
+ {value}
314
+ </Button>
315
+ ))}
316
+ </>
317
+ )}
318
+ </aside>
319
+ );
320
+ }
321
+ function AssetCardThumbnail({ asset }: { asset: OnlineAsset }) {
322
+ const [failed, setFailed] = useState(false);
323
+ return (
324
+ <span className="vgai-asset-card__preview">
325
+ <img
326
+ src={failed ? '/__editor/asset-library/local-placeholder.svg' : asset.thumbnailUrl}
327
+ alt={failed ? `${asset.name} preview unavailable` : ''}
328
+ loading="lazy"
329
+ onError={() => setFailed(true)}
330
+ />
331
+ {failed && <span className="vgai-asset-card__fallback">Preview unavailable</span>}
332
+ </span>
333
+ );
334
+ }
335
+
336
+ function VirtualAssetGrid(props: {
337
+ assets: readonly OnlineAsset[];
338
+ selectedId: string | null;
339
+ density: Density;
340
+ loading: boolean;
341
+ hasMore: boolean;
342
+ isFavorite(asset: OnlineAsset): boolean;
343
+ onSelect(asset: OnlineAsset): void;
344
+ onOpen(asset: OnlineAsset): void;
345
+ onLoadMore(): void;
346
+ }) {
347
+ const viewportRef = useRef<HTMLDivElement>(null);
348
+ const typeahead = useRef({ value: '', timer: 0 });
349
+ const [metrics, setMetrics] = useState({ width: 800, height: 600, scrollTop: 0 });
350
+ const cardWidth = props.density === 'compact' ? 136 : props.density === 'large' ? 208 : 168;
351
+ const rowHeight = props.density === 'compact' ? 178 : props.density === 'large' ? 254 : 214;
352
+ const gap = 10;
353
+ const columns = Math.max(1, Math.floor((metrics.width - gap) / (cardWidth + gap)));
354
+ const rows = Math.ceil(props.assets.length / columns);
355
+ const firstRow = Math.max(0, Math.floor(metrics.scrollTop / rowHeight) - 2);
356
+ const lastRow = Math.min(rows, Math.ceil((metrics.scrollTop + metrics.height) / rowHeight) + 3);
357
+ const firstIndex = firstRow * columns;
358
+ const lastIndex = Math.min(props.assets.length, lastRow * columns);
359
+ const visible = props.assets.slice(firstIndex, lastIndex);
360
+
361
+ useEffect(() => {
362
+ const element = viewportRef.current;
363
+ if (!element || typeof ResizeObserver === 'undefined') return;
364
+ const observer = new ResizeObserver(([entry]) => {
365
+ if (!entry) return;
366
+ setMetrics((current) => ({
367
+ ...current,
368
+ width: entry.contentRect.width,
369
+ height: entry.contentRect.height,
370
+ }));
371
+ });
372
+ observer.observe(element);
373
+ return () => observer.disconnect();
374
+ }, []);
375
+
376
+ useEffect(() => () => window.clearTimeout(typeahead.current.timer), []);
88
377
 
89
- export function OnlineAssetBrowser() {
378
+ const { hasMore, loading, onLoadMore } = props;
379
+ useEffect(() => {
380
+ if (hasMore && !loading && rows > 0 && lastRow >= rows - 2) onLoadMore();
381
+ }, [hasMore, lastRow, loading, onLoadMore, rows]);
382
+
383
+ const selectedIndex = props.assets.findIndex((asset) => asset.id === props.selectedId);
384
+ const moveSelection = (nextIndex: number) => {
385
+ const asset = props.assets[Math.max(0, Math.min(props.assets.length - 1, nextIndex))];
386
+ if (!asset) return;
387
+ props.onSelect(asset);
388
+ const row = Math.floor(Math.max(0, nextIndex) / columns);
389
+ if (viewportRef.current) viewportRef.current.scrollTop = row * rowHeight;
390
+ };
391
+ const onGridKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
392
+ const pageSize = Math.max(columns, Math.floor(metrics.height / rowHeight) * columns);
393
+ switch (event.key) {
394
+ case 'Enter':
395
+ if (selectedIndex >= 0) props.onOpen(props.assets[selectedIndex]!);
396
+ else return;
397
+ break;
398
+ case 'ArrowRight':
399
+ moveSelection(selectedIndex + 1);
400
+ break;
401
+ case 'ArrowLeft':
402
+ moveSelection(selectedIndex - 1);
403
+ break;
404
+ case 'ArrowDown':
405
+ moveSelection(selectedIndex + columns);
406
+ break;
407
+ case 'ArrowUp':
408
+ moveSelection(selectedIndex - columns);
409
+ break;
410
+ case 'Home':
411
+ moveSelection(0);
412
+ break;
413
+ case 'End':
414
+ moveSelection(props.assets.length - 1);
415
+ break;
416
+ case 'PageDown':
417
+ moveSelection(selectedIndex + pageSize);
418
+ break;
419
+ case 'PageUp':
420
+ moveSelection(selectedIndex - pageSize);
421
+ break;
422
+ default: {
423
+ if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
424
+ window.clearTimeout(typeahead.current.timer);
425
+ typeahead.current.value += event.key.toLowerCase();
426
+ typeahead.current.timer = window.setTimeout(() => {
427
+ typeahead.current.value = '';
428
+ }, 700);
429
+ const match = props.assets.findIndex((asset) =>
430
+ asset.name.toLowerCase().startsWith(typeahead.current.value),
431
+ );
432
+ if (match < 0) return;
433
+ moveSelection(match);
434
+ }
435
+ }
436
+ event.preventDefault();
437
+ };
438
+
439
+ return (
440
+ <div
441
+ ref={viewportRef}
442
+ className="vgai-asset-library__grid"
443
+ role="listbox"
444
+ aria-label="Asset results"
445
+ aria-busy={props.loading}
446
+ tabIndex={0}
447
+ onScroll={(event) =>
448
+ setMetrics((current) => ({ ...current, scrollTop: event.currentTarget.scrollTop }))
449
+ }
450
+ onKeyDown={onGridKeyDown}
451
+ >
452
+ <div
453
+ style={{ position: 'relative', height: Math.max(metrics.height, rows * rowHeight + gap) }}
454
+ >
455
+ {visible.map((asset, localIndex) => {
456
+ const index = firstIndex + localIndex;
457
+ const column = index % columns;
458
+ const row = Math.floor(index / columns);
459
+ const downloaded = getDownloadedAssetPath(asset.source, asset.id) != null;
460
+ return (
461
+ <Button
462
+ key={onlineAssetKey(asset)}
463
+ type="button"
464
+ role="option"
465
+ aria-selected={props.selectedId === asset.id}
466
+ aria-label={`${asset.name}, ${asset.type}, ${asset.source}`}
467
+ className="vgai-asset-card"
468
+ style={{ left: gap + column * (cardWidth + gap), top: gap + row * rowHeight }}
469
+ onClick={() => props.onSelect(asset)}
470
+ onDoubleClick={() => props.onOpen(asset)}
471
+ >
472
+ <AssetCardThumbnail asset={asset} />
473
+ <span className="vgai-asset-card__name" data-testid="online-asset-name">
474
+ {asset.name}
475
+ </span>
476
+ <span className="vgai-asset-card__meta">
477
+ <span>{asset.type}</span>
478
+ <span>·</span>
479
+ <span>{asset.source === 'local' ? 'Catalog' : asset.source}</span>
480
+ </span>
481
+ <span className="vgai-asset-card__badges">
482
+ {downloaded && <span className="vgai-asset-badge">In project</span>}
483
+ {!downloaded && asset.cloudHosted && (
484
+ <span className="vgai-asset-badge">
485
+ <EditorIcon icon={faCloud} /> Cloud
486
+ </span>
487
+ )}
488
+ {(asset.variantCount ?? 0) > 1 && (
489
+ <span className="vgai-asset-badge">{asset.variantCount} variants</span>
490
+ )}
491
+ {props.isFavorite(asset) && <span className="vgai-asset-badge">Favorite</span>}
492
+ </span>
493
+ </Button>
494
+ );
495
+ })}
496
+ {props.loading && (
497
+ <div role="status" style={{ position: 'absolute', left: 16, top: rows * rowHeight + 12 }}>
498
+ <EditorIcon icon={faSpinner} spin /> Loading assets…
499
+ </div>
500
+ )}
501
+ </div>
502
+ </div>
503
+ );
504
+ }
505
+
506
+ interface OnlineAssetBrowserProps {
507
+ layout?: 'workspace' | 'navigator';
508
+ }
509
+
510
+ function VirtualAssetList(props: {
511
+ assets: readonly OnlineAsset[];
512
+ selectedId: string | null;
513
+ loading: boolean;
514
+ hasMore: boolean;
515
+ onSelect(asset: OnlineAsset): void;
516
+ onOpen(asset: OnlineAsset): void;
517
+ onLoadMore(): void;
518
+ }) {
519
+ const viewportRef = useRef<HTMLDivElement>(null);
520
+ const [metrics, setMetrics] = useState({ height: 320, scrollTop: 0 });
521
+ const rowHeight = 52;
522
+ const firstIndex = Math.max(0, Math.floor(metrics.scrollTop / rowHeight) - 3);
523
+ const lastIndex = Math.min(
524
+ props.assets.length,
525
+ Math.ceil((metrics.scrollTop + metrics.height) / rowHeight) + 4,
526
+ );
527
+ const visible = props.assets.slice(firstIndex, lastIndex);
528
+ const selectedIndex = props.assets.findIndex((asset) => asset.id === props.selectedId);
529
+
530
+ useEffect(() => {
531
+ const element = viewportRef.current;
532
+ if (!element || typeof ResizeObserver === 'undefined') return;
533
+ const observer = new ResizeObserver(([entry]) => {
534
+ if (entry) setMetrics((current) => ({ ...current, height: entry.contentRect.height }));
535
+ });
536
+ observer.observe(element);
537
+ return () => observer.disconnect();
538
+ }, []);
539
+
540
+ const { hasMore, loading, onLoadMore } = props;
541
+ useEffect(() => {
542
+ if (hasMore && !loading && lastIndex >= props.assets.length - 4) onLoadMore();
543
+ }, [hasMore, lastIndex, loading, onLoadMore, props.assets.length]);
544
+
545
+ const moveSelection = (index: number) => {
546
+ const nextIndex = Math.max(0, Math.min(props.assets.length - 1, index));
547
+ const asset = props.assets[nextIndex];
548
+ if (!asset) return;
549
+ props.onSelect(asset);
550
+ if (viewportRef.current) viewportRef.current.scrollTop = nextIndex * rowHeight;
551
+ };
552
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
553
+ switch (event.key) {
554
+ case 'ArrowDown':
555
+ moveSelection(selectedIndex + 1);
556
+ break;
557
+ case 'ArrowUp':
558
+ moveSelection(selectedIndex < 0 ? 0 : selectedIndex - 1);
559
+ break;
560
+ case 'Home':
561
+ moveSelection(0);
562
+ break;
563
+ case 'End':
564
+ moveSelection(props.assets.length - 1);
565
+ break;
566
+ case 'PageDown':
567
+ moveSelection(selectedIndex + Math.max(1, Math.floor(metrics.height / rowHeight)));
568
+ break;
569
+ case 'PageUp':
570
+ moveSelection(selectedIndex - Math.max(1, Math.floor(metrics.height / rowHeight)));
571
+ break;
572
+ default:
573
+ return;
574
+ }
575
+ event.preventDefault();
576
+ };
577
+
578
+ return (
579
+ <div
580
+ ref={viewportRef}
581
+ className="vgai-asset-library-navigator__results"
582
+ role="listbox"
583
+ aria-label="Asset results"
584
+ aria-busy={props.loading}
585
+ tabIndex={0}
586
+ onKeyDown={onKeyDown}
587
+ onScroll={(event) =>
588
+ setMetrics((current) => ({ ...current, scrollTop: event.currentTarget.scrollTop }))
589
+ }
590
+ >
591
+ <div
592
+ className="vgai-asset-library-navigator__result-space"
593
+ style={{ height: Math.max(metrics.height, props.assets.length * rowHeight) }}
594
+ >
595
+ {visible.map((asset, localIndex) => {
596
+ const index = firstIndex + localIndex;
597
+ return (
598
+ <Button
599
+ key={onlineAssetKey(asset)}
600
+ type="button"
601
+ role="option"
602
+ aria-selected={props.selectedId === asset.id}
603
+ aria-label={`${asset.name}, ${asset.type}, ${asset.source}`}
604
+ className="vgai-asset-library-row"
605
+ style={{ top: index * rowHeight }}
606
+ onClick={() => props.onSelect(asset)}
607
+ onDoubleClick={() => props.onOpen(asset)}
608
+ onKeyDown={(event) => {
609
+ if (event.key !== 'Enter') return;
610
+ props.onOpen(asset);
611
+ event.preventDefault();
612
+ }}
613
+ >
614
+ <AssetCardThumbnail asset={asset} />
615
+ <span className="vgai-asset-library-row__copy">
616
+ <span className="vgai-asset-library-row__name" data-testid="online-asset-name">
617
+ {asset.name}
618
+ </span>
619
+ <span className="vgai-asset-library-row__meta">
620
+ {asset.type} · {asset.source === 'local' ? 'Catalog' : asset.source}
621
+ </span>
622
+ </span>
623
+ </Button>
624
+ );
625
+ })}
626
+ {props.loading && (
627
+ <span className="vgai-asset-library-navigator__loading" role="status">
628
+ <EditorIcon icon={faSpinner} spin /> Loading assets…
629
+ </span>
630
+ )}
631
+ </div>
632
+ </div>
633
+ );
634
+ }
635
+
636
+ function NavigatorAssetLibrary(props: {
637
+ assets: readonly OnlineAsset[];
638
+ selectedId: string | null;
639
+ source: Source;
640
+ assetType: string;
641
+ sort: Sort;
642
+ searchQuery: string;
643
+ total: number;
644
+ loading: boolean;
645
+ error: string | null;
646
+ hasMore: boolean;
647
+ filtersOpen: boolean;
648
+ filters: React.ReactNode;
649
+ canSaveSearch: boolean;
650
+ onSource(source: Source): void;
651
+ onType(type: string): void;
652
+ onSort(sort: Sort): void;
653
+ onSearch(query: string): void;
654
+ onSelect(asset: OnlineAsset): void;
655
+ onOpen(asset: OnlineAsset): void;
656
+ onLoadMore(): void;
657
+ onRetry(): void;
658
+ onSaveSearch(): void;
659
+ onToggleFilters(): void;
660
+ }) {
661
+ const sourceConfig = SOURCES.find((entry) => entry.value === props.source)!;
662
+ return (
663
+ <div className="vgai-asset-library-navigator">
664
+ <div className="vgai-asset-library-navigator__controls">
665
+ <div className="vgai-asset-library-navigator__search-row">
666
+ <TextInput
667
+ className="vgai-input"
668
+ aria-label="Search asset library"
669
+ placeholder="Search Library"
670
+ value={props.searchQuery}
671
+ onChange={(event) => props.onSearch(event.target.value)}
672
+ />
673
+ <Button
674
+ variant="ghost"
675
+ size="compact"
676
+ aria-expanded={props.filtersOpen}
677
+ onClick={props.onToggleFilters}
678
+ >
679
+ Filters
680
+ </Button>
681
+ </div>
682
+ <div className="vgai-asset-library-navigator__filters">
683
+ <Select
684
+ className="vgai-select"
685
+ aria-label="Library source"
686
+ value={props.source}
687
+ onChange={(event) => props.onSource(event.target.value as Source)}
688
+ >
689
+ {SOURCES.map((entry) => (
690
+ <option key={entry.value} value={entry.value}>
691
+ {entry.label}
692
+ </option>
693
+ ))}
694
+ </Select>
695
+ <Select
696
+ className="vgai-select"
697
+ aria-label="Library asset type"
698
+ value={props.assetType}
699
+ onChange={(event) => props.onType(event.target.value)}
700
+ >
701
+ {sourceConfig.types.map((entry) => (
702
+ <option key={entry.value} value={entry.value}>
703
+ {entry.label}
704
+ </option>
705
+ ))}
706
+ </Select>
707
+ <Select
708
+ className="vgai-select"
709
+ aria-label="Sort assets"
710
+ value={props.sort}
711
+ onChange={(event) => props.onSort(event.target.value as Sort)}
712
+ >
713
+ <option value="relevance">Relevant</option>
714
+ <option value="name">Name</option>
715
+ <option value="source">Source</option>
716
+ <option value="size">Size</option>
717
+ <option value="recently-added">Newest</option>
718
+ <option value="recently-used">Recent</option>
719
+ </Select>
720
+ </div>
721
+ <span className="vgai-asset-library-navigator__status" role="status" aria-live="polite">
722
+ {props.loading && props.assets.length === 0
723
+ ? 'Loading…'
724
+ : `${props.assets.length.toLocaleString()} of ${props.total.toLocaleString()}`}
725
+ </span>
726
+ </div>
727
+ {props.filtersOpen && (
728
+ <div className="vgai-asset-library-navigator__filter-drawer">
729
+ <div className="vgai-asset-library-navigator__filter-heading">
730
+ <span>Filters and saved views</span>
731
+ <Button variant="ghost" size="compact" onClick={props.onToggleFilters}>
732
+ Done
733
+ </Button>
734
+ </div>
735
+ {props.canSaveSearch && (
736
+ <Button
737
+ className="vgai-asset-library-navigator__save-search"
738
+ variant="ghost"
739
+ size="compact"
740
+ onClick={props.onSaveSearch}
741
+ >
742
+ Save current search
743
+ </Button>
744
+ )}
745
+ {props.filters}
746
+ </div>
747
+ )}
748
+ {props.error ? (
749
+ <StateSurface
750
+ compact
751
+ title="Library unavailable"
752
+ description={props.error}
753
+ action={
754
+ <Button variant="ghost" size="compact" onClick={props.onRetry}>
755
+ Retry
756
+ </Button>
757
+ }
758
+ />
759
+ ) : props.assets.length === 0 && !props.loading ? (
760
+ <StateSurface
761
+ compact
762
+ title="No matching assets"
763
+ description="Try another search or type."
764
+ />
765
+ ) : (
766
+ <VirtualAssetList
767
+ assets={props.assets}
768
+ selectedId={props.selectedId}
769
+ loading={props.loading}
770
+ hasMore={props.hasMore}
771
+ onSelect={props.onSelect}
772
+ onOpen={props.onOpen}
773
+ onLoadMore={props.onLoadMore}
774
+ />
775
+ )}
776
+ </div>
777
+ );
778
+ }
779
+
780
+ export function OnlineAssetBrowser({ layout = 'workspace' }: OnlineAssetBrowserProps = {}) {
90
781
  const store = useEditorStore();
91
- const [source, setSource] = useState<Source>('local');
92
- const [assetType, setAssetType] = useState('all');
782
+ const initial = useMemo(readState, []);
783
+ const [source, setSource] = useState<Source>(initial.source);
784
+ const [assetType, setAssetType] = useState(initial.assetType);
93
785
  const [category, setCategory] = useState('');
94
- const [searchQuery, setSearchQuery] = useState('');
786
+ const [categoryQuery, setCategoryQuery] = useState('');
787
+ const [searchQuery, setSearchQuery] = useState(initial.query);
788
+ const [density, setDensity] = useState<Density>(initial.density);
789
+ const [sort, setSort] = useState<Sort>(initial.sort);
790
+ const [favorites, setFavorites] = useState(new Set(initial.favorites));
791
+ const [savedSearches, setSavedSearches] = useState(initial.savedSearches);
792
+ const [recent, setRecent] = useState(initial.recent);
793
+ const [sourceWidth, setSourceWidth] = useState(initial.sourceWidth);
794
+ const [detailsWidth, setDetailsWidth] = useState(initial.detailsWidth);
795
+ const [sourceCollapsed, setSourceCollapsed] = useState(initial.sourceCollapsed);
796
+ const [detailsCollapsed, setDetailsCollapsed] = useState(initial.detailsCollapsed);
797
+ const [navigatorFiltersOpen, setNavigatorFiltersOpen] = useState(false);
798
+ const [favoritesOnly, setFavoritesOnly] = useState(false);
799
+ const [runtimeReadyOnly, setRuntimeReadyOnly] = useState(false);
800
+ const [animatedOnly, setAnimatedOnly] = useState(false);
801
+ const [downloaded, setDownloaded] = useState<'all' | 'downloaded' | 'not-downloaded'>('all');
802
+ const [license, setLicense] = useState('');
803
+ const [delivery, setDelivery] = useState('');
804
+ const [health, setHealth] = useState('');
95
805
  const [assets, setAssets] = useState<OnlineAsset[]>([]);
96
806
  const [total, setTotal] = useState(0);
97
807
  const [loading, setLoading] = useState(false);
808
+ const [error, setError] = useState<string | null>(null);
98
809
  const [offset, setOffset] = useState(0);
99
-
100
- // Track which asset is selected (highlighted in grid)
101
810
  const [selectedId, setSelectedId] = useState<string | null>(null);
811
+ const [categories, setCategories] = useState<string[]>([]);
812
+ const requestId = useRef(0);
813
+
814
+ useEffect(() => {
815
+ const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
816
+ localStorage.setItem(
817
+ assetLibraryStateKey(projectId),
818
+ JSON.stringify({
819
+ source,
820
+ assetType,
821
+ query: searchQuery,
822
+ density,
823
+ sort,
824
+ favorites: [...favorites],
825
+ savedSearches,
826
+ recent,
827
+ sourceWidth,
828
+ detailsWidth,
829
+ sourceCollapsed,
830
+ detailsCollapsed,
831
+ } satisfies PersistedLibraryState),
832
+ );
833
+ }, [
834
+ assetType,
835
+ density,
836
+ detailsCollapsed,
837
+ detailsWidth,
838
+ favorites,
839
+ recent,
840
+ savedSearches,
841
+ searchQuery,
842
+ sort,
843
+ source,
844
+ sourceCollapsed,
845
+ sourceWidth,
846
+ ]);
102
847
 
103
- // Seed cache from server on mount, re-render when assets get downloaded
104
- const [, setDownloadTick] = useState(0);
105
848
  useEffect(() => {
106
849
  loadDownloadedAssets();
107
- return onAssetDownloaded(() => setDownloadTick((t) => t + 1));
850
+ return onAssetDownloaded(() => setAssets((current) => [...current]));
108
851
  }, []);
109
852
 
110
- // Categories (Poly Haven)
111
- const [categories, setCategories] = useState<string[]>([]);
112
-
113
- // Debounce timer
114
- const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
115
-
116
- // Current source config
117
- const sourceConfig = SOURCES.find((s) => s.value === source)!;
118
-
119
- // Load categories when source/type changes (Poly Haven only)
120
853
  useEffect(() => {
121
854
  if (source === 'polyhaven' || source === 'local') {
122
855
  getAssetLibraryCategories(assetType, source)
123
856
  .then(setCategories)
124
857
  .catch(() => setCategories([]));
125
- } else {
126
- setCategories([]);
127
- }
858
+ } else setCategories([]);
128
859
  }, [source, assetType]);
129
860
 
130
- // Search function
131
861
  const doSearch = useCallback(
132
- async (q: string, off: number, append: boolean) => {
862
+ async (nextOffset: number, append: boolean) => {
863
+ const currentRequest = ++requestId.current;
133
864
  setLoading(true);
865
+ setError(null);
866
+ const params: Parameters<typeof searchOnlineAssets>[0] = {
867
+ source,
868
+ type: assetType,
869
+ offset: nextOffset,
870
+ limit: PAGE_SIZE,
871
+ ...(searchQuery ? { q: searchQuery } : {}),
872
+ ...(category ? { category } : {}),
873
+ };
134
874
  try {
135
- const params: Parameters<typeof searchOnlineAssets>[0] = {
136
- source,
137
- type: assetType,
138
- offset: off,
139
- limit: PAGE_SIZE,
140
- };
141
- if (q) params.q = q;
142
- if (category) params.category = category;
143
875
  const result = await searchOnlineAssets(params);
144
- setAssets((prev) => (append ? [...prev, ...result.assets] : result.assets));
876
+ if (currentRequest !== requestId.current) return;
877
+ setAssets((current) => (append ? [...current, ...result.assets] : result.assets));
145
878
  setTotal(result.total);
146
879
  } catch {
147
880
  if (!append) setAssets([]);
881
+ setError(
882
+ 'The Library could not load this page. Check the SSD or network delivery and retry.',
883
+ );
148
884
  } finally {
149
- setLoading(false);
885
+ if (currentRequest === requestId.current) setLoading(false);
150
886
  }
151
887
  },
152
- [source, assetType, category],
888
+ [assetType, category, searchQuery, source],
153
889
  );
154
890
 
155
- // Trigger search on filter changes
156
891
  useEffect(() => {
157
- setOffset(0);
158
- setSelectedId(null);
159
- if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
160
- searchTimerRef.current = setTimeout(() => {
161
- doSearch(searchQuery, 0, false);
162
- }, 300);
163
- return () => {
164
- if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
165
- };
166
- }, [source, assetType, category, searchQuery, doSearch]);
892
+ const timer = window.setTimeout(() => {
893
+ setOffset(0);
894
+ setSelectedId(null);
895
+ void doSearch(0, false);
896
+ }, 250);
897
+ return () => window.clearTimeout(timer);
898
+ }, [doSearch]);
167
899
 
168
- // Load more
169
900
  const loadMore = useCallback(() => {
170
- const newOffset = offset + PAGE_SIZE;
171
- setOffset(newOffset);
172
- doSearch(searchQuery, newOffset, true);
173
- }, [offset, searchQuery, doSearch]);
174
-
175
- // Single-click selects, double-click opens detail in right panel
176
- const onClickAsset = useCallback((asset: OnlineAsset) => {
177
- setSelectedId(asset.id);
178
- }, []);
901
+ if (loading || assets.length >= total) return;
902
+ const next = offset + PAGE_SIZE;
903
+ setOffset(next);
904
+ void doSearch(next, true);
905
+ }, [assets.length, doSearch, loading, offset, total]);
179
906
 
180
- const onDoubleClickAsset = useCallback(
181
- (asset: OnlineAsset) => {
182
- store.openOnlineAssetTab({
183
- source: asset.source,
184
- id: asset.id,
185
- name: asset.name,
186
- thumbnailUrl: asset.thumbnailUrl,
187
- type: asset.type,
188
- tags: asset.tags,
189
- ...(asset.license ? { license: asset.license } : {}),
190
- ...(asset.author ? { author: asset.author } : {}),
191
- ...(asset.sourceUrl ? { sourceUrl: asset.sourceUrl } : {}),
192
- });
193
- },
194
- [store],
907
+ const downloadedAsset = useCallback(
908
+ (asset: OnlineAsset) => getDownloadedAssetPath(asset.source, asset.id) != null,
909
+ [],
195
910
  );
196
-
197
- // Drag start — for model assets, set online payload (viewport resolves file options on drop)
198
- const onDragStart = useCallback((e: React.DragEvent, asset: OnlineAsset) => {
199
- if (asset.type !== 'model') return;
200
- const payload = {
201
- path: '',
202
- kind: 'model',
203
- name: asset.name,
204
- online: {
205
- source: asset.source,
206
- id: asset.id,
207
- name: asset.name,
208
- },
911
+ const filteredAssets = useMemo(
912
+ () =>
913
+ queryAssetLibrary(
914
+ assets,
915
+ {
916
+ licenses: new Set(license ? [license] : []),
917
+ deliveryKinds: new Set(delivery ? [delivery] : []),
918
+ health: new Set(health ? [health] : []),
919
+ animatedOnly,
920
+ runtimeReadyOnly,
921
+ downloaded,
922
+ favoritesOnly,
923
+ },
924
+ sort,
925
+ { favorites, recent, isDownloaded: downloadedAsset },
926
+ ),
927
+ [
928
+ animatedOnly,
929
+ assets,
930
+ delivery,
931
+ downloaded,
932
+ downloadedAsset,
933
+ favorites,
934
+ favoritesOnly,
935
+ health,
936
+ license,
937
+ recent,
938
+ runtimeReadyOnly,
939
+ sort,
940
+ ],
941
+ );
942
+ const facetCounts = useMemo(() => {
943
+ const count = (values: readonly string[]) => {
944
+ const result = new Map<string, number>();
945
+ for (const value of values) result.set(value, (result.get(value) ?? 0) + 1);
946
+ return result;
209
947
  };
210
- e.dataTransfer.setData('text/plain', asset.name);
211
- e.dataTransfer.setData('application/x-editor-asset', JSON.stringify(payload));
212
- }, []);
948
+ return {
949
+ licenses: count(assets.map((asset) => asset.license ?? 'Unknown')),
950
+ deliveries: count(assets.flatMap((asset) => asset.deliveryKinds ?? [])),
951
+ health: count(assets.map((asset) => asset.capabilities?.health ?? 'unsupported')),
952
+ };
953
+ }, [assets]);
954
+ const selected = assets.find((asset) => asset.id === selectedId) ?? null;
213
955
 
214
- // Source change resets type
215
- const onSourceChange = useCallback((newSource: Source) => {
216
- setSource(newSource);
217
- const newConfig = SOURCES.find((s) => s.value === newSource)!;
218
- setAssetType(newConfig.types[0]!.value);
956
+ const onSelect = (asset: OnlineAsset) => setSelectedId(asset.id);
957
+ const onOpen = (asset: OnlineAsset) => {
958
+ openOnlineAssetDocument(store, toOnlineInfo(asset));
959
+ const key = onlineAssetKey(asset);
960
+ setRecent((current) => [key, ...current.filter((item) => item !== key)].slice(0, 20));
961
+ };
962
+ const onSource = (nextSource: Source) => {
963
+ setSource(nextSource);
964
+ setAssetType(SOURCES.find((entry) => entry.value === nextSource)!.types[0]!.value);
219
965
  setCategory('');
220
- setSearchQuery('');
221
- }, []);
222
-
223
- const hasMore = assets.length < total;
966
+ };
967
+ const toggleFavorite = (asset: OnlineAsset) => {
968
+ const key = onlineAssetKey(asset);
969
+ setFavorites((current) => {
970
+ const next = new Set(current);
971
+ if (next.has(key)) next.delete(key);
972
+ else next.add(key);
973
+ return next;
974
+ });
975
+ };
976
+ const sourcePane = (
977
+ <SourcePane
978
+ source={source}
979
+ assetType={assetType}
980
+ category={category}
981
+ categoryQuery={categoryQuery}
982
+ categories={categories}
983
+ savedSearches={savedSearches}
984
+ favoritesOnly={favoritesOnly}
985
+ animatedOnly={animatedOnly}
986
+ runtimeReadyOnly={runtimeReadyOnly}
987
+ downloaded={downloaded}
988
+ license={license}
989
+ delivery={delivery}
990
+ health={health}
991
+ licenseCounts={facetCounts.licenses}
992
+ deliveryCounts={facetCounts.deliveries}
993
+ healthCounts={facetCounts.health}
994
+ onSource={onSource}
995
+ onType={(value) => {
996
+ setAssetType(value);
997
+ setCategory('');
998
+ }}
999
+ onCategory={setCategory}
1000
+ onCategoryQuery={setCategoryQuery}
1001
+ onSavedSearch={setSearchQuery}
1002
+ onFavoritesOnly={setFavoritesOnly}
1003
+ onAnimatedOnly={setAnimatedOnly}
1004
+ onRuntimeReadyOnly={setRuntimeReadyOnly}
1005
+ onDownloaded={setDownloaded}
1006
+ onLicense={setLicense}
1007
+ onDelivery={setDelivery}
1008
+ onHealth={setHealth}
1009
+ />
1010
+ );
224
1011
 
225
- return (
226
- <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
227
- {/* Filter bar */}
228
- <div
229
- style={{
230
- padding: '6px 8px',
231
- borderBottom: '1px solid #333',
232
- display: 'flex',
233
- flexWrap: 'wrap',
234
- gap: 6,
235
- alignItems: 'center',
236
- flexShrink: 0,
237
- }}
238
- >
239
- <select
240
- value={source}
241
- onChange={(e) => onSourceChange(e.target.value as Source)}
242
- style={selectStyle}
243
- >
244
- {SOURCES.map((s) => (
245
- <option key={s.value} value={s.value}>
246
- {s.label}
247
- </option>
248
- ))}
249
- </select>
250
-
251
- <select
252
- value={assetType}
253
- onChange={(e) => {
254
- setAssetType(e.target.value);
255
- setCategory('');
256
- }}
257
- style={selectStyle}
258
- >
259
- {sourceConfig.types.map((t) => (
260
- <option key={t.value} value={t.value}>
261
- {t.label}
262
- </option>
263
- ))}
264
- </select>
1012
+ const navigatorLayout = (
1013
+ <NavigatorAssetLibrary
1014
+ assets={filteredAssets}
1015
+ selectedId={selectedId}
1016
+ source={source}
1017
+ assetType={assetType}
1018
+ sort={sort}
1019
+ searchQuery={searchQuery}
1020
+ total={total}
1021
+ loading={loading}
1022
+ error={error}
1023
+ hasMore={assets.length < total}
1024
+ filtersOpen={navigatorFiltersOpen}
1025
+ filters={sourcePane}
1026
+ canSaveSearch={Boolean(searchQuery && !savedSearches.includes(searchQuery))}
1027
+ onSource={onSource}
1028
+ onType={(value) => {
1029
+ setAssetType(value);
1030
+ setCategory('');
1031
+ }}
1032
+ onSort={setSort}
1033
+ onSearch={setSearchQuery}
1034
+ onSelect={onSelect}
1035
+ onOpen={onOpen}
1036
+ onLoadMore={loadMore}
1037
+ onSaveSearch={() => setSavedSearches((current) => [...current, searchQuery])}
1038
+ onToggleFilters={() => setNavigatorFiltersOpen((value) => !value)}
1039
+ onRetry={() => {
1040
+ setOffset(0);
1041
+ void doSearch(0, false);
1042
+ }}
1043
+ />
1044
+ );
265
1045
 
266
- {categories.length > 0 && (
267
- <select
268
- value={category}
269
- onChange={(e) => setCategory(e.target.value)}
270
- style={selectStyle}
1046
+ const workspaceLayout = (
1047
+ <div
1048
+ className="vgai-asset-library"
1049
+ data-density={density}
1050
+ data-source-collapsed={sourceCollapsed}
1051
+ data-details-collapsed={detailsCollapsed}
1052
+ style={
1053
+ {
1054
+ '--asset-source-width': `${sourceCollapsed ? 0 : sourceWidth}px`,
1055
+ '--asset-details-width': `${detailsCollapsed ? 0 : detailsWidth}px`,
1056
+ } as React.CSSProperties
1057
+ }
1058
+ >
1059
+ {sourcePane}
1060
+ <main className="vgai-asset-library__results">
1061
+ <div className="vgai-asset-library__toolbar">
1062
+ <Button
1063
+ className="vgai-asset-library__filters-toggle"
1064
+ variant="ghost"
1065
+ size="comfortable"
1066
+ onClick={() => setSourceCollapsed((value) => !value)}
271
1067
  >
272
- <option value="">All Categories</option>
273
- {categories.map((c) => (
274
- <option key={c} value={c}>
275
- {c}
276
- </option>
277
- ))}
278
- </select>
279
- )}
280
-
281
- <input
282
- type="text"
283
- placeholder="Search..."
284
- value={searchQuery}
285
- onChange={(e) => setSearchQuery(e.target.value)}
286
- style={inputStyle}
287
- />
288
- </div>
289
-
290
- {/* Thumbnail grid */}
291
- <div style={{ flex: 1, overflowY: 'auto', scrollbarWidth: 'none', minHeight: 0 }}>
292
- <div
293
- style={{
294
- padding: 8,
295
- display: 'grid',
296
- gridTemplateColumns: 'repeat(auto-fill, 88px)',
297
- gap: 6,
298
- alignContent: 'start',
299
- }}
300
- >
301
- {assets.map((asset) => {
302
- const isSelected = selectedId === asset.id;
303
- const isDownloaded = getDownloadedAssetPath(asset.source, asset.name) != null;
304
- return (
305
- <div
306
- key={`${asset.source}-${asset.id}`}
307
- draggable={asset.type === 'model'}
308
- onDragStart={(e) => onDragStart(e, asset)}
309
- onClick={() => onClickAsset(asset)}
310
- onDoubleClick={() => onDoubleClickAsset(asset)}
311
- title={asset.name}
312
- style={{
313
- width: 88,
314
- padding: 4,
315
- borderRadius: 4,
316
- cursor: asset.type === 'model' ? 'grab' : 'pointer',
317
- background: isSelected ? '#579EFF22' : 'transparent',
318
- border: isSelected ? '1px solid #579EFF' : '1px solid transparent',
319
- textAlign: 'center',
320
- userSelect: 'none',
321
- position: 'relative',
322
- }}
323
- onMouseEnter={(e) => {
324
- if (!isSelected) e.currentTarget.style.background = '#333';
325
- }}
326
- onMouseLeave={(e) => {
327
- if (!isSelected) e.currentTarget.style.background = 'transparent';
328
- }}
329
- >
330
- {/* Thumbnail */}
331
- <div
332
- style={{
333
- width: 72,
334
- height: 72,
335
- margin: '0 auto 4px',
336
- borderRadius: 3,
337
- overflow: 'hidden',
338
- background: '#111',
339
- }}
340
- >
341
- <img
342
- src={asset.thumbnailUrl}
343
- alt={asset.name}
344
- loading="lazy"
345
- style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
346
- />
347
- </div>
348
- {/* Name */}
349
- <div
350
- style={{
351
- fontSize: 10,
352
- lineHeight: '13px',
353
- overflow: 'hidden',
354
- textOverflow: 'ellipsis',
355
- whiteSpace: 'nowrap',
356
- color: '#c5c8ce',
357
- }}
358
- >
359
- {asset.name}
360
- </div>
361
- {/* Downloaded badge */}
362
- {asset.cloudHosted && !isDownloaded && (
363
- <div
364
- title="Available from VGAI Cloud"
365
- style={{
366
- position: 'absolute',
367
- top: 4,
368
- right: 4,
369
- borderRadius: 3,
370
- background: '#579EFF',
371
- color: '#fff',
372
- fontSize: 8,
373
- lineHeight: '14px',
374
- padding: '0 4px',
375
- }}
376
- >
377
- CLOUD
378
- </div>
379
- )}
380
- {isDownloaded && (
381
- <div
382
- style={{
383
- position: 'absolute',
384
- top: 4,
385
- right: 4,
386
- width: 16,
387
- height: 16,
388
- borderRadius: '50%',
389
- background: '#22c55e',
390
- display: 'flex',
391
- alignItems: 'center',
392
- justifyContent: 'center',
393
- }}
394
- >
395
- <FontAwesomeIcon icon={faCheck} style={{ fontSize: 9, color: '#fff' }} />
396
- </div>
397
- )}
398
- </div>
399
- );
400
- })}
401
-
402
- {assets.length === 0 && !loading && (
403
- <div
404
- style={{
405
- gridColumn: '1 / -1',
406
- color: '#666',
407
- fontSize: 12,
408
- padding: 24,
409
- textAlign: 'center',
1068
+ {sourceCollapsed ? 'Show filters' : 'Hide filters'}
1069
+ </Button>
1070
+ <TextInput
1071
+ className="vgai-input"
1072
+ aria-label="Search asset library"
1073
+ placeholder="Search models, packs, authors, clips…"
1074
+ value={searchQuery}
1075
+ onChange={(event) => setSearchQuery(event.target.value)}
1076
+ style={{ flex: '1 1 220px' }}
1077
+ />
1078
+ <Select
1079
+ className="vgai-select vgai-asset-library__sort"
1080
+ aria-label="Sort assets"
1081
+ value={sort}
1082
+ onChange={(event) => setSort(event.target.value as Sort)}
1083
+ >
1084
+ <option value="relevance">Relevance</option>
1085
+ <option value="name">Name</option>
1086
+ <option value="source">Source</option>
1087
+ <option value="size">Size</option>
1088
+ <option value="recently-added">Recently added</option>
1089
+ <option value="recently-used">Recently used</option>
1090
+ </Select>
1091
+ <Select
1092
+ className="vgai-select vgai-asset-library__density"
1093
+ aria-label="Card density"
1094
+ value={density}
1095
+ onChange={(event) => setDensity(event.target.value as Density)}
1096
+ >
1097
+ <option value="compact">Compact</option>
1098
+ <option value="standard">Standard</option>
1099
+ <option value="large">Large</option>
1100
+ </Select>
1101
+ <Button
1102
+ className="vgai-asset-library__save-search"
1103
+ variant="ghost"
1104
+ size="comfortable"
1105
+ disabled={!searchQuery || savedSearches.includes(searchQuery)}
1106
+ onClick={() => setSavedSearches((current) => [...current, searchQuery])}
1107
+ >
1108
+ Save search
1109
+ </Button>
1110
+ <Button
1111
+ className="vgai-asset-library__details-toggle"
1112
+ variant="ghost"
1113
+ size="comfortable"
1114
+ onClick={() => setDetailsCollapsed((value) => !value)}
1115
+ >
1116
+ {detailsCollapsed ? 'Show details' : 'Hide details'}
1117
+ </Button>
1118
+ <span
1119
+ className="vgai-asset-library__status"
1120
+ role="status"
1121
+ aria-live="polite"
1122
+ style={{ fontSize: 11, color: text[3] }}
1123
+ >
1124
+ {filteredAssets.length} loaded · {total.toLocaleString()} total
1125
+ </span>
1126
+ </div>
1127
+ {error ? (
1128
+ <div role="alert" style={{ padding: 32, color: text[2], textAlign: 'center' }}>
1129
+ <div>{error}</div>
1130
+ <Button
1131
+ variant="ghost"
1132
+ size="comfortable"
1133
+ onClick={() => {
1134
+ setOffset(0);
1135
+ void doSearch(0, false);
410
1136
  }}
411
1137
  >
412
- {searchQuery ? 'No matching assets found.' : 'No assets found.'}
413
- </div>
414
- )}
415
- </div>
416
-
417
- {/* Loading indicator */}
418
- {loading && (
419
- <div style={{ textAlign: 'center', padding: 16, color: '#888', fontSize: 12 }}>
420
- <FontAwesomeIcon icon={faSpinner} spin style={{ marginRight: 6 }} />
421
- Loading...
1138
+ Retry
1139
+ </Button>
1140
+ </div>
1141
+ ) : filteredAssets.length === 0 && !loading ? (
1142
+ <div role="status" style={{ padding: 32, color: text[3], textAlign: 'center' }}>
1143
+ No assets match the current search and filters.
422
1144
  </div>
1145
+ ) : (
1146
+ <VirtualAssetGrid
1147
+ assets={filteredAssets}
1148
+ selectedId={selectedId}
1149
+ density={density}
1150
+ loading={loading}
1151
+ hasMore={assets.length < total}
1152
+ isFavorite={(asset) => favorites.has(onlineAssetKey(asset))}
1153
+ onSelect={onSelect}
1154
+ onOpen={onOpen}
1155
+ onLoadMore={loadMore}
1156
+ />
423
1157
  )}
424
-
425
- {/* Load more */}
426
- {!loading && hasMore && (
427
- <div style={{ textAlign: 'center', padding: 12 }}>
428
- <button
429
- type="button"
430
- onClick={loadMore}
1158
+ </main>
1159
+ <aside className="vgai-asset-library__details" aria-label="Selected asset details">
1160
+ <div className="vgai-asset-pane-size">
1161
+ <label>
1162
+ Detail width
1163
+ <TextInput
1164
+ aria-label="Detail pane width"
1165
+ type="range"
1166
+ min="280"
1167
+ max="640"
1168
+ value={detailsWidth}
1169
+ onChange={(event) => setDetailsWidth(Number(event.target.value))}
1170
+ />
1171
+ </label>
1172
+ </div>
1173
+ {selected ? (
1174
+ <>
1175
+ <div
431
1176
  style={{
432
- background: '#333',
433
- border: '1px solid #444',
434
- borderRadius: 4,
435
- color: '#c5c8ce',
436
- fontSize: 11,
437
- padding: '4px 16px',
438
- cursor: 'pointer',
1177
+ display: 'flex',
1178
+ gap: 8,
1179
+ padding: '8px 10px',
1180
+ borderBottom: '1px solid var(--vgai-border-1)',
439
1181
  }}
440
1182
  >
441
- Load more...
442
- </button>
1183
+ <Button variant="solid" size="comfortable" onClick={() => onOpen(selected)}>
1184
+ Open in Asset Editor
1185
+ </Button>
1186
+ <Button variant="ghost" size="comfortable" onClick={() => toggleFavorite(selected)}>
1187
+ {favorites.has(onlineAssetKey(selected)) ? 'Remove favorite' : 'Add favorite'}
1188
+ </Button>
1189
+ {getDownloadedAssetPath(selected.source, selected.id) && (
1190
+ <span className="vgai-asset-badge">
1191
+ <EditorIcon icon={faCheck} /> In project
1192
+ </span>
1193
+ )}
1194
+ </div>
1195
+ <OnlineAssetDetail
1196
+ online={toOnlineInfo(selected)}
1197
+ onResolved={() => setAssets((current) => [...current])}
1198
+ />
1199
+ </>
1200
+ ) : (
1201
+ <div role="status" style={{ padding: 24, color: text[3] }}>
1202
+ Select an asset to inspect its preview, variants, license, and project status.
443
1203
  </div>
444
1204
  )}
445
- </div>
1205
+ </aside>
1206
+ {!sourceCollapsed && (
1207
+ <TextInput
1208
+ className="vgai-asset-source-size"
1209
+ aria-label="Source pane width"
1210
+ type="range"
1211
+ min="176"
1212
+ max="360"
1213
+ value={sourceWidth}
1214
+ onChange={(event) => setSourceWidth(Number(event.target.value))}
1215
+ />
1216
+ )}
446
1217
  </div>
447
1218
  );
1219
+ return { navigator: navigatorLayout, workspace: workspaceLayout }[layout];
448
1220
  }