castle-web-cli 0.4.128 → 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 (157) 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 +40 -23
  16. package/kits/physics-2d/behaviors/Joints.jsx +4 -3
  17. package/kits/physics-2d/behaviors/SoundPlayer.jsx +266 -0
  18. package/kits/physics-2d/behaviors/Sprite.jsx +3 -3
  19. package/kits/physics-2d/behaviors/Video.jsx +4 -3
  20. package/kits/physics-2d/castle.json +1 -1
  21. package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
  22. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  23. package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
  24. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  25. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  26. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  27. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  28. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  29. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  30. package/kits/physics-2d/engine/audioContext.js +41 -0
  31. package/kits/physics-2d/engine/autoInspector.jsx +50 -33
  32. package/kits/physics-2d/engine/blueprint.js +15 -3
  33. package/kits/physics-2d/engine/liveReload.js +33 -21
  34. package/kits/physics-2d/engine/media.js +29 -39
  35. package/kits/physics-2d/engine/sound.js +316 -0
  36. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  37. package/kits/physics-2d/engine/ui.jsx +274 -5
  38. package/kits/physics-2d/engine/ui.module.css +137 -16
  39. package/kits/physics-2d/scripts/testsounds.mjs +84 -0
  40. package/kits/physics-2d/systems/media.js +31 -15
  41. package/kits/physics-3d/CLAUDE.md +109 -0
  42. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  43. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  44. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  45. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  46. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  47. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  48. package/kits/physics-3d/behaviors/Portal.jsx +19 -0
  49. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  50. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  51. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  52. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  53. package/kits/physics-3d/blueprints/crate.scene +29 -0
  54. package/kits/physics-3d/blueprints/door.scene +35 -0
  55. package/kits/physics-3d/blueprints/gem.scene +26 -0
  56. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  57. package/kits/physics-3d/blueprints/platform.scene +23 -0
  58. package/kits/physics-3d/blueprints/player.scene +29 -0
  59. package/kits/physics-3d/blueprints/portal.scene +21 -0
  60. package/kits/physics-3d/blueprints/rock.scene +14 -0
  61. package/kits/physics-3d/blueprints/statue.scene +26 -0
  62. package/kits/physics-3d/blueprints/tree.scene +14 -0
  63. package/kits/physics-3d/blueprints/wall.scene +26 -0
  64. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  65. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  66. package/kits/physics-3d/drawings/crate.pxart +26 -0
  67. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  68. package/kits/physics-3d/drawings/door.pxart +27 -0
  69. package/kits/physics-3d/drawings/face.pxart +26 -0
  70. package/kits/physics-3d/drawings/floor.pxart +27 -0
  71. package/kits/physics-3d/drawings/platform.pxart +27 -0
  72. package/kits/physics-3d/drawings/statue.pxart +27 -0
  73. package/kits/physics-3d/drawings/wall.pxart +27 -0
  74. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
  75. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  76. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  77. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  78. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  79. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  80. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  81. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  82. package/kits/physics-3d/engine3d/materials.js +174 -0
  83. package/kits/physics-3d/engine3d/meshops.js +123 -0
  84. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  85. package/kits/physics-3d/engine3d/modelFiles.js +139 -0
  86. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  87. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  88. package/kits/physics-3d/engine3d/world3d.js +216 -0
  89. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  90. package/kits/physics-3d/index.html +20 -0
  91. package/kits/physics-3d/main.jsx +30 -0
  92. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  93. package/kits/physics-3d/models/player.pxmodel +222 -0
  94. package/kits/physics-3d/models/rock.pxmodel +30 -0
  95. package/kits/physics-3d/models/tree.pxmodel +10 -0
  96. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  97. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  98. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  99. package/kits/physics-3d/scenes/main.scene +414 -0
  100. package/kits/physics-3d/scenes/model-lab.scene +127 -0
  101. package/kits/physics-3d/systems/physics3d.js +356 -0
  102. package/package.json +5 -2
  103. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  104. package/kits/basic-2d/CLAUDE.md +0 -221
  105. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  106. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  107. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  108. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  109. package/kits/basic-2d/behaviors/tint.js +0 -47
  110. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  111. package/kits/basic-2d/docs/pxart-format.md +0 -377
  112. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  113. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  114. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  115. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  116. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  117. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  118. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  119. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  120. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  121. package/kits/basic-2d/editors/editorHistory.js +0 -157
  122. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  123. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  124. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  125. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  126. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  127. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  128. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  129. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  130. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  131. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  132. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  133. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  134. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  135. package/kits/basic-2d/engine/assets.js +0 -15
  136. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  137. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  138. package/kits/basic-2d/engine/blueprint.js +0 -557
  139. package/kits/basic-2d/engine/collider.js +0 -200
  140. package/kits/basic-2d/engine/files.js +0 -141
  141. package/kits/basic-2d/engine/liveReload.js +0 -88
  142. package/kits/basic-2d/engine/pxart.js +0 -1032
  143. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  144. package/kits/basic-2d/engine/scene.js +0 -696
  145. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  146. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  147. package/kits/basic-2d/engine/ui.jsx +0 -695
  148. package/kits/basic-2d/engine/ui.module.css +0 -2287
  149. package/kits/basic-2d/index.html +0 -24
  150. package/kits/basic-2d/main.jsx +0 -24
  151. package/kits/basic-2d/scenes/main.scene +0 -16
  152. package/kits/basic-2d/scripts/draw.mjs +0 -121
  153. package/kits/physics-2d/behaviors/Sound.jsx +0 -163
  154. package/kits/physics-2d/behaviors/Tone.jsx +0 -95
  155. package/kits/physics-2d/engine/tone.js +0 -112
  156. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  157. /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
