castle-web-cli 0.4.82 → 0.4.83

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.
@@ -0,0 +1,146 @@
1
+ // Single source of truth for the Collider rect. Lives in engine/ (not
2
+ // behaviors/) so SceneRuntime can use it directly, with behaviors/Collider.jsx
3
+ // (its `draw`, and its `getColliderRect`/`intersects` re-exports) delegating
4
+ // here instead of keeping a second, drifting copy.
5
+ import { frameCount, renderSpriteFrame } from './pxart';
6
+ import { spriteDestRect } from './spriteGeometry';
7
+
8
+ // Union bounding box (fraction of native resolution, 0..1) of every opaque
9
+ // (alpha > 0) pixel across ALL animation frames of a sprite, or null when the
10
+ // art is fully transparent. Unioning across frames keeps an animated sprite's
11
+ // collider from pulsing frame to frame. Cached in a WeakMap keyed by the
12
+ // sprite object -- a sprite edit produces a fresh object (same invalidation
13
+ // lifecycle as Sprite.jsx's rendered-canvas cache), so a stale entry never
14
+ // outlives the art it was computed from.
15
+ const opaqueBoundsCache = new WeakMap();
16
+
17
+ // Expand `acc` (in pixel units) to cover every opaque pixel of the canvas's
18
+ // current contents.
19
+ function accumulateOpaquePixels(acc, ctx, canvas) {
20
+ if (!ctx || canvas.width === 0 || canvas.height === 0) return;
21
+ const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
22
+ for (let y = 0; y < canvas.height; y++) {
23
+ for (let x = 0; x < canvas.width; x++) {
24
+ if (data[(y * canvas.width + x) * 4 + 3] === 0) continue;
25
+ if (x < acc.minX) acc.minX = x;
26
+ if (y < acc.minY) acc.minY = y;
27
+ if (x + 1 > acc.maxX) acc.maxX = x + 1;
28
+ if (y + 1 > acc.maxY) acc.maxY = y + 1;
29
+ }
30
+ }
31
+ }
32
+
33
+ function opaqueBoundsFraction(sprite) {
34
+ if (opaqueBoundsCache.has(sprite)) return opaqueBoundsCache.get(sprite);
35
+
36
+ const { width, height } = sprite.resolution;
37
+ const canvas = document.createElement('canvas');
38
+ const ctx = canvas.getContext('2d');
39
+ const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
40
+ const total = frameCount(sprite);
41
+ for (let frame = 0; frame < total; frame++) {
42
+ renderSpriteFrame(sprite, frame, canvas);
43
+ accumulateOpaquePixels(acc, ctx, canvas);
44
+ }
45
+
46
+ const bounds =
47
+ acc.minX <= acc.maxX && acc.minY <= acc.maxY && width > 0 && height > 0
48
+ ? {
49
+ x: acc.minX / width,
50
+ y: acc.minY / height,
51
+ width: (acc.maxX - acc.minX) / width,
52
+ height: (acc.maxY - acc.minY) / height,
53
+ }
54
+ : null;
55
+ opaqueBoundsCache.set(sprite, bounds);
56
+ return bounds;
57
+ }
58
+
59
+ // True when a resolved sprite has no opaque pixels across any frame -- fully
60
+ // transparent art (e.g. a brand-new blank drawing). The editor draws an
61
+ // empty-actor placeholder for these so a blank actor isn't invisible. Shares
62
+ // `opaqueBoundsFraction`'s per-sprite cache (a sprite edit yields a fresh
63
+ // object, which invalidates it).
64
+ export function spriteIsEmpty(sprite) {
65
+ return !sprite || opaqueBoundsFraction(sprite) === null;
66
+ }
67
+
68
+ // The `mode: 'auto'` rect (before offsets), in world units, or null when auto
69
+ // falls back to the full Layout box: no Sprite, an unresolved sprite file,
70
+ // `Sprite.mode === 'tile'` (tiled art fills its cell by definition, so a
71
+ // per-cell bbox doesn't compose), or fully transparent art. Deliberately does
72
+ // NOT follow Sprite.jsx's FALLBACK_FILE placeholder-art fallback -- an
73
+ // unresolvable file means the Layout box, period.
74
+ function autoRect(layout, spriteProps, sprite) {
75
+ if (!sprite || spriteProps.mode === 'tile') return null;
76
+ const bounds = opaqueBoundsFraction(sprite);
77
+ if (!bounds) return null;
78
+ const dest = spriteDestRect(spriteProps.mode, layout, sprite.resolution);
79
+ const rect = {
80
+ x: dest.x + bounds.x * dest.width,
81
+ y: dest.y + bounds.y * dest.height,
82
+ width: bounds.width * dest.width,
83
+ height: bounds.height * dest.height,
84
+ };
85
+ // `cover`'s dest overflows the Layout box and the draw clips to it, so the
86
+ // opaque-bounds rect can spill outside the actor -- clamp to the visible box.
87
+ // A no-op for `stretch`/`fit`, whose dest already sits inside the box.
88
+ if (spriteProps.mode === 'cover') return intersectRect(rect, layout);
89
+ return rect;
90
+ }
91
+
92
+ // Rect intersection in world units; zero-size (never negative) when disjoint.
93
+ function intersectRect(rect, box) {
94
+ const x0 = Math.max(rect.x, box.x);
95
+ const y0 = Math.max(rect.y, box.y);
96
+ const x1 = Math.min(rect.x + rect.width, box.x + box.width);
97
+ const y1 = Math.min(rect.y + rect.height, box.y + box.height);
98
+ return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
99
+ }
100
+
101
+ // `mode: 'manual'` rect (before offsets): `width`/`height`, centered in the
102
+ // Layout box.
103
+ function manualRect(layout, collider) {
104
+ const width = collider.width ?? layout.width;
105
+ const height = collider.height ?? layout.height;
106
+ return {
107
+ x: layout.x + (layout.width - width) / 2,
108
+ y: layout.y + (layout.height - height) / 2,
109
+ width,
110
+ height,
111
+ };
112
+ }
113
+
114
+ const fullLayoutRect = (layout) => ({ x: layout.x, y: layout.y, width: layout.width, height: layout.height });
115
+
116
+ // The un-offset rect for whichever mode is active: `manualRect` for
117
+ // `mode: 'manual'`, otherwise the auto sprite-bbox rect (or its Layout-box
118
+ // fallback).
119
+ function modeRect(layout, collider, spriteProps, sprites) {
120
+ if ((collider.mode ?? 'auto') === 'manual') return manualRect(layout, collider);
121
+ const sprite = spriteProps ? sprites?.[spriteProps.file] : null;
122
+ return autoRect(layout, spriteProps, sprite) ?? fullLayoutRect(layout);
123
+ }
124
+
125
+ // The Collider rect for an actor, from its Layout + Collider (+ Sprite, for
126
+ // `mode: 'auto'`), or null if it lacks a Layout or Collider. `sprites` is the
127
+ // scene's sprite map (file path -> parsed sprite); omit it (or pass a map
128
+ // missing the actor's file) to force the Layout-box fallback, e.g. from a
129
+ // caller that only has raw scene data and no loaded sprites.
130
+ export function getColliderRect(actor, sprites) {
131
+ const layout = actor?.components?.Layout;
132
+ const collider = actor?.components?.Collider;
133
+ if (!layout || !collider) return null;
134
+
135
+ const rect = modeRect(layout, collider, actor.components.Sprite, sprites);
136
+ return {
137
+ x: rect.x + (collider.offsetX ?? 0),
138
+ y: rect.y + (collider.offsetY ?? 0),
139
+ width: rect.width,
140
+ height: rect.height,
141
+ };
142
+ }
143
+
144
+ export function intersects(a, b) {
145
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
146
+ }
@@ -1,5 +1,6 @@
1
1
  import { initialFiles, parseJsonFile } from './files';
