@jgengine/editor 0.11.0 → 0.12.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 (169) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -0
  3. package/dist/AssetBrowser.d.ts +26 -1
  4. package/dist/AssetBrowser.js +54 -1
  5. package/dist/AssetDropZone.d.ts +9 -0
  6. package/dist/AssetDropZone.js +65 -0
  7. package/dist/CatalogsPanel.d.ts +4 -2
  8. package/dist/CatalogsPanel.js +162 -9
  9. package/dist/CityPreview.d.ts +11 -0
  10. package/dist/CityPreview.js +45 -0
  11. package/dist/CollectionsPanel.js +5 -5
  12. package/dist/DebugDraw.d.ts +3 -1
  13. package/dist/DebugDraw.js +37 -19
  14. package/dist/EditorApp.d.ts +9 -1
  15. package/dist/EditorApp.js +147 -35
  16. package/dist/EditorChrome.d.ts +25 -6
  17. package/dist/EditorChrome.js +310 -130
  18. package/dist/InspectorPanel.d.ts +17 -7
  19. package/dist/InspectorPanel.js +173 -27
  20. package/dist/LightingPanel.d.ts +11 -0
  21. package/dist/LightingPanel.js +99 -0
  22. package/dist/MaterialsWorkspacePanel.d.ts +12 -0
  23. package/dist/MaterialsWorkspacePanel.js +86 -0
  24. package/dist/ParentPickerMenu.d.ts +13 -0
  25. package/dist/ParentPickerMenu.js +33 -0
  26. package/dist/PerfProbe.js +33 -0
  27. package/dist/PrefabsPanel.js +5 -5
  28. package/dist/RuntimePlayBridge.d.ts +5 -3
  29. package/dist/RuntimePlayBridge.js +6 -13
  30. package/dist/ScatterPreview.d.ts +3 -1
  31. package/dist/ScatterPreview.js +10 -7
  32. package/dist/SchemaInspector.js +21 -8
  33. package/dist/ScriptingPanel.d.ts +15 -0
  34. package/dist/ScriptingPanel.js +46 -0
  35. package/dist/SelectionGizmo.js +114 -30
  36. package/dist/StandaloneEditor.d.ts +16 -9
  37. package/dist/StandaloneEditor.js +19 -20
  38. package/dist/TerrainPanel.d.ts +3 -2
  39. package/dist/TerrainPanel.js +16 -5
  40. package/dist/TerrainSculpt.js +98 -59
  41. package/dist/agent/AgentPanel.d.ts +5 -2
  42. package/dist/agent/AgentPanel.js +11 -6
  43. package/dist/agent/toolBridge.js +5 -0
  44. package/dist/assetImport.d.ts +36 -0
  45. package/dist/assetImport.js +66 -0
  46. package/dist/authoredComponentMeta.d.ts +25 -0
  47. package/dist/authoredComponentMeta.js +65 -0
  48. package/dist/gizmoPivot.d.ts +4 -0
  49. package/dist/gizmoPivot.js +34 -0
  50. package/dist/handlers/catalogs.d.ts +3 -0
  51. package/dist/handlers/catalogs.js +137 -0
  52. package/dist/handlers/collections.d.ts +3 -0
  53. package/dist/handlers/collections.js +57 -0
  54. package/dist/handlers/context.d.ts +36 -0
  55. package/dist/handlers/context.js +1 -0
  56. package/dist/handlers/document.d.ts +3 -0
  57. package/dist/handlers/document.js +350 -0
  58. package/dist/handlers/grids.d.ts +3 -0
  59. package/dist/handlers/grids.js +142 -0
  60. package/dist/handlers/hierarchy.d.ts +3 -0
  61. package/dist/handlers/hierarchy.js +55 -0
  62. package/dist/handlers/index.d.ts +8 -0
  63. package/dist/handlers/index.js +23 -0
  64. package/dist/handlers/minimap.d.ts +9 -0
  65. package/dist/handlers/minimap.js +32 -0
  66. package/dist/handlers/runtime.d.ts +3 -0
  67. package/dist/handlers/runtime.js +191 -0
  68. package/dist/handlers/terrain.d.ts +3 -0
  69. package/dist/handlers/terrain.js +204 -0
  70. package/dist/index.d.ts +3 -2
  71. package/dist/index.js +3 -2
  72. package/dist/listDocumentTriggers.d.ts +42 -0
  73. package/dist/listDocumentTriggers.js +49 -0
  74. package/dist/materialAssignments.d.ts +63 -0
  75. package/dist/materialAssignments.js +84 -0
  76. package/dist/mcp/fieldSpec.d.ts +32 -0
  77. package/dist/mcp/fieldSpec.js +173 -0
  78. package/dist/mcp/rpcRequest.d.ts +8 -5
  79. package/dist/mcp/rpcRequest.js +72 -93
  80. package/dist/mcp/tools.js +104 -912
  81. package/dist/networkSnapshot.d.ts +82 -0
  82. package/dist/networkSnapshot.js +73 -0
  83. package/dist/parentCandidates.d.ts +12 -0
  84. package/dist/parentCandidates.js +25 -0
  85. package/dist/session.d.ts +77 -4
  86. package/dist/session.js +36 -1017
  87. package/dist/shell/AnimationPanel.d.ts +17 -0
  88. package/dist/shell/AnimationPanel.js +134 -0
  89. package/dist/shell/AssetThumbnail.d.ts +12 -0
  90. package/dist/shell/AssetThumbnail.js +23 -0
  91. package/dist/shell/BottomDock.d.ts +25 -0
  92. package/dist/shell/BottomDock.js +40 -0
  93. package/dist/shell/CommandPalette.d.ts +12 -0
  94. package/dist/shell/CommandPalette.js +56 -0
  95. package/dist/shell/ConsolePanel.d.ts +8 -0
  96. package/dist/shell/ConsolePanel.js +45 -0
  97. package/dist/shell/ContentBrowser.d.ts +21 -0
  98. package/dist/shell/ContentBrowser.js +130 -0
  99. package/dist/shell/HierarchyPanel.d.ts +26 -0
  100. package/dist/shell/HierarchyPanel.js +387 -0
  101. package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
  102. package/dist/shell/NetworkWorkspacePanel.js +49 -0
  103. package/dist/shell/PlayModeBar.d.ts +12 -0
  104. package/dist/shell/PlayModeBar.js +27 -0
  105. package/dist/shell/ProfilerPanel.d.ts +10 -0
  106. package/dist/shell/ProfilerPanel.js +41 -0
  107. package/dist/shell/SceneToolbar.d.ts +43 -0
  108. package/dist/shell/SceneToolbar.js +122 -0
  109. package/dist/shell/StatusBar.d.ts +18 -0
  110. package/dist/shell/StatusBar.js +41 -0
  111. package/dist/shell/TopAppBar.d.ts +33 -0
  112. package/dist/shell/TopAppBar.js +64 -0
  113. package/dist/shell/ViewportOverlays.d.ts +23 -0
  114. package/dist/shell/ViewportOverlays.js +136 -0
  115. package/dist/shell/WorkspaceRail.d.ts +20 -0
  116. package/dist/shell/WorkspaceRail.js +31 -0
  117. package/dist/shell/cameraTelemetry.d.ts +27 -0
  118. package/dist/shell/cameraTelemetry.js +24 -0
  119. package/dist/shell/commandRegistry.d.ts +58 -0
  120. package/dist/shell/commandRegistry.js +133 -0
  121. package/dist/shell/consoleSink.d.ts +11 -0
  122. package/dist/shell/consoleSink.js +17 -0
  123. package/dist/shell/consoleStore.d.ts +27 -0
  124. package/dist/shell/consoleStore.js +43 -0
  125. package/dist/shell/fields.d.ts +39 -0
  126. package/dist/shell/fields.js +104 -0
  127. package/dist/shell/formatSavedRelative.d.ts +2 -0
  128. package/dist/shell/formatSavedRelative.js +14 -0
  129. package/dist/shell/glbThumbnailCache.d.ts +45 -0
  130. package/dist/shell/glbThumbnailCache.js +241 -0
  131. package/dist/shell/hierarchyA11y.d.ts +21 -0
  132. package/dist/shell/hierarchyA11y.js +35 -0
  133. package/dist/shell/icons.d.ts +13 -0
  134. package/dist/shell/icons.js +85 -0
  135. package/dist/shell/layoutStore.d.ts +68 -0
  136. package/dist/shell/layoutStore.js +156 -0
  137. package/dist/shell/pathFlythrough.d.ts +37 -0
  138. package/dist/shell/pathFlythrough.js +54 -0
  139. package/dist/shell/perfHistory.d.ts +70 -0
  140. package/dist/shell/perfHistory.js +130 -0
  141. package/dist/shell/renameObject.d.ts +3 -0
  142. package/dist/shell/renameObject.js +25 -0
  143. package/dist/shell/theme.d.ts +36 -0
  144. package/dist/shell/theme.js +37 -0
  145. package/dist/shell/ui.d.ts +84 -0
  146. package/dist/shell/ui.js +90 -0
  147. package/dist/shell/useGlbThumbnail.d.ts +8 -0
  148. package/dist/shell/useGlbThumbnail.js +19 -0
  149. package/dist/uiStore.d.ts +27 -0
  150. package/dist/uiStore.js +10 -0
  151. package/dist/viewportContextMenu.d.ts +4 -2
  152. package/dist/viewportContextMenu.js +5 -1
  153. package/package.json +4 -4
  154. package/skills/jgengine/SKILL.md +7 -4
  155. package/skills/jgengine/api.md +172 -79
  156. package/skills/jgengine/capabilities.md +104 -2
  157. package/skills/jgengine/recipes/minimal-game.md +88 -0
  158. package/skills/jgengine-editor/SKILL.md +18 -3
  159. package/skills/jgengine-editor/api.md +397 -18
  160. package/skills/jgengine-editor/capabilities.md +46 -0
  161. package/skills/jgengine-editor/reference.md +87 -0
  162. package/skills/level-design/SKILL.md +54 -0
  163. package/skills/level-design/agents/openai.yaml +4 -0
  164. package/skills/level-design/references/application-playbook.md +192 -0
  165. package/skills/level-design/references/field-guide.md +337 -0
  166. package/skills/level-design/references/live-editor-loop.md +179 -0
  167. package/dist/OutlinerPanel.d.ts +0 -14
  168. package/dist/OutlinerPanel.js +0 -84
  169. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -1,67 +1,41 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { editorDocumentSize, extractEditorFragment, listEditorKinds, WELL_KNOWN_MARKER_KINDS, } from "@jgengine/core/editor/index";
