castle-web-cli 0.4.177 → 0.4.179

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 (40) hide show
  1. package/dist/agent-failures.js +4 -4
  2. package/dist/agent-prompts.d.ts +9 -1
  3. package/dist/agent-prompts.js +15 -2
  4. package/dist/agent.js +125 -40
  5. package/dist/deckLocatorShape.d.ts +16 -0
  6. package/dist/deckLocatorShape.js +28 -0
  7. package/dist/editorConfig.d.ts +1 -0
  8. package/dist/shell/assets/index-C5DS_7UM.js +447 -0
  9. package/dist/shell/assets/index-CoU3ETYM.css +1 -0
  10. package/dist/shell/index.html +2 -2
  11. package/kits/base/CLAUDE.md +3 -0
  12. package/kits/base/castle.json +21 -11
  13. package/kits/physics-2d/CLAUDE.md +41 -8
  14. package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
  15. package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
  16. package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
  17. package/kits/physics-2d/behaviors/Style.jsx +270 -0
  18. package/kits/physics-2d/behaviors/Text.jsx +213 -0
  19. package/kits/physics-2d/behaviors/Video.jsx +8 -4
  20. package/kits/physics-2d/blueprints/text.scene +12 -0
  21. package/kits/physics-2d/castle.json +10 -6
  22. package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
  23. package/kits/physics-2d/editors/deckFont.js +65 -55
  24. package/kits/physics-2d/editors/fontPreview.js +6 -38
  25. package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
  26. package/kits/physics-2d/engine/blueprint.js +28 -0
  27. package/kits/physics-2d/engine/fonts.js +125 -23
  28. package/kits/physics-2d/engine/paletteField.jsx +235 -0
  29. package/kits/physics-2d/engine/physics/controls.js +5 -81
  30. package/kits/physics-2d/engine/popoverDismiss.js +17 -0
  31. package/kits/physics-2d/engine/scene.js +12 -3
  32. package/kits/physics-2d/engine/spriteField.jsx +2 -15
  33. package/kits/physics-2d/engine/tap.js +90 -0
  34. package/kits/physics-2d/engine/text.js +94 -0
  35. package/kits/physics-2d/engine/ui.jsx +20 -0
  36. package/kits/physics-2d/engine/ui.module.css +10 -2
  37. package/kits/physics-3d/castle.json +4 -2
  38. package/package.json +1 -1
  39. package/dist/shell/assets/index-BWOEraUy.js +0 -447
  40. package/dist/shell/assets/index-BkVF1OXc.css +0 -1
@@ -27,12 +27,14 @@ import {
27
27
  screenToCard,
28
28
  setActorComponent,
29
29
  } from '../engine/scene';
