@jgengine/editor 0.10.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 (179) hide show
  1. package/CHANGELOG.md +27 -1
  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 +208 -37
  16. package/dist/EditorChrome.d.ts +25 -6
  17. package/dist/EditorChrome.js +310 -118
  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 +69 -2
  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/TerrainReadout.d.ts +17 -0
  41. package/dist/TerrainReadout.js +163 -0
  42. package/dist/TerrainReadoutHud.d.ts +9 -0
  43. package/dist/TerrainReadoutHud.js +20 -0
  44. package/dist/TerrainSculpt.js +98 -59
  45. package/dist/agent/AgentPanel.d.ts +5 -2
  46. package/dist/agent/AgentPanel.js +11 -6
  47. package/dist/agent/toolBridge.js +5 -0
  48. package/dist/assetImport.d.ts +36 -0
  49. package/dist/assetImport.js +66 -0
  50. package/dist/authoredComponentMeta.d.ts +25 -0
  51. package/dist/authoredComponentMeta.js +65 -0
  52. package/dist/gizmoPivot.d.ts +4 -0
  53. package/dist/gizmoPivot.js +34 -0
  54. package/dist/handlers/catalogs.d.ts +3 -0
  55. package/dist/handlers/catalogs.js +137 -0
  56. package/dist/handlers/collections.d.ts +3 -0
  57. package/dist/handlers/collections.js +57 -0
  58. package/dist/handlers/context.d.ts +36 -0
  59. package/dist/handlers/context.js +1 -0
  60. package/dist/handlers/document.d.ts +3 -0
  61. package/dist/handlers/document.js +350 -0
  62. package/dist/handlers/grids.d.ts +3 -0
  63. package/dist/handlers/grids.js +142 -0
  64. package/dist/handlers/hierarchy.d.ts +3 -0
  65. package/dist/handlers/hierarchy.js +55 -0
  66. package/dist/handlers/index.d.ts +8 -0
  67. package/dist/handlers/index.js +23 -0
  68. package/dist/handlers/minimap.d.ts +9 -0
  69. package/dist/handlers/minimap.js +32 -0
  70. package/dist/handlers/runtime.d.ts +3 -0
  71. package/dist/handlers/runtime.js +191 -0
  72. package/dist/handlers/terrain.d.ts +3 -0
  73. package/dist/handlers/terrain.js +204 -0
  74. package/dist/index.d.ts +3 -2
  75. package/dist/index.js +3 -2
  76. package/dist/listDocumentTriggers.d.ts +42 -0
  77. package/dist/listDocumentTriggers.js +49 -0
  78. package/dist/materialAssignments.d.ts +63 -0
  79. package/dist/materialAssignments.js +84 -0
  80. package/dist/mcp/fieldSpec.d.ts +32 -0
  81. package/dist/mcp/fieldSpec.js +173 -0
  82. package/dist/mcp/rpcRequest.d.ts +8 -5
  83. package/dist/mcp/rpcRequest.js +72 -83
  84. package/dist/mcp/tools.js +104 -763
  85. package/dist/networkSnapshot.d.ts +82 -0
  86. package/dist/networkSnapshot.js +73 -0
  87. package/dist/parentCandidates.d.ts +12 -0
  88. package/dist/parentCandidates.js +25 -0
  89. package/dist/perfPill.d.ts +21 -0
  90. package/dist/perfPill.js +20 -0
  91. package/dist/session.d.ts +167 -5
  92. package/dist/session.js +36 -879
  93. package/dist/shell/AnimationPanel.d.ts +17 -0
  94. package/dist/shell/AnimationPanel.js +134 -0
  95. package/dist/shell/AssetThumbnail.d.ts +12 -0
  96. package/dist/shell/AssetThumbnail.js +23 -0
  97. package/dist/shell/BottomDock.d.ts +25 -0
  98. package/dist/shell/BottomDock.js +40 -0
  99. package/dist/shell/CommandPalette.d.ts +12 -0
  100. package/dist/shell/CommandPalette.js +56 -0
  101. package/dist/shell/ConsolePanel.d.ts +8 -0
  102. package/dist/shell/ConsolePanel.js +45 -0
  103. package/dist/shell/ContentBrowser.d.ts +21 -0
  104. package/dist/shell/ContentBrowser.js +130 -0
  105. package/dist/shell/HierarchyPanel.d.ts +26 -0
  106. package/dist/shell/HierarchyPanel.js +387 -0
  107. package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
  108. package/dist/shell/NetworkWorkspacePanel.js +49 -0
  109. package/dist/shell/PlayModeBar.d.ts +12 -0
  110. package/dist/shell/PlayModeBar.js +27 -0
  111. package/dist/shell/ProfilerPanel.d.ts +10 -0
  112. package/dist/shell/ProfilerPanel.js +41 -0
  113. package/dist/shell/SceneToolbar.d.ts +43 -0
  114. package/dist/shell/SceneToolbar.js +122 -0
  115. package/dist/shell/StatusBar.d.ts +18 -0
  116. package/dist/shell/StatusBar.js +41 -0
  117. package/dist/shell/TopAppBar.d.ts +33 -0
  118. package/dist/shell/TopAppBar.js +64 -0
  119. package/dist/shell/ViewportOverlays.d.ts +23 -0
  120. package/dist/shell/ViewportOverlays.js +136 -0
  121. package/dist/shell/WorkspaceRail.d.ts +20 -0
  122. package/dist/shell/WorkspaceRail.js +31 -0
  123. package/dist/shell/cameraTelemetry.d.ts +27 -0
  124. package/dist/shell/cameraTelemetry.js +24 -0
  125. package/dist/shell/commandRegistry.d.ts +58 -0
  126. package/dist/shell/commandRegistry.js +133 -0
  127. package/dist/shell/consoleSink.d.ts +11 -0
  128. package/dist/shell/consoleSink.js +17 -0
  129. package/dist/shell/consoleStore.d.ts +27 -0
  130. package/dist/shell/consoleStore.js +43 -0
  131. package/dist/shell/fields.d.ts +39 -0
  132. package/dist/shell/fields.js +104 -0
  133. package/dist/shell/formatSavedRelative.d.ts +2 -0
  134. package/dist/shell/formatSavedRelative.js +14 -0
  135. package/dist/shell/glbThumbnailCache.d.ts +45 -0
  136. package/dist/shell/glbThumbnailCache.js +241 -0
  137. package/dist/shell/hierarchyA11y.d.ts +21 -0
  138. package/dist/shell/hierarchyA11y.js +35 -0
  139. package/dist/shell/icons.d.ts +13 -0
  140. package/dist/shell/icons.js +85 -0
  141. package/dist/shell/layoutStore.d.ts +68 -0
  142. package/dist/shell/layoutStore.js +156 -0
  143. package/dist/shell/pathFlythrough.d.ts +37 -0
  144. package/dist/shell/pathFlythrough.js +54 -0
  145. package/dist/shell/perfHistory.d.ts +70 -0
  146. package/dist/shell/perfHistory.js +130 -0
  147. package/dist/shell/renameObject.d.ts +3 -0
  148. package/dist/shell/renameObject.js +25 -0
  149. package/dist/shell/theme.d.ts +36 -0
  150. package/dist/shell/theme.js +37 -0
  151. package/dist/shell/ui.d.ts +84 -0
  152. package/dist/shell/ui.js +90 -0
  153. package/dist/shell/useGlbThumbnail.d.ts +8 -0
  154. package/dist/shell/useGlbThumbnail.js +19 -0
  155. package/dist/terrainReadoutStore.d.ts +19 -0
  156. package/dist/terrainReadoutStore.js +36 -0
  157. package/dist/uiStore.d.ts +33 -0
  158. package/dist/uiStore.js +13 -0
  159. package/dist/viewportContextMenu.d.ts +4 -2
  160. package/dist/viewportContextMenu.js +5 -1
  161. package/package.json +4 -4
  162. package/skills/jgengine/SKILL.md +35 -804
  163. package/skills/jgengine/api.md +190 -79
  164. package/skills/jgengine/capabilities.md +112 -2
  165. package/skills/jgengine/recipes/minimal-game.md +88 -0
  166. package/skills/jgengine-editor/SKILL.md +29 -362
  167. package/skills/jgengine-editor/api.md +519 -18
  168. package/skills/jgengine-editor/capabilities.md +91 -0
  169. package/skills/jgengine-editor/reference.md +113 -0
  170. package/skills/level-design/SKILL.md +54 -0
  171. package/skills/level-design/agents/openai.yaml +4 -0
  172. package/skills/level-design/references/application-playbook.md +192 -0
  173. package/skills/level-design/references/field-guide.md +337 -0
  174. package/skills/level-design/references/live-editor-loop.md +179 -0
  175. package/dist/OutlinerPanel.d.ts +0 -14
  176. package/dist/OutlinerPanel.js +0 -84
  177. package/skills/jgengine/reference-cartridge.md +0 -72
  178. package/skills/jgengine/reference-orphans.md +0 -9
  179. package/skills/jgengine/reference-systems.md +0 -60
