castle-web-cli 0.4.132 → 0.4.134

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.
@@ -10,8 +10,8 @@
10
10
  <link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
11
11
  <link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
12
12
  <link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
13
- <script type="module" crossorigin src="/__castle/ide/assets/index-BpOLUyVO.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-D6K-0YDB.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-DNDk8nRG.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-Bx4sNpO5.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -131,5 +131,5 @@
131
131
  "main": "main.jsx",
132
132
  "autoUpdateWhenImported": true,
133
133
  "title": "physics-2d",
134
- "publishedVersion": "2026-08-21T22:39:24.016Z"
134
+ "publishedVersion": "2026-08-21T23:53:35.620Z"
135
135
  }
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Lifecycle } from 'castle-web-sdk';
3
- import { initialFiles, parseJsonFile } from '../engine/files';
3
+ import { parseJsonFile } from '../engine/files';
4
+ import { useLiveDeckFiles } from '../engine/liveReload';
4
5
  import { collectAssets } from '../engine/assets';
5
6
  import { ScenePlayer } from '../engine/ScenePlayer';
6
7
  import { behaviorClasses } from './behaviorRegistry';
@@ -22,7 +23,7 @@ import { behaviorClasses } from './behaviorRegistry';
22
23
  const DEFAULT_SCENE = 'scenes/main.scene';
23
24
 
