castle-web-cli 0.4.129 → 0.4.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/dist/agent-prompts.js +4 -4
  2. package/dist/filesChanged.d.ts +2 -0
  3. package/dist/filesChanged.js +6 -2
  4. package/dist/ide.js +15 -4
  5. package/dist/importBrowse.js +1 -0
  6. package/dist/index.js +10 -4
  7. package/dist/init.js +63 -15
  8. package/dist/native/loop.js +1 -1
  9. package/dist/native/tools.js +1 -1
  10. package/dist/serve.d.ts +1 -0
  11. package/dist/serve.js +20 -1
  12. package/dist/shell/assets/index-BpOLUyVO.js +441 -0
  13. package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/physics-2d/CLAUDE.md +9 -10
  16. package/kits/physics-2d/castle.json +1 -1
  17. package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
  18. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  19. package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
  20. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  21. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  22. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  23. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  24. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  25. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  26. package/kits/physics-2d/engine/blueprint.js +15 -3
  27. package/kits/physics-2d/engine/liveReload.js +33 -21
  28. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  29. package/kits/physics-2d/engine/ui.jsx +2 -2
  30. package/kits/physics-2d/engine/ui.module.css +42 -0
  31. package/kits/physics-3d/CLAUDE.md +109 -0
  32. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  33. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  34. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  35. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  36. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  37. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  38. package/kits/physics-3d/behaviors/Portal.jsx +19 -0
  39. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  40. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  41. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  42. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  43. package/kits/physics-3d/blueprints/crate.scene +29 -0
  44. package/kits/physics-3d/blueprints/door.scene +35 -0
  45. package/kits/physics-3d/blueprints/gem.scene +26 -0
  46. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  47. package/kits/physics-3d/blueprints/platform.scene +23 -0
  48. package/kits/physics-3d/blueprints/player.scene +29 -0
  49. package/kits/physics-3d/blueprints/portal.scene +21 -0
  50. package/kits/physics-3d/blueprints/rock.scene +14 -0
  51. package/kits/physics-3d/blueprints/statue.scene +26 -0
  52. package/kits/physics-3d/blueprints/tree.scene +14 -0
  53. package/kits/physics-3d/blueprints/wall.scene +26 -0
  54. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  55. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  56. package/kits/physics-3d/drawings/crate.pxart +26 -0
  57. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  58. package/kits/physics-3d/drawings/door.pxart +27 -0
  59. package/kits/physics-3d/drawings/face.pxart +26 -0
  60. package/kits/physics-3d/drawings/floor.pxart +27 -0
  61. package/kits/physics-3d/drawings/platform.pxart +27 -0
  62. package/kits/physics-3d/drawings/statue.pxart +27 -0
  63. package/kits/physics-3d/drawings/wall.pxart +27 -0
  64. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
  65. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  66. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  67. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  68. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  69. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  70. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  71. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  72. package/kits/physics-3d/engine3d/materials.js +174 -0
  73. package/kits/physics-3d/engine3d/meshops.js +123 -0
  74. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  75. package/kits/physics-3d/engine3d/modelFiles.js +139 -0
  76. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  77. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  78. package/kits/physics-3d/engine3d/world3d.js +216 -0
  79. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  80. package/kits/physics-3d/index.html +20 -0
  81. package/kits/physics-3d/main.jsx +30 -0
  82. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  83. package/kits/physics-3d/models/player.pxmodel +222 -0
  84. package/kits/physics-3d/models/rock.pxmodel +30 -0
  85. package/kits/physics-3d/models/tree.pxmodel +10 -0
  86. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  87. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  88. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  89. package/kits/physics-3d/scenes/main.scene +414 -0
  90. package/kits/physics-3d/scenes/model-lab.scene +127 -0
  91. package/kits/physics-3d/systems/physics3d.js +356 -0
  92. package/package.json +5 -2
  93. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  94. package/kits/basic-2d/CLAUDE.md +0 -221
  95. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  96. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  97. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  98. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  99. package/kits/basic-2d/behaviors/tint.js +0 -47
  100. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  101. package/kits/basic-2d/docs/pxart-format.md +0 -377
  102. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  103. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  104. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  105. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  106. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  107. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  108. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  109. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  110. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  111. package/kits/basic-2d/editors/editorHistory.js +0 -157
  112. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  113. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  114. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  115. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  116. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  117. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  118. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  119. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  120. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  121. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  122. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  123. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  124. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  125. package/kits/basic-2d/engine/assets.js +0 -15
  126. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  127. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  128. package/kits/basic-2d/engine/blueprint.js +0 -557
  129. package/kits/basic-2d/engine/collider.js +0 -200
  130. package/kits/basic-2d/engine/files.js +0 -141
  131. package/kits/basic-2d/engine/liveReload.js +0 -88
  132. package/kits/basic-2d/engine/pxart.js +0 -1032
  133. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  134. package/kits/basic-2d/engine/scene.js +0 -696
  135. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  136. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  137. package/kits/basic-2d/engine/ui.jsx +0 -695
  138. package/kits/basic-2d/engine/ui.module.css +0 -2287
  139. package/kits/basic-2d/index.html +0 -24
  140. package/kits/basic-2d/main.jsx +0 -24
  141. package/kits/basic-2d/scenes/main.scene +0 -16
  142. package/kits/basic-2d/scripts/draw.mjs +0 -121
  143. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  144. /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
