castle-web-cli 0.4.82 → 0.4.84

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 (37) 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 +27 -0
  4. package/dist/agent.js +614 -57
  5. package/dist/ide.js +150 -1
  6. package/dist/native/loop.js +40 -1
  7. package/dist/native/openrouter.d.ts +12 -1
  8. package/dist/native/openrouter.js +45 -2
  9. package/dist/native/types.d.ts +6 -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-BOgm5T3W.js +144 -0
  14. package/dist/shell/assets/index-DonnH--m.css +1 -0
  15. package/dist/shell/index.html +2 -2
  16. package/dist/shell/operator.png +0 -0
  17. package/kits/basic-2d/CLAUDE.md +27 -23
  18. package/kits/basic-2d/behaviors/Collider.jsx +24 -30
  19. package/kits/basic-2d/behaviors/Layout.jsx +9 -6
  20. package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
  21. package/kits/basic-2d/blueprints/cauldron.scene +3 -5
  22. package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
  23. package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
  24. package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
  25. package/kits/basic-2d/editors/inspectorSheet.js +5 -1
  26. package/kits/basic-2d/engine/ScenePlayer.jsx +98 -7
  27. package/kits/basic-2d/engine/autoInspector.jsx +26 -7
  28. package/kits/basic-2d/engine/blueprint.js +35 -8
  29. package/kits/basic-2d/engine/collider.js +146 -0
  30. package/kits/basic-2d/engine/scene.js +53 -30
  31. package/kits/basic-2d/engine/spriteGeometry.js +32 -0
  32. package/kits/basic-2d/engine/ui.jsx +89 -30
  33. package/kits/basic-2d/engine/ui.module.css +157 -53
  34. package/kits/basic-2d/scenes/main.scene +3 -3
  35. package/package.json +2 -1
  36. package/dist/shell/assets/index-ByhgiJoP.js +0 -141
  37. package/dist/shell/assets/index-D6hM_VlW.css +0 -1
@@ -7,7 +7,7 @@ import { cx, Icon, styles, useElementSize } from '../engine/ui';
7
7
  // as a sibling of the `<canvas>` and mirrors the `SceneUI` overlay approach:
8
8
  //
9
9
  // - A ResizeObserver measures the canvas-sized box so a card-unit (500x700)
10
- // layer can be `scale()`d onto it -- the chrome (dashed box, corner dots,
10
+ // layer can be `scale()`d onto it -- the chrome (solid box, corner dots,
11
11
  // center pivot) lives in card units inside that scaled layer and is
12
12
  // translated by -camera so it tracks the edit camera exactly like the canvas
13
13
  // draw path (`ctx.translate(-camera.x, -camera.y)`).
@@ -27,6 +27,11 @@ const ACTION_GAP_PX = 22; // px from a box edge to clone/delete buttons
27
27
  const ROTATE_STEP = 15; // degrees -- rotation snaps to this increment when grid snap is on
28
28
  const ROTATE_MAGNET = 7; // degrees -- within this band of the pre-drag angle, snap back to it exactly
29
29
  const MIN_LAYOUT_SIZE = 4; // card units; prevents inverted / zero-size Layout boxes
30
+ // Below this on-screen box extent (px) along an axis, the mid-edge scale handle
31
+ // for that axis would collide with the corner handles, so we drop it and keep
32
+ // only the corners. Gated per-axis: n/s depend on the box's px width, e/w on its
33
+ // px height (a wide, short box can keep n/s while dropping e/w, and vice versa).
34
+ const EDGE_HANDLE_MIN_PX = 40;
30
35
  // Move/grab handle is intentionally hidden for now (its drag logic + helpers
31
36
  // stay wired below so it can be re-enabled later); flip this to render it.
32
37
  const SHOW_MOVE_HANDLE = false;