4
- import { VEGETATION_VOLUME_KIND } from "@jgengine/core/world/vegetation";
3
+ import { editorDocumentSize, editorParentOf, extractEditorFragment, } from "@jgengine/core/editor/index";
5
4
  import { scatterRegionEstimate, SCATTER_PATH_KIND } from "@jgengine/core/world/scatterRegion";
6
- import { listSceneKinds } from "@jgengine/core/scene/sceneKinds";
7
- import { AssetBrowser } from "./AssetBrowser.js";
8
- import { AgentPanel } from "./agent/AgentPanel.js";
5
+ import { editorAssetFromImport, mergeEditorAssets, } from "./AssetBrowser.js";
9
6
  import { CatalogsPanel } from "./CatalogsPanel.js";
10
7
  import { CollectionsPanel } from "./CollectionsPanel.js";
11
8
  import { EditorContextMenu } from "./EditorContextMenu.js";
12
- import { OutlinerPanel } from "./OutlinerPanel.js";
9
+ import { ParentPickerMenu } from "./ParentPickerMenu.js";
10
+ import { MaterialsWorkspacePanel } from "./MaterialsWorkspacePanel.js";
13
11
  import { PrefabsPanel } from "./PrefabsPanel.js";
12
+ import { listParentCandidates } from "./parentCandidates.js";
14
13
  import { buildEditorContextMenu, } from "./viewportContextMenu.js";
15
14
  import { buildOutlinerGroups } from "./outlinerModel.js";
16
- import { classifyEditorPerf } from "./perfPill.js";
15
+ import { importAssetToHost, loadDroppedAssets, } from "./assetImport.js";
17
16
  import { newPlacementId } from "./uiStore.js";
18
17
  import { useF2Chord } from "./useF2Chord.js";
19
- import { BTN, MICRO } from "./chromeStyles.js";
20
18
  import { TerrainPanel } from "./TerrainPanel.js";
19
+ import { LightingPanel } from "./LightingPanel.js";
21
20
  import { InspectorPanel } from "./InspectorPanel.js";
22
- const PERF_POLL_MS = 500;
23
- const ADD_VOLUME_ENTRIES = [
24
- { label: "Zone (sphere)", tool: { tool: "volume", kind: "zone", shape: "sphere" } },
25
- { label: "Zone (box)", tool: { tool: "volume", kind: "zone", shape: "box" } },
26
- { label: "Zone (cylinder)", tool: { tool: "volume", kind: "zone", shape: "cylinder" } },
27
- { label: "Aggro range", tool: { tool: "volume", kind: "aggro", shape: "sphere" } },
28
- { label: "Leash range", tool: { tool: "volume", kind: "leash", shape: "sphere" } },
29
- { label: "Discover area", tool: { tool: "volume", kind: "discover", shape: "sphere" } },
30
- { label: "Capture area", tool: { tool: "volume", kind: "capture", shape: "cylinder" } },
31
- { label: "Vegetation (box)", tool: { tool: "volume", kind: VEGETATION_VOLUME_KIND, shape: "box" } },
32
- { label: "Vegetation (circle)", tool: { tool: "volume", kind: VEGETATION_VOLUME_KIND, shape: "sphere" } },
33
- ];
34
- const SNAP_MODES = [
35
- { mode: "ground", label: "Snap: ground" },
36
- { mode: "grid", label: "Snap: grid" },
37
- { mode: "off", label: "Snap: off" },
38
- ];
39
- function usePerfSample(api) {
40
- const [sample, setSample] = useState(null);
41
- useEffect(() => {
42
- const timer = setInterval(() => setSample(api.getPerf()), PERF_POLL_MS);
43
- return () => clearInterval(timer);
44
- }, [api]);
45
- return sample;
46
- }
47
- function formatTriangles(count) {
48
- if (count >= 1_000_000)
49
- return `${(count / 1_000_000).toFixed(1)}M`;
50
- if (count >= 1_000)
51
- return `${(count / 1_000).toFixed(0)}k`;
52
- return String(count);
53
- }
54
- const PERF_TONE_CLASS = {
55
- idle: "text-neutral-500",
56
- healthy: "text-emerald-400",
57
- busy: "text-rose-400",
58
- };
59
- /** Toolbar perf pill: neutral "idle" when the loop is throttled/at rest, red only for active low fps. */
60
- function PerfPill({ perf }) {
61
- const tone = classifyEditorPerf(perf);
62
- const rate = tone === "idle" ? "idle" : `${perf.fps.toFixed(0)} fps`;
63
- return (_jsxs("span", { className: `ml-3 ${PERF_TONE_CLASS[tone]}`, children: [rate, " \u00B7 ", perf.drawCalls, " draws \u00B7 ", formatTriangles(perf.triangles), " tris"] }));
64
- }
21
+ import { BottomDock } from "./shell/BottomDock.js";
22
+ import { CommandPalette } from "./shell/CommandPalette.js";
23
+ import { HierarchyPanel } from "./shell/HierarchyPanel.js";
24
+ import { SceneToolbar } from "./shell/SceneToolbar.js";
25
+ import { StatusBar } from "./shell/StatusBar.js";
26
+ import { TopAppBar } from "./shell/TopAppBar.js";
27
+ import { OrientationWidget, PerformanceOverlay, ViewportUtilityPanel } from "./shell/ViewportOverlays.js";
28
+ import { NetworkWorkspacePanel } from "./shell/NetworkWorkspacePanel.js";
29
+ import { WorkspaceRail } from "./shell/WorkspaceRail.js";
30
+ import { buildPaletteCommands } from "./shell/commandRegistry.js";
31
+ import { createEditorConsoleStore } from "./shell/consoleStore.js";
32
+ import { installEditorConsoleSink } from "./shell/consoleSink.js";
33
+ import { Icon } from "./shell/icons.js";
34
+ import { createShellLayoutStore, } from "./shell/layoutStore.js";
35
+ import { createPerfHistoryStore } from "./shell/perfHistory.js";
36
+ import { BORDER, FOCUS_RING } from "./shell/theme.js";
37
+ import { IconButton, Kbd, PanelResizer } from "./shell/ui.js";
38
+ import { ScriptingPanel } from "./ScriptingPanel.js";
65
39
  let clipboardFragment = null;