@@ -1,11 +1,10 @@
1
1
  # physics-2d kit
2
2
 
3
- This is the `basic-2d` actor/behavior/scene framework plus a built-in 2D
4
- **physics** system (matter-js). Everything in `basic-2d` works the same; physics
5
- adds `RigidBody`, physics fields on `Collider`, a world-gravity scene setting,
6
- collision callbacks, `Joints` that link actors (spring/rod/pin/weld/rope, several
7
- per actor), and ready-made touch-first controls (`Draggable`, `Slingshot`, `AnalogStick`).
8
- See `## Physics` below.
3
+ An actor / behavior / scene framework for 2D games, with a built-in
4
+ **physics** system (matter-js): `RigidBody`, physics fields on `Collider`, a
5
+ world-gravity scene setting, collision callbacks, `Joints` that link actors
6
+ (spring/rod/pin/weld/rope, several per actor), and ready-made touch-first
7
+ controls (`Draggable`, `Slingshot`, `AnalogStick`). See `## Physics` below.
9
8
 
10
9
  ## Welcome message
11
10
 
@@ -25,7 +24,7 @@ Do you already know what you want to make, or do you want to figure it out toget
25
24
  - A deck can also hold uploaded media (the Files panel's Upload button): `Sprite` draws image files as well as `.sprite`, and `Video` plays video files. Point their `file` at the deck path (`assets/logo.png`). For audio see `## Sound` below -- sound effects need no component at all.
26
25
  - **Supported media formats are exactly**: `.png` `.jpg` `.jpeg` `.gif` `.webp` `.svg` images, `.mp3` `.wav` `.m4a` audio, `.mp4` (H.264) video. Nothing else — a published deck inlines every asset as a `data:` URI, where the browser trusts the declared type instead of sniffing the bytes, so a format that merely works while serving (`.mov` is the classic case) can be dead once published. Convert, don't improvise.
27
26
  - This kit is plain JavaScript. Use `.jsx` for files with JSX, `.js` otherwise; do not add TypeScript files or a new build step.
28
- - After any code, scene, or drawing edit, run `npm run restart`.
27
+ - Do not reload the deck after an edit. Nothing auto-reloads any more: the person applies your changes when they are ready, from the Play panel's restart button or a panel's own reload control.
29
28
  - Space is reserved by the editor for play/stop; do not bind Space to gameplay.
30
29
  - Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`, and the physics ones — `RigidBody.jsx`, `Joints.jsx`, `Draggable.jsx`, `Slingshot.jsx`, `AnalogStick.jsx`) to build a game. Their public API is documented below.
31
30
  - Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
@@ -57,8 +56,8 @@ Write the smallest game that satisfies what the user asked for. No sound, partic
57
56
 
58
57
  The deck is already serving when you start (`castle-web init` set that up; see `.castle/serve.json` for the URL). The user is watching that page right now. Your job is to make it interesting incrementally:
59
58
 
60
- 1. **Build incrementally.** Start with the smallest playable thing (one mechanic, one scene change), `npm run restart`, then add the next piece. Do NOT write the whole game in one shot.
61
- 2. **After every edit:** `npm run restart` (no hot reload). The served page refreshes and the user sees the change.
59
+ 1. **Build incrementally.** Start with the smallest playable thing (one mechanic, one scene change), then add the next piece. Do NOT write the whole game in one shot.
60
+ 2. **Do not reload.** Data edits (scenes, drawings, sprites) appear in the open editors on their own. Code edits wait for the person to apply them -- reloading a panel someone is working in loses their place.
62
61
  3. **Prefer real, editable assets.** For game objects, characters, and scenery, make actual pixel-art sprites and place them as real actors in `scenes/*.scene` — not shapes drawn in code. Real assets let the creator move and re-skin things in the editor and let other creators remix the deck. Make art as `.sprite` via the `draw` command (see **Creating pixel art** below). Data-driven UI (health bars, score/text, HUD gauges) and dynamic things (bullets, particles, effects) are correctly procedural/code — don't force those into sprites.
63
62
  4. **Separate scenes per screen.** Use a separate `scenes/*.scene` file for each distinct screen — menu/title, each level, game-over, etc. — not one mega-scene. Each stays independently editable in the editor. Switch at runtime with `scene.loadFromFile('gameover.scene')` (reads the file and transitions) on play / win / level change. NEVER `import` a `.scene` file as a module — scene files are data, not modules; use `scene.readFromFile` / `scene.loadFromFile`.
64
63
 
@@ -477,7 +476,7 @@ Don't hand-write pixel grids. Generate sprites with the `draw` command: **emit a
477
476
  ```
478
477
 
479
478
  writes `drawings/ship.sprite`. You can also pass `--from file.svg` instead of stdin. An undecodable svg (no usable rects) errors with a nonzero exit and writes nothing.
480
- - **After writing, `npm run restart`.** Newly created files aren't in the kit's static glob until a restart; until then the missing-sprite fallback renders the placeholder (so an actor pointing at a not-yet-drawn `.sprite` is still playable).
479
+ - **A newly created file is not in the kit's static glob until the panel reloads**, so until then the missing-sprite fallback renders the placeholder -- an actor pointing at a not-yet-drawn `.sprite` is still playable. Do not force a reload to fix it.
481
480
 
482
481
  Point a `Sprite` component's `file` at the generated `drawings/<name>.sprite` to put the art on an actor.
483
482
 
@@ -131,5 +131,5 @@
131
131
  "main": "main.jsx",
132
132
  "autoUpdateWhenImported": true,
133
133
  "title": "physics-2d",
134
- "publishedVersion": "2026-08-20T22:15:34.812Z"
134
+ "publishedVersion": "2026-08-21T22:11:52.086Z"
135
135
  }
@@ -1,26 +1,35 @@
1
1
  import React from 'react';
2
2
  import { Lifecycle } from 'castle-web-sdk';
3
- import { parseJsonFile } from '../engine/files';
4
- import { useLiveDeckFiles } from '../engine/liveReload';
3
+ import { initialFiles, parseJsonFile } from '../engine/files';
5
4
  import { collectAssets } from '../engine/assets';
6
5
  import { ScenePlayer } from '../engine/ScenePlayer';
7
6
  import { behaviorClasses } from './behaviorRegistry';
8
7
  // Play-mode entry point. Plays the scene named by `?scene=<path>` (the shell's
9
8
  // Play panel sets this), defaulting to `scenes/main.scene`. It renders ONLY the
10
9
  // game — the scene picker lives in the Play panel chrome (the shell), not here,
11
- // so nothing floats over the game surface and steals input. Files are live:
12
- // scene/drawing edits re-key the player against fresh data.
10
+ // so nothing floats over the game surface and steals input.
11
+ //
12
+ // Nothing is folded into a run in progress -- not a drawing, not a scene. A
13
+ // file changing on disk can always have altered how the run would have gone up
14
+ // to this point, so applying one mid-run shows a state the deck could never
15
+ // have reached.
16
+ //
17
+ // Getting current is therefore always a whole-panel reload, and the shell owns
18
+ // when: it reloads Play on arrival if changes landed while it was hidden, and
19
+ // offers the restart button when it is the panel you are looking at. A reload
20
+ // remounts this and re-runs the build-time glob, so code and data arrive
21
+ // together.
13
22
  const DEFAULT_SCENE = 'scenes/main.scene';
14
23
 
15
24
  export function PlayOnly({ initialScene } = {}) {
16
- const { files, dataVersion } = useLiveDeckFiles();
25
+ const files = initialFiles;
17
26
  const scenePath = initialScene && files[initialScene] !== undefined ? initialScene : DEFAULT_SCENE;
18
27
  const { value: sceneData } = parseJsonFile(scenePath, files[scenePath] ?? '');
19
28
  if (!sceneData) return null;
20
29
  const { sprites } = collectAssets(files);
21
30
  return (
22
31
  <ScenePlayer
23
- key={`${scenePath}:${dataVersion}`}
32
+ key={scenePath}
24
33
  sceneData={sceneData}
25
34
  sprites={sprites}
26
35
  files={files}
@@ -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}