@jgengine/editor 0.13.0 → 0.15.0-next.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 (53) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/ClipPreviewLayer.d.ts +15 -0
  3. package/dist/ClipPreviewLayer.js +60 -0
  4. package/dist/EditorApp.js +4 -3
  5. package/dist/EditorCameraDriver.d.ts +6 -1
  6. package/dist/EditorCameraDriver.js +25 -1
  7. package/dist/EditorChrome.js +4 -6
  8. package/dist/InspectorPanel.d.ts +4 -1
  9. package/dist/InspectorPanel.js +47 -5
  10. package/dist/ParentPickerMenu.js +2 -6
  11. package/dist/RuntimePlayBridge.js +10 -15
  12. package/dist/SchemaInspector.d.ts +3 -1
  13. package/dist/SchemaInspector.js +63 -32
  14. package/dist/TerrainPanel.js +3 -4
  15. package/dist/TerrainReadout.js +1 -3
  16. package/dist/agent/AgentPanel.js +7 -11
  17. package/dist/camera/orbitFraming.d.ts +62 -0
  18. package/dist/camera/orbitFraming.js +69 -0
  19. package/dist/chromeFields.js +9 -4
  20. package/dist/handlers/document.d.ts +1 -1
  21. package/dist/handlers/document.js +78 -9
  22. package/dist/mcp/fieldSpec.js +4 -2
  23. package/dist/mcp/tools.js +4 -2
  24. package/dist/modelAnimationAuthoring.d.ts +75 -0
  25. package/dist/modelAnimationAuthoring.js +164 -0
  26. package/dist/session.d.ts +54 -15
  27. package/dist/shell/AnimationPanel.d.ts +10 -8
  28. package/dist/shell/AnimationPanel.js +120 -26
  29. package/dist/shell/BottomDock.d.ts +3 -1
  30. package/dist/shell/BottomDock.js +2 -2
  31. package/dist/shell/CommandPalette.js +1 -5
  32. package/dist/shell/HierarchyPanel.js +17 -26
  33. package/dist/shell/PlayModeBar.d.ts +7 -0
  34. package/dist/shell/PlayModeBar.js +12 -3
  35. package/dist/shell/SceneToolbar.js +9 -5
  36. package/dist/shell/clipPreview.d.ts +85 -0
  37. package/dist/shell/clipPreview.js +82 -0
  38. package/dist/shell/fields.js +8 -8
  39. package/dist/shell/glbThumbnailCache.js +39 -8
  40. package/dist/shell/scatterCoverage.d.ts +22 -0
  41. package/dist/shell/scatterCoverage.js +40 -0
  42. package/dist/uiStore.d.ts +6 -0
  43. package/dist/uiStore.js +1 -0
  44. package/dist/useStoreSelector.d.ts +12 -0
  45. package/dist/useStoreSelector.js +23 -0
  46. package/package.json +5 -5
  47. package/skills/ce-handoff/SKILL.md +149 -0
  48. package/skills/jgengine/SKILL.md +7 -3
  49. package/skills/jgengine/api.md +17 -7
  50. package/skills/jgengine/capabilities.md +12 -0
  51. package/skills/jgengine/recipes/minimal-game.md +7 -4
  52. package/skills/jgengine-editor/api.md +56 -4
  53. package/skills/jgengine-editor/reference.md +44 -0
@@ -44,10 +44,8 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
44
44
  const [query, setQuery] = useState("");
45
45
  const [collapsedKinds, setCollapsedKinds] = useState({});
46
46
  const [collapsedNodes, setCollapsedNodes] = useState({});
47
- const [visibilityTick, setVisibilityTick] = useState(0);
48
- useEffect(() => api.subscribeVisibility(() => setVisibilityTick((value) => value + 1)), [api]);
49
- void visibilityTick;
50
- const visibility = api.getVisibility();
47
+ const visibilityStore = useMemo(() => ({ getState: api.getVisibility, subscribe: api.subscribeVisibility }), [api]);
48
+ const visibility = useStoreSelector(visibilityStore, (v) => v);
51
49
  const rows = useMemo(() => flattenOutliner(document, { view, query, collapsedKinds, collapsedNodes }), [document, view, query, collapsedKinds, collapsedNodes]);
52
50
  const total = document.markers.length + document.volumes.length + document.paths.length + document.annotations.length;
53
51
  const scrollRef = useRef(null);
@@ -79,15 +77,8 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
79
77
  const [activeId, setActiveId] = useState(null);
80
78
  const [renamingId, setRenamingId] = useState(null);
81
79
  const [renameDraft, setRenameDraft] = useState("");
82
- const renameInputRef = useRef(null);
83
- useEffect(() => {
84
- if (activeId !== null && !navigableIds.includes(activeId))
85
- setActiveId(navigableIds[0] ?? null);
86
- }, [activeId, navigableIds]);
87
- useEffect(() => {
88
- if (renamingId !== null)
89
- renameInputRef.current?.focus();
90
- }, [renamingId]);
80
+ // Raw intent clamped at read time: a filtered-away active row falls back to the first row.
81
+ const effectiveActiveId = activeId === null ? null : navigableIds.includes(activeId) ? activeId : (navigableIds[0] ?? null);
91
82
  const focusTree = () => {
92
83
  scrollRef.current?.focus({ preventScroll: true });
93
84
  };