66
40
  const SHORTCUTS = [
67
41
  { keys: "W / E / R", action: "Move · rotate · scale gizmo" },
@@ -69,13 +43,15 @@ const SHORTCUTS = [
69
43
  { keys: "F", action: "Frame selection (or whole scene)" },
70
44
  { keys: "G", action: "Toggle reference grid" },
71
45
  { keys: "N", action: "Cycle instances of selected row" },
46
+ { keys: "Ctrl+K", action: "Command palette" },
72
47
  { keys: "Arrows", action: "Nudge selection on X/Z (Shift ×5)" },
73
48
  { keys: "PgUp / PgDn", action: "Nudge selection on Y (Shift ×5)" },
74
49
  { keys: "Ctrl+A", action: "Select all visible objects" },
75
50
  { keys: "Ctrl+C / X / V", action: "Copy · cut · paste selection" },
76
51
  { keys: "Ctrl+D", action: "Duplicate selection" },
77
52
  { keys: "Ctrl+Z / Y", action: "Undo · redo" },
78
- { keys: "Ctrl+B", action: "Asset browser" },
53
+ { keys: "Ctrl+B", action: "Content browser" },
54
+ { keys: "Ctrl+S", action: "Save scene" },
79
55
  { keys: "Delete", action: "Remove selection" },
80
56
  { keys: "Enter / Esc", action: "Finish · cancel path drawing" },
81
57
  { keys: "Shift+click", action: "Multi-select · keep placing" },
@@ -95,9 +71,10 @@ function downloadText(filename, text) {
95
71
  anchor.click();
96
72
  URL.revokeObjectURL(url);
97
73
  }
98
- function useDocumentSave(session, save) {
74
+ function useDocumentSave(session, save, onResult) {
99
75
  const [saveState, setSaveState] = useState("idle");
100
76
  const [saveError, setSaveError] = useState(null);
77
+ const [lastSavedAt, setLastSavedAt] = useState(null);
101
78
  const savedDocRef = useRef(session.getState().document);
102
79
  const dirty = session.getState().document !== savedDocRef.current;
103
80
  const doSave = () => {
@@ -110,29 +87,55 @@ function useDocumentSave(session, save) {
110
87
  savedDocRef.current = document;
111
88
  setSaveError(null);
112
89
  setSaveState("saved");
90
+ setLastSavedAt(Date.now());
91
+ onResult?.(true, result.path === undefined ? "Scene saved" : `Scene saved to ${result.path}`);
113
92
  }
114
93
  else {
115
94
  setSaveError(result.error ?? "save failed");
116
95
  setSaveState("error");
96
+ onResult?.(false, `Save failed: ${result.error ?? "unknown error"}`);
117
97
  }
118
98
  });
119
99
  };
120
- return { available: save !== undefined, dirty, saveState, saveError, doSave };
100
+ return { available: save !== undefined, dirty, saveState, saveError, lastSavedAt, doSave };
121
101
  }
102
+ const LEFT_PAGES = [
103
+ { id: "hierarchy", label: "Hierarchy" },
104
+ { id: "collections", label: "Sets" },
105
+ { id: "prefabs", label: "Prefabs" },
106
+ { id: "catalogs", label: "Data" },
107
+ ];
122
108
  /**
123
- * The full editor UI shell — toolbar, left panels (outliner/prefabs/sets/layers), viewport overlays,
124
- * the selector-subscribed {@link InspectorPanel}, the embedded {@link AgentPanel}, and the asset
125
- * browser — wired to the session, UI store, and host RPC. Mounted by `EditorApp`; not a game-author entry point.
109
+ * The full editor UI shell — global app bar, contextual scene toolbar, workspace rail, resizable
110
+ * hierarchy/inspector docks, tabbed bottom dock (content browser, console, profiler, AI
111
+ * assistant), viewport overlays, and status bar — wired to the session, UI store, layout store,
112
+ * and host RPC. Mounted by `EditorApp`; not a game-author entry point.
126
113
  */
127
- export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, save, }) {
114
+ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, }) {
128
115
  const [, setTick] = useState(0);
129
- const [activePanel, setActivePanel] = useState("outliner");
130
- const [leftOpen, setLeftOpen] = useState(true);
131
- const [rightOpen, setRightOpen] = useState(true);
132
- const [agentOpen, setAgentOpen] = useState(false);
133
- const [bottomOpen, setBottomOpen] = useState(false);
134
- const [addOpen, setAddOpen] = useState(false);
116
+ const layoutRef = useRef(null);
117
+ layoutRef.current ??= createShellLayoutStore(gameId);
118
+ const layout = layoutRef.current;
119
+ const consoleRef = useRef(null);
120
+ consoleRef.current ??= createEditorConsoleStore();
121
+ const consoleStore = consoleRef.current;
122
+ const perfHistoryRef = useRef(null);
123
+ perfHistoryRef.current ??= createPerfHistoryStore();
124
+ const perfHistory = perfHistoryRef.current;
125
+ /** Models added this session via Content Browser Import / drop — merged over the game catalog. */
126
+ const [importedAssets, setImportedAssets] = useState([]);
127
+ const [importBusy, setImportBusy] = useState(false);
128
+ // Bridge global RPC/agent console emits into the dock console for this chrome instance.
129
+ useEffect(() => installEditorConsoleSink((severity, source, message) => {
130
+ consoleStore.log(severity, source, message);
131
+ }), [consoleStore]);
132
+ const liveAssets = useMemo(() => mergeEditorAssets(assets, importedAssets), [assets, importedAssets]);
133
+ // Keep the host asset list in step with the browser so place_asset / list_assets see imports.
134
+ useEffect(() => {
135
+ api.setAssets(liveAssets);
136
+ }, [api, liveAssets]);
135
137
  const [helpOpen, setHelpOpen] = useState(false);
138
+ const [paletteQuery, setPaletteQuery] = useState(null);
136
139
  const [toast, setToast] = useState(null);
137
140
  const toastTimer = useRef(null);
138
141
  const notify = useCallback((text, tone = "info") => {
@@ -140,18 +143,17 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
140
143
  clearTimeout(toastTimer.current);
141
144
  setToast({ text, tone });
142
145
  toastTimer.current = setTimeout(() => setToast(null), 2600);
143
- }, []);
146
+ consoleStore.log(tone === "error" ? "error" : "info", "ui", text);
147
+ }, [consoleStore]);
144
148
  useEffect(() => () => {
145
149
  if (toastTimer.current !== null)
146
150
  clearTimeout(toastTimer.current);
147
151
  }, []);
148
152
  const importInputRef = useRef(null);
149
153
  const state = session.getState();
150
- const visibility = api.getVisibility();
151
- const perf = usePerfSample(api);
152
154
  const uiState = ui.getState();
153
- const gizmoMode = uiState.gizmoMode;
154
- const docSave = useDocumentSave(session, save);
155
+ const layoutState = layout.getState();
156
+ const docSave = useDocumentSave(session, save, (ok, detail) => consoleStore.log(ok ? "info" : "error", "save", detail));
155
157
  const docSaveRef = useRef(docSave);
156
158
  docSaveRef.current = docSave;
157
159
  const outlinerGroups = useMemo(() => buildOutlinerGroups(state.document), [state.document]);
@@ -166,16 +168,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
166
168
  foliage += scatterRegionEstimate(path).count;
167
169
  return { objects, foliage };
168
170
  }, [state.document]);
169
- const showPanel = (panel) => {
170
- setActivePanel(panel);
171
- if (panel === "assets")
172
- setBottomOpen(true);
173
- else
174
- setLeftOpen(true);
175
- };
176
171
  useF2Chord("KeyE", () => api.setMode("play"));
177
172
  useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
178
173
  useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
