castle-web-cli 0.4.83 → 0.4.85

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 (85) hide show
  1. package/dist/agent-failures.d.ts +17 -0
  2. package/dist/agent-failures.js +151 -0
  3. package/dist/agent.d.ts +26 -0
  4. package/dist/agent.js +317 -74
  5. package/dist/ide.js +35 -13
  6. package/dist/native/loop.js +35 -0
  7. package/dist/native/openrouter.d.ts +7 -0
  8. package/dist/native/openrouter.js +25 -1
  9. package/dist/native/types.d.ts +2 -0
  10. package/dist/native/types.js +0 -38
  11. package/dist/openrouter-catalog.d.ts +28 -0
  12. package/dist/openrouter-catalog.js +299 -0
  13. package/dist/shell/assets/{index-C9Zhmien.js → index-BJLaUTJE.js} +60 -58
  14. package/dist/shell/assets/{index-BMkQt27u.css → index-DonnH--m.css} +1 -1
  15. package/dist/shell/index.html +2 -2
  16. package/kits/physics-2d/.prettierrc +8 -0
  17. package/kits/physics-2d/CLAUDE.md +329 -0
  18. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  19. package/kits/physics-2d/behaviors/Collider.jsx +199 -0
  20. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  21. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  22. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  23. package/kits/physics-2d/behaviors/tint.js +47 -0
  24. package/kits/physics-2d/blueprints/ball.scene +14 -0
  25. package/kits/physics-2d/blueprints/block.scene +12 -0
  26. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  27. package/kits/physics-2d/blueprints/crate.scene +14 -0
  28. package/kits/physics-2d/blueprints/goal.scene +12 -0
  29. package/kits/physics-2d/castle.json +13 -0
  30. package/kits/physics-2d/docs/pxart-format.md +377 -0
  31. package/kits/physics-2d/drawings/block.pxart +25 -0
  32. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  33. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  34. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  35. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  36. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  37. package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
  38. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  39. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  40. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  41. package/kits/physics-2d/editors/editorHistory.js +157 -0
  42. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  43. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  44. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  45. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  46. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  47. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  48. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  49. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  51. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  52. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  53. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  54. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  55. package/kits/physics-2d/engine/assets.js +15 -0
  56. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  57. package/kits/physics-2d/engine/blueprint.js +521 -0
  58. package/kits/physics-2d/engine/collider.js +196 -0
  59. package/kits/physics-2d/engine/files.js +117 -0
  60. package/kits/physics-2d/engine/liveReload.js +88 -0
  61. package/kits/physics-2d/engine/pxart.js +1032 -0
  62. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  63. package/kits/physics-2d/engine/scene.js +686 -0
  64. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  65. package/kits/physics-2d/engine/ui.jsx +688 -0
  66. package/kits/physics-2d/engine/ui.module.css +2287 -0
  67. package/kits/physics-2d/eslint.config.js +71 -0
  68. package/kits/physics-2d/index.html +24 -0
  69. package/kits/physics-2d/main.jsx +24 -0
  70. package/kits/physics-2d/package-lock.json +2706 -0
  71. package/kits/physics-2d/package.json +42 -0
  72. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  73. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  74. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  75. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  76. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  77. package/kits/physics-2d/physics/controls.js +79 -0
  78. package/kits/physics-2d/physics/index.js +26 -0
  79. package/kits/physics-2d/physics/matterBridge.js +126 -0
  80. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  81. package/kits/physics-2d/scenes/main.scene +12 -0
  82. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  83. package/kits/physics-2d/scripts/draw.mjs +121 -0
  84. package/kits/physics-2d/vite.config.js +1 -0
  85. package/package.json +1 -1