@@ -129,7 +120,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
129
120
  const onListFocus = () => {
130
121
  if (renamingId !== null)
131
122
  return;
132
- if (activeId !== null && navigableIds.includes(activeId))
123
+ if (effectiveActiveId !== null)
133
124
  return;
134
125
  const seed = selectedId !== undefined && navigableIds.includes(selectedId)
135
126
  ? selectedId
@@ -142,7 +133,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
142
133
  return;
143
134
  if (navigableIds.length === 0)
144
135
  return;
145
- const currentIndex = activeId === null ? -1 : navigableIds.indexOf(activeId);
136
+ const currentIndex = effectiveActiveId === null ? -1 : navigableIds.indexOf(effectiveActiveId);
146
137
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
147
138
  event.preventDefault();
148
139
  const delta = event.key === "ArrowDown" ? 1 : -1;
@@ -164,17 +155,17 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
164
155
  activateId(navigableIds[navigableIds.length - 1], event.shiftKey);
165
156
  return;
166
157
  }
167
- if (event.key === "Enter" && activeId !== null) {
158
+ if (event.key === "Enter" && effectiveActiveId !== null) {
168
159
  event.preventDefault();
169
- api.handle({ method: "camera_goto", id: activeId });
160
+ api.handle({ method: "camera_goto", id: effectiveActiveId });
170
161
  return;
171
162
  }
172
- if (event.key === "F2" && activeId !== null) {
163
+ if (event.key === "F2" && effectiveActiveId !== null) {
173
164
  event.preventDefault();
174
- const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] === activeId) ||
175
- (entry.type === "treeItem" && entry.id === activeId));
165
+ const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] === effectiveActiveId) ||
166
+ (entry.type === "treeItem" && entry.id === effectiveActiveId));
176
167
  if (row !== undefined && (row.type === "kindItem" || row.type === "treeItem")) {
177
- beginRename(activeId, row.label);
168
+ beginRename(effectiveActiveId, row.label);
178
169
  }
179
170
  }
180
171
  };
@@ -245,7 +236,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
245
236
  return;
246
237
  session.dispatch({ type: "setObjectFlags", ids: [...ids], patch: { locked: true } });
247
238
  };
248
- const activeDescendant = hierarchyActiveDescendantId(activeId, navigableIds);
239
+ const activeDescendant = hierarchyActiveDescendantId(effectiveActiveId, navigableIds);
249
240
  return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 border-b border-white/[0.06] p-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx(Icon, { name: "search", size: 12, className: "pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-neutral-600" }), _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search\u2026", "aria-label": "Search scene objects", className: `h-7 w-full pl-7 pr-2 ${INPUT_CLS}` })] }), _jsx(IconButton, { icon: "plus", label: "Add object\u2026", onClick: onAdd })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Segmented, { ariaLabel: "Outliner grouping", value: view, onChange: setView, options: [
250
241
  { value: "kind", label: "By kind", icon: "layers" },
251
242
  { value: "tree", label: "Tree", icon: "folder" },
@@ -273,7 +264,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
273
264
  const hidden = rowHidden(row.ids);
274
265
  const collectionOnlyLock = locked &&
275
266
  row.ids.every((id) => isEditorObjectCollectionLocked(document, id) && !isEditorObjectSelfLocked(document, id));
276
- const active = activeId === primaryId;
267
+ const active = effectiveActiveId === primaryId;
277
268
  const renaming = renamingId === primaryId && row.ids.length === 1;