174
+ useEffect(() => layout.subscribe(() => setTick((value) => value + 1)), [layout]);
179
175
  const copySelection = useCallback(() => {
180
176
  const current = session.getState();
181
177
  if (current.selection.length === 0)
@@ -194,6 +190,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
194
190
  if (ui.getState().contextMenu !== null)
195
191
  ui.patch({ contextMenu: null });
196
192
  }, [ui]);
193
+ const [parentPicker, setParentPicker] = useState(null);
194
+ const openBottomTab = useCallback((tab) => {
195
+ layout.patch({ bottomOpen: true, bottomTab: tab });
196
+ }, [layout]);
197
197
  const runContextAction = useCallback((action) => {
198
198
  const menu = ui.getState().contextMenu;
199
199
  closeContextMenu();
@@ -246,10 +246,29 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
246
246
  const id = `prefab_${Date.now().toString(36)}`;
247
247
  const name = `Prefab ${current.document.prefabs.length + 1}`;
248
248
  api.handle({ method: "create_prefab", id, name, ids: [...selection] });
249
- showPanel("prefabs");
249
+ layout.patch({ leftOpen: true, leftPage: "prefabs" });
250
250
  notify(`Created prefab “${name}”`);
251
251
  }
252
252
  return;
253
+ case "parentTo": {
254
+ const ids = selection.length > 0
255
+ ? selection
256
+ : menu?.hitId !== null && menu?.hitId !== undefined
257
+ ? [menu.hitId]
258
+ : [];
259
+ if (ids.length === 0 || menu === null)
260
+ return;
261
+ setParentPicker({ clientX: menu.clientX, clientY: menu.clientY, ids: [...ids] });
262
+ return;
263
+ }
264
+ case "unparent": {
265
+ const ids = selection.length > 0 ? selection : menu?.hitId !== null && menu?.hitId !== undefined ? [menu.hitId] : [];
266
+ if (ids.length === 0)
267
+ return;
268
+ api.handle({ method: "set_parent", ids: [...ids], parentId: null });
269
+ notify(ids.length === 1 ? "Unparented object" : `Unparented ${ids.length} objects`);
270
+ return;
271
+ }
253
272
  case "addMarker":
254
273
  if (ground !== null) {
255
274
  session.dispatch({
@@ -301,10 +320,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
301
320
  }
302
321
  return;
303
322
  case "openAssets":
304
- showPanel("assets");
323
+ openBottomTab("content");
305
324
  return;
306
325
  }
307
- }, [api, closeContextMenu, copySelection, notify, session, ui]);
326
+ }, [api, closeContextMenu, copySelection, layout, notify, openBottomTab, session, ui]);
308
327
  useEffect(() => {
309
328
  const onKey = (event) => {
310
329
  if (event.defaultPrevented)
@@ -314,6 +333,11 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
314
333
  docSaveRef.current.doSave();
315
334
  return;
316
335
  }
336
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
337
+ event.preventDefault();
338
+ setPaletteQuery((value) => (value === null ? "" : null));
339
+ return;
340
+ }
317
341
  const target = event.target;
318
342
  const isTyping = target instanceof HTMLInputElement ||
319
343
  target instanceof HTMLTextAreaElement ||
@@ -335,18 +359,26 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
335
359
  }
336
360
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "b") {
337
361
  event.preventDefault();
338
- showPanel("assets");
362
+ openBottomTab("content");
339
363
  return;
340
364
  }
341
365
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
342
366
  event.preventDefault();
343
- const state = session.getState();
367
+ const current = session.getState();
344
368
  const visibility = api.getVisibility();
345
369
  const ids = [
346
- ...state.document.markers.filter((m) => visibility[m.kind] !== false).map((m) => m.id),
347
- ...state.document.volumes.filter((v) => visibility[v.kind] !== false).map((v) => v.id),
348
- ...state.document.paths.filter((p) => visibility[p.kind] !== false).map((p) => p.id),
349
- ...(visibility["note"] !== false ? state.document.annotations.map((n) => n.id) : []),
370
+ ...current.document.markers
371
+ .filter((m) => visibility[m.kind] !== false && m.hidden !== true)
372
+ .map((m) => m.id),
373
+ ...current.document.volumes
374
+ .filter((v) => visibility[v.kind] !== false && v.hidden !== true)
375
+ .map((v) => v.id),
376
+ ...current.document.paths
377
+ .filter((p) => visibility[p.kind] !== false && p.hidden !== true)
378
+ .map((p) => p.id),
379
+ ...(visibility["note"] !== false
380
+ ? current.document.annotations.filter((n) => n.hidden !== true).map((n) => n.id)
381
+ : []),
350
382
  ];
351
383
  if (ids.length > 0)
352
384
  session.dispatch({ type: "select", ids });
@@ -387,6 +419,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
387
419
  }
388
420
  if (event.key === "Escape") {
389
421
  setHelpOpen(false);
422
+ if (paletteQuery !== null) {
423
+ setPaletteQuery(null);
424
+ return;
425
+ }
390
426
  const current = ui.getState();
391
427
  if (current.contextMenu !== null) {
392
428
  ui.patch({ contextMenu: null });
@@ -479,17 +515,7 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
479
515
  };
480
516
  window.addEventListener("keydown", onKey);
481
517
  return () => window.removeEventListener("keydown", onKey);
482
- }, [session, ui, api, notify, copySelection]);
483
- const kinds = useMemo(() => listEditorKinds(state.document), [state.document]);
484
- const allKinds = useMemo(() => [
485
- ...new Set([
486
- ...kinds.markers,
487
- ...kinds.volumes,
488
- ...kinds.paths,
489
- ...(state.document.annotations.length > 0 ? ["note"] : []),
490
- ]),
491
- ].sort(), [kinds, state.document.annotations.length]);
492
- const studioKinds = useMemo(() => listSceneKinds().filter((definition) => definition.addCategory !== undefined), []);
518
+ }, [session, ui, api, notify, copySelection, openBottomTab, paletteQuery]);
493
519
  const placeAsset = (entry) => {
494
520
  const focus = api.getFocusTarget();
495
521
  const focused = session.getState();
@@ -497,7 +523,7 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
497
523
  const selected = focused.document.markers.find((marker) => marker.id === focusedId)?.position ??
498
524
  focused.document.volumes.find((volume) => volume.id === focusedId)?.center;
499
525
  const position = focus ?? selected ?? { x: 0, y: 0, z: 0 };
500
- api.handle({
526
+ const response = api.handle({
501
527
  method: "place_asset",
502
528
  id: entry.id,
503
529
  kind: entry.kind === "model" ? "prop" : entry.kind,
@@ -505,16 +531,61 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
505
531
  y: position.y,
506
532
  z: position.z,
507
533
  });
534
+ if (response.ok)
535
+ notify(`Placed ${entry.label}`);
536
+ else
537
+ notify(`Place failed: ${response.error ?? "unknown error"}`, "error");
508
538
  };
539
+ const importModels = useCallback(async (files) => {
540
+ if (files.length === 0 || importBusy)
541
+ return;
542
+ setImportBusy(true);
543
+ try {
544
+ const next = await loadDroppedAssets(files, importAsset);
545
+ if (next.length === 0) {
546
+ notify("No .glb / .gltf models found in the drop", "error");
547
+ return;
548
+ }
549
+ const entries = next.map(editorAssetFromImport);
550
+ for (const entry of entries) {
551
+ if (entry.url !== undefined)
552
+ onRegisterAsset?.(entry.id, entry.url);
553
+ }
554
+ setImportedAssets((current) => mergeEditorAssets(current, entries));
555
+ layout.patch({ bottomOpen: true, bottomTab: "content" });
556
+ const durable = entries.filter((entry) => entry.url !== undefined && !entry.url.startsWith("blob:")).length;
557
+ const ephemeral = entries.length - durable;
558
+ if (ephemeral === 0) {
559
+ notify(entries.length === 1 ? `Imported ${entries[0].label}` : `Imported ${entries.length} models`);
560
+ }
561
+ else if (durable === 0) {
562
+ notify(entries.length === 1
563
+ ? `Imported ${entries[0].label} (session-only — no host importer)`
564
+ : `Imported ${entries.length} models (session-only — no host importer)`);
565
+ }
566
+ else {
567
+ notify(`Imported ${entries.length} models (${ephemeral} session-only)`);
568
+ }
569
+ }
570
+ catch (error) {
571
+ notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
572
+ }
573
+ finally {
574
+ setImportBusy(false);
575
+ }
576
+ }, [importAsset, importBusy, layout, notify, onRegisterAsset]);
509
577
  const importFile = (file) => {
510
578
  void file.text().then((text) => {
511
579
  try {
512
580
  session.dispatch({ type: "importJson", json: text });
513
581
  const doc = session.getState().document;
514
582
  notify(`Imported ${editorDocumentSize(doc)} objects from ${file.name}`);
583
+ consoleStore.log("info", "import", `Imported ${editorDocumentSize(doc)} objects from ${file.name}`);
515
584
  }
516
585
  catch (error) {
517
- notify(`Import failed: ${error instanceof Error ? error.message : "invalid JSON"}`, "error");
586
+ const message = error instanceof Error ? error.message : "invalid JSON";
587
+ notify(`Import failed: ${message}`, "error");
588
+ consoleStore.log("error", "import", `Import failed: ${message}`);
518
589
  }
519
590
  });
520
591
  };
@@ -527,45 +598,154 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
527
598
  notify("Clipboard unavailable — use Export instead", "error");
528
599
  }
529
600
  };