@@ -1,55 +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";
15
+ import { importAssetToHost, loadDroppedAssets, } from "./assetImport.js";
16
16
  import { newPlacementId } from "./uiStore.js";
17
17
  import { useF2Chord } from "./useF2Chord.js";
18
- import { BTN, MICRO } from "./chromeStyles.js";
19
18
  import { TerrainPanel } from "./TerrainPanel.js";
19
+ import { LightingPanel } from "./LightingPanel.js";
20
20
  import { InspectorPanel } from "./InspectorPanel.js";
21
- const PERF_POLL_MS = 500;
22
- const ADD_VOLUME_ENTRIES = [
23
- { label: "Zone (sphere)", tool: { tool: "volume", kind: "zone", shape: "sphere" } },
24
- { label: "Zone (box)", tool: { tool: "volume", kind: "zone", shape: "box" } },
25
- { label: "Zone (cylinder)", tool: { tool: "volume", kind: "zone", shape: "cylinder" } },
26
- { label: "Aggro range", tool: { tool: "volume", kind: "aggro", shape: "sphere" } },
27
- { label: "Leash range", tool: { tool: "volume", kind: "leash", shape: "sphere" } },
28
- { label: "Discover area", tool: { tool: "volume", kind: "discover", shape: "sphere" } },
29
- { label: "Capture area", tool: { tool: "volume", kind: "capture", shape: "cylinder" } },
30
- { label: "Vegetation (box)", tool: { tool: "volume", kind: VEGETATION_VOLUME_KIND, shape: "box" } },
31
- { label: "Vegetation (circle)", tool: { tool: "volume", kind: VEGETATION_VOLUME_KIND, shape: "sphere" } },
32
- ];
33
- const SNAP_MODES = [
34
- { mode: "ground", label: "Snap: ground" },
35
- { mode: "grid", label: "Snap: grid" },
36
- { mode: "off", label: "Snap: off" },
37
- ];
38
- function usePerfSample(api) {
39
- const [sample, setSample] = useState(null);
40
- useEffect(() => {
41
- const timer = setInterval(() => setSample(api.getPerf()), PERF_POLL_MS);
42
- return () => clearInterval(timer);
43
- }, [api]);
44
- return sample;
45
- }
46
- function formatTriangles(count) {
47
- if (count >= 1_000_000)
48
- return `${(count / 1_000_000).toFixed(1)}M`;
49
- if (count >= 1_000)
50
- return `${(count / 1_000).toFixed(0)}k`;
51
- return String(count);
52
- }
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";
53
39
  let clipboardFragment = null;