@@ -16,7 +16,7 @@ import {
16
16
  pickerPageIndexFor,
17
17
  pickerPagesFor,
18
18
  } from '../engine/palettes';
19
- import { EditorBody, Icon, IconButton, styles } from '../engine/ui';
19
+ import { cx, EditorBody, Icon, IconButton, styles } from '../engine/ui';
20
20
  import { eventToCell, eventToPoint } from './pixelCanvas';
21
21
  import { forEachDab } from './pixelGeometry';
22
22
  import { drawPathOverlay } from './pathOverlay';
@@ -195,7 +195,22 @@ const PATH_ONLY_TOOL_IDS = new Set(
195
195
  )
196
196
  );
197
197
 
198
- export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
198
+ // Layer-visibility overlay for a read-only file. The eye toggle stays live so
199
+ // you can hide reference layers and read the sprite; it never writes.
200
+ function applyLayerVisibility(sprite, overrides) {
201
+ if (!sprite || !overrides) return sprite;
202
+ let changed = false;
203
+ const layers = sprite.layers.map((layer, i) => {
204
+ if (!Object.prototype.hasOwnProperty.call(overrides, i) || overrides[i] === layer.visible) {
205
+ return layer;
206
+ }
207
+ changed = true;
208
+ return { ...layer, visible: overrides[i] };
209
+ });
210
+ return changed ? { ...sprite, layers } : sprite;
211
+ }
212
+
213
+ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...chrome }) {
199
214
  const canvasRef = useRef(null);
200
215
  const smoothRef = useRef(null);
201
216
  const overlayRef = useRef(null);
@@ -261,14 +276,22 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
261
276
  eraseSize,
262
277
  setEraseSize,
263
278
  } = toolState;
264
- const { history, headerShell } = usePixelEditorShell(text, onChange, path);
279
+ const { history, headerShell } = usePixelEditorShell(text, onChange, path, { readOnly });
265
280
  const canvasWrapRef = useRef(null);
266
281
  const canvasSizeBarRef = useRef(null);
267
282
  const colorButtonRef = useRef(null);
268
283
  const editorBodyRef = useRef(null);
269
284
  const [paletteOpen, setPaletteOpen] = useState(false);
270
285
 
271
- const sprite = deriveSprite(text);
286
+ const [visibilityOverrides, setVisibilityOverrides] = useState({});
287
+ // Drop browse-only hides when the file is replaced (import update, external
288
+ // edit). Eye toggles don't change `text`, so they survive. Indices would
289
+ // otherwise point at the wrong layer after a rewrite.
290
+ useEffect(() => {
291
+ setVisibilityOverrides({});
292
+ }, [path, text]);
293
+ const fileSprite = deriveSprite(text);
294
+ const sprite = readOnly ? applyLayerVisibility(fileSprite, visibilityOverrides) : fileSprite;
272
295
  const deckPalette = useMemo(() => deckPaletteFromFiles(files), [files]);
273
296
  const deckHexes = useMemo(() => pickerColorsFor(deckPalette.colors), [deckPalette]);
274
297
  const pages = useMemo(() => pickerPagesFor(deckPalette), [deckPalette]);
@@ -435,8 +458,8 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
435
458
  onGestureStart: abortStroke,
436
459
  onGestureMove: applyZoomPan,
437
460
  // Procreate convention: two-finger tap undoes, three-finger tap redoes.
438
- onUndo: () => history.undo(),
439
- onRedo: () => history.redo(),
461
+ onUndo: readOnly ? undefined : () => history.undo(),
462
+ onRedo: readOnly ? undefined : () => history.redo(),
440
463
  });