530
- const currentJson = useMemo(() => session.exportJson(true), [session, state.document]);
531
- const dirty = baselineJson !== undefined && currentJson !== baselineJson;
601
+ const exportJson = () => {
602
+ downloadText(`${gameId}-editor.json`, session.exportJson(true));
603
+ notify(`Exported ${gameId}-editor.json`);
604
+ };
605
+ // Reference compare, not a serialize: stringifying the whole document per edit dispatch burned
606
+ // milliseconds on every nudge/stroke. Undoing back to pristine may leave the dot on (history
607
+ // restores clones), which is the cheap side of wrong for an unsaved-edits hint.
608
+ const baselineDirty = baselineDocument !== undefined && state.document !== baselineDocument;
609
+ const dirty = docSave.available ? docSave.dirty : baselineDirty;
532
610
  const startPlacement = (tool) => {
533
- setAddOpen(false);
534
611
  ui.startPlacement(tool);
535
612
  };
613
+ const selectWorkspace = (workspace) => {
614
+ layout.setWorkspace(workspace);
615
+ if (workspace === "terrain")
616
+ ui.setTool("terrain");
617
+ else if (ui.getState().tool === "terrain")
618
+ ui.setTool("select");
619
+ };
620
+ const paletteObjects = useMemo(() => {
621
+ const doc = state.document;
622
+ const objects = [];
623
+ for (const marker of doc.markers)
624
+ objects.push({ id: marker.id, label: marker.label ?? marker.id, kind: marker.kind });
625
+ for (const volume of doc.volumes)
626
+ objects.push({ id: volume.id, label: volume.label ?? volume.id, kind: volume.kind });
627
+ for (const path of doc.paths)
628
+ objects.push({ id: path.id, label: path.label ?? path.id, kind: path.kind });
629
+ for (const note of doc.annotations)
630
+ objects.push({ id: note.id, label: note.text.slice(0, 48) || note.id, kind: "note" });
631
+ return objects;
632
+ }, [state.document]);
633
+ const paletteCommands = useMemo(() => buildPaletteCommands({
634
+ setGizmoMode: (mode) => ui.patch({ gizmoMode: mode }),
635
+ setTool: (tool) => ui.setTool(tool),
636
+ toggleGrid: () => ui.patch({ showGrid: !ui.getState().showGrid }),
637
+ toggleContours: () => ui.patch({ showContours: !ui.getState().showContours }),
638
+ toggleSurfaceGrid: () => ui.patch({ showSurfaceGrid: !ui.getState().showSurfaceGrid }),
639
+ toggleElevation: () => ui.patch({ showElevation: !ui.getState().showElevation }),
640
+ setSnapMode: (mode) => ui.patch({ snapMode: mode }),
641
+ frameAll: () => api.handle({ method: "camera_frame" }),
642
+ frameSelection: () => {
643
+ const selected = session.getState().selection[0];
644
+ if (selected !== undefined)
645
+ api.handle({ method: "camera_goto", id: selected });
646
+ else
647
+ api.handle({ method: "camera_frame" });
648
+ },
649
+ undo: () => session.dispatch({ type: "undo" }),
650
+ redo: () => session.dispatch({ type: "redo" }),
651
+ save: () => docSaveRef.current.doSave(),
652
+ exportJson,
653
+ importJson: () => importInputRef.current?.click(),
654
+ copyJson: copyExportJson,
655
+ setMode: (mode) => api.setMode(mode),
656
+ startPlacement,
657
+ openBottomTab,
658
+ toggleLeftDock: () => layout.patch({ leftOpen: !layout.getState().leftOpen }),
659
+ toggleRightDock: () => layout.patch({ rightOpen: !layout.getState().rightOpen }),
660
+ toggleHelp: () => setHelpOpen((value) => !value),
661
+ resetLayout: () => layout.reset(),
662
+ objects: paletteObjects,
663
+ gotoObject: (id) => {
664
+ session.dispatch({ type: "select", ids: [id] });
665
+ api.handle({ method: "camera_goto", id });
666
+ },
667
+ }), [api, layout, openBottomTab, paletteObjects, session, ui]);
536
668
  const placement = uiState.placement;
537
669
  const placementHint = placement === null
538
670
  ? null
539
671
  : placement.tool === "path"
540
672
  ? `Drawing ${placement.kind}: click to add points (${uiState.pathDraft.length}) · Enter finish · Esc cancel`
541
673
  : `Placing ${placement.tool === "note" ? "note" : placement.kind}: click the world · Shift-click places more · Esc cancel`;