278
269
  return (_jsxs("div", { id: hierarchyRowDomId(primaryId), role: "treeitem", "aria-selected": rowSelected, "aria-level": 2, style: { height: ROW_HEIGHT }, className: `group flex w-full items-center gap-0.5 rounded-[5px] pl-6 pr-0.5 text-[11px] transition-colors ${dropActive
279
270
  ? "bg-cyan-500/25 ring-1 ring-inset ring-cyan-300/50"
@@ -294,7 +285,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
294
285
  event.preventDefault();
295
286
  setActiveId(primaryId);
296
287
  onRowContextMenu?.({ clientX: event.clientX, clientY: event.clientY }, primaryId);
297
- }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { ref: renameInputRef, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
288
+ }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { autoFocus: true, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
298
289
  event.stopPropagation();
299
290
  if (event.key === "Enter") {
300
291
  event.preventDefault();
@@ -331,7 +322,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
331
322
  const hidden = isEditorObjectHidden(document, row.id);
332
323
  const collectionOnlyLock = isEditorObjectCollectionLocked(document, row.id) && !isEditorObjectSelfLocked(document, row.id);
333
324
  const dragging = draggingObjectId === row.id;
334
- const active = activeId === row.id;
325
+ const active = effectiveActiveId === row.id;
335
326
  const renaming = renamingId === row.id;
336
327
  const expanded = row.hasChildren ? collapsedNodes[row.id] !== true : undefined;
337
328
  return (_jsxs("div", { id: hierarchyRowDomId(row.id), role: "treeitem", "aria-selected": rowSelected, "aria-level": row.depth + 1, "aria-expanded": expanded, style: { height: ROW_HEIGHT, paddingLeft: `${row.depth * 12}px` }, className: `group flex items-center ${hidden ? "opacity-55" : ""}`, children: [row.hasChildren ? (_jsx("button", { type: "button", tabIndex: -1, "aria-label": collapsedNodes[row.id] === true ? "Expand children" : "Collapse children", className: `flex h-4 w-4 shrink-0 items-center justify-center text-neutral-500 transition-colors hover:text-neutral-200 ${FOCUS_RING}`, onMouseDown: preventFocusSteal, onClick: () => setCollapsedNodes((previous) => ({ ...previous, [row.id]: !(previous[row.id] === true) })), children: _jsx(Icon, { name: collapsedNodes[row.id] === true ? "chevronRight" : "chevronDown", size: 10 }) })) : (_jsx("span", { className: "w-4 shrink-0", "aria-hidden": "true" })), _jsxs("button", { type: "button", tabIndex: -1, draggable: true, className: `flex h-full min-w-0 flex-1 items-center gap-1.5 truncate rounded-[5px] px-1.5 text-left text-[11px] transition-colors ${FOCUS_RING} ${materialDrop || reparentDrop
@@ -364,7 +355,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
364
355
  if (commitMaterialDrop(event, [row.id]))
365
356
  return;
366
357
  commitReparentDrop(event, row.id);
367
- }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { ref: renameInputRef, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
358
+ }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { autoFocus: true, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
368
359
  event.stopPropagation();
369
360
  if (event.key === "Enter") {
370
361
  event.preventDefault();
@@ -1,4 +1,11 @@
1
+ import type { RuntimePlayControl } from "@jgengine/core/editor/index";
1
2
  import type { EditorHostApi } from "../session.js";
3
+ /**
4
+ * Subscribe to the host runtime play-control (pause / pending steps) as an external store.
5
+ * Shared by the play-mode chrome pieces so none of them hand-rolls a `setState` mirror.
6
+ * @internal
7
+ */
8
+ export declare function usePlayControl(api: EditorHostApi): RuntimePlayControl;
2
9
  /**
3
10
  * Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
4
11
  * does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
@@ -1,8 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
2
+ import { useMemo } from "react";
3
+ import { useStoreSelector } from "../useStoreSelector.js";
3
4
  import { Icon } from "./icons.js";
4
5
  import { BORDER, FOCUS_RING } from "./theme.js";
5
6
  import { IconButton, ToolbarDivider } from "./ui.js";
7
+ /**
8
+ * Subscribe to the host runtime play-control (pause / pending steps) as an external store.
9
+ * Shared by the play-mode chrome pieces so none of them hand-rolls a `setState` mirror.
10
+ * @internal
11
+ */
12
+ export function usePlayControl(api) {
13
+ const store = useMemo(() => ({ getState: api.getPlayControl, subscribe: api.subscribePlayControl }), [api]);
14
+ return useStoreSelector(store, (play) => play);
15
+ }
6
16
  /**
7
17
  * Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
8
18
  * does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
@@ -10,8 +20,7 @@ import { IconButton, ToolbarDivider } from "./ui.js";
10
20
  * @internal
11
21
  */
12
22
  export function PlayModeBar({ gameId, api, onExit, }) {
13
- const [play, setPlay] = useState(() => api.getPlayControl());
14
- useEffect(() => api.subscribePlayControl(setPlay), [api]);
23
+ const play = usePlayControl(api);
15
24
  const pause = () => {
16
25
  api.handle({ method: "runtime_pause" });
17
26
  };
@@ -112,11 +112,15 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
112
112
  ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-100"
113
113
  : "border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "camera", size: 12 }), cameraProjection === "perspective" ? "Persp" : "Ortho"] }), _jsx(ToolbarDivider, {}), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAddOpen((value) => !value), "aria-expanded": addOpen, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors ${FOCUS_RING} ${addOpen || placementActive
114
114
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40"
115
- : "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Studios" }), studioKinds.map((definition) => (_jsxs("button", { type: "button", className: `${MENU_ITEM}`, style: { color: definition.accent ?? "#34d399" }, onClick: () => pick(definition.target === "path"
116
- ? { tool: "path", kind: definition.kind }
117
- : definition.target === "volume"
118
- ? { tool: "volume", kind: definition.kind, shape: "box" }
119
- : { 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_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
115
+ : "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
116
+ const category = definition.addCategory ?? "Studios";
117
+ (groups.get(category) ?? groups.set(category, []).get(category)).push(definition);
118
+ return groups;
119
+ }, new Map())].map(([category, defs]) => (_jsxs("div", { children: [_jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: category }), defs.map((definition) => (_jsxs("button", { type: "button", className: `${MENU_ITEM}`, style: { color: definition.accent ?? "#34d399" }, onClick: () => pick(definition.target === "path"
120
+ ? { tool: "path", kind: definition.kind }
121
+ : definition.target === "volume"
122
+ ? { tool: "volume", kind: definition.kind, shape: "box" }
123
+ : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
120
124
  ? "border border-violet-400/40 bg-violet-500/20 text-violet-100"
121
125
  : "border border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d] hover:text-neutral-100"}`, title: "AI assistant \u2014 drives the scene through the same RPC and undo as the toolbar", children: [_jsx(Icon, { name: "sparkle", size: 13 }), "AI"] }) })] }));
122
126
  }
@@ -0,0 +1,85 @@
1
+ import { type ClipRole } from "@jgengine/core/game/clipRoles";
2
+ import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
3
+ /**
4
+ * Editor viewport clip-preview model — pure logic, no JSX / R3F. Mirrors how `pathFlythrough.ts`
5
+ * splits the flythrough math out of {@link AnimationPanel}: derives a rigged asset's playable clip
6
+ * list and models a scrub/loop/speed playback driver that the viewport layer and the dock panel
7
+ * both consume. The actual mixer is the shell's `useModelAnimation`; this module only shapes the
8
+ * config it plays and the transitions the controls dispatch.
9
+ *
10
+ * @internal
11
+ */
12
+ /** A rigged asset carrying animation clip names — the browser entry / catalog `ModelAssetRef`. */
13
+ export interface ClipSourceRef {
14
+ readonly clips?: readonly string[];
15
+ }
16
+ /** One previewable clip with its detected semantic role (null when unclassified). */
17
+ export interface ClipEntry {
18
+ readonly name: string;
19
+ readonly role: ClipRole | null;
20
+ }
21
+ /**
22
+ * Lists a rigged asset's clips with their classified roles, preserving catalog order. Empty when
23
+ * the ref carries no clips (not rigged, or clip metadata absent).
24
+ */
25
+ export declare function clipEntriesFromRef(ref: ClipSourceRef | null | undefined): ClipEntry[];
26
+ /** A rigged asset resolved for viewport preview: its catalog id, label, GLB url, and clip names. */
27
+ export interface ClipPreviewSource {
28
+ readonly assetId: string;
29
+ readonly label: string;
30
+ readonly url: string;
31
+ readonly clips: readonly string[];
32
+ }
33
+ /**
34
+ * The live clip-preview session shared across panels via the editor UI store: which asset is being
35
+ * previewed, the playback driver, and the selected clip's measured duration (published by the
36
+ * viewport layer once its GLB loads; 0 while unknown).
37
+ */
38
+ export interface ClipPreviewSession {
39
+ readonly source: ClipPreviewSource;
40
+ readonly driver: ClipPreviewState;
41
+ readonly duration: number;
42
+ }
43
+ /** Playback state of the clip preview driver. `time` is seconds into the clip. */
44
+ export interface ClipPreviewState {
45
+ /** Clip currently loaded into the preview, or null when nothing is selected. */
46
+ clipName: string | null;
47
+ playing: boolean;
48
+ loop: boolean;
49
+ /** Playback rate multiplier; clamped to a sane preview range. */
50
+ speed: number;
51
+ /** Playhead position in seconds. Meaningful while paused/scrubbing. */
52
+ time: number;
53
+ }
54
+ /** Lowest / highest preview playback rate the speed control allows. */
55
+ /** Lower bound for the clip-preview playback rate. */
56
+ export declare const MIN_PREVIEW_SPEED = 0.1;
57
+ /** Upper bound for the clip-preview playback rate. */
58
+ export declare const MAX_PREVIEW_SPEED = 4;
59
+ /** Fresh driver state for a clip (auto-plays when a clip is given, like the browser badge implies). */
60
+ export declare function initialClipPreviewState(clipName?: string | null): ClipPreviewState;
61
+ /** Switch to a different clip: resets the playhead and starts playing it. */
62
+ export declare function selectPreviewClip(state: ClipPreviewState, clipName: string): ClipPreviewState;
63
+ /** Sets whether the previewed clip is playing; pausing keeps the current scrub time. */
64
+ export declare function setPreviewPlaying(state: ClipPreviewState, playing: boolean): ClipPreviewState;
65
+ /** Toggles play/pause of the previewed clip without losing the scrub position. */
66
+ export declare function togglePreviewPlaying(state: ClipPreviewState): ClipPreviewState;
67
+ /** Sets whether the previewed clip loops when it reaches its end. */
68
+ export declare function setPreviewLoop(state: ClipPreviewState, loop: boolean): ClipPreviewState;
69
+ /** Sets the preview playback rate, clamped to [MIN_PREVIEW_SPEED, MAX_PREVIEW_SPEED]. */
70
+ export declare function setPreviewSpeed(state: ClipPreviewState, speed: number): ClipPreviewState;
71
+ /** Scrub to an absolute time (seconds). Pauses playback, matching the path-flythrough scrubber. */
72
+ export declare function scrubPreview(state: ClipPreviewState, time: number): ClipPreviewState;
73
+ /**
74
+ * Advances the playhead by `dt` seconds at the current speed, wrapping when looping and clamping
75
+ * (then stopping) at `duration` otherwise. No-op while paused, clip-less, or the duration is
76
+ * unknown (<= 0). Uses the same time += speed·dt formula the shell mixer runs, so a panel that ticks
77
+ * this in lockstep with the freerunning viewport mixer stays in sync.
78
+ */
79
+ export declare function advancePreview(state: ClipPreviewState, dt: number, duration: number): ClipPreviewState;
80
+ /**
81
+ * The `ModelConfig.animation` config the preview layer feeds to `useModelAnimation`. Playing yields
82
+ * a freerunning single-clip config (mixer owns the clock, no per-frame churn); paused yields a held
83
+ * `paused`+`time` pose so a scrub renders the exact frame. Returns undefined with no clip (bind pose).
84
+ */
85
+ export declare function previewAnimationConfig(state: ClipPreviewState): ModelAnimationConfig | undefined;
@@ -0,0 +1,82 @@
1
+ import { classifyClip } from "@jgengine/core/game/clipRoles";
2
+ /**
3
+ * Lists a rigged asset's clips with their classified roles, preserving catalog order. Empty when
4
+ * the ref carries no clips (not rigged, or clip metadata absent).
5
+ */
6
+ export function clipEntriesFromRef(ref) {
7
+ const clips = ref?.clips;
8
+ if (clips === undefined || clips.length === 0)
9
+ return [];
10
+ return clips.map((name) => ({ name, role: classifyClip(name) }));
11
+ }
12
+ /** Lowest / highest preview playback rate the speed control allows. */
13
+ /** Lower bound for the clip-preview playback rate. */
14
+ export const MIN_PREVIEW_SPEED = 0.1;
15
+ /** Upper bound for the clip-preview playback rate. */
16
+ export const MAX_PREVIEW_SPEED = 4;
17
+ function clampSpeed(speed) {
18
+ if (!Number.isFinite(speed))
19
+ return 1;
20
+ return Math.max(MIN_PREVIEW_SPEED, Math.min(MAX_PREVIEW_SPEED, speed));
21
+ }
22
+ /** Fresh driver state for a clip (auto-plays when a clip is given, like the browser badge implies). */
23
+ export function initialClipPreviewState(clipName = null) {
24
+ return { clipName, playing: clipName !== null, loop: true, speed: 1, time: 0 };
25
+ }
26
+ /** Switch to a different clip: resets the playhead and starts playing it. */
27
+ export function selectPreviewClip(state, clipName) {
28
+ return { ...state, clipName, time: 0, playing: true };
29
+ }
30
+ /** Sets whether the previewed clip is playing; pausing keeps the current scrub time. */
31
+ export function setPreviewPlaying(state, playing) {
32
+ if (state.clipName === null)
33
+ return { ...state, playing: false };
34
+ return { ...state, playing };
35
+ }
36
+ /** Toggles play/pause of the previewed clip without losing the scrub position. */
37
+ export function togglePreviewPlaying(state) {
38
+ return setPreviewPlaying(state, !state.playing);
39
+ }
40
+ /** Sets whether the previewed clip loops when it reaches its end. */
41
+ export function setPreviewLoop(state, loop) {
42
+ return { ...state, loop };
43
+ }
44
+ /** Sets the preview playback rate, clamped to [MIN_PREVIEW_SPEED, MAX_PREVIEW_SPEED]. */
45
+ export function setPreviewSpeed(state, speed) {
46
+ return { ...state, speed: clampSpeed(speed) };
47
+ }
48
+ /** Scrub to an absolute time (seconds). Pauses playback, matching the path-flythrough scrubber. */
49
+ export function scrubPreview(state, time) {
50
+ const next = Number.isFinite(time) ? Math.max(0, time) : 0;
51
+ return { ...state, time: next, playing: false };
52
+ }
53
+ /**
54
+ * Advances the playhead by `dt` seconds at the current speed, wrapping when looping and clamping
55
+ * (then stopping) at `duration` otherwise. No-op while paused, clip-less, or the duration is
56
+ * unknown (<= 0). Uses the same time += speed·dt formula the shell mixer runs, so a panel that ticks
57
+ * this in lockstep with the freerunning viewport mixer stays in sync.
58
+ */
59
+ export function advancePreview(state, dt, duration) {
60
+ if (!state.playing || state.clipName === null || duration <= 0 || !Number.isFinite(dt) || dt <= 0) {
61
+ return state;
62
+ }
63
+ let next = state.time + state.speed * dt;
64
+ if (next >= duration) {
65
+ if (state.loop)
66
+ next %= duration;
67
+ else
68
+ return { ...state, time: duration, playing: false };
69
+ }
70
+ return { ...state, time: next };
71
+ }
72
+ /**
73
+ * The `ModelConfig.animation` config the preview layer feeds to `useModelAnimation`. Playing yields
74
+ * a freerunning single-clip config (mixer owns the clock, no per-frame churn); paused yields a held
75
+ * `paused`+`time` pose so a scrub renders the exact frame. Returns undefined with no clip (bind pose).
76
+ */
77
+ export function previewAnimationConfig(state) {
78
+ if (state.clipName === null)
79
+ return undefined;
80
+ const base = { clip: state.clipName, loop: state.loop, timeScale: state.speed };
81
+ return state.playing ? base : { ...base, paused: true, time: state.time };
82
+ }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useRef, useState } from "react";
3
3
  import { AXIS_COLORS, FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
4
4
  /** Pixels of pointer drag per `step` of value change when scrubbing an axis chip. */
5
5
  const DRAG_PX_PER_STEP = 4;
@@ -15,14 +15,12 @@ function formatValue(value, precision) {
15
15
  * values on change and re-normalizes its text on blur. Invalid text never commits.
16
16
  */
17
17
  export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, precision = 3, disabled = false, mixed = false, }) {
18
- const [text, setText] = useState(() => (mixed ? "—" : formatValue(value, precision)));
18
+ // `text` only exists while the input has focus; otherwise the shown value derives from props.
19
+ const [text, setText] = useState("");
19
20
  const [editing, setEditing] = useState(false);
20
21
  const inputRef = useRef(null);
21
22
  const dragRef = useRef(null);
22
- useEffect(() => {
23
- if (!editing)
24
- setText(mixed ? "—" : formatValue(value, precision));
25
- }, [value, precision, editing, mixed]);
23
+ const formatted = mixed ? "—" : formatValue(value, precision);
26
24
  const commitText = (raw) => {
27
25
  if (raw === "—" || raw.trim().length === 0)
28
26
  return;
@@ -69,7 +67,10 @@ export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, prec
69
67
  }
70
68
  };
71
69
  const colors = axis !== undefined ? AXIS_COLORS[axis] : null;
72
- return (_jsxs("label", { title: `Drag to adjust ${label} · Alt-click or focus input to type`, onPointerDown: onScrubPointerDown, onPointerMove: onScrubPointerMove, onPointerUp: onScrubPointerUp, className: `flex min-w-0 flex-1 cursor-ew-resize items-center overflow-hidden rounded-[5px] border border-white/[0.08] bg-black/30 focus-within:border-cyan-400/50 ${editing ? "cursor-text" : ""}`, children: [_jsxs("span", { className: `relative flex h-6.5 w-6 shrink-0 select-none items-center justify-center border-r border-white/[0.06] bg-white/[0.03] text-[10px] font-semibold uppercase ${colors !== null ? colors.text : "text-neutral-500"}`, children: [colors !== null ? _jsx("span", { className: `absolute inset-y-0 left-0 w-0.5 ${colors.bar}` }) : null, label] }), _jsx("input", { ref: inputRef, type: "text", inputMode: "decimal", value: text, disabled: disabled, "aria-label": label, onFocus: () => setEditing(true), onDoubleClick: (event) => {
70
+ return (_jsxs("label", { title: `Drag to adjust ${label} · Alt-click or focus input to type`, onPointerDown: onScrubPointerDown, onPointerMove: onScrubPointerMove, onPointerUp: onScrubPointerUp, className: `flex min-w-0 flex-1 cursor-ew-resize items-center overflow-hidden rounded-[5px] border border-white/[0.08] bg-black/30 focus-within:border-cyan-400/50 ${editing ? "cursor-text" : ""}`, children: [_jsxs("span", { className: `relative flex h-6.5 w-6 shrink-0 select-none items-center justify-center border-r border-white/[0.06] bg-white/[0.03] text-[10px] font-semibold uppercase ${colors !== null ? colors.text : "text-neutral-500"}`, children: [colors !== null ? _jsx("span", { className: `absolute inset-y-0 left-0 w-0.5 ${colors.bar}` }) : null, label] }), _jsx("input", { ref: inputRef, type: "text", inputMode: "decimal", value: editing ? text : formatted, disabled: disabled, "aria-label": label, onFocus: () => {
71
+ setText(formatted);
72
+ setEditing(true);
73
+ }, onDoubleClick: (event) => {
73
74
  event.stopPropagation();
74
75
  event.currentTarget.select();
75
76
  }, onChange: (event) => {
@@ -78,7 +79,6 @@ export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, prec
78
79
  }, onBlur: (event) => {
79
80
  setEditing(false);
80
81
  commitText(event.target.value);
81
- setText(formatValue(Number.isFinite(Number(event.target.value)) ? Number(event.target.value) : value, precision));
82
82
  }, onKeyDown: (event) => {
83
83
  if (event.key === "Enter")
84
84
  event.currentTarget.blur();
@@ -15,6 +15,16 @@ const MAX_CONCURRENT = 1;
15
15
  const cache = new Map();
16
16
  const queue = [];
17
17
  let active = 0;
18
+ /** Shared idle snapshot — one stable identity for every "no thumbnail yet" case. */
19
+ const IDLE_STATE = { status: "idle", dataUrl: null };
20
+ /**
21
+ * Last snapshot handed out per URL. `getGlbThumbnailState` is the `getSnapshot`
22
+ * for `useSyncExternalStore`, which requires a referentially stable result while
23
+ * the underlying state is unchanged: returning a fresh object literal every call
24
+ * makes React's post-commit consistency check ("Maximum update depth exceeded")
25
+ * fire forever. We reuse the prior object until status/dataUrl actually changes.
26
+ */
27
+ const snapshots = new Map();
18
28
  let renderer = null;
19
29
  let scene = null;
20
30
  let camera = null;
@@ -32,6 +42,7 @@ export function resetGlbThumbnailLoader() {
32
42
  /** @internal Clear cache + dispose GPU resources (tests / hot dispose). */
33
43
  export function clearGlbThumbnailCache() {
34
44
  cache.clear();
45
+ snapshots.clear();
35
46
  queue.length = 0;
36
47
  active = 0;
37
48
  disposeRenderer();
@@ -132,6 +143,7 @@ function evictIfNeeded() {
132
143
  if (entry.status !== "ready")
133
144
  continue;
134
145
  cache.delete(key);
146
+ snapshots.delete(key);
135
147
  if (cache.size <= MAX_CACHE)
136
148
  return;
137
149
  }
@@ -193,15 +205,34 @@ function pump() {
193
205
  */
194
206
  export function getGlbThumbnailState(url) {
195
207
  if (url === undefined || url.length === 0)
196
- return { status: "idle", dataUrl: null };
208
+ return IDLE_STATE;
197
209
  const entry = cache.get(url);
198
- if (entry === undefined)
199
- return { status: "idle", dataUrl: null };
200
- if (entry.status === "ready")
201
- return { status: "ready", dataUrl: entry.dataUrl };
202
- if (entry.status === "error")
203
- return { status: "error", dataUrl: null };
204
- return { status: "loading", dataUrl: null };
210
+ if (entry === undefined) {
211
+ snapshots.delete(url);
212
+ return IDLE_STATE;
213
+ }
214
+ const prev = snapshots.get(url);
215
+ // Reuse the prior snapshot object whenever the observable state is unchanged so
216
+ // useSyncExternalStore sees a stable reference (no infinite re-render loop).
217
+ if (entry.status === "ready") {
218
+ if (prev !== undefined && prev.status === "ready" && prev.dataUrl === entry.dataUrl)
219
+ return prev;
220
+ const next = { status: "ready", dataUrl: entry.dataUrl };
221
+ snapshots.set(url, next);
222
+ return next;
223
+ }
224
+ if (entry.status === "error") {
225
+ if (prev !== undefined && prev.status === "error")
226
+ return prev;
227
+ const next = { status: "error", dataUrl: null };
228
+ snapshots.set(url, next);
229
+ return next;
230
+ }
231
+ if (prev !== undefined && prev.status === "loading")
232
+ return prev;
233
+ const next = { status: "loading", dataUrl: null };
234
+ snapshots.set(url, next);
235
+ return next;
205
236
  }
206
237
  /**
207
238
  * Ensures a thumbnail render is in flight (or already cached) for `url`.
@@ -0,0 +1,22 @@
1
+ import type { ParamSchema, ParsedParams, SceneKindCoverage, SceneKindObject } from "@jgengine/core/scene/sceneKinds";
2
+ /** A patch to a single meta key, coalesced under a stable undo key (matches `SchemaInspector`). */
3
+ type MetaPatch = (patch: Record<string, unknown>, coalesce: string) => void;
4
+ /**
5
+ * The shared leading block for every scatterable kind (`grass_field` / `scatter` / `city`): one
6
+ * consistent Area → Assets → Density gesture. Renders the footprint Area readout, a compact summary
7
+ * of what fills it (the weighted palette, when the kind declares one), the single Density slider
8
+ * hoisted out of the schema body, and the kind's budget clamp-and-warn `note`. The rest of the kind's
9
+ * parameters render below via `SchemaInspector` (with the density key hidden). One module so grass,
10
+ * scatter, and city never re-derive the "how much stuff grows here" affordance.
11
+ * @internal — mounted by `InspectorPanel`'s `KindInspector` when a kind declares `coverage`.
12
+ */
13
+ export declare function CoverageSection({ schema, coverage, object, params, note, accent, onMeta, }: {
14
+ schema: ParamSchema;
15
+ coverage: SceneKindCoverage;
16
+ object: SceneKindObject;
17
+ params: ParsedParams;
18
+ note?: string;
19
+ accent: string;
20
+ onMeta: MetaPatch;
21
+ }): import("react").JSX.Element;
22
+ export {};
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { scatterFootprintArea } from "@jgengine/core/world/scatterCoverage";
3
+ import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
4
+ import { MICRO_LABEL } from "./theme.js";
5
+ const fmt = (n) => Math.round(n).toLocaleString();
6
+ /**
7
+ * The hoisted density slider: live-mirrored thumb + readout, debounced commit flushed on release
8
+ * (#1372). Scatter/grass/city regen is heavy, so one write per pause instead of ~50 per drag.
9
+ */
10
+ function CoverageDensitySlider({ field, density, densityKey, onMeta, }) {
11
+ const { value: local, onInput, flush } = useDebouncedCommit(density, (next) => onMeta({ [densityKey]: next }, `kind:${densityKey}`));
12
+ return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO_LABEL, children: field.label ?? field.key }), _jsxs("span", { className: "text-cyan-200", children: [local.toFixed(field.step !== undefined && field.step >= 1 ? 0 : 2), field.unit ?? ""] })] }), _jsx("input", { type: "range", min: field.min, max: field.max, step: field.step ?? (field.max - field.min) / 100, value: local, className: "w-full accent-emerald-400", "aria-label": `${field.label ?? field.key} density`, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush })] }));
13
+ }
14
+ /** One-line summary of a weighted "what fills it" palette: count + the leading item ids. */
15
+ function assetsSummary(entries) {
16
+ if (entries === undefined || entries.length === 0)
17
+ return null;
18
+ const ids = entries.map((entry) => entry.item).filter((item) => item.length > 0);
19
+ if (ids.length === 0)
20
+ return null;
21
+ const head = ids.slice(0, 4).join(", ");
22
+ const rest = ids.length > 4 ? ` +${ids.length - 4}` : "";
23
+ return `${ids.length} ${ids.length === 1 ? "type" : "types"} · ${head}${rest}`;
24
+ }
25
+ /**
26
+ * The shared leading block for every scatterable kind (`grass_field` / `scatter` / `city`): one
27
+ * consistent Area → Assets → Density gesture. Renders the footprint Area readout, a compact summary
28
+ * of what fills it (the weighted palette, when the kind declares one), the single Density slider
29
+ * hoisted out of the schema body, and the kind's budget clamp-and-warn `note`. The rest of the kind's
30
+ * parameters render below via `SchemaInspector` (with the density key hidden). One module so grass,
31
+ * scatter, and city never re-derive the "how much stuff grows here" affordance.
32
+ * @internal — mounted by `InspectorPanel`'s `KindInspector` when a kind declares `coverage`.
33
+ */
34
+ export function CoverageSection({ schema, coverage, object, params, note, accent, onMeta, }) {
35
+ const area = scatterFootprintArea(object);
36
+ const densityField = schema.fields.find((field) => field.key === coverage.densityKey);
37
+ const density = typeof params[coverage.densityKey] === "number" ? params[coverage.densityKey] : 0;
38
+ const assets = coverage.assetsKey === undefined ? null : assetsSummary(params[coverage.assetsKey]);
39
+ return (_jsxs("div", { className: "space-y-2 rounded-lg border p-2.5", style: { borderColor: `${accent}26`, backgroundColor: `${accent}10` }, children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em]", style: { color: accent }, children: "Coverage" }), _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "area" }), _jsx("span", { className: "text-cyan-200", children: area > 0 ? `${fmt(area)} m²` : "—" })] }), assets !== null ? (_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "fills with" }), _jsx("span", { className: "min-w-0 truncate text-right text-[10px] text-neutral-300", title: assets, children: assets })] })) : null, densityField !== undefined && densityField.type === "range" ? (_jsx(CoverageDensitySlider, { field: densityField, density: density, densityKey: coverage.densityKey, onMeta: onMeta })) : null, note !== undefined && note.length > 0 ? _jsx("div", { className: "text-[10px] text-neutral-500", children: note }) : null] }));
40
+ }
package/dist/uiStore.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { EditorSession, EditorVec3, EditorVolumeShape } from "@jgengine/core/editor/index";
2
2
  import type { TerraformFalloff, TerraformShape } from "@jgengine/core/world/terraform";
3
+ import type { ClipPreviewSession } from "./shell/clipPreview.js";
3
4
  /** Which top-level editor tool is active: object placement/selection, or terrain sculpting. */
4
5
  export type EditorTool = "select" | "terrain";
5
6
  /** A heightfield sculpt brush the terrain tool can apply. */
@@ -120,6 +121,11 @@ export interface EditorUiState {
120
121
  sculpt: SculptSettings;
121
122
  paint: PaintSettings;
122
123
  contextMenu: EditorContextMenuState | null;
124
+ /**
125
+ * Active viewport clip-preview session (rigged asset + playback driver), or null when nothing is
126
+ * previewing. Driven by the Animation dock's clip mode; rendered by `ClipPreviewLayer`.
127
+ */
128
+ clipPreview: ClipPreviewSession | null;
123
129
  }
124
130
  /** Subscribable store for the editor's transient UI state (gizmo, snapping, placement). */
125
131
  export interface EditorUiStore {
package/dist/uiStore.js CHANGED
@@ -61,6 +61,7 @@ export function createEditorUiStore() {
61
61
  sculpt: { ...DEFAULT_SCULPT_SETTINGS },
62
62
  paint: { ...DEFAULT_PAINT_SETTINGS },
63
63
  contextMenu: null,
64
+ clipPreview: null,
64
65
  };
65
66
  const listeners = new Set();
66
67
  const emit = () => {
@@ -10,6 +10,18 @@ export interface SubscribableStore<S> {
10
10
  * slice. The selected value is memoized so an equal slice keeps its reference (no render churn).
11
11
  */
12
12
  export declare function useStoreSelector<S, T>(store: SubscribableStore<S>, selector: (state: S) => T, isEqual?: (a: T, b: T) => boolean): T;
13
+ /** A notification-only source — `subscribe` is all a coarse invalidation needs. */
14
+ export interface SubscribableSource {
15
+ subscribe(listener: () => void): () => void;
16
+ }
17
+ /**
18
+ * Subscribe a component to **every** notification from one or more sources, returning a monotonic
19
+ * version number via `useSyncExternalStore`. For components that read `store.getState()` directly
20
+ * in render (whole-store chrome) or fan several sources into one coarse invalidation — the version
21
+ * is a `useMemo` cache key, not data.
22
+ * @internal
23
+ */
24
+ export declare function useStoreVersion(...sources: readonly SubscribableSource[]): number;
13
25
  /** Shallow array equality — for selectors that return id lists (`selection`) or small tuples. */
14
26
  export declare function shallowArrayEqual<T>(a: readonly T[], b: readonly T[]): boolean;
15
27
  /** The visible slice of a fixed-row-height list: which rows to mount and the spacer geometry. */