54
40
  const SHORTCUTS = [
55
41
  { keys: "W / E / R", action: "Move · rotate · scale gizmo" },
@@ -57,13 +43,15 @@ const SHORTCUTS = [
57
43
  { keys: "F", action: "Frame selection (or whole scene)" },
58
44
  { keys: "G", action: "Toggle reference grid" },
59
45
  { keys: "N", action: "Cycle instances of selected row" },
46
+ { keys: "Ctrl+K", action: "Command palette" },
60
47
  { keys: "Arrows", action: "Nudge selection on X/Z (Shift ×5)" },
61
48
  { keys: "PgUp / PgDn", action: "Nudge selection on Y (Shift ×5)" },
62
49
  { keys: "Ctrl+A", action: "Select all visible objects" },
63
50
  { keys: "Ctrl+C / X / V", action: "Copy · cut · paste selection" },
64
51
  { keys: "Ctrl+D", action: "Duplicate selection" },
65
52
  { keys: "Ctrl+Z / Y", action: "Undo · redo" },
66
- { keys: "Ctrl+B", action: "Asset browser" },
53
+ { keys: "Ctrl+B", action: "Content browser" },
54
+ { keys: "Ctrl+S", action: "Save scene" },
67
55
  { keys: "Delete", action: "Remove selection" },
68
56
  { keys: "Enter / Esc", action: "Finish · cancel path drawing" },
69
57
  { keys: "Shift+click", action: "Multi-select · keep placing" },
@@ -83,9 +71,10 @@ function downloadText(filename, text) {
83
71
  anchor.click();
84
72
  URL.revokeObjectURL(url);
85
73
  }
86
- function useDocumentSave(session, save) {
74
+ function useDocumentSave(session, save, onResult) {
87
75
  const [saveState, setSaveState] = useState("idle");
88
76
  const [saveError, setSaveError] = useState(null);
77
+ const [lastSavedAt, setLastSavedAt] = useState(null);
89
78
  const savedDocRef = useRef(session.getState().document);
90
79
  const dirty = session.getState().document !== savedDocRef.current;
91
80
  const doSave = () => {
@@ -98,29 +87,55 @@ function useDocumentSave(session, save) {
98
87
  savedDocRef.current = document;
99
88
  setSaveError(null);
100
89
  setSaveState("saved");
90
+ setLastSavedAt(Date.now());
91
+ onResult?.(true, result.path === undefined ? "Scene saved" : `Scene saved to ${result.path}`);
101
92
  }
102
93
  else {
103
94
  setSaveError(result.error ?? "save failed");
104
95
  setSaveState("error");
96
+ onResult?.(false, `Save failed: ${result.error ?? "unknown error"}`);
105
97
  }
106
98
  });
107
99
  };
108
- return { available: save !== undefined, dirty, saveState, saveError, doSave };
100
+ return { available: save !== undefined, dirty, saveState, saveError, lastSavedAt, doSave };
109
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
+ ];
110
108
  /**
111
- * The full editor UI shell — toolbar, left panels (outliner/prefabs/sets/layers), viewport overlays,
112
- * the selector-subscribed {@link InspectorPanel}, the embedded {@link AgentPanel}, and the asset
113
- * 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.
114
113
  */
115
- 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, }) {
116
115
  const [, setTick] = useState(0);
117
- const [activePanel, setActivePanel] = useState("outliner");
118
- const [leftOpen, setLeftOpen] = useState(true);
119
- const [rightOpen, setRightOpen] = useState(true);
120
- const [agentOpen, setAgentOpen] = useState(false);
121
- const [bottomOpen, setBottomOpen] = useState(false);
122
- 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]);
123
137
  const [helpOpen, setHelpOpen] = useState(false);