441
464
  // Middle-button drag pans by scrolling the viewport. Intercept in the capture
442
465
  // phase so the press never reaches the canvas's draw handler (startTool).
@@ -503,6 +526,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
503
526
  setEraseSize,
504
527
  setBrushMode,
505
528
  isPathLayer,
529
+ readOnly,
506
530
  });
507
531
  const isCompactLayout = useEditorCompactLayout(editorBodyRef);
508
532
  useEffect(() => {
@@ -680,7 +704,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
680
704
  // Escape deselects; Delete/Backspace clears the selected pixels. Both only
681
705
  // while the select tool is active and a marquee exists.
682
706
  useEffect(() => {
683
- if (tool !== 'select' || !marquee) return undefined;
707
+ if (readOnly || tool !== 'select' || !marquee) return undefined;
684
708
  function onKeyDown(event) {
685
709
  if (event.metaKey || event.ctrlKey || event.altKey) return;
686
710
  if (isTypingTarget(event.target || document.activeElement)) return;
@@ -694,11 +718,11 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
694
718
  }
695
719
  window.addEventListener('keydown', onKeyDown);
696
720
  return () => window.removeEventListener('keydown', onKeyDown);
697
- }, [tool, marquee, text, selection.layerIndex, selection.frameIndex]);
721
+ }, [readOnly, tool, marquee, text, selection.layerIndex, selection.frameIndex]);
698
722
  // Path-layer keyboard: pen close/open, shape/anchor delete / z-order / nudge / duplicate.
699
723
  // Plain [ / ] are free on path tools (brush-size adjust only binds on brush/erase).