30
+ import { fontsReady, ensureOfficialFace } from '../engine/fonts';
30
31
  import {
31
32
  addActorWithBlueprint,
32
33
  blueprintDropXY,
33
34
  cascadeDeleteBlueprint,
34
35
  countBlueprintInstances,
35
36
  createBlueprint,
37
+ forkBlueprint,
36
38
  getBlueprintTemplate,
37
39
  importAliasOf,
38
40
  isBlueprintPath,
@@ -679,6 +681,22 @@ export function SceneEditor({
679
681
  void writeFile(result.blueprintFile.path, result.blueprintFile.text);
680
682
  commitScene(result.sceneData);
681
683
  };
684
+ const onForkBlueprint = () => {
685
+ // New deck-owned file from the selected blueprint's template. Instances of
686
+ // the source are untouched — unlike new-from-actor, which reparents one.
687
+ if (!selectedBlueprintPath) return;
688
+ const result = forkBlueprint(files, selectedBlueprintPath);
689
+ if (!result) return;
690
+ if (onChangeFile) {
691
+ if (result.drawingFile) onChangeFile(result.drawingFile.path, result.drawingFile.text);
692
+ onChangeFile(result.blueprintFile.path, result.blueprintFile.text);
693
+ } else {
694
+ if (result.drawingFile) void writeFile(result.drawingFile.path, result.drawingFile.text);
695
+ void writeFile(result.blueprintFile.path, result.blueprintFile.text);
696
+ }
697
+ onSelectBlueprint(result.blueprintFile.path);
698
+ setFocusName(result.blueprintFile.path);
699
+ };
682
700
  return (
683
701
  <>
684
702
  <EditorHeader
@@ -846,6 +864,7 @@ export function SceneEditor({
846
864
  files={files}
847
865
  onEditSprite={onEditSprite}
848
866
  onViewSource={onViewSource}
867
+ onFork={onForkBlueprint}
849
868
  autoFocusName={focusName === selectedBlueprintPath}
850
869
  onNameFocused={() => setFocusName(null)}
851
870
  onRename={blueprintActions.rename}
@@ -1745,6 +1764,17 @@ function useScenePlayLoop({
1745
1764
  const canvas = canvasRef.current;
1746
1765
  const ctx = canvas.getContext('2d');
1747
1766
  if (!ctx) return undefined;
1767
+ // Play pane awaits this before the first frame; the editor must kick the
1768
+ // same install or canvas text (Text actors, badges) keeps the fallback face.
1769
+ // The loop keeps drawing, so the face snaps in once document.fonts settles.
1770
+ void fontsReady();
1771
+ // Faces chosen mid-session (or present on actors before fonts.generated.js
1772
+ // was reloaded) are not in the eager module fontsReady reads — pull those
1773
+ // in so edit mode matches play without a panel reload.
1774
+ for (const actor of sceneData.actors ?? []) {
1775
+ const font = actor?.components?.Text?.font;
1776
+ if (font) void ensureOfficialFace(font);
1777
+ }
1748
1778
  const viewport = isPlaying ? undefined : editViewportForZoom(editCameraRef.current.zoom);
1749
1779
  configureSceneCanvas(canvas, ctx, viewport);
1750
1780
  let raf = 0;
@@ -1956,6 +1986,7 @@ function BlueprintInspector({
1956
1986
  files,
1957
1987
  onEditSprite,
1958
1988
  onViewSource,
1989
+ onFork,
1959
1990
  autoFocusName,
1960
1991
  onNameFocused,
1961
1992
  onRename,
@@ -2028,6 +2059,16 @@ function BlueprintInspector({
2028
2059
  </span>
2029
2060
  </div>
2030
2061
  </div>
2062
+ {onFork ? (
2063
+ <button
2064
+ type="button"
2065
+ className={styles.instanceForkButton}
2066
+ onClick={onFork}
2067
+ title="New deck blueprint copied from this one">
2068
+ <Icon name="clone" />
2069
+ <span>Fork blueprint</span>
2070
+ </button>
2071
+ ) : null}
2031
2072
  </div>
2032
2073
  <ActorInspector
2033
2074
  ownerKey={blueprintPath}
@@ -1,18 +1,24 @@
1
- // Writing the deck's font: the generated module, and keeping it honest.
1
+ // Writing the deck's fonts: the generated module, and keeping it honest.
2
2
  //
3
- // `theme.style` records WHICH face; `fonts.generated.js` carries its bytes into
4
- // the bundle (engine/fonts.js explains why it takes two files). The picker
5
- // writes both together, so they agree the moment a font is chosen.
3
+ // `theme.style` records the PRIMARY face; `fonts.generated.js` carries bytes
4
+ // for that face plus any `Text.font` values used in the deck (engine/fonts.js).
5
+ // The Theme picker writes both together for the primary; the Text inspector
6
+ // rewrites the module when a per-actor face is chosen.
6
7
  //
7
8
  // They can still drift -- someone edits `theme.style` in the code editor, or
8
9
  // takes a scene file from another deck. `syncDeckFontModule` is the repair, run
9
- // once per edit-mode panel load: it compares what the module carries against
10
- // what the record asks for and rewrites the module when they disagree. Play
11
- // mode never repairs, and never needs to: a published deck's module was on disk
12
- // when `save-deck` bundled it.
10
+ // once per edit-mode panel load.
13
11
 
14
12
  import * as castleSdk from 'castle-web-sdk';
15
- import { DECK_FONT_MODULE, generatedFontName, themeFontName } from '../engine/fonts';
13
+ import {
14
+ DECK_FONT_MODULE,
15
+ collectTextFontsFromFiles,
16
+ fontsGeneratedSource,
17
+ generatedFontName,
18
+ generatedFontNames,
19
+ orderedDeckFonts,
20
+ themeFontName,
21
+ } from '../engine/fonts';
16
22
  import { initialFiles } from '../engine/files';
17
23
  import { parseThemeData, THEME_STYLE_PATH } from '../engine/palettes';
18
24
 
@@ -22,67 +28,71 @@ import { parseThemeData, THEME_STYLE_PATH } from '../engine/palettes';
22
28
  // nothing, which renders the same and just leaves a stale file behind.
23
29
  const deleteFile = castleSdk.deleteFile ?? null;
24
30
 
25
- /** The whole content of `fonts.generated.js` for one face. */
26
- export function deckFontModuleSource(name) {
27
- return `// Generated by the Theme editor. Do not edit -- picking a font rewrites it.
28
- //
29
- // \`theme.style\` records that this deck's font is ${name}; this module is what
30
- // puts ${name}'s bytes in the published bundle. It has to name the face in a
31
- // static import: reaching it through the name in \`theme.style\` instead would
32
- // inline all nine Castle faces into the deck, since no bundler can tell which
33
- // one a runtime lookup reads.
34
- export { install${name} as installDeckFont } from 'castle-web-fonts';
35
- export const deckFont = '${name}';
36
- `;
31
+ /** The whole content of `fonts.generated.js` for a primary + extras. */
32
+ export function deckFontModuleSource(name, extras = []) {
33
+ return fontsGeneratedSource(name, extras);
37
34
  }
38
35
 
39
- /** An empty module, for the SDK too old to delete a file. Renders as no deck
40
- * font, same as the file being absent. */
41
36
  function emptyModuleSource() {
42
- return `// Generated by the Theme editor. Do not edit.
43
- //
44
- // This deck has no font of its own; text renders in the default sans stack.
45
- export const deckFont = null;
46
- `;
37
+ return fontsGeneratedSource(null, []);
47
38
  }
48
39
 
49
- /** Point the deck at `name`, or at no font when it is null. Writes the module
50
- * through the host's debounced saver, the same path every other editor edit
51
- * takes. */
40
+ function textFontsFromDeck() {
41
+ return collectTextFontsFromFiles(initialFiles);
42
+ }
43
+
44
+ /** Point the deck at `name` as primary, or at no primary when null. Preserves
45
+ * faces Text actors already use. Writes through the host's debounced saver. */
52
46
  export function writeDeckFontModule(onChangeFile, name) {
47
+ const extras = textFontsFromDeck();
53
48
  if (name) {
54
- onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(name));
49
+ onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(name, extras));
50
+ return;
51
+ }
52
+ // Primary cleared: keep Text-only faces if any, else remove / empty the module.
53
+ const remaining = orderedDeckFonts(null, extras);
54
+ if (remaining.length) {
55
+ onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(remaining[0], remaining));
55
56
  return;
56
57
  }
57
58
  if (deleteFile) void deleteFile(DECK_FONT_MODULE);
58
59
  else onChangeFile(DECK_FONT_MODULE, emptyModuleSource());
59
60
  }
60
61
 
61
- /** Edit mode only: make `fonts.generated.js` say what `theme.style` asks for.
62
- *
63
- * Both sides are read from the build-time snapshot, so they are compared as the
64
- * running page actually has them -- the write lands, the panel reloads, and the
65
- * next load agrees. Returns what it did, for the log, or null when there was
66
- * nothing to do, which is every load but the one after a hand-edit.
62
+ /** After a Text.font change: ensure that face (plus theme + other Text fonts)
63
+ * is in fonts.generated.js. `pendingFont` covers the value not yet on disk. */
64
+ export function ensureTextFontsInModule(files, pendingFont) {
65
+ const primary = themeFontName(parseThemeData(files?.[THEME_STYLE_PATH])) ?? generatedFontName;
66
+ const extras = collectTextFontsFromFiles(files);
67
+ if (pendingFont) extras.push(pendingFont);
68
+ const next = orderedDeckFonts(primary, extras);
69
+ const prev = generatedFontNames;
70
+ if (next.length === prev.length && next.every((name, i) => name === prev[i])) {
71
+ if ((primary ?? null) === (generatedFontName ?? null)) return null;
72
+ }
73
+ const source = next.length
74
+ ? fontsGeneratedSource(primary ?? next[0], next)
75
+ : emptyModuleSource();
76
+ void castleSdk.writeFile(DECK_FONT_MODULE, source).catch(() => {});
77
+ return `Text fonts -- rewriting ${DECK_FONT_MODULE} (${next.join(', ') || 'none'})`;
78
+ }
79
+
80
+ /** Edit mode only: make `fonts.generated.js` match theme primary + Text fonts.
67
81
  *
68
- * It only ever WRITES, never deletes, and that is not a shortcut. Clearing the
69
- * font by hand is repaired with the empty module rather than by removing the
70
- * file, so the repair goes through the durable save queue like every other
71
- * write -- see below. Picking "None" in the editor still deletes the file
72
- * outright; that is a person clicking, with a connection that is up. */
82
+ * Both sides are read from the build-time snapshot. Returns what it did, or
83
+ * null when there was nothing to do. */
73
84
  export function syncDeckFontModule() {
74
85
  const wanted = themeFontName(parseThemeData(initialFiles[THEME_STYLE_PATH]));
75
- if (wanted === generatedFontName) return null;
76
- // Deliberately not awaited. `writeFile` is durable and its promise settles
77
- // with the FIRST attempt -- which here is at module scope, usually before the
78
- // dev-server socket is even open, so awaiting it reports "not yet" as a
79
- // failure. The queue holds the text and writes it as soon as the connection
80
- // is there.
81
- void castleSdk.writeFile(
82
- DECK_FONT_MODULE,
83
- wanted ? deckFontModuleSource(wanted) : emptyModuleSource(),
84
- ).catch(() => {});
85
- return wanted
86
- ? `${THEME_STYLE_PATH} asks for ${wanted} -- rewriting ${DECK_FONT_MODULE}`
86
+ const extras = textFontsFromDeck();
87
+ const next = orderedDeckFonts(wanted, extras);
88
+ const prev = generatedFontNames;
89
+ const primaryMatch = (wanted ?? null) === (generatedFontName ?? null);
90
+ const listMatch = next.length === prev.length && next.every((name, i) => name === prev[i]);
91
+ if (primaryMatch && listMatch) return null;
92
+ void castleSdk
93
+ .writeFile(DECK_FONT_MODULE, next.length ? fontsGeneratedSource(wanted ?? next[0], next) : emptyModuleSource())
94
+ .catch(() => {});
95
+ return next.length
96
+ ? `fonts -- rewriting ${DECK_FONT_MODULE} (primary ${wanted ?? next[0]}; ${next.join(', ')})`
87
97
  : `${THEME_STYLE_PATH} sets no font -- clearing ${DECK_FONT_MODULE}`;
88
98
  }
@@ -4,48 +4,16 @@
4
4
  // deck must never do: importing all nine would inline 495 KB into every
5
5
  // published deck, and the whole physics-2d bundle is under 900 KB.
6
6
  //
7
- // So the specimens are not imported, they are FETCHED. The dev server serves
8
- // the deck's `node_modules` as static files, and `castle-web-fonts` ships the
9
- // raw woff2 alongside its modules, so a plain URL reaches every face at no cost
10
- // to the bundle. That only works while editing locally — which is the only
11
- // place an editor runs, so nothing is lost. A face that doesn't arrive leaves
12
- // its card in the UI font, and the picker still picks.
7
+ // So the specimens are not imported, they are FETCHED (see ensureOfficialFace).
8
+ // That only works while editing locally which is the only place an editor
9
+ // runs, so nothing is lost. A face that doesn't arrive leaves its card in the
10
+ // UI font, and the picker still picks.
13
11
 
14
12
  import { useEffect, useState } from 'react';
15
- import { OFFICIAL_FONTS } from '../engine/fonts';
13
+ import { OFFICIAL_FONTS, ensureOfficialFace } from '../engine/fonts';
16
14
 
17
15
  const FACE_NAMES = OFFICIAL_FONTS.map((face) => face.name);
18
16
 
19
- const faceUrl = (name) => `/node_modules/castle-web-fonts/woff2/${name}.woff2`;
20
-
21
- // Module-scope so the panel's re-renders don't refetch, and so switching files
22
- // and back is instant. Values are promises resolving to true / false.
23
- const requests = new Map();
24
-
25
- function request(name) {
26
- const pending = requests.get(name);
27
- if (pending) return pending;
28
- const load = loadFace(name);
29
- requests.set(name, load);
30
- return load;
31
- }
32
-
33
- async function loadFace(name) {
34
- if (typeof FontFace === 'undefined') return false;
35
- try {
36
- // Registered under the face's real family name, so a card just asks for
37
- // `font-family: Tektur`. When the deck's own font is already installed this
38
- // adds a second face for that family from the identical bytes — which
39
- // renders identically, and costs one entry in document.fonts.
40
- const face = new FontFace(name, `url("${faceUrl(name)}") format("woff2")`);
41
- await face.load();
42
- document.fonts.add(face);
43
- return true;
44
- } catch {
45
- return false; // no serve, or an install without the package: no specimen.
46
- }
47
- }
48
-
49
17
  /** Load every Castle face's specimen and report which have arrived. Cards
50
18
  * re-render into their own face as each one lands. */
51
19
  export function usePreviewFaces() {
@@ -53,7 +21,7 @@ export function usePreviewFaces() {
53
21
  useEffect(() => {
54
22
  let live = true;
55
23
  for (const name of FACE_NAMES) {
56
- void request(name).then((ok) => {
24
+ void ensureOfficialFace(name).then((ok) => {
57
25
  if (!ok || !live) return;
58
26
  setLoaded((current) => (current.has(name) ? current : new Set(current).add(name)));
59
27
  });
@@ -1,8 +1,10 @@
1
- import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
- import { createPortal } from 'react-dom';
1
+ import React, { useRef, useState } from 'react';
3
2
  import { GRID_RENDERER, RESOLUTION_STEPS, VECTOR_RENDERER } from '../engine/pxart';
3
+ import { PaletteGrid, PalettePager, PalettePopover } from '../engine/paletteField';
4
4
  import { cx, Icon, IconButton, Panel, styles } from '../engine/ui';
5
5
 
6
+ export { NoneSwatch, PaletteGrid, PalettePager, PalettePopover, sameHex } from '../engine/paletteField';
7
+
6
8
  // Brush/erase diameters offered by the pixel editors' size sliders.
7
9
  export const BRUSH_SIZES = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32];
8
10
 
@@ -223,86 +225,6 @@ function ResolutionSelect({ label, value, onChange, disabled = false }) {
223
225
  );
224
226
  }
225
227
 
226
- function sameHex(a, b) {
227
- return !!a && !!b && a.toLowerCase() === b.toLowerCase();
228
- }
229
-
230
- function NoneSwatch({ selected, onSelect }) {
231
- return (
232
- <button
233
- type="button"
234
- className={cx(styles.swatch, selected && styles.swatchSelected)}
235
- title="None"
236
- aria-label="None"
237
- style={{ position: 'relative', background: 'transparent' }}
238
- onClick={onSelect}>
239
- <span
240
- aria-hidden
241
- style={{
242
- position: 'absolute',
243
- inset: 4,
244
- background:
245
- 'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
246
- }}
247
- />
248
- </button>
249
- );
250
- }
251
-
252
- // Swatch strip shared by the docked sidebar and the compact popover.
253
- // `keys` paints a sprite's working palette; `hexes` paints a picker list.
254
- // `wrap` is the unlabeled sprite row; the named official page is an 8-col grid.
255
- export function PaletteGrid({
256
- keys,
257
- hexes,
258
- palette,
259
- activeKey,
260
- activeHex,
261
- onSelectKey,
262
- onSelectHex,
263
- allowNone = false,
264
- noneSelected = false,
265
- onSelectNone,
266
- wrap = false,
267
- }) {
268
- const items = hexes
269
- ? hexes.map((hex) => ({ id: hex, hex, selected: sameHex(activeHex, hex), onClick: () => onSelectHex?.(hex) }))
270
- : (keys ?? []).map((key) => ({
271
- id: key,
272
- hex: palette?.[key],
273
- selected: activeKey === key || sameHex(activeHex, palette?.[key]),
274
- onClick: () => onSelectKey?.(key),
275
- }));
276
- if (!allowNone && !items.length) return null;
277
- return (
278
- <div className={wrap ? styles.paletteSpriteRow : styles.palette}>
279
- {allowNone ? <NoneSwatch selected={noneSelected} onSelect={onSelectNone} /> : null}
280
- {items.map((item) => (
281
- <button
282
- key={item.id}
283
- type="button"
284
- className={cx(styles.swatch, item.selected && styles.swatchSelected)}
285
- title={item.hex}
286
- style={{ background: item.hex }}
287
- onClick={item.onClick}
288
- />
289
- ))}
290
- </div>
291
- );
292
- }
293
-
294
- function PalettePager({ name, onPage }) {
295
- return (
296
- <div className={styles.palettePager}>
297
- <span className={styles.palettePagerName}>{name}</span>
298
- <div className={styles.palettePagerBtns}>
299
- <IconButton icon="chevron-left" label="Previous palette" onClick={() => onPage(-1)} />
300
- <IconButton icon="chevron-right" label="Next palette" onClick={() => onPage(1)} />
301
- </div>
302
- </div>
303
- );
304
- }
305
-
306
228
  // Unlabeled sprite-color row (when the sprite has colors, or when fill/stroke
307
229
  // needs a none swatch) plus a paged official-palette grid. Paging only changes
308
230
  // which named page is shown — it does not write theme.style.
@@ -349,78 +271,6 @@ export function PaletteSections({
349
271
  );
350
272
  }
351
273
 
352
- // Anchored palette + eyedropper popover for compact/mobile paint-strip color tap.
353
- export function PalettePopover({ open, anchorRef, onClose, children }) {
354
- const popoverRef = useRef(null);
355
- const [position, setPosition] = useState({ top: 0, left: 0 });
356
-
357
- useLayoutEffect(() => {
358
- if (!open || !anchorRef.current) return;
359
-
360
- function positionPopover() {
361
- const anchor = anchorRef.current.getBoundingClientRect();
362
- const rect = popoverRef.current?.getBoundingClientRect();
363
- const margin = 8;
364
- const width = rect?.width ?? 231;
365
- const height = rect?.height ?? 320;
366
- const maxLeft = Math.max(margin, window.innerWidth - width - margin);
367
- const maxTop = Math.max(margin, window.innerHeight - height - margin);
368
- const leftSide = anchor.left - width - margin;
369
- const rightSide = anchor.right + margin;
370
- // Prefer the side with room, then clamp a too-wide picker inside this
371
- // iframe rather than sending it beyond either viewport edge.
372
- const left =
373
- leftSide >= margin
374
- ? leftSide
375
- : rightSide <= maxLeft
376
- ? rightSide
377
- : Math.min(maxLeft, Math.max(margin, anchor.left + anchor.width / 2 - width / 2));
378
- const top = Math.min(maxTop, Math.max(margin, anchor.top));
379
- setPosition({ top, left });
380
- }
381
-
382
- positionPopover();
383
- window.addEventListener('resize', positionPopover);
384
- window.addEventListener('scroll', positionPopover, true);
385
- return () => {
386
- window.removeEventListener('resize', positionPopover);
387
- window.removeEventListener('scroll', positionPopover, true);
388
- };
389
- }, [open, anchorRef]);
390
-
391
- useEffect(() => {
392
- if (!open) return undefined;
393
- function onKeyDown(event) {
394
- if (event.key === 'Escape') onClose();
395
- }
396
- function onPointerDown(event) {
397
- if (popoverRef.current?.contains(event.target) || anchorRef.current?.contains(event.target)) {
398
- return;
399
- }
400
- onClose();
401
- }
402
- window.addEventListener('keydown', onKeyDown);
403
- window.addEventListener('pointerdown', onPointerDown);
404
- return () => {
405
- window.removeEventListener('keydown', onKeyDown);
406
- window.removeEventListener('pointerdown', onPointerDown);
407
- };
408
- }, [open, onClose, anchorRef]);
409
-
410
- if (!open) return null;
411
- return createPortal(
412
- <div
413
- ref={popoverRef}
414
- className={styles.palettePopover}
415
- style={{ top: position.top, left: position.left }}
416
- role="dialog"
417
- aria-label="Palette">
418
- {children}
419
- </div>,
420
- document.body
421
- );
422
- }
423
-
424
274
  // The shape tool's sub-mode picker, shared by the compact paint strip and the
425
275
  // wide-layout settings panel. `buttonClass` styles each glyph button for its
426
276
  // host strip; `radio` switches the a11y semantics to a radiogroup.
@@ -696,6 +696,34 @@ export function newBlueprintFromActor(files, behaviors, sceneData, actorId) {
696
696
  return { sceneData: next, blueprintFile };
697
697
  }
698
698
 
699
+ // "Fork blueprint": mint a NEW deck-owned blueprint whose template is a copy of
700
+ // the selected one. Additive — existing instances keep pointing at the source.
701
+ // An import's art is copied into the deck (same as creating from a kit preset)
702
+ // so the fork is editable; a deck-owned source keeps sharing its art refs.
703
+ export function forkBlueprint(files, blueprintPath) {
704
+ const template = getBlueprintTemplate(files, blueprintPath);
705
+ if (!template) return null;
706
+ const existingPaths = new Set(Object.keys(files ?? {}));
707
+ const existingNames = listBlueprints(files)
708
+ .filter((blueprint) => !isImportedPath(blueprint.path))
709
+ .map((blueprint) => blueprint.name);
710
+ const base = `${template.name || 'Blueprint'} (forked)`;
711
+ const name = mintNameFromBase(existingNames, base);
712
+ const path =
713
+ mintPathFromName(existingPaths, name, BLUEPRINTS_DIR, '.scene') ??
714
+ mintBlueprintPath([...existingPaths]);
715
+ const components = structuredClone(template.components ?? {});
716
+ let drawingFile = null;
717
+ if (isImportedPath(blueprintPath)) {
718
+ drawingFile = adoptBuiltinArt(files, components, existingPaths, name);
719
+ }
720
+ for (const props of Object.values(components ?? {})) reprojectRefsIn(props, files, path);
721
+ return {
722
+ blueprintFile: { path, name, components, text: formatBlueprintFileText(name, components) },
723
+ drawingFile,
724
+ };
725
+ }
726
+
699
727
  // Every `scenes/*.scene` file's parsed data, for callers that need to scan
700
728
  // every scene in the deck (cascade delete, the instance-count badge/confirm).
701
729
  // Skips unparseable files rather than throwing -- a mid-edit scene with