542
- return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsxs("header", { className: "pointer-events-auto flex h-11 shrink-0 items-center gap-1.5 border-b border-white/[0.08] bg-gradient-to-b from-[#15171c]/95 to-[#0d0f13]/95 px-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] backdrop-blur-md", children: [_jsx("button", { type: "button", className: "rounded-md bg-gradient-to-br from-cyan-400/15 to-indigo-500/15 px-2 py-1 font-bold tracking-widest text-cyan-300 ring-1 ring-inset ring-cyan-400/25 transition-colors hover:from-cyan-400/25 hover:to-indigo-500/25", onClick: () => setLeftOpen((value) => !value), "aria-label": "Toggle hierarchy panel", children: "JG" }), _jsxs("span", { className: "hidden text-neutral-500 sm:inline", children: [gameId, dirty ? _jsx("span", { className: "ml-1 text-amber-400", title: "Unsaved edits \u2014 Export to save", children: "\u25CF" }) : null] }), _jsx("div", { className: "mx-1 h-5 w-px bg-white/[0.07]" }), _jsx("div", { className: "flex items-center gap-0.5 rounded-lg bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06]", children: ["select", "terrain"].map((tool) => (_jsx("button", { type: "button", className: `rounded-md px-2.5 py-1 capitalize transition-colors ${uiState.tool === tool ? "bg-amber-500/90 text-white shadow-sm shadow-amber-950/50" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => ui.setTool(tool), children: tool }, tool))) }), _jsx("div", { className: "h-5 w-px bg-white/[0.07]" }), _jsx("div", { className: `flex items-center gap-0.5 rounded-lg bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06] ${uiState.tool === "terrain" ? "opacity-40" : ""}`, children: ["translate", "rotate", "scale"].map((mode) => (_jsxs("button", { type: "button", className: `rounded-md px-2.5 py-1 capitalize transition-colors ${gizmoMode === mode ? "bg-cyan-500/90 text-white shadow-sm shadow-cyan-950/50" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => ui.patch({ gizmoMode: mode }), children: [mode, " ", _jsx("kbd", { className: `ml-0.5 font-sans text-[9px] ${gizmoMode === mode ? "text-cyan-100/80" : "text-neutral-500"}`, children: mode === "translate" ? "W" : mode === "rotate" ? "E" : "R" })] }, mode))) }), _jsx("div", { className: "h-5 w-px bg-white/[0.07]" }), _jsxs("div", { className: "relative", children: [_jsx("button", { type: "button", className: `rounded-md px-2.5 py-1 transition-colors ${addOpen || placement !== null ? "bg-cyan-500/90 text-white shadow-sm shadow-cyan-950/50" : "bg-white/[0.04] ring-1 ring-inset ring-white/[0.06] hover:bg-white/10"}`, onClick: () => setAddOpen((value) => !value), children: "+ Add" }), addOpen ? (_jsxs("div", { className: "absolute left-0 top-9 z-50 max-h-[60vh] w-56 overflow-auto rounded-xl border border-white/10 bg-[#111318]/95 p-1.5 shadow-2xl shadow-black/60 backdrop-blur-md", children: [_jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement(entry.tool), children: entry.label }, entry.label))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Studios" }), studioKinds.map((definition) => (_jsxs("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left transition-colors hover:bg-emerald-500/15 hover:text-emerald-100", style: { color: definition.accent ?? "#34d399" }, onClick: () => startPlacement(definition.target === "path"
543
- ? { tool: "path", kind: definition.kind }
544
- : definition.target === "volume"
545
- ? { tool: "volume", kind: definition.kind, shape: "box" }
546
- : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Other" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "note" }), children: "Note" })] })) : null] }), _jsxs("button", { type: "button", className: BTN, onClick: () => {
547
- const at = SNAP_MODES.findIndex((entry) => entry.mode === uiState.snapMode);
548
- ui.patch({ snapMode: SNAP_MODES[(at + 1) % SNAP_MODES.length].mode });
549
- }, children: [SNAP_MODES.find((entry) => entry.mode === uiState.snapMode)?.label, uiState.snapMode === "grid" ? ` ${uiState.gridSize}` : ""] }), uiState.snapMode === "grid" ? (_jsx("button", { type: "button", className: BTN, onClick: () => ui.patch({ gridSize: uiState.gridSize >= 8 ? 0.5 : uiState.gridSize * 2 }), children: "\u00B1" })) : null, _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showGrid ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showGrid: !uiState.showGrid }), children: "Grid G" }), _jsx("button", { type: "button", title: "Surface-following iso-elevation contours", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showContours ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showContours: !uiState.showContours }), children: "Contours" }), _jsx("button", { type: "button", title: "Terrain-draped reference grid", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showSurfaceGrid ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showSurfaceGrid: !uiState.showSurfaceGrid }), children: "Drape" }), _jsx("button", { type: "button", title: "Measurable elevation readout", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showElevation ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showElevation: !uiState.showElevation }), children: "Elev" }), _jsx("div", { className: "h-5 w-px bg-white/[0.07]" }), _jsx("button", { type: "button", className: BTN, onClick: () => api.handle({ method: "camera_frame" }), children: "Frame all" }), _jsx("button", { type: "button", className: `${BTN} disabled:opacity-40`, onClick: () => session.dispatch({ type: "undo" }), disabled: !session.canUndo(), children: "Undo" }), _jsx("button", { type: "button", className: `${BTN} disabled:opacity-40`, onClick: () => session.dispatch({ type: "redo" }), disabled: !session.canRedo(), children: "Redo" }), _jsx("button", { type: "button", className: BTN, onClick: () => showPanel("assets"), children: "Assets" }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx("button", { type: "button", className: BTN, onClick: () => setRightOpen((value) => !value), children: "Inspector" }), _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset transition-colors ${agentOpen
550
- ? "bg-violet-500/25 text-violet-100 ring-violet-400/40"
551
- : "bg-white/[0.04] text-neutral-300 ring-white/[0.06] hover:bg-white/10 hover:text-neutral-100"}`, onClick: () => setAgentOpen((value) => !value), title: "Embedded agent panel \u2014 same RPC/undo as the GUI", children: "Agent" }), _jsx("button", { type: "button", className: BTN, onClick: () => api.setMode("walk"), children: "Walk" }), _jsx("button", { type: "button", className: "rounded-md bg-gradient-to-b from-emerald-500 to-emerald-600 px-3 py-1 font-semibold text-white shadow-md shadow-emerald-950/50 transition-colors hover:from-emerald-400 hover:to-emerald-500", onClick: () => api.setMode("play"), children: "\u25B6 Play" }), _jsx("button", { type: "button", className: BTN, onClick: () => importInputRef.current?.click(), children: "Import" }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
552
- const file = event.target.files?.[0];
553
- if (file !== undefined)
554
- importFile(file);
555
- event.target.value = "";
556
- } }), docSave.available ? (_jsx("button", { type: "button", className: `rounded-md px-3 py-1 font-medium transition-colors ${docSave.saveState === "error"
557
- ? "bg-rose-600/90 text-white hover:bg-rose-500"
558
- : docSave.dirty
559
- ? "bg-gradient-to-b from-cyan-400 to-cyan-600 text-white shadow-md shadow-cyan-950/50 hover:from-cyan-300 hover:to-cyan-500"
560
- : "bg-white/[0.04] text-neutral-400 ring-1 ring-inset ring-white/[0.06] hover:bg-white/10"}`, onClick: docSave.doSave, disabled: docSave.saveState === "saving", title: docSave.saveError ?? "Write the scene to Games/<id>/src/editor.scene.json (Ctrl+S)", children: docSave.saveState === "saving"
561
- ? "Saving…"
562
- : docSave.saveState === "error"
563
- ? "Save failed"
564
- : docSave.dirty
565
- ? "Save"
566
- : "Saved ✓" })) : null, _jsx("button", { type: "button", className: "rounded-md bg-cyan-500/20 px-2.5 py-1 font-medium text-cyan-200 ring-1 ring-inset ring-cyan-400/30 transition-colors hover:bg-cyan-500/30", onClick: () => { downloadText(`${gameId}-editor.json`, session.exportJson(true)); notify(`Exported ${gameId}-editor.json`); }, children: "Export" }), _jsx("button", { type: "button", className: BTN, title: "Copy document JSON to clipboard", onClick: copyExportJson, children: "\u29C9" }), _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors ${helpOpen ? "bg-white/15 text-neutral-100" : "bg-white/[0.04] hover:bg-white/10"}`, title: "Keyboard shortcuts (?)", onClick: () => setHelpOpen((value) => !value), children: "?" })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [leftOpen ? (_jsxs("aside", { className: "pointer-events-auto flex w-72 min-w-56 max-w-[42vw] resize-x flex-col overflow-hidden border-r border-white/[0.08] bg-[#0d0f13]/95 backdrop-blur-md", children: [_jsxs("div", { className: "flex items-center gap-1 border-b border-white/[0.08] p-2", children: [_jsx("button", { type: "button", className: `rounded-md px-2 py-1 font-medium transition-colors ${activePanel === "outliner" ? "bg-white/10 text-neutral-100" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => showPanel("outliner"), children: "Hierarchy" }), _jsxs("button", { type: "button", className: `rounded-md px-2 py-1 font-medium transition-colors ${activePanel === "collections" ? "bg-white/10 text-neutral-100" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => showPanel("collections"), children: ["Sets ", state.document.collections.length] }), _jsxs("button", { type: "button", className: `rounded-md px-2 py-1 font-medium transition-colors ${activePanel === "prefabs" ? "bg-white/10 text-neutral-100" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => showPanel("prefabs"), children: ["Prefabs ", state.document.prefabs.length] }), _jsxs("button", { type: "button", className: `rounded-md px-2 py-1 font-medium transition-colors ${activePanel === "catalogs" ? "bg-white/10 text-neutral-100" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => showPanel("catalogs"), children: ["Data ", api.getCatalogDefinitions().length] }), _jsxs("button", { type: "button", className: "rounded-md px-2 py-1 text-neutral-400 transition-colors hover:bg-white/[0.06] hover:text-neutral-200", onClick: () => showPanel("assets"), children: ["Assets ", assets.length] }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => setLeftOpen(false), "aria-label": "Close hierarchy panel", children: "\u00D7" })] }), activePanel === "collections" ? (_jsx(CollectionsPanel, { session: session })) : activePanel === "prefabs" ? (_jsx(PrefabsPanel, { session: session, api: api })) : activePanel === "catalogs" ? (_jsx(CatalogsPanel, { session: session, definitions: api.getCatalogDefinitions() })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "border-b border-white/[0.08] p-2", children: [_jsx("div", { className: `mb-1.5 ${MICRO}`, children: "Layers" }), _jsxs("div", { className: "flex max-h-24 flex-wrap gap-1 overflow-auto", children: [allKinds.map((kind) => (_jsxs("label", { className: `cursor-pointer rounded-full px-2.5 py-0.5 ring-1 ring-inset transition-colors ${visibility[kind] !== false ? "bg-cyan-500/15 text-cyan-200 ring-cyan-400/25" : "bg-black/30 text-neutral-600 ring-white/[0.06] hover:text-neutral-400"}`, children: [_jsx("input", { type: "checkbox", className: "sr-only", checked: visibility[kind] !== false, onChange: (event) => { api.setVisibility({ ...api.getVisibility(), [kind]: event.target.checked }); setTick((value) => value + 1); } }), kind] }, kind))), allKinds.length === 0 ? _jsx("span", { className: "text-neutral-600", children: "No authored layers" }) : null] })] }), _jsx(OutlinerPanel, { session: session, api: api })] }))] })) : null, _jsxs("main", { className: "pointer-events-none relative min-w-0 flex-1", children: [uiState.tool === "terrain" ? _jsx(TerrainPanel, { session: session, ui: ui }) : null, placementHint !== null ? (_jsx("div", { className: "absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full border border-cyan-400/30 bg-cyan-950/85 px-4 py-1.5 text-[11px] text-cyan-100 shadow-lg shadow-cyan-950/40 backdrop-blur-md", children: placementHint })) : null, toast !== null ? (_jsx("div", { className: `absolute left-1/2 top-10 -translate-x-1/2 whitespace-nowrap rounded-full border px-4 py-1.5 text-[11px] shadow-lg backdrop-blur-md ${toast.tone === "error" ? "border-rose-400/40 bg-rose-950/90 text-rose-100 shadow-rose-950/40" : "border-emerald-400/30 bg-emerald-950/90 text-emerald-100 shadow-emerald-950/40"}`, children: toast.text })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-2xl border border-white/10 bg-[#101216]/95 p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsx("div", { className: "text-sm font-semibold tracking-wide text-neutral-100", children: "Keyboard shortcuts" }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => setHelpOpen(false), "aria-label": "Close shortcuts", children: "\u00D7" })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: SHORTCUTS.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("kbd", { className: "justify-self-start whitespace-nowrap rounded-md bg-white/[0.06] px-1.5 py-0.5 font-mono text-[10px] text-cyan-200 ring-1 ring-inset ring-white/10", children: entry.keys }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null, uiState.contextMenu !== null ? (_jsx(EditorContextMenu, { x: uiState.contextMenu.clientX, y: uiState.contextMenu.clientY, actions: buildEditorContextMenu({
567
- hitId: uiState.contextMenu.hitId,
568
- selection: state.selection,
569
- canPaste: clipboardFragment !== null,
570
- }), onPick: runContextAction, onClose: closeContextMenu })) : null, _jsxs("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full border border-white/[0.08] bg-black/70 px-4 py-1.5 text-[11px] text-neutral-400 shadow-lg shadow-black/40 backdrop-blur-md", children: [_jsx("span", { children: "RMB orbit \u00B7 MMB pan \u00B7 click select \u00B7 RMB menu \u00B7 W/E/R \u00B7 Ctrl+C/V \u00B7 F frame \u00B7 ? help" }), _jsxs("span", { className: "ml-3 text-neutral-500", children: [sceneStats.objects, " objs", sceneStats.foliage > 0 ? ` · ≈${formatTriangles(sceneStats.foliage)} foliage` : ""] }), perf !== null ? _jsx(PerfPill, { perf: perf }) : null] })] }), rightOpen ? _jsx(InspectorPanel, { session: session, ui: ui, onClose: () => setRightOpen(false) }) : null, agentOpen ? _jsx(AgentPanel, { api: api, onClose: () => setAgentOpen(false) }) : null] }), bottomOpen ? (_jsxs("section", { className: "pointer-events-auto flex h-64 min-h-40 max-h-[55vh] resize-y flex-col overflow-hidden border-t border-white/[0.08] bg-[#0d0f13]/95 backdrop-blur-md", children: [_jsxs("div", { className: "flex items-center border-b border-white/[0.08] px-3 py-2", children: [_jsx("div", { className: MICRO, children: "Asset browser" }), _jsxs("span", { className: "ml-2 text-neutral-600", children: [assets.length, " assets \u00B7 Ctrl+B"] }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => setBottomOpen(false), "aria-label": "Close asset browser", children: "\u00D7" })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-hidden p-2", children: _jsx(AssetBrowser, { assets: assets, session: session, onPlace: placeAsset }) })] })) : null] }));
674
+ const railActive = uiState.tool === "terrain" ? "terrain" : layoutState.workspace;
675
+ return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsx(TopAppBar, { gameId: gameId, dirty: dirty, saveState: docSave.saveState, lastSavedAt: docSave.lastSavedAt, saveAvailable: docSave.available, saveError: docSave.saveError, canUndo: session.canUndo(), canRedo: session.canRedo(), onUndo: () => session.dispatch({ type: "undo" }), onRedo: () => session.dispatch({ type: "redo" }), onSave: docSave.doSave, onPlay: () => api.setMode("play"), onWalk: () => api.setMode("walk"), onHud: () => api.setMode("hud"), onImport: () => importInputRef.current?.click(), onExport: exportJson, onCopyJson: copyExportJson, onOpenPalette: () => setPaletteQuery(""), onToggleHelp: () => setHelpOpen((value) => !value), onResetLayout: () => layout.reset() }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
676
+ const file = event.target.files?.[0];
677
+ if (file !== undefined)
678
+ importFile(file);
679
+ event.target.value = "";
680
+ } }), _jsx(SceneToolbar, { tool: uiState.tool, gizmoMode: uiState.gizmoMode, gizmoSpace: uiState.gizmoSpace, gizmoPivot: uiState.gizmoPivot, snapMode: uiState.snapMode, gridSize: uiState.gridSize, rotationSnapDeg: uiState.rotationSnapDeg, scaleSnap: uiState.scaleSnap, cameraProjection: uiState.cameraProjection, showGrid: uiState.showGrid, showContours: uiState.showContours, showSurfaceGrid: uiState.showSurfaceGrid, showElevation: uiState.showElevation, placementActive: placement !== null, onSetTool: (tool) => ui.setTool(tool), onSetGizmoMode: (mode) => ui.patch({ gizmoMode: mode }), onSetGizmoSpace: (space) => ui.patch({ gizmoSpace: space }), onSetGizmoPivot: (pivot) => ui.patch({ gizmoPivot: pivot }), onSetSnapMode: (mode) => ui.patch({ snapMode: mode }), onSetGridSize: (size) => ui.patch({ gridSize: size }), onSetRotationSnapDeg: (deg) => ui.patch({ rotationSnapDeg: deg }), onSetScaleSnap: (snap) => ui.patch({ scaleSnap: snap }), onSetCameraProjection: (projection) => ui.patch({ cameraProjection: projection }), onToggleGrid: () => ui.patch({ showGrid: !uiState.showGrid }), onToggleContours: () => ui.patch({ showContours: !uiState.showContours }), onToggleSurfaceGrid: () => ui.patch({ showSurfaceGrid: !uiState.showSurfaceGrid }), onToggleElevation: () => ui.patch({ showElevation: !uiState.showElevation }), onFrame: () => {
681
+ const selected = session.getState().selection[0];
682
+ if (selected !== undefined)
683
+ api.handle({ method: "camera_goto", id: selected });
684
+ else
685
+ api.handle({ method: "camera_frame" });
686
+ }, onStartPlacement: startPlacement, onOpenAssistant: () => openBottomTab("assistant"), assistantOpen: layoutState.bottomOpen && layoutState.bottomTab === "assistant" }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsx(WorkspaceRail, { active: railActive, onSelect: selectWorkspace }), layoutState.leftOpen ? (_jsxs(_Fragment, { children: [_jsxs("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-r ${BORDER} bg-[#111318] sm:flex`, style: { width: layoutState.leftWidth }, "aria-label": layoutState.workspace === "multiplayer"
687
+ ? "Network workspace dock"
688
+ : layoutState.workspace === "materials"
689
+ ? "Materials workspace dock"
690
+ : layoutState.workspace === "scripting"
691
+ ? "Scripting workspace dock"
692
+ : "Scene hierarchy dock", children: [_jsxs("div", { className: `flex h-8 shrink-0 items-center gap-1 border-b ${BORDER} px-1.5`, children: [layoutState.workspace === "multiplayer" ? (_jsx("span", { className: "px-2 text-[11px] font-medium text-neutral-200", children: "Network" })) : layoutState.workspace === "materials" ? (_jsx("span", { className: "px-2 text-[11px] text-neutral-100", children: "Materials" })) : layoutState.workspace === "scripting" ? (_jsxs(_Fragment, { children: [_jsx(Icon, { name: "script", size: 13, className: "ml-1 text-amber-300" }), _jsx("span", { className: "px-1 text-[11px] text-neutral-200", children: "Scripting" })] })) : (LEFT_PAGES.map((page) => {
693
+ const badge = page.id === "collections"
694
+ ? state.document.collections.length
695
+ : page.id === "prefabs"
696
+ ? state.document.prefabs.length
697
+ : page.id === "catalogs"
698
+ ? api.getCatalogDefinitions().length
699
+ : null;
700
+ const selected = layoutState.leftPage === page.id;
701
+ return (_jsxs("button", { type: "button", role: "tab", "aria-selected": selected, onClick: () => layout.patch({ leftPage: page.id }), className: `flex h-6.5 items-center gap-1 rounded-[5px] px-2 text-[11px] transition-colors ${FOCUS_RING} ${selected ? "bg-white/[0.08] text-neutral-100" : "text-neutral-500 hover:bg-white/[0.04] hover:text-neutral-300"}`, children: [page.label, badge !== null && badge > 0 ? _jsx("span", { className: "text-[9px] tabular-nums text-neutral-500", children: badge }) : null] }, page.id));
702
+ })), _jsx(IconButton, { icon: "close", label: "Collapse left panel", size: 11, tone: "ghost", className: "ml-auto", onClick: () => layout.patch({ leftOpen: false }) })] }), layoutState.workspace === "multiplayer" ? (_jsx(NetworkWorkspacePanel, { snapshot: networkSnapshot })) : layoutState.workspace === "materials" ? (_jsx(MaterialsWorkspacePanel, { session: session, api: api })) : layoutState.workspace === "scripting" ? (_jsx(ScriptingPanel, { session: session, api: api })) : layoutState.leftPage === "collections" ? (_jsx(CollectionsPanel, { session: session })) : layoutState.leftPage === "prefabs" ? (_jsx(PrefabsPanel, { session: session, api: api })) : layoutState.leftPage === "catalogs" ? (_jsx(CatalogsPanel, { session: session, definitions: api.getCatalogDefinitions() })) : (_jsx(HierarchyPanel, { session: session, api: api, onAdd: () => setPaletteQuery("add "), onRowContextMenu: (point, id) => {
703
+ const current = session.getState().selection;
704
+ if (!current.includes(id))
705
+ session.dispatch({ type: "select", ids: [id] });
706
+ ui.patch({
707
+ contextMenu: {
708
+ clientX: point.clientX,
709
+ clientY: point.clientY,
710
+ hitId: id,
711
+ ground: null,
712
+ },
713
+ });
714
+ } }))] }), _jsx(PanelResizer, { orientation: "vertical", label: "Resize hierarchy panel", onResize: (delta) => layout.resize("leftWidth", delta) })] })) : null, _jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [_jsxs("main", { className: "pointer-events-none relative min-h-0 min-w-0 flex-1", children: [_jsx(PerformanceOverlay, { api: api }), uiState.tool === "terrain" ? (_jsx(TerrainPanel, { session: session, ui: ui, api: api })) : layoutState.workspace === "lighting" ? (_jsx(LightingPanel, { session: session })) : (_jsx(ViewportUtilityPanel, { document: state.document, api: api, selectionCount: state.selection.length })), _jsx(OrientationWidget, {}), !layoutState.leftOpen ? (_jsx("div", { className: "pointer-events-auto absolute left-2.5 top-1/2 z-30 -translate-y-1/2", children: _jsx(IconButton, { icon: "chevronRight", label: "Open hierarchy panel", onClick: () => layout.patch({ leftOpen: true }) }) })) : null, !layoutState.rightOpen ? (_jsx("div", { className: "pointer-events-auto absolute right-2.5 top-1/2 z-30 -translate-y-1/2", children: _jsx(IconButton, { icon: "panel", label: "Open inspector panel", onClick: () => layout.patch({ rightOpen: true }) }) })) : null, placementHint !== null ? (_jsx("div", { className: "absolute left-1/2 top-2 z-40 -translate-x-1/2 whitespace-nowrap rounded-full border border-cyan-400/30 bg-cyan-950/85 px-4 py-1.5 text-[11px] text-cyan-100 shadow-lg shadow-cyan-950/40 backdrop-blur-md", children: placementHint })) : null, toast !== null ? (_jsx("div", { className: `absolute left-1/2 top-10 z-40 -translate-x-1/2 whitespace-nowrap rounded-full border px-4 py-1.5 text-[11px] shadow-lg backdrop-blur-md ${toast.tone === "error"
715
+ ? "border-rose-400/40 bg-rose-950/90 text-rose-100 shadow-rose-950/40"
716
+ : "border-emerald-400/30 bg-emerald-950/90 text-emerald-100 shadow-emerald-950/40"}`, children: toast.text })) : null, uiState.contextMenu !== null ? (_jsx(EditorContextMenu, { x: uiState.contextMenu.clientX, y: uiState.contextMenu.clientY, actions: buildEditorContextMenu({
717
+ hitId: uiState.contextMenu.hitId,
718
+ selection: state.selection,
719
+ canPaste: clipboardFragment !== null,
720
+ canUnparent: (() => {
721
+ const ids = state.selection.length > 0
722
+ ? state.selection
723
+ : uiState.contextMenu.hitId !== null
724
+ ? [uiState.contextMenu.hitId]
725
+ : [];
726
+ return ids.some((id) => editorParentOf(state.document, id) !== undefined);
727
+ })(),
728
+ }), onPick: runContextAction, onClose: closeContextMenu })) : null, parentPicker !== null ? (_jsx(ParentPickerMenu, { x: parentPicker.clientX, y: parentPicker.clientY, candidates: listParentCandidates(state.document, parentPicker.ids), onPick: (parentId) => {
729
+ const ids = [...parentPicker.ids];
730
+ setParentPicker(null);
731
+ const result = api.handle({ method: "set_parent", ids, parentId });
732
+ if (!result.ok) {
733
+ notify(result.error ?? "Parent to failed", "error");
734
+ return;
735
+ }
736
+ if (parentId === null) {
737
+ notify(ids.length === 1 ? "Unparented object" : `Unparented ${ids.length} objects`);
738
+ }
739
+ else {
740
+ notify(ids.length === 1
741
+ ? `Parented to ${parentId}`
742
+ : `Parented ${ids.length} objects to ${parentId}`);
743
+ }
744
+ }, onClose: () => setParentPicker(null) })) : null] }), layoutState.bottomOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "horizontal", label: "Resize bottom dock", sign: -1, onResize: (delta) => layout.resize("bottomHeight", delta) }), _jsx("div", { className: "flex shrink-0 flex-col", style: { height: layoutState.bottomHeight }, children: _jsx(BottomDock, { tab: layoutState.bottomTab, onSelectTab: (tab) => layout.patch({ bottomTab: tab }), onClose: () => layout.patch({ bottomOpen: false }), assets: liveAssets, session: session, api: api, consoleStore: consoleStore, perfHistory: perfHistory, browserView: layoutState.browserView, onSetBrowserView: (view) => layout.patch({ browserView: view }), onPlaceAsset: placeAsset, onImportModels: importModels, importBusy: importBusy }) })] })) : (_jsxs("div", { className: `pointer-events-auto flex h-7 shrink-0 items-center gap-1 border-t ${BORDER} bg-[#0e1014] px-1.5`, children: [[
745
+ ["content", "Content Browser"],
746
+ ["console", "Console"],
747
+ ["profiler", "Profiler"],
748
+ ["animation", "Animation"],
749
+ ["assistant", "AI Assistant"],
750
+ ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: true }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: SHORTCUTS.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
571
751
  }