@vgai/editor 0.4.0-canary.20260715.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/assets/{axe-CyiTVRi6.js → axe-BJjvBeOZ.js} +1 -1
  2. package/dist/assets/{browserAll-fGZ_P1P8.js → browserAll-Dn0pgD87.js} +1 -1
  3. package/dist/assets/{create-runtime-BzSHBTYw.js → create-runtime-BF9OD-U8.js} +1 -1
  4. package/dist/assets/{game-NUAnyPkS.js → game-B346oj_f.js} +1 -1
  5. package/dist/assets/{game-uR_lv3Mp.js → game-BCNN63uV.js} +1 -1
  6. package/dist/assets/{game-HqybI_X7.js → game-BR0GBNgF.js} +1 -1
  7. package/dist/assets/{game-DQ7YHNS8.js → game-CLNm2g0y.js} +1 -1
  8. package/dist/assets/{game-DX6Obomf.js → game-DQxhCYsZ.js} +1 -1
  9. package/dist/assets/{game-DoUJj8vY.js → game-DeTGdgYt.js} +1 -1
  10. package/dist/assets/{game-loop-o2CcBMFY.js → game-loop-t5W_oqxz.js} +1 -1
  11. package/dist/assets/{game-source-C8HM7gbP.js → game-source-DzE9taML.js} +1 -1
  12. package/dist/assets/{game-ck_dNIE6.js → game-u6c2Patu.js} +1 -1
  13. package/dist/assets/{game-hIf5LD4E.js → game-umrHIqFB.js} +1 -1
  14. package/dist/assets/{index-CoSRCKPS.js → index-CgcH1RpF.js} +1 -1
  15. package/dist/assets/{index-D93bBuUl.js → index-D3tcNB2T.js} +1 -1
  16. package/dist/assets/{index-B-HA1uu3.js → index-DbQzGLxv.js} +769 -769
  17. package/dist/assets/{index-CSBAZwMx.js → index-Fc8Ezr20.js} +1 -1
  18. package/dist/assets/index-bAGhEnnO.css +1 -0
  19. package/dist/assets/{ingest-siblings-CCkkE2h7.js → ingest-siblings-CW_xbvrF.js} +1 -1
  20. package/dist/assets/{mount-manifest-B2ECssU2.js → mount-manifest-DgUscyaD.js} +1 -1
  21. package/dist/assets/{react-18-Bmj05S8J.js → react-18-C4tVCii_.js} +1 -1
  22. package/dist/assets/webworkerAll-BA4aq7xq.js +1 -0
  23. package/dist/assets/{world-documents-DXPcAEA0.js → world-documents-BSaA5YIF.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/package.json +3 -3
  26. package/src/asset-editor-context.tsx +130 -0
  27. package/src/asset-preview.ts +4 -0
  28. package/src/asset-selection.ts +9 -4
  29. package/src/asset-workflow/thumbnail-system.ts +16 -7
  30. package/src/components/AssetBrowser.tsx +15 -7
  31. package/src/components/AssetEditorShell.tsx +42 -112
  32. package/src/components/AssetImportDetails.tsx +24 -6
  33. package/src/components/AssetLibraryPanel.tsx +1 -1
  34. package/src/components/GameHierarchy.tsx +26 -1
  35. package/src/components/Inspector.tsx +38 -2
  36. package/src/components/OnlineAssetBrowser.tsx +40 -409
  37. package/src/components/WorkspaceDock.tsx +6 -12
  38. package/src/components/asset-documents.tsx +41 -12
  39. package/src/components/asset-selection-section.tsx +34 -1
  40. package/src/components/asset-viewers/ModelViewer.tsx +7 -4
  41. package/src/components/asset-viewers/OnlineAssetDetail.tsx +233 -205
  42. package/src/components/asset-workflow.css +39 -220
  43. package/src/editor-api.ts +1 -1
  44. package/template/vgai.game.json +1 -1
  45. package/dist/assets/index-bu01fqch.css +0 -1
  46. package/dist/assets/webworkerAll-BC6gMOLZ.js +0 -1
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Context contributed by the active typed Asset Editor to the existing editor
3
+ * shell. Asset documents own content; the workspace owns placement. Keeping
4
+ * this as a tiny external store lets Dockview's persistent Hierarchy,
5
+ * Inspector, bottom drawer, and status bar render the active document's
6
+ * context without an Asset Editor recreating those panes inside the center.
7
+ */
8
+
9
+ import type { ReactNode } from 'react';
10
+ import { useSyncExternalStore } from 'react';
11
+ import { themeVars } from './design-system';
12
+ import { createHmrRegistrationGroup } from './hmr-registration-group';
13
+ import { registerWorkspaceStatus } from './workspace-status-registry';
14
+ import { registerWorkspaceUtility } from './workspace-utility-registry';
15
+
16
+ export const ASSET_EDITOR_CONTROLS_UTILITY_ID = 'asset-editor-controls';
17
+
18
+ export interface AssetEditorContextValue {
19
+ readonly documentId: string;
20
+ readonly title: string;
21
+ readonly type: string;
22
+ readonly structure?: ReactNode;
23
+ readonly details?: ReactNode;
24
+ readonly status?: string;
25
+ readonly controls?: ReactNode;
26
+ }
27
+
28
+ interface ActiveAssetEditorContext extends AssetEditorContextValue {
29
+ readonly token: symbol;
30
+ }
31
+
32
+ let active: ActiveAssetEditorContext | null = null;
33
+ let version = 0;
34
+ const listeners = new Set<() => void>();
35
+
36
+ function notifyChanged(): void {
37
+ version++;
38
+ for (const listener of listeners) listener();
39
+ }
40
+
41
+ export function subscribeAssetEditorContext(listener: () => void): () => void {
42
+ listeners.add(listener);
43
+ return () => listeners.delete(listener);
44
+ }
45
+
46
+ export function assetEditorContextVersion(): number {
47
+ return version;
48
+ }
49
+
50
+ export function getActiveAssetEditorContext(): AssetEditorContextValue | null {
51
+ return active;
52
+ }
53
+
54
+ /** Activate one document contribution. The returned disposer only clears the
55
+ * exact activation it created, so an older tab unmount cannot erase a newer
56
+ * active tab's context. */
57
+ export function activateAssetEditorContext(value: AssetEditorContextValue): () => void {
58
+ const token = Symbol(value.documentId);
59
+ active = { ...value, token };
60
+ notifyChanged();
61
+ return () => {
62
+ if (active?.token !== token) return;
63
+ active = null;
64
+ notifyChanged();
65
+ };
66
+ }
67
+
68
+ export function AssetEditorControlsUtility() {
69
+ useSyncExternalStore(subscribeAssetEditorContext, assetEditorContextVersion);
70
+ const context = getActiveAssetEditorContext();
71
+ if (!context?.controls) return null;
72
+ return (
73
+ <div
74
+ data-testid="asset-editor-controls"
75
+ style={{
76
+ minHeight: 0,
77
+ padding: 8,
78
+ color: themeVars.content.primary,
79
+ pointerEvents: 'auto',
80
+ }}
81
+ >
82
+ {context.controls}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ export function AssetEditorStatus() {
88
+ useSyncExternalStore(subscribeAssetEditorContext, assetEditorContextVersion);
89
+ const context = getActiveAssetEditorContext();
90
+ return context?.status ? (
91
+ <span data-testid="status-asset-editor" style={{ color: themeVars.content.muted }}>
92
+ {context.status}
93
+ </span>
94
+ ) : null;
95
+ }
96
+
97
+ const registrationGroup = createHmrRegistrationGroup(
98
+ import.meta.hot,
99
+ 'asset-editor-shell-contributions',
100
+ );
101
+
102
+ export function ensureAssetEditorShellContributionsRegistered(): void {
103
+ registrationGroup.ensure((track) => {
104
+ track(
105
+ registerWorkspaceUtility({
106
+ id: ASSET_EDITOR_CONTROLS_UTILITY_ID,
107
+ title: 'Asset Controls',
108
+ Content: AssetEditorControlsUtility,
109
+ available: () => getActiveAssetEditorContext()?.controls != null,
110
+ visibleByDefault: false,
111
+ closeable: true,
112
+ }),
113
+ );
114
+ track(
115
+ registerWorkspaceStatus({
116
+ id: 'asset-editor-status',
117
+ align: 'left',
118
+ order: 40,
119
+ Content: AssetEditorStatus,
120
+ }),
121
+ );
122
+ });
123
+ }
124
+
125
+ ensureAssetEditorShellContributionsRegistered();
126
+
127
+ export function __resetAssetEditorContextForTest(): void {
128
+ active = null;
129
+ notifyChanged();
130
+ }
@@ -785,6 +785,10 @@ function parseGltfData(
785
785
  data,
786
786
  new URL('.', sourceUrl).href,
787
787
  (gltf) => {
788
+ // GLTFLoader keeps clips on the GLTF result rather than its scene.
789
+ // Asset Editor's model contract is Object3D-shaped, so preserve those
790
+ // clips instead of silently degrading animated assets into static ones.
791
+ gltf.scene.animations = [...gltf.animations];
788
792
  if (!finish(() => resolve(gltf.scene))) {
789
793
  disposeOwnedModel(gltf.scene);
790
794
  }
@@ -13,19 +13,19 @@
13
13
  * (mirrors `inspector-section-registry.ts`): version counter + subscribe +
14
14
  * `__resetForTest`. It deliberately does NOT touch the EditorStore's entity
15
15
  * selection — clicking an asset must never deselect/reselect scene entities
16
- * (§6.1 "selection never unexpectedly..."); instead the Inspector section
17
- * simply appears for the selected asset, and the selection is auto-cleared
16
+ * (§6.1 "selection never unexpectedly..."); instead the Inspector gives the
17
+ * asset domain precedence while retaining that entity selection, and the asset is auto-cleared
18
18
  * when the ENTITY selection changes (the user moved on — see
19
19
  * `installAssetSelectionAutoClear`).
20
20
  */
21
21
 
22
22
  import type { AssetCapabilities } from './asset-workflow/asset-capabilities';
23
- import type { AssetKind } from './editor-store';
23
+ import type { AssetKind, OnlineAssetInfo } from './editor-store';
24
24
 
25
25
  /** The §5.1 compact-selection payload — enough for the Inspector section to
26
26
  * render preview + metadata without re-deriving browser state. */
27
27
  export interface SelectedAsset {
28
- /** Serving path (`/textures/crate.png`) — doubles as the open-document key. */
28
+ /** Project serving path, or a stable `online:<source>:<id>` document key. */
29
29
  readonly path: string;
30
30
  /** Source-owned file name as authored (§8 — `crate.png`). */
31
31
  readonly name: string;
@@ -39,6 +39,11 @@ export interface SelectedAsset {
39
39
  readonly dependencies?: readonly string[];
40
40
  readonly references?: readonly { ownerPath: string; jsonPath: string }[];
41
41
  readonly healthCodes?: readonly string[];
42
+ /** Selection came from the external acquisition Library rather than the
43
+ * project's owned file tree. `online` carries the provider identity and is
44
+ * intentionally absent from project assets. */
45
+ readonly origin?: 'project' | 'library';
46
+ readonly online?: OnlineAssetInfo;
42
47
  }
43
48
 
44
49
  let _selected: SelectedAsset | null = null;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { getStorageBackend } from '../storage';
2
+ import { getStorageBackend, type StorageBackend } from '../storage';
3
3
  import type { AssetCatalog } from './asset-types';
4
4
 
5
5
  export const THUMBNAIL_PROFILE = {
@@ -39,15 +39,24 @@ export const thumbnailManifestSchema = z.object({
39
39
  export type ThumbnailManifest = z.infer<typeof thumbnailManifestSchema>;
40
40
 
41
41
  let manifestPromise: Promise<ThumbnailManifest | null> | null = null;
42
+
43
+ /** Optional project thumbnail metadata must never block live fallback previews.
44
+ * HTTP-backed projects can return the Vite HTML shell for an absent file, so
45
+ * existence and parsing are both guarded at this storage boundary. */
46
+ export async function readProjectThumbnailManifest(
47
+ backend: Pick<StorageBackend, 'read'>,
48
+ ): Promise<ThumbnailManifest | null> {
49
+ try {
50
+ return thumbnailManifestSchema.parse(JSON.parse(await backend.read('.vgai/thumbnails.json')));
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
42
56
  export function loadProjectThumbnailManifest(): Promise<ThumbnailManifest | null> {
43
57
  if (!manifestPromise) {
44
58
  const backend = getStorageBackend() as ReturnType<typeof getStorageBackend> | undefined;
45
- manifestPromise = backend
46
- ? backend.read('.vgai/thumbnails.json').then(
47
- (content) => thumbnailManifestSchema.parse(JSON.parse(content)),
48
- () => null,
49
- )
50
- : Promise.resolve(null);
59
+ manifestPromise = backend ? readProjectThumbnailManifest(backend) : Promise.resolve(null);
51
60
  }
52
61
  return manifestPromise;
53
62
  }
@@ -33,7 +33,6 @@ import {
33
33
  ThumbnailJobQueue,
34
34
  } from '../asset-workflow/thumbnail-system';
35
35
  import {
36
- accent,
37
36
  Button,
38
37
  border,
39
38
  EditorIcon,
@@ -119,7 +118,12 @@ function AssetIcon({ kind, size = 24 }: { kind: BrowserKind; size?: number }) {
119
118
  const projectThumbnailQueue = new ThumbnailJobQueue<string>((url) => {
120
119
  const format = modelThumbnailFormat(url);
121
120
  if (!format) return Promise.reject(new Error(`No model preview loader for ${url}.`));
122
- return getModelThumbnailRenderer().render({ url, format });
121
+ return getModelThumbnailRenderer().render({
122
+ url,
123
+ format,
124
+ background: 'neutral',
125
+ output: 'webp',
126
+ });
123
127
  }, 2);
124
128
 
125
129
  function ModelThumbnail({ url }: { url: string }) {
@@ -175,7 +179,7 @@ function ModelThumbnail({ url }: { url: string }) {
175
179
  setManifestPath(null);
176
180
  setManifestRejected(true);
177
181
  }}
178
- style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
182
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
179
183
  />
180
184
  );
181
185
  }
@@ -209,7 +213,7 @@ function ModelThumbnail({ url }: { url: string }) {
209
213
  <img
210
214
  src={src}
211
215
  alt="model preview"
212
- style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
216
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
213
217
  />
214
218
  );
215
219
  }
@@ -521,8 +525,9 @@ function BrowserEntryCard({
521
525
 
522
526
  return (
523
527
  <div
524
- className="vgai-selectable"
528
+ className="vgai-selectable vgai-project-asset-entry"
525
529
  data-selected={selected || undefined}
530
+ data-view={viewMode}
526
531
  role="option"
527
532
  aria-selected={selected}
528
533
  tabIndex={0}
@@ -543,14 +548,15 @@ function BrowserEntryCard({
543
548
  ...(grid ? GRID_ENTRY_STYLE : LIST_ENTRY_STYLE),
544
549
  borderRadius: radius.sm,
545
550
  cursor,
546
- border: selected ? `1px solid ${accent}` : '1px solid transparent',
547
551
  userSelect: 'none',
548
552
  }}
549
553
  >
550
554
  <div
555
+ className="vgai-project-asset-entry__preview"
556
+ data-testid="asset-entry-preview"
551
557
  style={{
552
558
  ...(grid
553
- ? { width: 48, height: 48, margin: '0 auto 4px' }
559
+ ? { width: '100%', aspectRatio: '4 / 3', margin: '0 auto' }
554
560
  : { width: 18, height: 18, margin: 0 }),
555
561
  display: 'flex',
556
562
  alignItems: 'center',
@@ -1309,6 +1315,8 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1309
1315
 
1310
1316
  {/* One file-browser surface; grid/list are presentation modes, not navigation. */}
1311
1317
  <div
1318
+ className="vgai-project-assets-results"
1319
+ data-view={viewMode}
1312
1320
  style={{
1313
1321
  flex: 1,
1314
1322
  overflowY: 'auto',
@@ -1,141 +1,71 @@
1
1
  import type { ReactNode } from 'react';
2
- import { useEffect, useState } from 'react';
3
- import { Button, border, TextInput, text } from '../design-system';
4
- import { getCurrentProject } from '../project-manager';
5
-
6
- export interface AssetEditorLayout {
7
- structureCollapsed: boolean;
8
- detailsCollapsed: boolean;
9
- structureWidth: number;
10
- detailsWidth: number;
11
- }
12
-
13
- const defaults: AssetEditorLayout = {
14
- structureCollapsed: false,
15
- detailsCollapsed: false,
16
- structureWidth: 240,
17
- detailsWidth: 300,
18
- };
19
-
20
- export function assetEditorLayoutKey(
21
- type: string,
22
- projectId = getCurrentProject()?.rootPath ?? '__unscoped__',
23
- ): string {
24
- return `vgai:asset-editor-layout:${projectId}:${type}`;
25
- }
26
-
27
- export function readAssetEditorLayout(type: string): AssetEditorLayout {
28
- if (typeof localStorage === 'undefined') return defaults;
29
- try {
30
- return { ...defaults, ...JSON.parse(localStorage.getItem(assetEditorLayoutKey(type)) ?? '{}') };
31
- } catch {
32
- return defaults;
33
- }
34
- }
2
+ import { useEffect } from 'react';
3
+ import {
4
+ ASSET_EDITOR_CONTROLS_UTILITY_ID,
5
+ activateAssetEditorContext,
6
+ } from '../asset-editor-context';
7
+ import { Button } from '../design-system';
8
+ import { showWorkspaceUtility } from '../workspace-dock-controller';
35
9
 
36
10
  export function AssetEditorShell({
11
+ documentId,
12
+ active = true,
37
13
  type,
38
14
  title,
39
15
  toolbar,
40
16
  structure,
41
17
  details,
42
18
  status,
19
+ controls,
43
20
  children,
44
21
  }: {
22
+ documentId: string;
23
+ active?: boolean;
45
24
  type: string;
46
25
  title: string;
47
26
  toolbar?: ReactNode;
48
27
  structure?: ReactNode;
49
28
  details?: ReactNode;
50
- status?: ReactNode;
29
+ status?: string;
30
+ controls?: ReactNode;
51
31
  children: ReactNode;
52
32
  }) {
53
- const [layout, setLayout] = useState(() => readAssetEditorLayout(type));
54
33
  useEffect(() => {
55
- localStorage.setItem(assetEditorLayoutKey(type), JSON.stringify(layout));
56
- }, [layout, type]);
34
+ if (!active) return;
35
+ return activateAssetEditorContext({
36
+ documentId,
37
+ title,
38
+ type,
39
+ ...(structure ? { structure } : {}),
40
+ ...(details ? { details } : {}),
41
+ ...(status ? { status } : {}),
42
+ ...(controls ? { controls } : {}),
43
+ });
44
+ }, [active, controls, details, documentId, status, structure, title, type]);
45
+
46
+ const hasToolbar = toolbar != null || controls != null;
57
47
  return (
58
48
  <section
59
49
  className="vgai-asset-editor"
60
50
  data-testid={`asset-editor:${type}`}
61
- data-structure-collapsed={layout.structureCollapsed}
62
- data-details-collapsed={layout.detailsCollapsed}
63
- style={
64
- {
65
- '--asset-editor-structure-width': `${layout.structureWidth}px`,
66
- '--asset-editor-details-width': `${layout.detailsWidth}px`,
67
- } as React.CSSProperties
68
- }
51
+ aria-label={`${title} Asset Editor`}
69
52
  >
70
- <header className="vgai-asset-editor__toolbar">
71
- <strong title={title}>{title}</strong>
72
- <div className="vgai-asset-editor__commands">{toolbar}</div>
73
- {structure && (
74
- <Button
75
- type="button"
76
- aria-pressed={!layout.structureCollapsed}
77
- onClick={() =>
78
- setLayout((value) => ({ ...value, structureCollapsed: !value.structureCollapsed }))
79
- }
80
- >
81
- Structure
82
- </Button>
83
- )}
84
- {details && (
85
- <Button
86
- type="button"
87
- aria-pressed={!layout.detailsCollapsed}
88
- onClick={() =>
89
- setLayout((value) => ({ ...value, detailsCollapsed: !value.detailsCollapsed }))
90
- }
91
- >
92
- Details
93
- </Button>
94
- )}
95
- </header>
96
- {structure && (
97
- <aside className="vgai-asset-editor__structure" aria-label="Asset structure">
98
- {structure}
99
- <label className="vgai-asset-editor__resize">
100
- Structure width
101
- <TextInput
102
- aria-label="Asset structure pane width"
103
- type="range"
104
- min={180}
105
- max={420}
106
- value={layout.structureWidth}
107
- onChange={(event) =>
108
- setLayout((value) => ({ ...value, structureWidth: Number(event.target.value) }))
109
- }
110
- />
111
- </label>
112
- </aside>
53
+ {hasToolbar && (
54
+ <header className="vgai-asset-editor__toolbar">
55
+ <div className="vgai-asset-editor__commands">{toolbar}</div>
56
+ {controls != null && (
57
+ <Button
58
+ type="button"
59
+ variant="ghost"
60
+ size="compact"
61
+ onClick={() => showWorkspaceUtility(ASSET_EDITOR_CONTROLS_UTILITY_ID)}
62
+ >
63
+ Controls
64
+ </Button>
65
+ )}
66
+ </header>
113
67
  )}
114
68
  <main className="vgai-asset-editor__viewport">{children}</main>
115
- {details && (
116
- <aside className="vgai-asset-editor__details" aria-label="Asset details">
117
- {details}
118
- <label className="vgai-asset-editor__resize">
119
- Details width
120
- <TextInput
121
- aria-label="Asset details pane width"
122
- type="range"
123
- min={220}
124
- max={480}
125
- value={layout.detailsWidth}
126
- onChange={(event) =>
127
- setLayout((value) => ({ ...value, detailsWidth: Number(event.target.value) }))
128
- }
129
- />
130
- </label>
131
- </aside>
132
- )}
133
- <footer
134
- className="vgai-asset-editor__status"
135
- style={{ borderColor: border[1], color: text[2] }}
136
- >
137
- {status}
138
- </footer>
139
69
  </section>
140
70
  );
141
71
  }
@@ -15,20 +15,37 @@ export function AssetImportDetails({ assetPath }: { assetPath: string }) {
15
15
  useEffect(() => {
16
16
  let cancelled = false;
17
17
  const path = importRecipePath(assetPath);
18
- getStorageBackend()
19
- .read(path)
18
+ const backend = getStorageBackend();
19
+ backend
20
+ .exists(path)
21
+ .then((exists) => (exists ? backend.read(path) : null))
20
22
  .then(
21
23
  (content) => {
22
24
  if (cancelled) return;
23
- const plan = assetImportPlanSchema.parse(JSON.parse(content));
24
- setSaved(plan);
25
- setDraft(plan);
26
- setError(null);
25
+ if (content === null) {
26
+ setSaved(null);
27
+ setDraft(null);
28
+ setError(null);
29
+ return;
30
+ }
31
+ try {
32
+ const plan = assetImportPlanSchema.parse(JSON.parse(content));
33
+ setSaved(plan);
34
+ setDraft(plan);
35
+ setError(null);
36
+ } catch (cause) {
37
+ setSaved(null);
38
+ setDraft(null);
39
+ setError(
40
+ `Invalid import recipe: ${cause instanceof Error ? cause.message : String(cause)}`,
41
+ );
42
+ }
27
43
  },
28
44
  () => {
29
45
  if (!cancelled) {
30
46
  setSaved(null);
31
47
  setDraft(null);
48
+ setError(null);
32
49
  }
33
50
  },
34
51
  );
@@ -50,6 +67,7 @@ export function AssetImportDetails({ assetPath }: { assetPath: string }) {
50
67
  return (
51
68
  <SectionHeader label="Import">
52
69
  <div>No saved import recipe. Native project asset.</div>
70
+ {error && <div style={{ color: danger }}>{error}</div>}
53
71
  </SectionHeader>
54
72
  );
55
73
  const setSetting = <K extends keyof AssetImportPlan['settings']>(
@@ -8,7 +8,7 @@ import { OnlineAssetBrowser } from './OnlineAssetBrowser';
8
8
  export function AssetLibraryPanel() {
9
9
  return (
10
10
  <div className="vgai-asset-library-panel" data-testid="asset-library-panel">
11
- <OnlineAssetBrowser layout="navigator" />
11
+ <OnlineAssetBrowser />
12
12
  </div>
13
13
  );
14
14
  }
@@ -83,6 +83,11 @@ import {
83
83
  faTriangleExclamation,
84
84
  } from '@fortawesome/free-solid-svg-icons';
85
85
  import { useCallback, useEffect, useReducer, useRef, useState, useSyncExternalStore } from 'react';
86
+ import {
87
+ assetEditorContextVersion,
88
+ getActiveAssetEditorContext,
89
+ subscribeAssetEditorContext,
90
+ } from '../asset-editor-context';
86
91
  import { getActiveAuthoring, hasAuthoringOverride } from '../authoring/active-adapter';
87
92
  import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
88
93
  import { requestWorldFocus, worldFocusScenePath } from '../authoring/edit-mode-authoring';
@@ -1342,7 +1347,7 @@ function Row({
1342
1347
 
1343
1348
  // --- Main panel ---
1344
1349
 
1345
- export function GameHierarchy() {
1350
+ function SceneHierarchyContent() {
1346
1351
  const store = useEditorStore();
1347
1352
  useSyncExternalStore(store.subscribe, store.getSnapshot);
1348
1353
  const adapter = getActiveAuthoring(store);
@@ -1930,3 +1935,23 @@ export function GameHierarchy() {
1930
1935
  </Panel>
1931
1936
  );
1932
1937
  }
1938
+
1939
+ /** The navigator slot follows the active center document. Scene/world
1940
+ * documents use the normal authoring hierarchy; a structured Asset Editor
1941
+ * contributes its model/bone/material outline into this same outer panel. */
1942
+ export function GameHierarchy() {
1943
+ useSyncExternalStore(subscribeAssetEditorContext, assetEditorContextVersion);
1944
+ const context = getActiveAssetEditorContext();
1945
+ if (!context?.structure) return <SceneHierarchyContent />;
1946
+ return (
1947
+ <Panel
1948
+ name={`${context.title} Structure`}
1949
+ hideHeader
1950
+ data-testid="asset-editor-structure"
1951
+ onPointerDown={() => setActiveScope('hierarchy')}
1952
+ style={{ flex: 1, minWidth: 0, minHeight: 0, overflow: 'auto' }}
1953
+ >
1954
+ {context.structure}
1955
+ </Panel>
1956
+ );
1957
+ }
@@ -27,7 +27,16 @@ import type { AuthoringAdapter, EditorNode, PropertyDescriptor, Transform } from
27
27
  import { faXmark } from '@fortawesome/free-solid-svg-icons';
28
28
  import { useSyncExternalStore } from 'react';
29
29
  import * as THREE from 'three';
30
- import { assetSelectionVersion, subscribeAssetSelection } from '../asset-selection';
30
+ import {
31
+ assetEditorContextVersion,
32
+ getActiveAssetEditorContext,
33
+ subscribeAssetEditorContext,
34
+ } from '../asset-editor-context';
35
+ import {
36
+ assetSelectionVersion,
37
+ getSelectedAsset,
38
+ subscribeAssetSelection,
39
+ } from '../asset-selection';
31
40
  import { getActiveAuthoring } from '../authoring/active-adapter';
32
41
  import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
33
42
  import { ReactWorldAuthoringAdapter } from '../authoring/react-world-authoring-adapter';
@@ -57,6 +66,7 @@ import {
57
66
  workspaceDocumentRegistryVersion,
58
67
  } from '../workspace-document-registry';
59
68
  import { openEntityAssetDocument } from './asset-documents';
69
+ import { AssetSelectionSection } from './asset-selection-section';
60
70
  import { groupProperties } from './inspector-property-grouping';
61
71
  import { groupLabelStyle, LABEL_COL_WIDTH } from './inspector-widgets/shared';
62
72
 
@@ -179,7 +189,7 @@ function AxisRow({
179
189
  );
180
190
  }
181
191
 
182
- export function Inspector() {
192
+ function SceneInspector() {
183
193
  const store = useEditorStore();
184
194
  useSyncExternalStore(store.subscribe, store.getSnapshot);
185
195
  // Re-render when section registrations change (W3): project inspector
@@ -723,3 +733,29 @@ export function Inspector() {
723
733
  </Panel>
724
734
  );
725
735
  }
736
+
737
+ /** Asset documents reuse the ONE outer Inspector rather than rendering a
738
+ * second details rail inside the center document. */
739
+ export function Inspector() {
740
+ useSyncExternalStore(subscribeAssetEditorContext, assetEditorContextVersion);
741
+ useSyncExternalStore(subscribeAssetSelection, assetSelectionVersion);
742
+ const context = getActiveAssetEditorContext();
743
+ const selectedAsset = getSelectedAsset();
744
+ if (!context?.details && !selectedAsset) return <SceneInspector />;
745
+ return (
746
+ <Panel
747
+ name="Inspector"
748
+ hideHeader
749
+ data-testid="inspector-panel"
750
+ onPointerDown={() => setActiveScope('inspector')}
751
+ style={{ overflowY: 'auto' }}
752
+ >
753
+ <div
754
+ data-testid={context ? 'asset-editor-details' : 'asset-selection-details'}
755
+ style={{ pointerEvents: 'auto', padding: context ? 8 : 0 }}
756
+ >
757
+ {context?.details ?? <AssetSelectionSection />}
758
+ </div>
759
+ </Panel>
760
+ );
761
+ }