2
2
  import { getBlueprintTemplate, mergeComponents } from './blueprint';
3
+ import { getColliderRect, intersects, spriteIsEmpty } from './collider';
3
4
 
4
5
  const CARD_WIDTH = 500;
5
6
  const CARD_HEIGHT = 700;
@@ -127,7 +128,7 @@ export class SceneRuntime {
127
128
 
128
129
  colliderRect(actorOrId) {
129
130
  const actor = typeof actorOrId === 'string' ? this.getActor(actorOrId) : actorOrId;
130
- return getColliderRect(actor);
131
+ return getColliderRect(actor, this.sprites);
131
132
  }
132
133
 
133
134
  overlaps(first, second) {
@@ -204,12 +205,24 @@ export class SceneRuntime {
204
205
  const viewport = options.viewport ?? DEFAULT_VIEWPORT;
205
206
  ctx.clearRect(0, 0, viewport.width, viewport.height);
206
207
  const camera = options.useCamera && this.camera ? this.camera : { x: 0, y: 0 };
208
+ // When a blueprint is selected in the editor, its instances stay at full
209
+ // opacity and everything else -- the background and every other actor --
210
+ // dims, so the selection stands out.
211
+ const dimBlueprintPath = options.dimBlueprintPath ?? null;
207
212
  if (options.showCropOutline) {
208
213
  ctx.fillStyle = this.data.background ?? '#0f2a1d';
209
214
  ctx.fillRect(0, 0, viewport.width, viewport.height);
215
+ if (dimBlueprintPath) {
216
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
217
+ ctx.fillRect(0, 0, viewport.width, viewport.height);
218
+ }
210
219
  } else {
211
220
  ctx.fillStyle = this.data.background ?? '#0f2a1d';
212
221
  ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
222
+ if (dimBlueprintPath) {
223
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
224
+ ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
225
+ }
213
226
  }
214
227
 
215
228
  ctx.save();
@@ -220,15 +233,15 @@ export class SceneRuntime {
220
233
  );
221
234
  }
222
235
  if (options.showGrid) drawDotGrid(ctx, options.gridSize, viewport, camera);
223
- const editSelectedIds = options.editPlaceholders
224
- ? new Set(options.editSelectedActorIds ?? [])
225
- : null;
226
236
  for (const actor of this.getActors()) {
227
237
  // Per-actor transform: Layout's `rotation` (degrees) rotates every draw
228
238
  // behavior of the actor about its center.
229
239
  ctx.save();
240
+ if (dimBlueprintPath && actor.blueprint !== dimBlueprintPath) {
241
+ ctx.globalAlpha = 0.2;
242
+ }
230
243
  applyLayoutRotation(ctx, getLayout(actor));
231
- if (options.editPlaceholders && shouldDrawEditPlaceholder(actor, editSelectedIds)) {
244
+ if (options.editPlaceholders && shouldDrawEditPlaceholder(actor, this.sprites)) {
232
245
  drawEditPlaceholder(ctx, actor);
233
246
  }
234
247
  this.forEachBehavior(actor, (instance) => instance.draw?.(actor, this, ctx, options));
@@ -344,7 +357,7 @@ export function duplicateActors(sceneData, actorIds) {
344
357
  const source = next.actors.find((candidate) => candidate.id === id);
345
358
  if (!source) continue;
346
359
  const copy = structuredClone(source);
347
- copy.id = mintActorId(existingIds);
360
+ copy.id = dedupeActorId(existingIds, stripDupSuffix(source.id));
348
361
  existingIds.add(copy.id);
349
362
  const layout = copy.components.Layout;
350
363
  if (layout) {
@@ -471,6 +484,28 @@ export function mintActorId(existing) {
471
484
  return `${Date.now().toString(16)}${Math.floor(Math.random() * 0xffff).toString(16)}`;
472
485
  }
473
486
 
487
+ // Slug-suffix dedup: return `base`, else `base-2`, `base-3`, ... -- the first
488
+ // not already in `existing`. Callers pass an already-formed base (a blueprint
489
+ // name's slug for fresh placements, an existing id for clones) so newly created
490
+ // actors read as `cauldron` / `cauldron-2` instead of opaque hex. Falls back to
491
+ // a random hex id in the pathological case (100k straight collisions) so the
492
+ // caller always gets a unique id.
493
+ export function dedupeActorId(existing, base) {
494
+ const safe = typeof base === 'string' && base.length ? base : 'actor';
495
+ if (!existing.has(safe)) return safe;
496
+ for (let attempt = 2; attempt < 100000; attempt += 1) {
497
+ const candidate = `${safe}-${attempt}`;
498
+ if (!existing.has(candidate)) return candidate;
499
+ }
500
+ return mintActorId(existing);
501
+ }
502
+
503
+ // Strip a trailing `-<n>` dedup suffix so a clone of `brick-3` bases off
504
+ // `brick` (yielding the next free `brick-N`) instead of nesting `brick-3-2`.
505
+ function stripDupSuffix(id) {
506
+ return typeof id === 'string' ? id.replace(/-\d+$/, '') : id;
507
+ }
508
+
474
509
  export function screenToCard(canvas, clientX, clientY) {
475
510
  const rect = canvas.getBoundingClientRect();
476
511
  const viewport = readCanvasViewport(canvas);
@@ -529,13 +564,19 @@ function drawDotGrid(ctx, gridSize = 25, viewport = DEFAULT_VIEWPORT, camera = {
529
564
  }
530
565
 
531
566
  // Per-actor edit bounding box visibility: show it only when the actor lacks a
532
- // real sprite to look at (no Sprite behavior, or a Sprite with no .pxart file)
533
- // or when the actor is currently selected. A Sprite-backed, unselected actor
534
- // shows its art instead of the box.
535
- function shouldDrawEditPlaceholder(actor, selectedIds) {
536
- if (selectedIds && selectedIds.has(actor.id)) return true;
567
+ // real sprite to look at (no Sprite behavior, or a Sprite with no .pxart file).
568
+ // This gray box is purely the "sprite-less actor" indicator -- selection is
569
+ // represented separately by the DOM SelectionOverlay's solid white box, so a
570
+ // Sprite-backed actor never shows this box, selected or not.
571
+ function shouldDrawEditPlaceholder(actor, sprites) {
537
572
  const sprite = actor?.components?.Sprite;
538
- return !sprite || !sprite.file;
573
+ if (!sprite || !sprite.file) return true;
574
+ const resolved = sprites?.[sprite.file];
575
+ // An unresolved file falls back to Sprite.jsx's own placeholder art, so it's
576
+ // not invisible -- only draw the editor placeholder when the file resolves
577
+ // to fully-transparent (empty) art.
578
+ if (!resolved) return false;
579
+ return spriteIsEmpty(resolved);
539
580
  }
540
581
 
541
582
  function drawEditPlaceholder(ctx, actor) {
@@ -616,21 +657,3 @@ function applyLayoutRotation(ctx, layout) {
616
657
  function getLayout(actor) {
617
658
  return actor?.components?.Layout;
618
659
  }
619
-
620
- function getColliderRect(actor) {
621
- const layout = getLayout(actor);
622
- const collider = actor?.components?.Collider;
623
- if (!layout || !collider) return null;
624
- const width = collider.width ?? layout.width;
625
- const height = collider.height ?? layout.height;
626
- return {
627
- x: layout.x + (layout.width - width) / 2 + (collider.offsetX ?? 0),
628
- y: layout.y + (layout.height - height) / 2 + (collider.offsetY ?? 0),
629
- width,
630
- height,
631
- };
632
- }
633
-
634
- function intersects(a, b) {
635
- return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
636
- }
@@ -0,0 +1,32 @@
1
+ // Shared sprite-to-Layout geometry. Lives in engine/ (not behaviors/) so both
2
+ // Sprite.jsx (rendering) and the Collider rect logic (engine/collider.js) can
3
+ // use the same math without behaviors/ <-> engine/ import cycles.
4
+
5
+ // Destination rect for a draw mode: the Layout box for `stretch`/`tile`, or an
6
+ // aspect-ratio-preserving centered rect for `fit` and `cover`:
7
+ // - `fit` (CSS object-fit: contain): the largest rect with the art's aspect
8
+ // ratio that fits INSIDE the box -- letterboxed sides just aren't drawn.
9
+ // - `cover` (CSS object-fit: cover): the smallest rect with the art's aspect
10
+ // ratio that fully COVERS the box -- the overflow is cropped by the caller
11
+ // clipping to the Layout box (see Sprite.draw).
12
+ // `artSize` only needs `.width`/`.height` -- a rendered canvas or a sprite's
13
+ // `.resolution` both work.
14
+ export function spriteDestRect(mode, layout, artSize) {
15
+ if (mode !== 'fit' && mode !== 'cover') return layout;
16
+ const aspect = artSize.width / artSize.height;
17
+ let width = layout.width;
18
+ let height = width / aspect;
19
+ // `fit` shrinks until the art fits inside the box; `cover` grows until it
20
+ // fills the box. The comparison flips between the two.
21
+ const overflows = mode === 'fit' ? height > layout.height : height < layout.height;
22
+ if (overflows) {
23
+ height = layout.height;
24
+ width = height * aspect;
25
+ }
26
+ return {
27
+ x: layout.x + (layout.width - width) / 2,
28
+ y: layout.y + (layout.height - height) / 2,
29
+ width,
30
+ height,
31
+ };
32
+ }
@@ -223,12 +223,12 @@ export function Icon({ name }) {
223
223
  </svg>
224
224
  );
225
225
  }
226
- export function Panel({ title, action, children }) {
226
+ export function Panel({ title, action, children, overridden = false }) {
227
227
  return (
228
228
  <section className={styles.panel}>
229
229
  {title ? (
230
230
  <div className={styles.panelHeader}>
231
- <span>{title}</span>
231
+ <span className={cx(overridden && styles.panelTitleOverridden)}>{title}</span>
232
232
  {action}
233
233
  </div>
234
234
  ) : null}
@@ -236,17 +236,39 @@ export function Panel({ title, action, children }) {
236
236
  </section>
237
237
  );
238
238
  }
239
- export function FieldRow({ label, children }) {
240
- return (
241
- <label className={styles.fieldRow}>
242
- <span className={styles.fieldLabel}>{label}</span>
239
+ // Render the "Default: X" value in an instance override's sub-line.
240
+ function formatFieldDefault(value) {
241
+ if (typeof value === 'boolean') return value ? 'On' : 'Off';
242
+ if (value == null || value === '') return 'none';
243
+ return String(value);
244
+ }
245
+ export function FieldRow({ label, overridden, defaultValue, onReset, children }) {
246
+ const row = (
247
+ <label className={cx(styles.fieldRow, overridden && styles.fieldRowOverridden)}>
248
+ <span className={cx(styles.fieldLabel, overridden && styles.fieldLabelOverridden)}>{label}</span>
243
249
  {children}
244
250
  </label>
245
251
  );
252
+ if (!overridden) return row;
253
+ return (
254
+ <div className={styles.fieldOverride}>
255
+ {row}
256
+ <div className={styles.fieldDefault}>
257
+ <div className={styles.fieldDefaultInner}>
258
+ <span>Default: {formatFieldDefault(defaultValue)}</span>
259
+ {onReset ? (
260
+ <button type="button" className={styles.fieldResetBtn} onClick={onReset}>
261
+ Reset
262
+ </button>
263
+ ) : null}
264
+ </div>
265
+ </div>
266
+ </div>
267
+ );
246
268
  }
247
- export function TextField({ label, value, onChange }) {
269
+ export function TextField({ label, value, onChange, overridden, defaultValue, onReset }) {
248
270
  return (
249
- <FieldRow label={label}>
271
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
250
272
  <input
251
273
  className={styles.input}
252
274
  value={value ?? ''}
@@ -255,7 +277,7 @@ export function TextField({ label, value, onChange }) {
255
277
  </FieldRow>
256
278
  );
257
279
  }
258
- export function NumberField({ label, value, onChange, min, max, step = 1 }) {
280
+ export function NumberField({ label, value, onChange, min, max, step = 1, overridden, defaultValue, onReset }) {
259
281
  const current = Number.isFinite(value) ? (value ?? 0) : 0;
260
282
  const [draft, setDraft] = useState(String(current));
261
283
  const editingRef = useRef(false);
@@ -280,7 +302,7 @@ export function NumberField({ label, value, onChange, min, max, step = 1 }) {
280
302
  onChange(next);
281
303
  }
282
304
  return (
283
- <FieldRow label={label}>
305
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
284
306
  <div className={styles.numberField}>
285
307
  <input
286
308
  className={cx(styles.input, styles.numberInput)}
@@ -339,9 +361,9 @@ export function NumberField({ label, value, onChange, min, max, step = 1 }) {
339
361
  </FieldRow>
340
362
  );
341
363
  }
342
- export function CheckboxField({ label, checked, onChange }) {
364
+ export function CheckboxField({ label, checked, onChange, overridden, defaultValue, onReset }) {
343
365
  return (
344
- <FieldRow label={label}>
366
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
345
367
  <button
346
368
  type="button"
347
369
  className={cx(styles.toggle, checked && styles.toggleOn)}
@@ -353,12 +375,12 @@ export function CheckboxField({ label, checked, onChange }) {
353
375
  </FieldRow>
354
376
  );
355
377
  }
356
- export function ColorField({ label, value, onChange }) {
378
+ export function ColorField({ label, value, onChange, overridden, defaultValue, onReset }) {
357
379
  const hex = normalizeHex(value);
358
380
  const alpha = hex.length === 9 ? hex.slice(7) : '';
359
381
  const rgb = hex.slice(0, 7);
360
382
  return (
361
- <FieldRow label={label}>
383
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
362
384
  <input
363
385
  className={styles.colorInput}
364
386
  type="color"
@@ -437,12 +459,43 @@ function settleSheet(drag, setHeight, expandedRef) {
437
459
  const settled = setHeight(drag.startHeight - (drag.lastY - drag.startY));
438
460
  if (settled > peek + 2) expandedRef.current = settled;
439
461
  }
440
- export function useMobileSheet({ open = true, baseClassName }) {
441
- const [height, setHeightState] = useState(() => sheetSizes().expanded);
462
+ // Persist a sheet's resting height across reloads (an agent's CODE edit
463
+ // triggers requestReload, which would otherwise reset every sheet to the
464
+ // default size). Keyed per sheet via `storageKey`; sessionStorage-scoped and
465
+ // best-effort, the same degrade-to-memory-only contract as editorHistory.
466
+ function sheetHeightStorageKey(key) {
467
+ return `castle-sheet-height:${key}`;
468
+ }
469
+ function loadStoredSheetHeight(key) {
470
+ if (!key || typeof sessionStorage === 'undefined') return null;
471
+ try {
472
+ const raw = sessionStorage.getItem(sheetHeightStorageKey(key));
473
+ if (raw === null) return null;
474
+ const px = Number(raw);
475
+ return Number.isFinite(px) && px > 0 ? px : null;
476
+ } catch {
477
+ return null;
478
+ }
479
+ }
480
+ function saveStoredSheetHeight(key, px) {
481
+ if (!key || typeof sessionStorage === 'undefined') return;
482
+ try {
483
+ sessionStorage.setItem(sheetHeightStorageKey(key), String(Math.round(px)));
484
+ } catch {
485
+ // storage full/unavailable -- height still works in-memory this session
486
+ }
487
+ }
488
+ export function useMobileSheet({ open = true, baseClassName, storageKey }) {
489
+ const [height, setHeightState] = useState(() =>
490
+ clampSheetHeight(loadStoredSheetHeight(storageKey) ?? sheetSizes().expanded)
491
+ );
442
492
  const [dragging, setDragging] = useState(false);
443
493
  const heightRef = useRef(height);
444
- const expandedRef = useRef(height); // last height above the peek, for tap-restore
494
+ // Last height above the peek, for tap-restore: seed from the restored height
495
+ // when it isn't collapsed, else the default expanded size.
496
+ const expandedRef = useRef(height > sheetSizes().peek + 2 ? height : sheetSizes().expanded);
445
497
  const dragRef = useRef(null);
498
+ const didOpenMountRef = useRef(false);
446
499
  const setHeight = (px) => {
447
500
  const next = clampSheetHeight(px);
448
501
  heightRef.current = next;
@@ -450,8 +503,14 @@ export function useMobileSheet({ open = true, baseClassName }) {
450
503
  return next;
451
504
  };
452
505
  // Restore the last expanded height whenever the sheet (re)opens, and keep the
453
- // height in range across viewport resize / rotation.
506
+ // height in range across viewport resize / rotation. Skip the initial mount
507
+ // so a height restored from storage (including a collapsed peek) is kept
508
+ // rather than immediately overwritten by the default expanded size.
454
509
  useEffect(() => {
510
+ if (!didOpenMountRef.current) {
511
+ didOpenMountRef.current = true;
512
+ return;
513
+ }
455
514
  if (open) setHeight(expandedRef.current);
456
515
  }, [open]);
457
516
  useEffect(() => {
@@ -459,6 +518,11 @@ export function useMobileSheet({ open = true, baseClassName }) {
459
518
  window.addEventListener('resize', onResize);
460
519
  return () => window.removeEventListener('resize', onResize);
461
520
  }, []);
521
+ // Persist the resting height (never mid-drag, so a drag doesn't spam storage)
522
+ // so it survives a reload.
523
+ useEffect(() => {
524
+ if (!dragging) saveStoredSheetHeight(storageKey, height);
525
+ }, [dragging, height, storageKey]);
462
526
  function onPointerDown(event) {
463
527
  dragRef.current = {
464
528
  pointerId: event.pointerId,
@@ -510,16 +574,11 @@ export function useMobileSheet({ open = true, baseClassName }) {
510
574
  },
511
575
  };
512
576
  }
513
- export function SheetGrabHandle({ label, hint }) {
514
- return (
515
- <>
516
- <div className={styles.sheetGrabBar} />
517
- <div className={styles.sheetGrabLabelRow}>
518
- <span className={styles.sheetGrabLabel}>{label}</span>
519
- {hint ? <span className={styles.sheetGrabHint}>{hint}</span> : null}
520
- </div>
521
- </>
522
- );
577
+ // Just the drag-affordance pill for the mobile sheet. The label/hint header
578
+ // this used to render was dropped -- it duplicated each inspector's own header
579
+ // (blueprint name input, "<name> Actor", scene settings, etc.).
580
+ export function SheetGrabHandle() {
581
+ return <div className={styles.sheetGrabBar} />;
523
582
  }
524
583
  // Cursor-anchored right-click menu reusing the scene editor's "Arrange" menu
525
584
  // chrome (the shared `selArrangeMenu`/`selArrangeItem` classes). Opens at the
@@ -605,9 +664,9 @@ export function ConfirmDialog({ title, message, confirmLabel = 'Delete', danger
605
664
  </div>
606
665
  );
607
666
  }
608
- export function SelectField({ label, value, onChange, options }) {
667
+ export function SelectField({ label, value, onChange, options, overridden, defaultValue, onReset }) {
609
668
  return (
610
- <FieldRow label={label}>
669
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
611
670
  <select
612
671
  className={styles.select}
613
672
  value={value ?? ''}