138
+ const [paletteQuery, setPaletteQuery] = useState(null);
124
139
  const [toast, setToast] = useState(null);
125
140
  const toastTimer = useRef(null);
126
141
  const notify = useCallback((text, tone = "info") => {
@@ -128,18 +143,17 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
128
143
  clearTimeout(toastTimer.current);
129
144
  setToast({ text, tone });
130
145
  toastTimer.current = setTimeout(() => setToast(null), 2600);
131
- }, []);
146
+ consoleStore.log(tone === "error" ? "error" : "info", "ui", text);
147
+ }, [consoleStore]);
132
148
  useEffect(() => () => {
133
149
  if (toastTimer.current !== null)
134
150
  clearTimeout(toastTimer.current);
135
151
  }, []);
136
152
  const importInputRef = useRef(null);
137
153
  const state = session.getState();
138
- const visibility = api.getVisibility();
139
- const perf = usePerfSample(api);
140
154
  const uiState = ui.getState();
141
- const gizmoMode = uiState.gizmoMode;
142
- 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));
143
157
  const docSaveRef = useRef(docSave);
144
158
  docSaveRef.current = docSave;
145
159
  const outlinerGroups = useMemo(() => buildOutlinerGroups(state.document), [state.document]);
@@ -154,16 +168,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
154
168
  foliage += scatterRegionEstimate(path).count;
155
169
  return { objects, foliage };
156
170
  }, [state.document]);
157
- const showPanel = (panel) => {
158
- setActivePanel(panel);
159
- if (panel === "assets")
160
- setBottomOpen(true);
161
- else
162
- setLeftOpen(true);
163
- };
164
171
  useF2Chord("KeyE", () => api.setMode("play"));
