castle-web-cli 0.4.129 → 0.4.130

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