24
25
  export function PlayOnly({ initialScene } = {}) {
25
- const files = initialFiles;
26
+ const { files } = useLiveDeckFiles({ live: false });
26
27
  const scenePath = initialScene && files[initialScene] !== undefined ? initialScene : DEFAULT_SCENE;
27
28
  const { value: sceneData } = parseJsonFile(scenePath, files[scenePath] ?? '');
28
29
  if (!sceneData) return null;
@@ -13,8 +13,15 @@ import { initialFiles } from './files';
13
13
  const DATA_EXTS = ['.scene', '.pxart', '.sprite', '.style', '.drawing'];
14
14
  const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
15
15
 
16
+ // Extensions the DECK declared that this kit has never heard of, learned from
17
+ // the serve at mount (hydrateDeclaredFiles). Module-level because the change
18
+ // classifier calls isDataFile per event, with no state to thread through.
19
+ const declaredDataExts = new Set();
20
+
16
21
  export function isDataFile(path) {
17
- return DATA_EXTS.some((ext) => path.endsWith(ext));
22
+ if (DATA_EXTS.some((ext) => path.endsWith(ext))) return true;
23
+ for (const ext of declaredDataExts) if (path.endsWith(ext)) return true;
24
+ return false;
18
25
  }
19
26
 
20
27
  // Which iframe is speaking -- every panel runs its own copy of this.
@@ -47,34 +54,137 @@ async function readDeckFile(path) {
47
54
  return typeof data.contents === 'string' ? data.contents : '';
48
55
  }
49
56
 
50
- // Deck files as live state: starts from the build-time glob snapshot and stays
51
- // current as files change on disk from any source (editors, agents, terminal).
57
+ // The glob in `files.js` is a compile-time transform, so its patterns can only
58
+ // name extensions THIS KIT knows. A deck that declares its own file type is
59
+ // invisible to it: `files[path]` is undefined, so an editor for that type is
60
+ // handed '' forever -- it renders its empty state over a full file, and its
61
+ // saves never come back through `files`. That breaks the `text`/`onChange`
62
+ // contract the authoring doc teaches, for exactly the types the doc is for.
63
+ //
64
+ // So ask the SERVE what the deck actually declared and read those files in.
65
+ // Declaration-driven rather than another literal list, because a literal list
66
+ // is the bug. Best-effort: a published deck has no serve to ask, and there the
67
+ // glob snapshot is all there is (its play code imports data through the
68
+ // bundler anyway).
69
+ // Null rather than throwing, for every reason a read can come back unusable:
70
+ // no serve, a race with a delete, or a declared type that is binary (an image
71
+ // the deck claims) -- the serve flags those rather than mangling them, and an
72
+ // image decoded as utf-8 is nothing.
73
+ export async function readDeclaredFile(file) {
74
+ try {
75
+ const res = await fetch(`/__castle/files/read?path=${encodeURIComponent(file)}`);
76
+ if (!res.ok) return null;
77
+ const data = await res.json();
78
+ if (data.binary || typeof data.contents !== 'string') return null;
79
+ return data.contents;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // `knownExts` / `knownFiles` are the CALLER's, because each kit globs a
86
+ // different set: physics-3d also knows `.pxmodel`, and a type one kit already
87
+ // handles is not a declared type that needs fetching. Returns the extensions
88
+ // found as well as the files, so the caller can teach its own change
89
+ // classifier about them.
90
+ export async function hydrateDeclaredFiles({ knownExts, knownFiles, ensurePath }) {
91
+ let info;
92
+ let listing;
93
+ try {
94
+ [info, listing] = await Promise.all([
95
+ fetch('/__castle/files/info').then((r) => (r.ok ? r.json() : null)),
96
+ fetch('/__castle/files/list').then((r) => (r.ok ? r.json() : null)),
97
+ ]);
98
+ } catch {
99
+ return null;
100
+ }
101
+ if (!info || !listing) return null;
102
+ const exts = (info.fileTypes ?? [])
103
+ .filter((type) => type.data || (typeof type.editor === 'string' && type.editor !== 'kit'))
104
+ .map((type) => type.ext)
105
+ .filter((ext) => typeof ext === 'string' && ext && !knownExts.includes(ext));
106
+ if (exts.length === 0) return null;
107
+ const paths = (listing.files ?? []).filter(
108
+ (file) => knownFiles[file] === undefined && exts.some((ext) => file.endsWith(ext))
109
+ );
110
+ const entries = await Promise.all(
111
+ paths.map(async (file) => {
112
+ const text = await readDeclaredFile(file);
113
+ return text === null ? null : [file, text];
114
+ })
115
+ );
116
+ const extra = Object.fromEntries(entries.filter(Boolean));
117
+ // `list` is the deck's CURATED listing, so a declared file the deck left out
118
+ // of visiblePaths is not in it. The shell can still open that file by path,
119
+ // and an editor handed '' for it would be back to the original bug -- so make
120
+ // sure whatever was actually opened is present, curated or not.
121
+ if (ensurePath && knownFiles[ensurePath] === undefined && extra[ensurePath] === undefined) {
122
+ const one = await readDeclaredFile(ensurePath);
123
+ if (one !== null) extra[ensurePath] = one;
124
+ }
125
+ return { extra, exts };
126
+ }
127
+
128
+ // Deck files as live state: starts from the build-time glob snapshot, fills in
129
+ // the deck's own declared types from the serve, and stays current as files
130
+ // change on disk from any source (editors, agents, terminal).
131
+ //
52
132
  // `shouldSkipPath` lets an editor keep its own in-flight edits from being
53
- // clobbered by the fs echo of a stale write.
54
- export function useLiveDeckFiles({ shouldSkipPath } = {}) {
133
+ // clobbered by the fs echo of a stale write. `ensurePath` names the file this
134
+ // consumer actually opened, so a declared type outside the curated listing is
135
+ // still read. `live: false` takes the hydration without the subscription --
136
+ // what Play wants, since it never folds a change into a run in progress.
137
+ export function useLiveDeckFiles({ shouldSkipPath, ensurePath, live = true } = {}) {
55
138
  const [files, setFiles] = useState(initialFiles);
139
+ // False until the declared-type read above has settled. An editor mounted
140
+ // before then would be handed '' for a file that exists, and an edit made in
141
+ // that window writes its empty document over the real one -- the same data
142
+ // loss the glob gap caused outright.
143
+ const [hydrated, setHydrated] = useState(false);
56
144
  const skipRef = useRef(shouldSkipPath);
57
145
  skipRef.current = shouldSkipPath;
58
146
 
59
- useEffect(
60
- () =>
61
- onFilesChanged((event) => {
62
- const stale = event.changes.find(isCodeChange);
63
- if (stale) console.log(`[reload] ${panelTag()} code stale <- ${stale.path}`);
64
- // Data still applies in the same event: a change that carries both is
65
- // one edit, and holding the data back would leave the panel showing
66
- // neither the old state nor the new one.
67
- const dataChanges = event.changes.filter(
68
- (change) => isDataFile(change.path) && !skipRef.current?.(change.path)
69
- );
70
- if (dataChanges.length === 0) return;
71
- console.log(`[reload] ${panelTag()} data <- ${dataChanges.map((c) => c.path).join(', ')}`);
72
- void applyDataChanges(dataChanges, setFiles);
73
- }),
74
- []
75
- );
147
+ useEffect(() => {
148
+ let alive = true;
149
+ void hydrateDeclaredFiles({
150
+ knownExts: DATA_EXTS,
151
+ knownFiles: initialFiles,
152
+ ensurePath,
153
+ }).then((found) => {
154
+ if (!alive) return;
155
+ if (found) {
156
+ for (const ext of found.exts) declaredDataExts.add(ext);
157
+ // `current` wins: by the time this lands the editor may already have
158
+ // made an optimistic edit, and the disk copy behind it is stale.
159
+ if (Object.keys(found.extra).length) {
160
+ setFiles((current) => ({ ...found.extra, ...current }));
161
+ }
162
+ }
163
+ setHydrated(true);
164
+ });
165
+ return () => {
166
+ alive = false;
167
+ };
168
+ }, [ensurePath]);
169
+
170
+ useEffect(() => {
171
+ if (!live) return undefined;
172
+ return onFilesChanged((event) => {
173
+ const stale = event.changes.find(isCodeChange);
174
+ if (stale) console.log(`[reload] ${panelTag()} code stale <- ${stale.path}`);
175
+ // Data still applies in the same event: a change that carries both is
176
+ // one edit, and holding the data back would leave the panel showing
177
+ // neither the old state nor the new one.
178
+ const dataChanges = event.changes.filter(
179
+ (change) => isDataFile(change.path) && !skipRef.current?.(change.path)
180
+ );
181
+ if (dataChanges.length === 0) return;
182
+ console.log(`[reload] ${panelTag()} data <- ${dataChanges.map((c) => c.path).join(', ')}`);
183
+ void applyDataChanges(dataChanges, setFiles);
184
+ });
185
+ }, [live]);
76
186
 
77
- return { files, setFiles };
187
+ return { files, setFiles, hydrated };
78
188
  }
79
189
 
80
190
  async function applyDataChanges(changes, setFiles) {
@@ -25,7 +25,8 @@
25
25
  "scenes/**",
26
26
  "blueprints/**",
27
27
  "behaviors/**",
28
- "assets/**"
28
+ "assets/**",
29
+ "theme.style"
29
30
  ],
30
31
  "fileTypes": [
31
32
  {
@@ -64,12 +65,12 @@
64
65
  "imports": {
65
66
  "castle.physics-2d": {
66
67
  "deckId": "ckRZGFW4iPrx",
67
- "version": "2026-08-14T21:20:31.152Z"
68
+ "version": "2026-08-21T23:52:56.888Z"
68
69
  }
69
70
  },
70
71
  "main": "main.jsx",
71
72
  "autoUpdateWhenImported": true,
72
73
  "deckId": "JH0SclbPVP0y",
73
74
  "cardId": "eXfAaUdbSU5A",
74
- "publishedVersion": "2026-08-21T22:39:26.451Z"
75
+ "publishedVersion": "2026-08-21T23:53:37.801Z"
75
76
  }
@@ -66,8 +66,9 @@ function descendantIds(parts, rootId) {
66
66
 
67
67
  export function PxModelEditor({ path }) {
68
68
  const saver = useFileSaver3D();
69
- const { files, setFiles, dataVersion } = useLiveDeckFiles3D({
69
+ const { files, setFiles, dataVersion, hydrated } = useLiveDeckFiles3D({
70
70
  shouldSkipPath: saver.hasPending,
71
+ ensurePath: path,
71
72
  });
72
73
  const text = files[path];
73
74
  useEffect(() => {
@@ -265,6 +266,16 @@ export function PxModelEditor({ path }) {
265
266
  );
266
267
  }
267
268
 
269
+ if (!hydrated && files[path] === undefined) {
270
+ return (
271
+ <div className={styles.panelBare}>
272
+ <MainEditor>
273
+ <EditorBody>loading…</EditorBody>
274
+ </MainEditor>
275
+ </div>
276
+ );
277
+ }
278
+
268
279
  return (
269
280
  <div className={styles.panelBare}>
270
281
  <MainEditor>
@@ -65,8 +65,9 @@ export function Scene3DEditor({ path }) {
65
65
  const stashKey = `scene3d-editor:${path}`;
66
66
  const [stash] = useState(() => takeReloadState(stashKey));
67
67
  const saver = useFileSaver3D();
68
- const { files, setFiles, dataVersion } = useLiveDeckFiles3D({
68
+ const { files, setFiles, dataVersion, hydrated } = useLiveDeckFiles3D({
69
69
  shouldSkipPath: saver.hasPending,
70
+ ensurePath: path,
70
71
  });
71
72
  const text = files[path] ?? '';
72
73
  const onChangeText = (next) => {
@@ -305,6 +306,16 @@ export function Scene3DEditor({ path }) {
305
306
  onSelectActorIds([]);
306
307
  };
307
308
 
309
+ if (!hydrated && files[path] === undefined) {
310
+ return (
311
+ <div className={styles.panelBare}>
312
+ <MainEditor>
313
+ <EditorBody>loading…</EditorBody>
314
+ </MainEditor>
315
+ </div>
316
+ );
317
+ }
318
+
308
319
  return (
309
320
  <div className={styles.panelBare}>
310
321
  <MainEditor>
@@ -8,6 +8,7 @@
8
8
  import { useEffect, useRef, useState } from 'react';
9
9
  import { onFilesChanged } from 'castle-web-sdk';
10
10
  import { initialFiles, initialMedia, isImageFile } from '@imports/castle.physics-2d/engine/files';
11
+ import { hydrateDeclaredFiles } from '@imports/castle.physics-2d/engine/liveReload';
11
12
  import { parseFull } from '@imports/castle.physics-2d/engine/pxart';
12
13
  import { imageArt } from '@imports/castle.physics-2d/engine/art';
13
14
 
@@ -31,8 +32,15 @@ export const initialFiles3D = {
31
32
  const DATA_EXTS = ['.scene', '.pxart', '.drawing', '.pxmodel'];
32
33
  const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
33
34
 
35
+ // Extensions the DECK declared that this kit has never heard of, learned from
36
+ // the serve at mount. Module-level because the change classifier runs per
37
+ // event, with no state to thread through -- same shape as the imported kit's.
38
+ const declaredDataExts = new Set();
39
+
34
40
  export function isDataFile3D(path) {
35
- return DATA_EXTS.some((ext) => path.endsWith(ext));
41
+ if (DATA_EXTS.some((ext) => path.endsWith(ext))) return true;
42
+ for (const ext of declaredDataExts) if (path.endsWith(ext)) return true;
43
+ return false;
36
44
  }
37
45
 
38
46
  function needsContextReload(change) {
@@ -50,7 +58,7 @@ async function readDeckFile(path) {
50
58
  return typeof data.contents === 'string' ? data.contents : '';
51
59
  }
52
60
 
53
- async function applyDataChanges(changes, setFiles) {
61
+ async function applyDataChanges(changes, setFiles, setDataVersion) {
54
62
  const updates = await Promise.all(
55
63
  changes.map(async (change) => {
56
64
  if (change.event === 'delete') return { path: change.path, text: null };
@@ -71,6 +79,7 @@ async function applyDataChanges(changes, setFiles) {
71
79
  }
72
80
  return next;
73
81
  });
82
+ setDataVersion((version) => version + 1);
74
83
  }
75
84
 
76
85
  // Live deck files including models. Same contract as the imported
@@ -80,11 +89,43 @@ async function applyDataChanges(changes, setFiles) {
80
89
  // A code change does nothing here, matching the imported hook: only a page
81
90
  // reload picks up new code, and throwing one at a panel someone is working in
82
91
  // loses their place, so the panel header offers it instead.
83
- export function useLiveDeckFiles3D({ shouldSkipPath } = {}) {
92
+ export function useLiveDeckFiles3D({ shouldSkipPath, ensurePath } = {}) {
84
93
  const [files, setFiles] = useState(initialFiles3D);
94
+ // False until the declared-type read has settled. An editor mounted before
95
+ // then would be handed '' for a file that exists, and an edit made in that
96
+ // window writes its empty document over the real one.
97
+ const [hydrated, setHydrated] = useState(false);
98
+ // Bumped on every applied data change. The editors watch it to re-apply when
99
+ // a file they REFERENCE changed -- their own `text` is unchanged then, so it
100
+ // is the only signal that the drawing under an actor moved.
101
+ const [dataVersion, setDataVersion] = useState(0);
85
102
  const skipRef = useRef(shouldSkipPath);
86
103
  skipRef.current = shouldSkipPath;
87
104
 
105
+ // Same gap, same fix as the imported kit: the glob above is a compile-time
106
+ // transform, so a type the DECK declares is invisible to it. `initialFiles3D`
107
+ // is what this kit already knows, models included.
108
+ useEffect(() => {
109
+ let alive = true;
110
+ void hydrateDeclaredFiles({
111
+ knownExts: DATA_EXTS,
112
+ knownFiles: initialFiles3D,
113
+ ensurePath,
114
+ }).then((found) => {
115
+ if (!alive) return;
116
+ if (found) {
117
+ for (const ext of found.exts) declaredDataExts.add(ext);
118
+ if (Object.keys(found.extra).length) {
119
+ setFiles((current) => ({ ...found.extra, ...current }));
120
+ }
121
+ }
122
+ setHydrated(true);
123
+ });
124
+ return () => {
125
+ alive = false;
126
+ };
127
+ }, [ensurePath]);
128
+
88
129
  useEffect(
89
130
  () =>
90
131
  onFilesChanged((event) => {
@@ -92,12 +133,12 @@ export function useLiveDeckFiles3D({ shouldSkipPath } = {}) {
92
133
  (change) => isDataFile3D(change.path) && !skipRef.current?.(change.path)
93
134
  );
94
135
  if (dataChanges.length === 0) return;
95
- void applyDataChanges(dataChanges, setFiles);
136
+ void applyDataChanges(dataChanges, setFiles, setDataVersion);
96
137
  }),
97
138
  []
98
139
  );
99
140
 
100
- return { files, setFiles };
141
+ return { files, setFiles, dataVersion, hydrated };
101
142
  }
102
143
 
103
144
  // The sprites map with parsed pxarts CACHED BY TEXT: re-parsing every file on
@@ -0,0 +1,3 @@
1
+ {
2
+ "palette": "endesga-64"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.132",
3
+ "version": "0.4.134",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"