@@ -0,0 +1,688 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import {
4
+ faArrowsAlt,
5
+ faArrowsAltH,
6
+ faArrowsAltV,
7
+ faBars,
8
+ faChevronDown,
9
+ faChevronRight,
10
+ faChevronUp,
11
+ faCircle,
12
+ faClone,
13
+ faCode,
14
+ faCodeBranch,
15
+ faEraser,
16
+ faExternalLinkAlt,
17
+ faEyeDropper,
18
+ faFile,
19
+ faFilm,
20
+ faFillDrip,
21
+ faGlobe,
22
+ faImage,
23
+ faLayerGroup,
24
+ faObjectGroup,
25
+ faPalette,
26
+ faPencilAlt,
27
+ faPlay,
28
+ faPlus,
29
+ faRedo,
30
+ faShapes,
31
+ faSlash,
32
+ faVectorSquare,
33
+ faSquare,
34
+ faStamp,
35
+ faStop,
36
+ faSyncAlt,
37
+ faTimes,
38
+ faTrash,
39
+ faUndo,
40
+ faVideo,
41
+ } from '@fortawesome/free-solid-svg-icons';
42
+ import styles from './ui.module.css';
43
+ export { styles };
44
+ export const theme = {
45
+ transparentChecker:
46
+ 'repeating-linear-gradient(45deg, #121212, #121212 4px, #1a1a1a 4px, #1a1a1a 8px)',
47
+ };
48
+ export function cx(...parts) {
49
+ return parts.filter(Boolean).join(' ');
50
+ }
51
+ // Track an element's content box via ResizeObserver. Shared by the canvas
52
+ // overlays (SceneUI, SelectionOverlay) that scale a card-unit layer onto the
53
+ // live canvas size.
54
+ export function useElementSize(ref) {
55
+ const [size, setSize] = useState({ width: 0, height: 0 });
56
+ useEffect(() => {
57
+ const node = ref.current;
58
+ if (!node) return undefined;
59
+ const measure = () => setSize({ width: node.clientWidth, height: node.clientHeight });
60
+ const observer = new ResizeObserver(measure);
61
+ observer.observe(node);
62
+ measure();
63
+ return () => observer.disconnect();
64
+ }, [ref]);
65
+ return size;
66
+ }
67
+ // True when the viewport is in the compact (≤600px) layout, where the file
68
+ // list and inspector become bottom sheets. Mirrors the `@media (max-width:
69
+ // 600px)` breakpoint so JS behavior (toggle target) tracks the CSS layout.
70
+ export function useCompactViewport() {
71
+ const query = '(max-width: 600px)';
72
+ const [compact, setCompact] = useState(
73
+ () => typeof window !== 'undefined' && window.matchMedia(query).matches
74
+ );
75
+ useEffect(() => {
76
+ const mql = window.matchMedia(query);
77
+ const onChange = (event) => setCompact(event.matches);
78
+ mql.addEventListener('change', onChange);
79
+ setCompact(mql.matches);
80
+ return () => mql.removeEventListener('change', onChange);
81
+ }, []);
82
+ return compact;
83
+ }
84
+ export function AppShell({ children }) {
85
+ return <div className={styles.appShell}>{children}</div>;
86
+ }
87
+ export function MainEditor({ children }) {
88
+ return <main className={styles.mainEditor}>{children}</main>;
89
+ }
90
+ // The header is a full-width row spanning the file list + workspace. Each
91
+ // editor still owns its header content (title/subtitle/right controls depend
92
+ // on per-editor state), so EditorHeader portals its DOM into the editor-root
93
+ // header slot (`headerHost`) that App renders above the `[files | workspace]`
94
+ // grid. The files toggle lives on the left and works on every viewport.
95
+ export function EditorHeader({ title, subtitle, right, onToggleFiles, filesOpen, headerHost }) {
96
+ const header = (
97
+ <header className={styles.editorHeader}>
98
+ <div className={styles.editorHeaderLeft}>
99
+ {onToggleFiles ? (
100
+ <IconButton
101
+ icon="bars"
102
+ label="Files"
103
+ title="Toggle file list"
104
+ active={filesOpen}
105
+ aria-pressed={filesOpen ? 'true' : 'false'}
106
+ onClick={onToggleFiles}
107
+ />
108
+ ) : null}
109
+ <div>
110
+ <div className={styles.editorTitle}>{title}</div>
111
+ {subtitle ? <div className={styles.muted}>{subtitle}</div> : null}
112
+ </div>
113
+ </div>
114
+ {right ? <div className={styles.editorHeaderRight}>{right}</div> : null}
115
+ </header>
116
+ );
117
+ if (!headerHost) return null;
118
+ return createPortal(header, headerHost);
119
+ }
120
+ // Safari only moves keyboard focus into an iframe -- at the browser level,
121
+ // not just document.activeElement -- when the click lands on something
122
+ // Safari itself focuses on click (a text input; NOT a button, div, or
123
+ // canvas). PxArtEditor/SceneEditor each already focus their own canvas on
124
+ // pointer-down for exactly this reason, but that only covers clicks that
125
+ // land on the canvas: clicking a tool button, a palette swatch, the
126
+ // timeline, etc. does nothing, so Safari's frame focus can stay wherever it
127
+ // was and the NEXT keyboard shortcut or Cmd+Z falls through to the browser
128
+ // -- reproducing right after the user just clicked in the editor. Claiming
129
+ // focus here, once, for every pointer-down anywhere in the editor body
130
+ // closes that gap for both editors without duplicating the fix. Real text
131
+ // inputs still win focus normally right after: the browser's own
132
+ // click-focuses-inputs behavior runs as mousedown's default action, which
133
+ // fires AFTER this pointerdown handler.
134
+ function claimEditorFocus(event) {
135
+ event.currentTarget.focus({ preventScroll: true });
136
+ }
137
+ export const EditorBody = React.forwardRef(function EditorBody({ children }, ref) {
138
+ return (
139
+ <div ref={ref} className={styles.editorBody} tabIndex={-1} onPointerDown={claimEditorFocus}>
140
+ {children}
141
+ </div>
142
+ );
143
+ });
144
+ export function Button({ children, active = false, variant = '', className = '', ...props }) {
145
+ return (
146
+ <button
147
+ className={cx(
148
+ styles.button,
149
+ active && styles.buttonActive,
150
+ variant === 'danger' && styles.buttonDanger,
151
+ className
152
+ )}
153
+ {...props}>
154
+ {children}
155
+ </button>
156
+ );
157
+ }
158
+ export function IconButton({ icon, label, active = false, variant = '', ...props }) {
159
+ return (
160
+ <Button
161
+ active={active}
162
+ variant={variant}
163
+ className={styles.iconButton}
164
+ aria-label={label}
165
+ title={label}
166
+ {...props}>
167
+ <Icon name={icon} />
168
+ </Button>
169
+ );
170
+ }
171
+ const icons = {
172
+ bars: faBars,
173
+ camera: faVideo,
174
+ 'chevron-down': faChevronDown,
175
+ 'chevron-right': faChevronRight,
176
+ 'chevron-up': faChevronUp,
177
+ circle: faCircle,
178
+ clone: faClone,
179
+ code: faCode,
180
+ // FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
181
+ 'code-fork': faCodeBranch,
182
+ eraser: faEraser,
183
+ external: faExternalLinkAlt,
184
+ eyedropper: faEyeDropper,
185
+ fill: faFillDrip,
186
+ 'flip-h': faArrowsAltH,
187
+ 'flip-v': faArrowsAltV,
188
+ file: faFile,
189
+ film: faFilm,
190
+ globe: faGlobe,
191
+ image: faImage,
192
+ 'layer-group': faLayerGroup,
193
+ marquee: faVectorSquare,
194
+ move: faArrowsAlt,
195
+ 'object-group': faObjectGroup,
196
+ palette: faPalette,
197
+ pencil: faPencilAlt,
198
+ play: faPlay,
199
+ plus: faPlus,
200
+ redo: faRedo,
201
+ rotate: faSyncAlt,
202
+ shapes: faShapes,
203
+ slash: faSlash,
204
+ square: faSquare,
205
+ stamp: faStamp,
206
+ stop: faStop,
207
+ times: faTimes,
208
+ // Hand-rolled equilateral-ish triangle glyph (FA free-solid has no plain
209
+ // triangle); shaped to match the filled-glyph look of the imported icons.
210
+ triangle: { icon: [512, 512, [], '', 'M256 64 L496 448 L16 448 Z'] },
211
+ trash: faTrash,
212
+ undo: faUndo,
213
+ };
214
+ export function Icon({ name }) {
215
+ const def = icons[name];
216
+ if (!def) return null;
217
+ const [width, height, , , path] = def.icon;
218
+ const d = Array.isArray(path) ? path.join(' ') : path;
219
+ return (
220
+ <svg
221
+ viewBox={`0 0 ${width} ${height}`}
222
+ aria-hidden="true"
223
+ style={{ width: '1em', height: '1em', display: 'inline-block', fill: 'currentColor' }}>
224
+ <path d={d} />
225
+ </svg>
226
+ );
227
+ }
228
+ export function Panel({ title, action, children, overridden = false }) {
229
+ return (
230
+ <section className={styles.panel}>
231
+ {title ? (
232
+ <div className={styles.panelHeader}>
233
+ <span className={cx(overridden && styles.panelTitleOverridden)}>{title}</span>
234
+ {action}
235
+ </div>
236
+ ) : null}
237
+ <div className={styles.panelBody}>{children}</div>
238
+ </section>
239
+ );
240
+ }
241
+ // Render the "Default: X" value in an instance override's sub-line.
242
+ function formatFieldDefault(value) {
243
+ if (typeof value === 'boolean') return value ? 'On' : 'Off';
244
+ if (value == null || value === '') return 'none';
245
+ return String(value);
246
+ }
247
+ export function FieldRow({ label, overridden, defaultValue, onReset, children }) {
248
+ const row = (
249
+ <label className={cx(styles.fieldRow, overridden && styles.fieldRowOverridden)}>
250
+ <span className={cx(styles.fieldLabel, overridden && styles.fieldLabelOverridden)}>{label}</span>
251
+ {children}
252
+ </label>
253
+ );
254
+ if (!overridden) return row;
255
+ return (
256
+ <div className={styles.fieldOverride}>
257
+ {row}
258
+ <div className={styles.fieldDefault}>
259
+ <div className={styles.fieldDefaultInner}>
260
+ <span>Default: {formatFieldDefault(defaultValue)}</span>
261
+ {onReset ? (
262
+ <button type="button" className={styles.fieldResetBtn} onClick={onReset}>
263
+ Reset
264
+ </button>
265
+ ) : null}
266
+ </div>
267
+ </div>
268
+ </div>
269
+ );
270
+ }
271
+ export function TextField({ label, value, onChange, overridden, defaultValue, onReset }) {
272
+ return (
273
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
274
+ <input
275
+ className={styles.input}
276
+ value={value ?? ''}
277
+ onChange={(event) => onChange(event.target.value)}
278
+ />
279
+ </FieldRow>
280
+ );
281
+ }
282
+ export function NumberField({ label, value, onChange, min, max, step = 1, overridden, defaultValue, onReset }) {
283
+ const current = Number.isFinite(value) ? (value ?? 0) : 0;
284
+ const [draft, setDraft] = useState(String(current));
285
+ const editingRef = useRef(false);
286
+ const cancelRef = useRef(false);
287
+ // keep the draft in sync with the committed value while not editing
288
+ if (!editingRef.current && draft !== String(current)) {
289
+ setDraft(String(current));
290
+ }
291
+ function commit(raw) {
292
+ let next = Number(raw);
293
+ if (!Number.isFinite(next)) next = current;
294
+ if (min != null && next < min) next = min;
295
+ if (max != null && next > max) next = max;
296
+ setDraft(String(next));
297
+ onChange(next);
298
+ }
299
+ function stepBy(delta) {
300
+ let next = current + delta;
301
+ if (min != null && next < min) next = min;
302
+ if (max != null && next > max) next = max;
303
+ setDraft(String(next));
304
+ onChange(next);
305
+ }
306
+ return (
307
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
308
+ <div className={styles.numberField}>
309
+ <input
310
+ className={cx(styles.input, styles.numberInput)}
311
+ type="number"
312
+ min={min}
313
+ max={max}
314
+ step={step}
315
+ value={draft}
316
+ onFocus={() => {
317
+ editingRef.current = true;
318
+ }}
319
+ onChange={(event) => setDraft(event.target.value)}
320
+ onBlur={(event) => {
321
+ editingRef.current = false;
322
+ if (cancelRef.current) {
323
+ cancelRef.current = false;
324
+ setDraft(String(current));
325
+ return;
326
+ }
327
+ commit(event.target.value);
328
+ }}
329
+ onKeyDown={(event) => {
330
+ if (event.key === 'Enter') {
331
+ event.preventDefault();
332
+ commit(event.currentTarget.value);
333
+ event.currentTarget.blur();
334
+ } else if (event.key === 'Escape') {
335
+ event.preventDefault();
336
+ cancelRef.current = true;
337
+ setDraft(String(current));
338
+ event.currentTarget.blur();
339
+ }
340
+ }}
341
+ />
342
+ <div className={styles.numberSteppers}>
343
+ <button
344
+ type="button"
345
+ className={styles.numberStepper}
346
+ tabIndex={-1}
347
+ aria-label="Increment"
348
+ disabled={max != null && current >= max}
349
+ onClick={() => stepBy(step)}>
350
+ <Icon name="chevron-up" />
351
+ </button>
352
+ <button
353
+ type="button"
354
+ className={styles.numberStepper}
355
+ tabIndex={-1}
356
+ aria-label="Decrement"
357
+ disabled={min != null && current <= min}
358
+ onClick={() => stepBy(-step)}>
359
+ <Icon name="chevron-down" />
360
+ </button>
361
+ </div>
362
+ </div>
363
+ </FieldRow>
364
+ );
365
+ }
366
+ export function CheckboxField({ label, checked, onChange, overridden, defaultValue, onReset }) {
367
+ return (
368
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
369
+ <button
370
+ type="button"
371
+ className={cx(styles.toggle, checked && styles.toggleOn)}
372
+ role="switch"
373
+ aria-checked={!!checked}
374
+ onClick={() => onChange(!checked)}>
375
+ <span className={styles.toggleKnob} />
376
+ </button>
377
+ </FieldRow>
378
+ );
379
+ }
380
+ export function ColorField({ label, value, onChange, overridden, defaultValue, onReset }) {
381
+ const hex = normalizeHex(value);
382
+ const alpha = hex.length === 9 ? hex.slice(7) : '';
383
+ const rgb = hex.slice(0, 7);
384
+ return (
385
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
386
+ <input
387
+ className={styles.colorInput}
388
+ type="color"
389
+ value={rgb}
390
+ onChange={(event) => onChange(event.target.value + alpha)}
391
+ />
392
+ </FieldRow>
393
+ );
394
+ }
395
+ function normalizeHex(value) {
396
+ const raw = (value ?? '').trim();
397
+ if (/^#[0-9a-fA-F]{3}$/.test(raw)) {
398
+ return '#' + raw.slice(1).replace(/./g, (c) => c + c);
399
+ }
400
+ if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(raw)) return raw;
401
+ return '#000000';
402
+ }
403
+ export function isHexColor(value) {
404
+ return (
405
+ typeof value === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/.test(value)
406
+ );
407
+ }
408
+ // --- Bottom-sheet sizing (mobile only) -------------------------------------
409
+ // The inspector / file sheets are docked panels on desktop; below the CSS
410
+ // breakpoint they become drag-resizable bottom sheets. `useMobileSheet` owns a
411
+ // continuous pixel height so the sheet tracks the finger 1:1 and can rest at
412
+ // any height (the old version only read the drag delta at release and jumped
413
+ // between two fixed snaps, which felt dead mid-drag).
414
+ const SHEET_PEEK_FRACTION = 0.18; // collapsed "peek" height, fraction of viewport
415
+ const SHEET_DEFAULT_FRACTION = 0.62; // expanded default height
416
+ const SHEET_MAX_FRACTION = 0.92; // never quite cover the whole screen
417
+ const SHEET_MIN_PX = 96; // floor so the grab handle always fits
418
+ const SHEET_TAP_PX = 6; // release movement under this reads as a tap, not a drag
419
+ const SHEET_FLICK_VELOCITY = 0.6; // px/ms; a faster release flings to an end
420
+ function sheetViewportHeight() {
421
+ return typeof window !== 'undefined' ? window.innerHeight : 800;
422
+ }
423
+ function sheetSizes() {
424
+ const vh = sheetViewportHeight();
425
+ const peek = Math.max(SHEET_MIN_PX, vh * SHEET_PEEK_FRACTION);
426
+ const max = vh * SHEET_MAX_FRACTION;
427
+ const expanded = Math.min(max, Math.max(peek, vh * SHEET_DEFAULT_FRACTION));
428
+ return { peek, expanded, max };
429
+ }
430
+ function clampSheetHeight(px) {
431
+ const { peek, max } = sheetSizes();
432
+ return Math.min(max, Math.max(peek, px));
433
+ }
434
+ function releasePointer(event, pointerId) {
435
+ try {
436
+ event.currentTarget.releasePointerCapture(pointerId);
437
+ } catch {
438
+ // Capture may already be gone after a cancel / blur.
439
+ }
440
+ }
441
+ // Decide where the sheet rests when the finger lifts: a stationary press toggles
442
+ // between the peek and the last expanded height; a fast flick flings to the
443
+ // nearest end; otherwise it stays exactly where it was dragged (free resize).
444
+ function settleSheet(drag, setHeight, expandedRef) {
445
+ const { peek, expanded, max } = sheetSizes();
446
+ if (drag.moved < SHEET_TAP_PX) {
447
+ const wasCollapsed = drag.startHeight <= peek + 2;
448
+ if (!wasCollapsed) expandedRef.current = drag.startHeight;
449
+ setHeight(wasCollapsed ? expandedRef.current || expanded : peek);
450
+ return;
451
+ }
452
+ if (drag.velocity > SHEET_FLICK_VELOCITY) {
453
+ setHeight(peek);
454
+ return;
455
+ }
456
+ if (drag.velocity < -SHEET_FLICK_VELOCITY) {
457
+ expandedRef.current = max;
458
+ setHeight(max);
459
+ return;
460
+ }
461
+ const settled = setHeight(drag.startHeight - (drag.lastY - drag.startY));
462
+ if (settled > peek + 2) expandedRef.current = settled;
463
+ }
464
+ // Persist a sheet's resting height across reloads (an agent's CODE edit
465
+ // triggers requestReload, which would otherwise reset every sheet to the
466
+ // default size). Keyed per sheet via `storageKey`; sessionStorage-scoped and
467
+ // best-effort, the same degrade-to-memory-only contract as editorHistory.
468
+ function sheetHeightStorageKey(key) {
469
+ return `castle-sheet-height:${key}`;
470
+ }
471
+ function loadStoredSheetHeight(key) {
472
+ if (!key || typeof sessionStorage === 'undefined') return null;
473
+ try {
474
+ const raw = sessionStorage.getItem(sheetHeightStorageKey(key));
475
+ if (raw === null) return null;
476
+ const px = Number(raw);
477
+ return Number.isFinite(px) && px > 0 ? px : null;
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+ function saveStoredSheetHeight(key, px) {
483
+ if (!key || typeof sessionStorage === 'undefined') return;
484
+ try {
485
+ sessionStorage.setItem(sheetHeightStorageKey(key), String(Math.round(px)));
486
+ } catch {
487
+ // storage full/unavailable -- height still works in-memory this session
488
+ }
489
+ }
490
+ export function useMobileSheet({ open = true, baseClassName, storageKey }) {
491
+ const [height, setHeightState] = useState(() =>
492
+ clampSheetHeight(loadStoredSheetHeight(storageKey) ?? sheetSizes().expanded)
493
+ );
494
+ const [dragging, setDragging] = useState(false);
495
+ const heightRef = useRef(height);
496
+ // Last height above the peek, for tap-restore: seed from the restored height
497
+ // when it isn't collapsed, else the default expanded size.
498
+ const expandedRef = useRef(height > sheetSizes().peek + 2 ? height : sheetSizes().expanded);
499
+ const dragRef = useRef(null);
500
+ const didOpenMountRef = useRef(false);
501
+ const setHeight = (px) => {
502
+ const next = clampSheetHeight(px);
503
+ heightRef.current = next;
504
+ setHeightState(next);
505
+ return next;
506
+ };
507
+ // Restore the last expanded height whenever the sheet (re)opens, and keep the
508
+ // height in range across viewport resize / rotation. Skip the initial mount
509
+ // so a height restored from storage (including a collapsed peek) is kept
510
+ // rather than immediately overwritten by the default expanded size.
511
+ useEffect(() => {
512
+ if (!didOpenMountRef.current) {
513
+ didOpenMountRef.current = true;
514
+ return;
515
+ }
516
+ if (open) setHeight(expandedRef.current);
517
+ }, [open]);
518
+ useEffect(() => {
519
+ const onResize = () => setHeight(heightRef.current);
520
+ window.addEventListener('resize', onResize);
521
+ return () => window.removeEventListener('resize', onResize);
522
+ }, []);
523
+ // Persist the resting height (never mid-drag, so a drag doesn't spam storage)
524
+ // so it survives a reload.
525
+ useEffect(() => {
526
+ if (!dragging) saveStoredSheetHeight(storageKey, height);
527
+ }, [dragging, height, storageKey]);
528
+ function onPointerDown(event) {
529
+ dragRef.current = {
530
+ pointerId: event.pointerId,
531
+ startY: event.clientY,
532
+ startHeight: heightRef.current,
533
+ lastY: event.clientY,
534
+ lastT: event.timeStamp,
535
+ velocity: 0,
536
+ moved: 0,
537
+ };
538
+ setDragging(true);
539
+ try {
540
+ event.currentTarget.setPointerCapture(event.pointerId);
541
+ } catch {
542
+ // No active pointer to capture (e.g. synthetic events) -- drag still works.
543
+ }
544
+ }
545
+ function onPointerMove(event) {
546
+ const drag = dragRef.current;
547
+ if (!drag || drag.pointerId !== event.pointerId) return;
548
+ const dy = event.clientY - drag.startY; // down is positive
549
+ drag.moved = Math.max(drag.moved, Math.abs(dy));
550
+ const dt = event.timeStamp - drag.lastT;
551
+ if (dt > 0) drag.velocity = (event.clientY - drag.lastY) / dt;
552
+ drag.lastY = event.clientY;
553
+ drag.lastT = event.timeStamp;
554
+ setHeight(drag.startHeight - dy); // dragging up grows the sheet
555
+ }
556
+ function endDrag(event) {
557
+ const drag = dragRef.current;
558
+ if (!drag || drag.pointerId !== event.pointerId) return;
559
+ dragRef.current = null;
560
+ setDragging(false);
561
+ releasePointer(event, drag.pointerId);
562
+ settleSheet(drag, setHeight, expandedRef);
563
+ }
564
+ return {
565
+ rootProps: {
566
+ className: cx(baseClassName, dragging && styles.sheetDragging),
567
+ 'data-sheet-open': open ? 'true' : 'false',
568
+ style: { '--sheet-h': `${Math.round(height)}px` },
569
+ },
570
+ grabProps: {
571
+ className: cx(styles.sheetGrab, styles.mobileOnly),
572
+ onPointerDown,
573
+ onPointerMove,
574
+ onPointerUp: endDrag,
575
+ onPointerCancel: endDrag,
576
+ },
577
+ };
578
+ }
579
+ // Just the drag-affordance pill for the mobile sheet. The label/hint header
580
+ // this used to render was dropped -- it duplicated each inspector's own header
581
+ // (blueprint name input, "<name> Actor", scene settings, etc.).
582
+ export function SheetGrabHandle() {
583
+ return <div className={styles.sheetGrabBar} />;
584
+ }
585
+ // Cursor-anchored right-click menu reusing the scene editor's "Arrange" menu
586
+ // chrome (the shared `selArrangeMenu`/`selArrangeItem` classes). Opens at the
587
+ // given client point, clamps into the viewport, and closes on outside-click or
588
+ // Escape. Items are `{ key, label, disabled?, onClick }` descriptors. The
589
+ // timeline keeps its own inline copy; this one backs the FileBrowser menus.
590
+ export function ContextMenu({ x, y, items, onClose }) {
591
+ const ref = useRef(null);
592
+ const [pos, setPos] = useState({ left: x, top: y });
593
+ useLayoutEffect(() => {
594
+ const el = ref.current;
595
+ if (!el) return;
596
+ const { width, height } = el.getBoundingClientRect();
597
+ const margin = 8;
598
+ const maxLeft = Math.max(margin, window.innerWidth - width - margin);
599
+ const maxTop = Math.max(margin, window.innerHeight - height - margin);
600
+ setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
601
+ }, [x, y]);
602
+ useEffect(() => {
603
+ const dismiss = (event) => {
604
+ if (event.type === 'keydown' && event.key !== 'Escape') return;
605
+ if (event.type === 'pointerdown' && ref.current?.contains(event.target)) return;
606
+ onClose();
607
+ };
608
+ window.addEventListener('pointerdown', dismiss);
609
+ window.addEventListener('keydown', dismiss);
610
+ return () => {
611
+ window.removeEventListener('pointerdown', dismiss);
612
+ window.removeEventListener('keydown', dismiss);
613
+ };
614
+ }, [onClose]);
615
+ const pick = (item) => {
616
+ if (item.disabled) return;
617
+ item.onClick();
618
+ onClose();
619
+ };
620
+ const menuStyle = { position: 'fixed', left: pos.left, top: pos.top, transform: 'none' };
621
+ return (
622
+ <div
623
+ ref={ref}
624
+ role="menu"
625
+ style={menuStyle}
626
+ onContextMenu={(event) => event.preventDefault()}
627
+ className={styles.selArrangeMenu}>
628
+ {items.map((item) => (
629
+ <button
630
+ type="button"
631
+ key={item.key}
632
+ disabled={item.disabled}
633
+ onClick={() => pick(item)}
634
+ className={cx(styles.selArrangeItem, item.disabled && styles.selArrangeItemDisabled)}
635
+ role="menuitem">
636
+ {item.label}
637
+ </button>
638
+ ))}
639
+ </div>
640
+ );
641
+ }
642
+ // Cursor-independent modal confirm (unlike `ContextMenu`, which anchors to a
643
+ // click point) -- for actions with real consequences, like the blueprint
644
+ // library's cascade delete. Dismisses on Escape or a backdrop click, same as
645
+ // cancelling.
646
+ export function ConfirmDialog({ title, message, confirmLabel = 'Delete', danger = true, onConfirm, onCancel }) {
647
+ useEffect(() => {
648
+ const onKeyDown = (event) => {
649
+ if (event.key === 'Escape') onCancel();
650
+ };
651
+ window.addEventListener('keydown', onKeyDown);
652
+ return () => window.removeEventListener('keydown', onKeyDown);
653
+ }, [onCancel]);
654
+ return (
655
+ <div className={styles.confirmBackdrop} onPointerDown={onCancel}>
656
+ <div className={styles.confirmDialog} role="alertdialog" onPointerDown={(event) => event.stopPropagation()}>
657
+ <div className={styles.confirmTitle}>{title}</div>
658
+ <div className={styles.confirmMessage}>{message}</div>
659
+ <div className={styles.confirmActions}>
660
+ <Button onClick={onCancel}>Cancel</Button>
661
+ <Button variant={danger ? 'danger' : ''} onClick={onConfirm}>
662
+ {confirmLabel}
663
+ </Button>
664
+ </div>
665
+ </div>
666
+ </div>
667
+ );
668
+ }
669
+ export function SelectField({ label, value, onChange, options, overridden, defaultValue, onReset }) {
670
+ return (
671
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
672
+ <select
673
+ className={styles.select}
674
+ value={value ?? ''}
675
+ onChange={(event) => onChange(event.target.value)}>
676
+ {options.map((option) => {
677
+ const value = typeof option === 'string' ? option : option.value;
678
+ const label = typeof option === 'string' ? option : option.label;
679
+ return (
680
+ <option key={value} value={value}>
681
+ {label}
682
+ </option>
683
+ );
684
+ })}
685
+ </select>
686
+ </FieldRow>
687
+ );
688
+ }