@@ -61,6 +66,7 @@ export function SelectionOverlay({
61
66
  editCameraRef,
62
67
  sceneData,
63
68
  previewSceneData,
69
+ sprites,
64
70
  selectedActorIds,
65
71
  snap,
66
72
  onArrange,
@@ -107,14 +113,14 @@ export function SelectionOverlay({
107
113
  if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
108
114
  const startPoint = screenToCard(canvas, event.clientX, event.clientY);
109
115
  const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
110
- const state = { recorded: false };
116
+ const state = { recorded: false, lastKey: '0,0' };
111
117
  return {
112
118
  onMove: (moveEvent) => {
113
119
  const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
114
120
  const dx = snapDelta(point.x - startPoint.x, snap);
115
121
  const dy = snapDelta(point.y - startPoint.y, snap);
116
122
  const next = moveSelected(sceneData, previewSceneData, selectedActorIds, starts, dx, dy);
117
- applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
123
+ applyDragResult(next, sceneData, `${dx},${dy}`, state, recordSnapshot, applyScene);
118
124
  },
119
125
  };
120
126
  });
@@ -139,11 +145,11 @@ export function SelectionOverlay({
139
145
  // pointer-down snapshot. `onMove` closes over the `sceneData` captured at
140
146
  // drag-start, where the actor still sits at its original angle, so when
141
147
  // the magnet pulls `delta` back to 0 `rotateSelected` returns that
142
- // unchanged snapshot. `applyDragResult`'s `next === sceneData` guard
143
- // would treat that as a no-op and skip it, stranding the actor on the
144
- // last grid step -- the reason returning to the original angle felt
145
- // impossible. Re-applying whenever the delta changes lets delta 0
146
- // actually restore the start angle.
148
+ // unchanged snapshot; an identity guard would treat that as a no-op and
149
+ // skip it, stranding the actor on the last grid step -- the reason
150
+ // returning to the original angle felt impossible. Re-applying whenever
151
+ // the delta changes lets delta 0 actually restore the start angle.
152
+ // (Scale/move share this contract via `applyDragResult`'s `key` param.)
147
153
  if (delta === state.lastDelta) return;
148
154
  state.lastDelta = delta;
149
155
  setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
@@ -165,7 +171,7 @@ export function SelectionOverlay({
165
171
  const startTransform = makeBoundsTransform(frame, frame.rotation);
166
172
  const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
167
173
  const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
168
- const state = { recorded: false };
174
+ const state = { recorded: false, lastKey: '0,0,0' };
169
175
  return {
170
176
  onMove: (moveEvent) => {
171
177
  const point = pointerLocal(canvas, editCameraRef, moveEvent, startTransform);
@@ -179,7 +185,8 @@ export function SelectionOverlay({
179
185
  moveEvent.shiftKey
180
186
  );
181
187
  const next = scaleSelected(sceneData, previewSceneData, selectedActorIds, starts, startTransform, nextBounds);
182
- applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
188
+ // Include shift (uniform) in the key so toggling it without moving still re-applies.
189
+ applyDragResult(next, sceneData, `${dx},${dy},${moveEvent.shiftKey ? 1 : 0}`, state, recordSnapshot, applyScene);
183
190
  },
184
191
  };
185
192
  });
@@ -190,7 +197,7 @@ export function SelectionOverlay({
190
197
  }
191
198
 
192
199
  const geometry = getOverlayGeometry(frame, box, camera);
193
- const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds);
200
+ const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds, sprites);
194
201
 
195
202
  return (
196
203
  <div ref={rootRef} className={styles.selOverlayRoot}>
@@ -221,17 +228,6 @@ export function SelectionOverlay({
221
228
  }}
222
229
  />
223
230
  ))}
224
- {/* Rotate about the actor center, in card units, exactly like the
225
- canvas. Sits between the scale + camera-translate layers and the
226
- card-unit chrome. */}
227
- <div
228
- className={styles.selChromeRotate}
229
- style={{
230
- transformOrigin: `${geometry.centerCardX}px ${geometry.centerCardY}px`,
231
- transform: `rotate(${geometry.rotation}deg)`,
232
- }}>
233
- <SelectionChrome frame={frame} stemLength={geometry.stemLength} />
234
- </div>
235
231
  </div>
236
232
  </div>
237
233
 
@@ -239,11 +235,21 @@ export function SelectionOverlay({
239
235
  readable. The rotate handle orbits via its anchor; clone/delete flip
240
236
  above/below to stay clear of it. */}
241
237
  <div className={styles.selButtonLayer}>
242
- {/* Dashed bounding box + center pivot render here (un-scaled px layer)
243
- rather than the chrome layer, so their stroke width, dash pattern,
244
- and pivot size stay constant with zoom. Their px dimensions are
245
- projected from the box's screen extents, so the box still traces the
246
- actual bounds. */}
238
+ {/* Solid bounding box, connector stem, and center pivot render here
239
+ (un-scaled px layer) rather than the chrome layer, so their stroke
240
+ width and pivot size stay constant with zoom. Their px dimensions
241
+ are projected from the box's screen extents, so the box still traces
242
+ the actual bounds. The stem is anchored at the box's rotated
243
+ right-center edge and rotates about that point out to the handle. */}
244
+ <div
245
+ className={styles.selStem}
246
+ style={{
247
+ left: geometry.stemStart.x,
248
+ top: geometry.stemStart.y,
249
+ width: geometry.stemLengthPx,
250
+ transform: `translateY(-50%) rotate(${geometry.rotation}deg)`,
251
+ }}
252
+ />
247
253
  <div
248
254
  className={styles.selBox}
249
255
  style={{
@@ -332,22 +338,6 @@ export function SelectionOverlay({
332
338
  );
333
339
  }
334
340
 
335
- // Chrome in card units: dashed bounding box, scale handles, connector stem, and
336
- // center pivot dot. The layer itself ignores pointer events; only the handles
337
- // opt back in so the canvas still receives ordinary selection gestures.
338
- function SelectionChrome({ frame, stemLength }) {
339
- const left = frame.x;
340
- const top = frame.y;
341
- const width = frame.width;
342
- const height = frame.height;
343
- const centerY = top + height / 2;
344
- if (stemLength <= 0) return null;
345
- // Connector stem from the right-center edge out to the rotate handle. Lives in
346
- // the rotated chrome so it swings with the actor. (The box + pivot render in
347
- // the un-scaled px layer so their stroke/size stay constant with zoom.)
348
- return <div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />;
349
- }
350
-
351
341
  // `sceneData` here is always the MERGED preview scene (see the note on
352
342
  // `SelectionOverlay` above) -- every Layout read in this file is.
353
343
  function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
@@ -381,20 +371,28 @@ function getOverlayGeometry(frame, box, camera) {
381
371
  const halfW = (frame.width / 2 + HANDLE_OFFSET) * sx;
382
372
  const halfH = (frame.height / 2 + HANDLE_OFFSET) * sy;
383
373
  const rotateAnchor = getRotateAnchor({ centerX, centerY, halfW, rotation });
374
+ // Stem starts at the box's rotated right-center edge and runs out to the
375
+ // rotate handle. Projected to screen px (like the box + handles) so its
376
+ // thickness stays constant with zoom instead of scaling with the chrome layer.
377
+ const rad = (rotation * Math.PI) / 180;
378
+ const halfWpx = (frame.width / 2) * sx;
379
+ const stemStart = {
380
+ x: centerX + halfWpx * Math.cos(rad),
381
+ y: centerY + halfWpx * Math.sin(rad),
382
+ };
384
383
  return {
385
384
  sx,
386
385
  sy,
387
386
  camX,
388
387
  camY,
389
388
  rotation,
390
- centerCardX,
391
- centerCardY,
392
389
  centerX,
393
390
  centerY,
394
391
  halfH,
395
392
  rotateAnchor,
396
393
  cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
397
- stemLength: HANDLE_OFFSET + GAP_PX / sx,
394
+ stemStart,
395
+ stemLengthPx: Math.hypot(rotateAnchor.x - stemStart.x, rotateAnchor.y - stemStart.y),
398
396
  boxWidthPx: frame.width * sx,
399
397
  boxHeightPx: frame.height * sy,
400
398
  scaleHandles: getScaleHandleAnchors({
@@ -407,13 +405,23 @@ function getOverlayGeometry(frame, box, camera) {
407
405
  };
408
406
  }
409
407
 
410
- // Project the 8 scale handles from the box's rotated corners/edges into screen
411
- // px so they can render in the un-scaled button layer at a constant size.
408
+ // Project the scale handles from the box's rotated corners/edges into screen px
409
+ // so they can render in the un-scaled button layer at a constant size. Corners
410
+ // are always shown; mid-edge handles are dropped once the box is too small along
411
+ // their axis (see EDGE_HANDLE_MIN_PX) so they don't overlap the corners.
412
412
  function getScaleHandleAnchors({ centerX, centerY, halfWpx, halfHpx, rotation }) {
413
413
  const rad = (rotation * Math.PI) / 180;
414
414
  const cosR = Math.cos(rad);
415
415
  const sinR = Math.sin(rad);
416
- return SCALE_HANDLES.map((handle) => {
416
+ const boxWidthPx = halfWpx * 2;
417
+ const boxHeightPx = halfHpx * 2;
418
+ return SCALE_HANDLES.filter((handle) => {
419
+ if (handle.corner) return true;
420
+ // n/s run along the box's width; e/w run along its height.
421
+ return handle.x === 0
422
+ ? boxWidthPx >= EDGE_HANDLE_MIN_PX
423
+ : boxHeightPx >= EDGE_HANDLE_MIN_PX;
424
+ }).map((handle) => {
417
425
  const lx = handle.x * halfWpx;
418
426
  const ly = handle.y * halfHpx;
419
427
  return {
@@ -447,7 +455,7 @@ function getActionAnchor({ centerX, centerY, halfH, rotateAnchor }) {
447
455
  };
448
456
  }
449
457
 
450
- function getSelectedColliderFrames(sceneData, actorIds) {
458
+ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
451
459
  if (!sceneData || !actorIds || actorIds.length === 0) return [];
452
460
  const wanted = new Set(actorIds);
453
461
  return sceneData.actors
@@ -455,7 +463,7 @@ function getSelectedColliderFrames(sceneData, actorIds) {
455
463
  .map((actor) => {
456
464
  const layout = actor.components.Layout;
457
465
  const collider = actor.components.Collider;
458
- const rect = getColliderRect(actor);
466
+ const rect = getColliderRect(actor, sprites);
459
467
  if (!layout || !collider || !rect) return null;
460
468
  return {
461
469
  actorId: actor.id,
@@ -598,9 +606,20 @@ function usePointerDragHandle(begin) {
598
606
  }, []);
599
607
  }
600
608
 
601
- function applyDragResult(next, sceneData, state, recordSnapshot, applyScene) {
602
- if (next === sceneData) return;
603
- if (!state.recorded) {
609
+ // Gate the commit on the gesture's snapped input (`key`), not on
610
+ // `next === sceneData`. During a drag the handlers compute `next` relative to
611
+ // the pointer-down `sceneData`, so returning the handle to its start value
612
+ // makes `next` identity-equal to that snapshot -- an identity guard would treat
613
+ // that as a no-op and skip `applyScene`, stranding the actor on the last grid
614
+ // step (the reason handles couldn't be dragged back to their origin). Keying on
615
+ // the snapped input re-applies whenever it changes, including back to the start
616
+ // key, so the origin is reachable. The identity check now only decides whether
617
+ // to record the undo snapshot, so a pure no-op drag never touches history. This
618
+ // mirrors the rotate handle's delta-keyed commit.
619
+ function applyDragResult(next, sceneData, key, state, recordSnapshot, applyScene) {
620
+ if (key === state.lastKey) return;
621
+ state.lastKey = key;
622
+ if (next !== sceneData && !state.recorded) {
604
623
  recordSnapshot();
605
624
  state.recorded = true;
606
625
  }
@@ -5,5 +5,9 @@ import { styles, useMobileSheet } from '../engine/ui';
5
5
  // visibility; the sheet hook owns the resize height and the settle-on-release
6
6
  // behavior (drag the grab handle to resize, tap it to toggle peek / expanded).
7
7
  export function useInspectorSheet(inspectorOpen) {
8
- return useMobileSheet({ open: inspectorOpen, baseClassName: styles.inspector });
8
+ return useMobileSheet({
9
+ open: inspectorOpen,
10
+ baseClassName: styles.inspector,
11
+ storageKey: 'pxart-inspector',
12
+ });
9
13
  }
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { configureSceneCanvas, makeScene } from './scene';
3
3
  import { SceneUI } from './SceneUI';
4
4
  // Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
@@ -9,6 +9,12 @@ import { SceneUI } from './SceneUI';
9
9
  export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirstFrame }) {
10
10
  const canvasRef = useRef(null);
11
11
  const runtimeRef = useRef(null);
12
+ // An error thrown from a behavior's update/draw kills the raf loop (it can't
13
+ // schedule the next frame), which otherwise just black-screens the preview
14
+ // with the throw buried in the console. Capture it here and show a redbox
15
+ // overlay instead. `runId` bumps to remount the loop on Restart.
16
+ const [error, setError] = useState(null);
17
+ const [runId, setRunId] = useState(0);
12
18
  const getRuntime = useCallback(() => runtimeRef.current, []);
13
19
  useEffect(() => {
14
20
  const canvas = canvasRef.current;
@@ -55,11 +61,17 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
55
61
  canvas.addEventListener('pointerup', onPointerUp);
56
62
  canvas.addEventListener('pointercancel', onPointerUp);
57
63
  // Don't steal focus from the embedding shell (e.g. the chat composer) on
58
- // reload/remount. A real standalone/top-level play, or an iframe that's
64
+ // reload/remount. A real standalone/play, or an iframe that's
59
65
  // already focused, still auto-focuses; a click into the game always
60
66
  // focuses via onPointerDown above.
61
67
  if (document.hasFocus() || window.parent === window) canvas.focus();
62
- const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame);
68
+ const onLoopError = (err) => {
69
+ // Forward to the console (the serve log picks it up) and surface it.
70
+ // eslint-disable-next-line no-console
71
+ console.error(err);
72
+ setError(err);
73
+ };
74
+ const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onLoopError);
63
75
  return () => {
64
76
  stopLoop();
65
77
  window.removeEventListener('keydown', onKeyDown);
@@ -71,6 +83,10 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
71
83
  runtimeRef.current = null;
72
84
  };
73
85
  // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, [runId]);
87
+ const onRestart = useCallback(() => {
88
+ setError(null);
89
+ setRunId((n) => n + 1);
74
90
  }, []);
75
91
  return (
76
92
  <div style={{ position: 'fixed', inset: 0, background: '#000' }}>
@@ -80,19 +96,28 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
80
96
  style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
81
97
  />
82
98
  <SceneUI getRuntime={getRuntime} />
99
+ {error ? <PlayErrorOverlay error={error} onRestart={onRestart} /> : null}
83
100
  </div>
84
101
  );
85
102
  }
86
- function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
103
+ function startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onError) {
87
104
  let raf = 0;
88
105
  let previousTime = performance.now();
89
106
  let firstFrameSignaled = false;
90
107
  const tick = (now) => {
91
108
  const dt = Math.min(0.033, (now - previousTime) / 1000);
92
109
  previousTime = now;
93
- runtime.update(dt);
94
- configureSceneCanvas(canvas, ctx);
95
- runtime.draw(ctx, { useCamera: true });
110
+ // A throw in a behavior's update/draw must not abort the loop silently:
111
+ // catch it, surface it via onError, and stop scheduling frames (so it
112
+ // doesn't rethrow 60x/second). Restart remounts a fresh loop.
113
+ try {
114
+ runtime.update(dt);
115
+ configureSceneCanvas(canvas, ctx);
116
+ runtime.draw(ctx, { useCamera: true });
117
+ } catch (err) {
118
+ onError?.(err);
119
+ return;
120
+ }
96
121
  if (!firstFrameSignaled) {
97
122
  firstFrameSignaled = true;
98
123
  // Wait one frame so the draw composites before reveal, avoiding a blank flash.
@@ -103,3 +128,69 @@ function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
103
128
  raf = requestAnimationFrame(tick);
104
129
  return () => cancelAnimationFrame(raf);
105
130
  }
131
+ // Redbox overlay shown when the play loop throws. Covers the canvas with the
132
+ // error name/message and stack so a broken behavior is legible instead of a
133
+ // black screen. Restart remounts the loop; fixing the code and restarting the
134
+ // serve reloads the whole preview fresh.
135
+ function PlayErrorOverlay({ error, onRestart }) {
136
+ const name = error?.name ?? 'Error';
137
+ const message = error?.message ?? String(error);
138
+ const stack = typeof error?.stack === 'string' ? error.stack : '';
139
+ return (
140
+ <div
141
+ style={{
142
+ position: 'absolute',
143
+ inset: 0,
144
+ zIndex: 2147483000,
145
+ display: 'flex',
146
+ flexDirection: 'column',
147
+ gap: 12,
148
+ padding: '20px 22px',
149
+ background: 'rgba(20, 8, 8, 0.94)',
150
+ color: '#ffd9dc',
151
+ font: "13px/1.5 'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
152
+ overflow: 'auto',
153
+ boxSizing: 'border-box',
154
+ }}
155
+ >
156
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
157
+ <span style={{ fontSize: 15, fontWeight: 700, color: '#ff6b74' }}>Play error</span>
158
+ <button
159
+ type="button"
160
+ onClick={onRestart}
161
+ style={{
162
+ marginLeft: 'auto',
163
+ padding: '4px 12px',
164
+ border: '1px solid #a02828',
165
+ borderRadius: 4,
166
+ background: '#a02828',
167
+ color: '#fff',
168
+ font: 'inherit',
169
+ cursor: 'pointer',
170
+ }}
171
+ >
172
+ Restart
173
+ </button>
174
+ </div>
175
+ <div style={{ color: '#fff', fontWeight: 600, wordBreak: 'break-word' }}>
176
+ {name}: {message}
177
+ </div>
178
+ {stack ? (
179
+ <pre
180
+ style={{
181
+ margin: 0,
182
+ whiteSpace: 'pre-wrap',
183
+ wordBreak: 'break-word',
184
+ color: '#e79aa0',
185
+ fontSize: 12,
186
+ }}
187
+ >
188
+ {stack}
189
+ </pre>
190
+ ) : null}
191
+ <div style={{ color: '#c98a8f', fontSize: 12 }}>
192
+ The preview stopped. Fix the code and restart the serve to reload, or press Restart to retry.
193
+ </div>
194
+ </div>
195
+ );
196
+ }
@@ -1,6 +1,18 @@
1
1
  import React from 'react';
2
2
  import { CheckboxField, ColorField, NumberField, Panel, TextField, isHexColor } from './ui';
3
- export function AutoFields({ defaultProps, component, setComponent, only, exclude }) {
3
+ // Build the override-indicator field props (purple tint + "Default: X [Reset]")
4
+ // for a single property from a behavior panel's override context. Returns an
5
+ // empty object when there's no context (blueprint template editing) or the
6
+ // property isn't overridden on this instance, so fields render normally.
7
+ export function overrideProps(override, prop) {
8
+ if (!override || !override.isOverridden(prop)) return {};
9
+ return {
10
+ overridden: true,
11
+ defaultValue: override.baseline(prop),
12
+ onReset: () => override.reset(prop),
13
+ };
14
+ }
15
+ export function AutoFields({ defaultProps, component, setComponent, only, exclude, override }) {
4
16
  const keys = Object.keys(defaultProps).filter((key) => {
5
17
  if (only) return only.includes(key);
6
18
  if (exclude) return !exclude.includes(key);
@@ -14,14 +26,15 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
14
26
  const set = (value) => setComponent({ [key]: value });
15
27
  const label = humanizeKey(key);
16
28
  const sample = fallback ?? current;
29
+ const ov = overrideProps(override, key);
17
30
  if (typeof sample === 'number') {
18
- return <NumberField key={key} label={label} value={current} onChange={set} />;
31
+ return <NumberField key={key} label={label} value={current} onChange={set} {...ov} />;
19
32
  }
20
33
  if (typeof sample === 'boolean') {
21
- return <CheckboxField key={key} label={label} checked={current} onChange={set} />;
34
+ return <CheckboxField key={key} label={label} checked={current} onChange={set} {...ov} />;
22
35
  }
23
36
  if (isHexColor(sample)) {
24
- return <ColorField key={key} label={label} value={current} onChange={set} />;
37
+ return <ColorField key={key} label={label} value={current} onChange={set} {...ov} />;
25
38
  }
26
39
  return (
27
40
  <TextField
@@ -29,16 +42,22 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
29
42
  label={label}
30
43
  value={current == null ? '' : String(current)}
31
44
  onChange={set}
45
+ {...ov}
32
46
  />
33
47
  );
34
48
  })}
35
49
  </>
36
50
  );
37
51
  }
38
- export function AutoInspector({ behaviorName, defaultProps, component, setComponent }) {
52
+ export function AutoInspector({ behaviorName, defaultProps, component, setComponent, override }) {
39
53
  return (
40
- <Panel title={humanizeKey(behaviorName)}>
41
- <AutoFields defaultProps={defaultProps} component={component} setComponent={setComponent} />
54
+ <Panel title={humanizeKey(behaviorName)} overridden={override?.anyOverridden()}>
55
+ <AutoFields
56
+ defaultProps={defaultProps}
57
+ component={component}
58
+ setComponent={setComponent}
59
+ override={override}
60
+ />
42
61
  </Panel>
43
62
  );
44
63
  }
@@ -8,7 +8,7 @@
8
8
  // editors (which also mint/fork/migrate/cascade-delete blueprint files).
9
9
  import { formatJson } from './files';
10
10
  import { DEFAULT_RESOLUTION, TRANSPARENT, serializeCompact } from './pxart';
11
- import { cardSize, mintActorId } from './scene';
11
+ import { cardSize, dedupeActorId } from './scene';
12
12
 
13
13
  export const BLUEPRINTS_DIR = 'blueprints';
14
14
  export const DRAWINGS_DIR = 'drawings';
@@ -146,6 +146,16 @@ function slugifyActorId(id) {
146
146
  .replace(/^-+|-+$/g, '');
147
147
  }
148
148
 
149
+ // Mint a readable, unique actor id from a display name (a blueprint's `name`):
150
+ // slugified and slug-suffix-deduped against `existing` (`cauldron`,
151
+ // `cauldron-2`, ...). This is the editor's placement/creation default, so a
152
+ // freshly placed actor reads as `cauldron` rather than an opaque hex id.
153
+ // `mintActorId` (hex) still backs runtime spawns and the dedup fallback.
154
+ export function mintActorIdFromName(existing, name) {
155
+ const base = slugifyActorId(typeof name === 'string' ? name : '');
156
+ return dedupeActorId(existing, base);
157
+ }
158
+
149
159
  // Derive a migration blueprint's { name, path } from the orphan actor's own
150
160
  // `id` -- the one meaningful signal available at migration time (vs. the
151
161
  // anonymous "Blueprint N" counter), so e.g. an actor `id: "logo"` mints
@@ -323,9 +333,23 @@ function mintDrawingPath(files) {
323
333
  return `${DRAWINGS_DIR}/drawing-${max + 1}.pxart`;
324
334
  }
325
335
 
336
+ // The z that draws on top of every current actor: max effective z + 1 (0 for
337
+ // an empty scene). An actor's effective z resolves its own Layout override
338
+ // first, then its blueprint template, then 0 -- so this beats instances that
339
+ // only inherit z from their blueprint.
340
+ function topZ(actors, files) {
341
+ let max = -Infinity;
342
+ for (const actor of actors ?? []) {
343
+ const template = actor.blueprint ? getBlueprintTemplate(files, actor.blueprint) : null;
344
+ const z = actor.components?.Layout?.z ?? template?.components?.Layout?.z ?? 0;
345
+ if (z > max) max = z;
346
+ }
347
+ return max === -Infinity ? 0 : max + 1;
348
+ }
349
+
326
350
  export function addActorWithBlueprint(sceneData, files) {
327
- const width = 64;
328
- const height = 64;
351
+ const width = 50;
352
+ const height = 50;
329
353
  const drawingFile = { path: mintDrawingPath(files), text: blankPxArtText() };
330
354
  const components = {
331
355
  Layout: { width, height, z: 0 },
@@ -334,10 +358,12 @@ export function addActorWithBlueprint(sceneData, files) {
334
358
  const blueprintFile = mintBlueprintFile(files, components);
335
359
  const next = structuredClone(sceneData);
336
360
  const existingIds = new Set(next.actors.map((actor) => actor.id));
337
- const newId = mintActorId(existingIds);
361
+ const newId = mintActorIdFromName(existingIds, blueprintFile.name);
338
362
  const x = Math.round((cardSize.width - width) / 2);
339
363
  const y = Math.round((cardSize.height - height) / 2);
340
- next.actors.push({ id: newId, blueprint: blueprintFile.path, components: { Layout: { x, y } } });
364
+ // Guarantee the new actor renders on top of whatever's already there.
365
+ const z = topZ(next.actors, files);
366
+ next.actors.push({ id: newId, blueprint: blueprintFile.path, components: { Layout: { x, y, z } } });
341
367
  return { sceneData: next, newId, blueprintFile, drawingFile };
342
368
  }
343
369
 
@@ -347,8 +373,8 @@ export function addActorWithBlueprint(sceneData, files) {
347
373
  export function blueprintDropXY(files, blueprintPath, position, snap) {
348
374
  const template = getBlueprintTemplate(files, blueprintPath);
349
375
  const layout = template?.components?.Layout ?? {};
350
- const width = layout.width ?? 64;
351
- const height = layout.height ?? 64;
376
+ const width = layout.width ?? 50;
377
+ const height = layout.height ?? 50;
352
378
  const dropX = position?.x ?? cardSize.width / 2;
353
379
  const dropY = position?.y ?? cardSize.height / 2;
354
380
  return {
@@ -364,7 +390,8 @@ export function blueprintDropXY(files, blueprintPath, position, snap) {
364
390
  export function placeBlueprintInstance(sceneData, files, blueprintPath, position, snap) {
365
391
  const next = structuredClone(sceneData);
366
392
  const existingIds = new Set(next.actors.map((actor) => actor.id));
367
- const newId = mintActorId(existingIds);
393
+ const template = getBlueprintTemplate(files, blueprintPath);
394
+ const newId = mintActorIdFromName(existingIds, template?.name);
368
395
  const { x, y } = blueprintDropXY(files, blueprintPath, position, snap);
369
396
  next.actors.push({ id: newId, blueprint: blueprintPath, components: { Layout: { x, y } } });
370
397
  return { sceneData: next, newId };