165
172
  useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
166
173
  useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
174
+ useEffect(() => layout.subscribe(() => setTick((value) => value + 1)), [layout]);
167
175
  const copySelection = useCallback(() => {
168
176
  const current = session.getState();
169
177
  if (current.selection.length === 0)
@@ -182,6 +190,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
182
190
  if (ui.getState().contextMenu !== null)
183
191
  ui.patch({ contextMenu: null });
184
192
  }, [ui]);
193
+ const [parentPicker, setParentPicker] = useState(null);
194
+ const openBottomTab = useCallback((tab) => {
195
+ layout.patch({ bottomOpen: true, bottomTab: tab });
196
+ }, [layout]);
185
197
  const runContextAction = useCallback((action) => {
186
198
  const menu = ui.getState().contextMenu;
187
199
  closeContextMenu();
@@ -234,10 +246,29 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
234
246
  const id = `prefab_${Date.now().toString(36)}`;
235
247
  const name = `Prefab ${current.document.prefabs.length + 1}`;
236
248
  api.handle({ method: "create_prefab", id, name, ids: [...selection] });
237
- showPanel("prefabs");
249
+ layout.patch({ leftOpen: true, leftPage: "prefabs" });
238
250
  notify(`Created prefab “${name}”`);
239
251
  }
240
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
+ }
241
272
  case "addMarker":
242
273
  if (ground !== null) {
243
274
  session.dispatch({
@@ -289,10 +320,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
289
320
  }
290
321
  return;
291
322
  case "openAssets":
292
- showPanel("assets");
323
+ openBottomTab("content");
293
324
  return;
294
325
  }
295
- }, [api, closeContextMenu, copySelection, notify, session, ui]);
326
+ }, [api, closeContextMenu, copySelection, layout, notify, openBottomTab, session, ui]);
296
327
  useEffect(() => {
297
328
  const onKey = (event) => {
298
329
  if (event.defaultPrevented)
@@ -302,6 +333,11 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
302
333
  docSaveRef.current.doSave();
303
334
  return;
304
335
  }
336
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
337
+ event.preventDefault();
338
+ setPaletteQuery((value) => (value === null ? "" : null));
339
+ return;
340
+ }
305
341
  const target = event.target;
306
342
  const isTyping = target instanceof HTMLInputElement ||
307
343
  target instanceof HTMLTextAreaElement ||
@@ -323,18 +359,26 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
323
359
  }
324
360
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "b") {
325
361
  event.preventDefault();
326
- showPanel("assets");
362
+ openBottomTab("content");
327
363
  return;
328
364
  }
329
365
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
330
366
  event.preventDefault();
331
- const state = session.getState();
367
+ const current = session.getState();
332
368
  const visibility = api.getVisibility();
333
369
  const ids = [
334
- ...state.document.markers.filter((m) => visibility[m.kind] !== false).map((m) => m.id),
335
- ...state.document.volumes.filter((v) => visibility[v.kind] !== false).map((v) => v.id),
336
- ...state.document.paths.filter((p) => visibility[p.kind] !== false).map((p) => p.id),
337
- ...(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
+ : []),
338
382
  ];
339
383
  if (ids.length > 0)
340
384
  session.dispatch({ type: "select", ids });
@@ -375,6 +419,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
375
419
  }