700
724
  useEffect(() => {
701
- if (!isPathLayer) return undefined;
725
+ if (readOnly || !isPathLayer) return undefined;
702
726
  function onKeyDown(event) {
703
727
  if (isTypingTarget(event.target || document.activeElement)) return;
704
728
  if (tool === 'brush') {
@@ -769,6 +793,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
769
793
  window.addEventListener('keydown', onKeyDown);
770
794
  return () => window.removeEventListener('keydown', onKeyDown);
771
795
  }, [
796
+ readOnly,
772
797
  isPathLayer,
773
798
  tool,
774
799
  brushMode,
@@ -810,13 +835,15 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
810
835
 
811
836
  const { width, height } = sprite.resolution;
812
837
  const overSelection = tool === 'select' && marquee && hoverCell && pointInRect(hoverCell, marquee);
813
- const cursor = picking
814
- ? 'crosshair'
815
- : overSelection
816
- ? 'move'
817
- : isPathLayer && tool === 'brush'
818
- ? 'crosshair'
819
- : (TOOL_CURSORS[tool] ?? 'default');
838
+ const cursor = readOnly
839
+ ? 'default'
840
+ : picking
841
+ ? 'crosshair'
842
+ : overSelection
843
+ ? 'move'
844
+ : isPathLayer && tool === 'brush'
845
+ ? 'crosshair'
846
+ : (TOOL_CURSORS[tool] ?? 'default');
820
847
 
821
848
  function togglePalette(next) {
822
849
  setPaletteOpen((previous) => (typeof next === 'boolean' ? next : !previous));
@@ -1826,6 +1853,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
1826
1853
  // after clicking a non-focusable canvas, so we focus it explicitly. Done
1827
1854
  // before the early-return so even a click outside a cell claims focus.
1828
1855
  event.currentTarget.focus({ preventScroll: true });
1856
+ if (readOnly) return;
1829
1857
  if (isPathLayer) {
1830
1858
  event.currentTarget.setPointerCapture(event.pointerId);
1831
1859
  const point = eventToPoint(event, width, height);
@@ -2212,6 +2240,8 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2212
2240
  setPlaying,
2213
2241
  playing,
2214
2242
  setOnion,
2243
+ readOnly,
2244
+ setVisibilityOverrides,
2215
2245
  });
2216
2246
  function pickSampledKey(key) {
2217
2247
  if (key === TRANSPARENT) return;
@@ -2241,6 +2271,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2241
2271
  setTool(id);
2242
2272
  }}
2243
2273
  ariaLabel={isPathLayer ? 'Path tools' : 'Sprite tools'}
2274
+ disabled={readOnly}
2244
2275
  />
2245
2276
  {/* Undo/redo in their own band below the tools (the flow mount shows
2246
2277
  no editor header, so this is the on-screen affordance here). */}
@@ -2249,7 +2280,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2249
2280
  type="button"
2250
2281
  title="Undo"
2251
2282
  aria-label="Undo"
2252
- disabled={!history.canUndo}
2283
+ disabled={readOnly || !history.canUndo}
2253
2284
  className={styles.drawingToolButton}
2254
2285
  onClick={history.undo}>
2255
2286
  <Icon name="undo" />
@@ -2258,7 +2289,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2258
2289
  type="button"
2259
2290
  title="Redo"
2260
2291
  aria-label="Redo"
2261
- disabled={!history.canRedo}
2292
+ disabled={readOnly || !history.canRedo}
2262
2293
  className={styles.drawingToolButton}
2263
2294
  onClick={history.redo}>
2264
2295
  <Icon name="redo" />
@@ -2272,11 +2303,13 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2272
2303
  <CanvasSizeBar
2273
2304
  width={width}
2274
2305
  height={height}
2306
+ disabled={readOnly}
2275
2307
  onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
2276
2308
  />
2277
2309
  <FinishBar
2278
2310
  renderer={sprite.renderer}
2279
2311
  cornerRadius={sprite.cornerRadius}
2312
+ disabled={readOnly}
2280
2313
  onChange={(finish) => history.commit(serializeModel(setFinish(sprite, finish)))}
2281
2314
  />
2282
2315
  </div>
@@ -2299,18 +2332,25 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2299
2332
  ...latticeBackground(isPathLayer, artboardCanvasStyle, width, height),
2300
2333
  cursor,
2301
2334
  }}
2302
- handlers={{
2303
- onPointerDown: startTool,
2304
- onPointerMove: handlePointerMove,
2305
- onPointerUp: finishTool,
2306
- onPointerCancel: finishTool,
2307
- onPointerLeave: clearHover,
2308
- }}
2335
+ handlers={
2336
+ readOnly
2337
+ ? {}
2338
+ : {
2339
+ onPointerDown: startTool,
2340
+ onPointerMove: handlePointerMove,
2341
+ onPointerUp: finishTool,
2342
+ onPointerCancel: finishTool,
2343
+ onPointerLeave: clearHover,
2344
+ }
2345
+ }
2309
2346
  />
2310
2347
  </div>
2311
2348
  </div>
2312
2349
  </div>
2313
- <div className={styles.paintColumn}>
2350
+ <div
2351
+ className={cx(styles.paintColumn, readOnly && styles.drawingReadOnly)}
2352
+ aria-disabled={readOnly || undefined}
2353
+ inert={readOnly || undefined}>
2314
2354
  <PaintStrip
2315
2355
  toolState={toolState}
2316
2356
  activeKey={paintKey}
@@ -2352,6 +2392,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2352
2392
  playing={playing}
2353
2393
  onion={onion}
2354
2394
  actions={actions}
2395
+ readOnly={readOnly}
2355
2396
  />
2356
2397
  </div>
2357
2398
  <PxArtInspectorDock
@@ -2369,6 +2410,7 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2369
2410
  moveActions={moveActions}
2370
2411
  selectActions={selectActions}
2371
2412
  pathActions={isPathLayer ? pathActions : null}
2413
+ readOnly={readOnly}
2372
2414
  />
2373
2415
  </div>
2374
2416
  </PxArtShell>
@@ -2378,9 +2420,22 @@ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
2378
2420
  // Build the timeline's mutation contract. Each action composes an immutable
2379
2421
  // model op with a history commit, updating the (layer, frame) selection where a
2380
2422
  // structural change moves it.
2381
- function buildActions({ sprite, selection, history, setSelection, setPlaying, playing, setOnion }) {
2423
+ function buildActions({
2424
+ sprite,
2425
+ selection,
2426
+ history,
2427
+ setSelection,
2428
+ setPlaying,
2429
+ playing,
2430
+ setOnion,
2431
+ readOnly,
2432
+ setVisibilityOverrides,
2433
+ }) {
2382
2434
  const { layerIndex, frameIndex } = selection;
2383
- const commit = (next) => history.commit(serializeModel(next));
2435
+ const commit = (next) => {
2436
+ if (readOnly) return;
2437
+ history.commit(serializeModel(next));
2438
+ };
2384
2439
  return {
2385
2440
  selectCell: (li, fi) => setSelection({ layerIndex: li, frameIndex: fi }),
2386
2441
  addLayer: (li, kind = 'pixel') => {
@@ -2399,7 +2454,15 @@ function buildActions({ sprite, selection, history, setSelection, setPlaying, pl
2399
2454
  commit(moveLayer(sprite, li, dir));
2400
2455
  setSelection({ layerIndex: li + dir, frameIndex });
2401
2456
  },
2402
- patchLayer: (li, patch) => commit(patchLayer(sprite, li, patch)),
2457
+ patchLayer: (li, patch) => {
2458
+ if (readOnly) {
2459
+ if (typeof patch.visible === 'boolean') {
2460
+ setVisibilityOverrides((prev) => ({ ...prev, [li]: patch.visible }));
2461
+ }
2462
+ return;
2463
+ }
2464
+ commit(patchLayer(sprite, li, patch));
2465
+ },
2403
2466
  setLayerKind: (li, kind) => commit(setLayerKind(sprite, li, kind)),
2404
2467
  addFrame: (fi) => {
2405
2468
  commit(addFrame(sprite, fi));
@@ -2529,15 +2592,16 @@ function latticeBackground(isPathLayer, canvasStyle, gridWidth, gridHeight) {
2529
2592
 
2530
2593
  // Window-scoped tool keyboard shortcuts, mounted only while the editor is.
2531
2594
  function useToolShortcuts(ctx) {
2532
- const { tool, picking, brushMode, setTool, setPicking, setBrushSize, setEraseSize, setBrushMode, isPathLayer } =
2595
+ const { tool, picking, brushMode, setTool, setPicking, setBrushSize, setEraseSize, setBrushMode, isPathLayer, readOnly } =
2533
2596
  ctx;
2534
2597
  useEffect(() => {
2535
2598
  function onKeyDown(event) {
2599
+ if (readOnly) return;
2536
2600
  if (handleToolShortcut(event, ctx)) event.preventDefault();
2537
2601
  }
2538
2602
  window.addEventListener('keydown', onKeyDown);
2539
2603
  return () => window.removeEventListener('keydown', onKeyDown);
2540
- }, [tool, picking, brushMode, setTool, setPicking, setBrushSize, setEraseSize, setBrushMode, isPathLayer]);
2604
+ }, [tool, picking, brushMode, setTool, setPicking, setBrushSize, setEraseSize, setBrushMode, isPathLayer, readOnly]);
2541
2605
  }
2542
2606
 
2543
2607
  function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
@@ -164,11 +164,11 @@ export function SceneEditor({
164
164
  const runtimeRef = useRef(null);
165
165
  const marqueeRef = useRef(null);
166
166
  // Ephemeral editor state (camera, play mode, inspected blueprint) restored
167
- // across a reload: an agent's CODE edit triggers requestReload, which would
168
- // otherwise reset the viewport/play state on every change. Data edits fold in
169
- // live without a reload, so they never need this. `takeReloadState` is
170
- // one-shot (clears on read), so read it once here. Selection is stashed
171
- // separately by SingleEditor.
167
+ // across a reload: picking up a code change means reloading the panel, which
168
+ // would otherwise reset the viewport/play state. Data edits fold in live
169
+ // without a reload, so they never need this. `takeReloadState` is one-shot
170
+ // (clears on read), so read it once here. Selection is stashed separately by
171
+ // SingleEditor.
172
172
  const reloadStashKey = `scene-editor:${path}`;
173
173
  const [reloadStash] = useState(() => takeReloadState(reloadStashKey));
174
174
  const editCameraRef = useRef(reloadStash?.editCamera ?? { x: 0, y: 0, zoom: 1 });
@@ -5,7 +5,13 @@
5
5
  // file browsing and the code/text editor are now builtin shell panels.
6
6
 
7
7
  import React, { useEffect, useRef, useState } from 'react';
8
- import { onBeforeRestart, onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
8
+ import {
9
+ isImportedFile,
10
+ onBeforeRestart,
11
+ onSaveReloadState,
12
+ takeReloadState,
13
+ writeFile,
14
+ } from 'castle-web-sdk';
9
15
  import { getFileKind } from '../engine/files';
10
16
  import { hasEditorModule, loadEditorModule } from './editorRegistry';
11
17
  import { useLiveDeckFiles } from '../engine/liveReload';
@@ -24,6 +30,9 @@ function useFileSaver() {
24
30
  const versions = useRef({});
25
31
  const pending = useRef({});
26
32
  function commit(path, text, version) {
33
+ // Imports are someone else's deck. The serve refuses these writes anyway;
34
+ // skip the request so a missed UI lock doesn't look like a save that failed.
35
+ if (isImportedFile(path)) return Promise.resolve();
27
36
  return writeFile(path, text)
28
37
  .then(() => {
29
38
  if (versions.current[path] === version && pending.current[path] === text) {
@@ -37,6 +46,7 @@ function useFileSaver() {
37
46
  });
38
47
  }
39
48
  function schedule(path, text) {
49
+ if (isImportedFile(path)) return;
40
50
  const version = (versions.current[path] ?? 0) + 1;
41
51
  versions.current[path] = version;
42
52
  pending.current[path] = text;
@@ -71,7 +81,7 @@ function useFileSaver() {
71
81
  // render it, rather than matching the path against this kit's own list. This is
72
82
  // what lets a deck hold two kits -- each type reaches its own declarer's editor,
73
83
  // and neither kit has to know the other exists.
74
- function DeclaredEditor({ path, module: modulePath, text, onChange, files, onChangeFile }) {
84
+ function DeclaredEditor({ path, module: modulePath, text, onChange, files, onChangeFile, readOnly }) {
75
85
  const [Editor, setEditor] = useState(null);
76
86
  const [error, setError] = useState(null);
77
87
  useEffect(() => {
@@ -89,7 +99,14 @@ function DeclaredEditor({ path, module: modulePath, text, onChange, files, onCha
89
99
  if (error) return <div className={styles.editorBody}>{error}</div>;
90
100
  if (!Editor) return <div className={styles.editorBody}>loading editor…</div>;
91
101
  return (
92
- <Editor path={path} text={text} onChange={onChange} files={files} onChangeFile={onChangeFile} />
102
+ <Editor
103
+ path={path}
104
+ text={text}
105
+ onChange={onChange}
106
+ files={files}
107
+ onChangeFile={onChangeFile}
108
+ readOnly={readOnly}
109
+ />
93
110
  );
94
111
  }
95
112
 
@@ -101,7 +118,7 @@ export function SingleEditor({ path, editor, editorModule }) {
101
118
  const { schedule, hasPending } = useFileSaver();
102
119
  // Live deck files; our own in-flight (debounced, unsaved) edits win over the
103
120
  // fs echo of a stale write, so a drag in progress isn't clobbered.
104
- const { files, setFiles } = useLiveDeckFiles({ shouldSkipPath: hasPending });
121
+ const { files, setFiles, hydrated } = useLiveDeckFiles({ shouldSkipPath: hasPending, ensurePath: path });
105
122
  const [selectedActorIds, setSelectedActorIds] = useState(stash?.selectedActorIds ?? []);
106
123
  const [multiSelectMode, setMultiSelectMode] = useState(stash?.multiSelectMode ?? false);
107
124
  useEffect(
@@ -109,12 +126,17 @@ export function SingleEditor({ path, editor, editorModule }) {
109
126
  [stashKey, selectedActorIds, multiSelectMode]
110
127
  );
111
128
  const { sprites } = collectAssets(files);
129
+ // The serve refuses writes under `imports/`. Lock the UI to match so an
130
+ // editor doesn't collect keystrokes / clicks that can never be saved.
131
+ const readOnly = isImportedFile(path);
112
132
  // Optimistic cross-file edit: fold the new text into live files state NOW
113
133
  // (so merged previews update this frame) and debounce the real write; the
114
134
  // fs echo of our own write is skipped while pending (shouldSkipPath above).
115
135
  // Used for this editor's own file AND for blueprint-file edits made from a
116
- // scene panel's blueprint inspector.
136
+ // scene panel's blueprint inspector. An imported target is skipped entirely
137
+ // -- even the optimistic fold -- so a missed lock can't fake a local edit.
117
138
  function onChangeFile(targetPath, nextText) {
139
+ if (isImportedFile(targetPath)) return;
118
140
  setFiles((current) => ({ ...current, [targetPath]: nextText }));
119
141
  schedule(targetPath, nextText);
120
142
  }
@@ -128,7 +150,13 @@ export function SingleEditor({ path, editor, editorModule }) {
128
150
  // over its own types, so the kit's editors are reachable the same way anyone
129
151
  // else's are once physics-2d names them in castle.json.
130
152
  if (editorModule && hasEditorModule(editorModule)) {
131
- body = (
153
+ // A declared type is not in engine/files.js's glob, so its text arrives from
154
+ // the serve a moment after mount. Wait for it: an editor handed '' for a
155
+ // file that exists renders its empty state, and one edit there overwrites
156
+ // the real document.
157
+ body = !hydrated && files[path] === undefined ? (
158
+ <div className={styles.editorBody}>loading…</div>
159
+ ) : (
132
160
  <DeclaredEditor
133
161
  path={path}
134
162
  module={editorModule}
@@ -136,6 +164,7 @@ export function SingleEditor({ path, editor, editorModule }) {
136
164
  onChange={onChange}
137
165
  files={files}
138
166
  onChangeFile={onChangeFile}
167
+ readOnly={readOnly}
139
168
  />
140
169
  );
141
170
  } else if (kind === 'scene') {
@@ -154,9 +183,9 @@ export function SingleEditor({ path, editor, editorModule }) {
154
183
  />
155
184
  );
156
185
  } else if (kind === 'pxart') {
157
- body = <PxArtEditor path={path} text={text} onChange={onChange} files={files} />;
186
+ body = <PxArtEditor path={path} text={text} onChange={onChange} files={files} readOnly={readOnly} />;
158
187
  } else if (kind === 'style') {
159
- body = <StyleEditor path={path} text={text} onChange={onChange} />;
188
+ body = <StyleEditor path={path} text={text} onChange={onChange} readOnly={readOnly} />;
160
189
  } else if (kind === 'image') {
161
190
  // Viewers read their file's BYTES over the serve, not the text file map --
162
191
  // `files` holds source, and an image decoded as utf-8 is nothing.
@@ -1,7 +1,9 @@
1
1
  // Deck-palette picker for `theme.style`. Official entries come from
2
2
  // OFFICIAL_PALETTES; a custom `{ name, colors }` in the file shows as a
3
3
  // read-only selected card. Opening the editor never writes — only an
4
- // explicit official pick does.
4
+ // explicit official pick does. An imported file is the same static-card
5
+ // treatment applied to the whole catalog: still a viewer of what's selected,
6
+ // never a picker.
5
7
 
6
8
  import { createElement } from 'react';
7
9
  import { OFFICIAL_PALETTES, parseThemeData, resolveDeckPalette } from '../engine/palettes';
@@ -58,10 +60,11 @@ function customCardName(palette) {
58
60
  return name && name !== 'custom' ? name : 'Custom';
59
61
  }
60
62
 
61
- export function StyleEditor({ text, onChange }) {
63
+ export function StyleEditor({ text, onChange, readOnly = false }) {
62
64
  const current = resolveDeckPalette(parseThemeData(text));
63
65
  const isCustom = current.id == null;
64
66
  function pick(id) {
67
+ if (readOnly) return;
65
68
  onChange(applyOfficialPalette(text, id));
66
69
  }
67
70
  return createElement(
@@ -76,7 +79,7 @@ export function StyleEditor({ text, onChange }) {
76
79
  badge: 'Custom',
77
80
  })
78
81
  : null}
79
- {isCustom ? (
82
+ {isCustom && !readOnly ? (
80
83
  <p className={styles.replaceNote}>Picking an official palette replaces this custom palette.</p>
81
84
  ) : null}
82
85
  <div className={styles.list}>
@@ -86,7 +89,7 @@ export function StyleEditor({ text, onChange }) {
86
89
  name: palette.name,
87
90
  colors: palette.colors,
88
91
  selected: current.id === palette.id,
89
- onPick: () => pick(palette.id),
92
+ onPick: readOnly ? undefined : () => pick(palette.id),
90
93
  })
91
94
  )}
92
95
  </div>
@@ -5,18 +5,18 @@ import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
5
5
  // Shared shell state for the pixel editors: text undo/redo history and undo/redo
6
6
  // keyboard shortcuts. Returns the `history` controller and `headerShell` for the
7
7
  // header (undo/redo only — paint controls live in the artboard layout).
8
- export function usePixelEditorShell(text, onChange, path) {
8
+ export function usePixelEditorShell(text, onChange, path, { readOnly = false } = {}) {
9
9
  const history = useEditHistory(text, onChange, path);
10
- useUndoRedoShortcuts(history);
11
- const headerShell = { history };
10
+ useUndoRedoShortcuts(history, !readOnly);
11
+ const headerShell = { history, readOnly };
12
12
  return { history, headerShell };
13
13
  }
14
14
 
15
- function PixelEditorTools({ history }) {
15
+ function PixelEditorTools({ history, readOnly }) {
16
16
  return (
17
17
  <>
18
- <IconButton icon="undo" label="Undo" onClick={history.undo} disabled={!history.canUndo} />
19
- <IconButton icon="redo" label="Redo" onClick={history.redo} disabled={!history.canRedo} />
18
+ <IconButton icon="undo" label="Undo" onClick={history.undo} disabled={readOnly || !history.canUndo} />
19
+ <IconButton icon="redo" label="Redo" onClick={history.redo} disabled={readOnly || !history.canRedo} />
20
20
  </>
21
21
  );
22
22
  }
@@ -27,7 +27,7 @@ export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
27
27
  <EditorHeader
28
28
  title={title}
29
29
  subtitle={subtitle}
30
- right={<PixelEditorTools history={shell.history} />}
30
+ right={<PixelEditorTools history={shell.history} readOnly={shell.readOnly} />}
31
31
  onToggleFiles={chrome.onToggleFiles}
32
32
  filesOpen={chrome.filesOpen}
33
33
  headerHost={chrome.headerHost}
@@ -132,14 +132,14 @@ export function PixelToolStrip({ tools, tool, onSelectTool, ariaLabel, disabled
132
132
  }
133
133
 
134
134
  // Compact W × H selects above the artboard (not a sidebar panel).
135
- export function CanvasSizeBar({ width, height, onResize }) {
135
+ export function CanvasSizeBar({ width, height, onResize, disabled = false }) {
136
136
  return (
137
137
  <div className={styles.canvasSizeBar} aria-label="Canvas size">
138
- <ResolutionSelect label="Width" value={width} onChange={(w) => onResize(w, height)} />
138
+ <ResolutionSelect label="Width" value={width} onChange={(w) => onResize(w, height)} disabled={disabled} />
139
139
  <span className={styles.canvasSizeSep} aria-hidden="true">
140
140
  ×
141
141
  </span>
142
- <ResolutionSelect label="Height" value={height} onChange={(h) => onResize(width, h)} />
142
+ <ResolutionSelect label="Height" value={height} onChange={(h) => onResize(width, h)} disabled={disabled} />
143
143
  </div>
144
144
  );
145
145
  }
@@ -175,13 +175,13 @@ const FINISH_STEPS = [
175
175
  // (properties of the .pxart asset — see docs/pxart-format.md — not per-actor
176
176
  // Sprite props). Highlights whichever preset the pair exactly matches, or none
177
177
  // when it matches no preset.
178
- export function FinishBar({ renderer, cornerRadius, onChange }) {
178
+ export function FinishBar({ renderer, cornerRadius, onChange, disabled = false }) {
179
179
  const isOn = (step) =>
180
180
  step.renderer === (renderer ?? GRID_RENDERER) && step.cornerRadius === (cornerRadius ?? 0);
181
181
  return (
182
182
  <div className={styles.finishBar} aria-label="Finish">
183
183
  <span>Finish</span>
184
- <div className={styles.finishSegments} role="radiogroup" aria-label="Finish">
184
+ <div className={styles.finishSegments} role="radiogroup" aria-label="Finish" aria-disabled={disabled || undefined}>
185
185
  {FINISH_STEPS.map((step) => (
186
186
  <button
187
187
  key={step.id}
@@ -189,6 +189,7 @@ export function FinishBar({ renderer, cornerRadius, onChange }) {
189
189
  role="radio"
190
190
  aria-label={step.label}
191
191
  aria-checked={isOn(step) ? 'true' : 'false'}
192
+ disabled={disabled}
192
193
  className={cx(styles.finishSegment, isOn(step) && styles.finishSegmentOn)}
193
194
  title={step.title}
194
195
  onClick={() =>
@@ -202,7 +203,7 @@ export function FinishBar({ renderer, cornerRadius, onChange }) {
202
203
  );
203
204
  }
204
205
 
205
- function ResolutionSelect({ label, value, onChange }) {
206
+ function ResolutionSelect({ label, value, onChange, disabled = false }) {
206
207
  const options = RESOLUTION_STEPS.includes(value)
207
208
  ? RESOLUTION_STEPS
208
209
  : [...RESOLUTION_STEPS, value].sort((a, b) => a - b);
@@ -211,6 +212,7 @@ function ResolutionSelect({ label, value, onChange }) {
211
212
  className={styles.canvasSizeSelect}
212
213
  aria-label={label}
213
214
  value={value}
215
+ disabled={disabled}
214
216
  onChange={(event) => onChange(Number(event.target.value))}>
215
217
  {options.map((step) => (
216
218
  <option key={step} value={step}>
@@ -940,10 +942,14 @@ export function PxArtInspectorDock({
940
942
  moveActions,
941
943
  selectActions,
942
944
  pathActions,
945
+ readOnly = false,
943
946
  }) {
944
947
  const { tool, picking, setPicking } = toolState;
945
948
  return (
946
- <aside className={cx(styles.pxArtInspector, styles.drawingToolsPanel)}>
949
+ <aside
950
+ className={cx(styles.pxArtInspector, styles.drawingToolsPanel, readOnly && styles.drawingReadOnly)}
951
+ aria-disabled={readOnly || undefined}
952
+ inert={readOnly || undefined}>
947
953
  <div className={cx(styles.sheetBody, styles.inspectorBody, styles.drawingSettingsColumn)}>
948
954
  {PALETTE_TOOLS.has(tool) || picking ? (
949
955
  <Panel