376
420
  if (event.key === "Escape") {
377
421
  setHelpOpen(false);
422
+ if (paletteQuery !== null) {
423
+ setPaletteQuery(null);
424
+ return;
425
+ }
378
426
  const current = ui.getState();
379
427
  if (current.contextMenu !== null) {
380
428
  ui.patch({ contextMenu: null });
@@ -467,17 +515,7 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
467
515
  };
468
516
  window.addEventListener("keydown", onKey);
469
517
  return () => window.removeEventListener("keydown", onKey);
470
- }, [session, ui, api, notify, copySelection]);
471
- const kinds = useMemo(() => listEditorKinds(state.document), [state.document]);
472
- const allKinds = useMemo(() => [
473
- ...new Set([
474
- ...kinds.markers,
475
- ...kinds.volumes,
476
- ...kinds.paths,
477
- ...(state.document.annotations.length > 0 ? ["note"] : []),
478
- ]),
479
- ].sort(), [kinds, state.document.annotations.length]);
480
- const studioKinds = useMemo(() => listSceneKinds().filter((definition) => definition.addCategory !== undefined), []);
518
+ }, [session, ui, api, notify, copySelection, openBottomTab, paletteQuery]);
481
519
  const placeAsset = (entry) => {
482
520
  const focus = api.getFocusTarget();
483
521
  const focused = session.getState();
@@ -485,7 +523,7 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
485
523
  const selected = focused.document.markers.find((marker) => marker.id === focusedId)?.position ??
486
524
  focused.document.volumes.find((volume) => volume.id === focusedId)?.center;
487
525
  const position = focus ?? selected ?? { x: 0, y: 0, z: 0 };
488
- api.handle({
526
+ const response = api.handle({
489
527
  method: "place_asset",
490
528
  id: entry.id,
491
529
  kind: entry.kind === "model" ? "prop" : entry.kind,
@@ -493,16 +531,61 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
493
531
  y: position.y,
494
532
  z: position.z,
495
533
  });
534
+ if (response.ok)
535
+ notify(`Placed ${entry.label}`);
536
+ else
537
+ notify(`Place failed: ${response.error ?? "unknown error"}`, "error");
496
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]);
497
577
  const importFile = (file) => {
498
578
  void file.text().then((text) => {
499
579
  try {
500
580
  session.dispatch({ type: "importJson", json: text });
501
581
  const doc = session.getState().document;
502
582
  notify(`Imported ${editorDocumentSize(doc)} objects from ${file.name}`);
583
+ consoleStore.log("info", "import", `Imported ${editorDocumentSize(doc)} objects from ${file.name}`);
503
584
  }
504
585
  catch (error) {
505
- 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}`);
506
589
  }
507
590
  });
508
591
  };
@@ -515,45 +598,154 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
515
598
  notify("Clipboard unavailable — use Export instead", "error");
516
599
  }
517
600
  };
518
- const currentJson = useMemo(() => session.exportJson(true), [session, state.document]);
519
- 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;
520
610
  const startPlacement = (tool) => {
521
- setAddOpen(false);
522
611
  ui.startPlacement(tool);
523
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]);
524
668
  const placement = uiState.placement;
525
669
  const placementHint = placement === null
526
670
  ? null
527
671
  : placement.tool === "path"
528
672
  ? `Drawing ${placement.kind}: click to add points (${uiState.pathDraft.length}) · Enter finish · Esc cancel`
529
673
  : `Placing ${placement.tool === "note" ? "note" : placement.kind}: click the world · Shift-click places more · Esc cancel`;
530
- 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"
531
- ? { tool: "path", kind: definition.kind }
532
- : definition.target === "volume"
533
- ? { tool: "volume", kind: definition.kind, shape: "box" }
534
- : { 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: () => {
535
- const at = SNAP_MODES.findIndex((entry) => entry.mode === uiState.snapMode);
536
- ui.patch({ snapMode: SNAP_MODES[(at + 1) % SNAP_MODES.length].mode });
537
- }, 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("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
538
- ? "bg-violet-500/25 text-violet-100 ring-violet-400/40"
539
- : "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) => {
540
- const file = event.target.files?.[0];
541
- if (file !== undefined)
542
- importFile(file);
543
- event.target.value = "";
544
- } }), docSave.available ? (_jsx("button", { type: "button", className: `rounded-md px-3 py-1 font-medium transition-colors ${docSave.saveState === "error"
545
- ? "bg-rose-600/90 text-white hover:bg-rose-500"
546
- : docSave.dirty
547
- ? "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"
548
- : "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"
549
- ? "Saving…"
550
- : docSave.saveState === "error"
551
- ? "Save failed"
552
- : docSave.dirty
553
- ? "Save"
554
- : "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({
555
- hitId: uiState.contextMenu.hitId,
556
- selection: state.selection,
557
- canPaste: clipboardFragment !== null,
558
- }), 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 ? _jsxs("span", { className: `ml-3 ${perf.fps < 30 ? "text-rose-400" : "text-emerald-400"}`, children: [perf.fps.toFixed(0), " fps \u00B7 ", perf.drawCalls, " draws \u00B7 ", formatTriangles(perf.triangles), " tris"] }) : 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] }));
559
751
  }