castle-web-cli 0.4.176 → 0.4.178

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 (41) 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 +98 -34
  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/serve.js +3 -1
  9. package/dist/shell/assets/index-C890YbXX.css +1 -0
  10. package/dist/shell/assets/index-z6shfW4S.js +447 -0
  11. package/dist/shell/index.html +2 -2
  12. package/kits/base/CLAUDE.md +3 -0
  13. package/kits/base/castle.json +21 -11
  14. package/kits/physics-2d/CLAUDE.md +41 -8
  15. package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
  16. package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
  17. package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
  18. package/kits/physics-2d/behaviors/Style.jsx +270 -0
  19. package/kits/physics-2d/behaviors/Text.jsx +213 -0
  20. package/kits/physics-2d/behaviors/Video.jsx +8 -4
  21. package/kits/physics-2d/blueprints/text.scene +12 -0
  22. package/kits/physics-2d/castle.json +10 -6
  23. package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
  24. package/kits/physics-2d/editors/deckFont.js +65 -55
  25. package/kits/physics-2d/editors/fontPreview.js +6 -38
  26. package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
  27. package/kits/physics-2d/engine/blueprint.js +28 -0
  28. package/kits/physics-2d/engine/fonts.js +125 -23
  29. package/kits/physics-2d/engine/paletteField.jsx +235 -0
  30. package/kits/physics-2d/engine/physics/controls.js +5 -81
  31. package/kits/physics-2d/engine/popoverDismiss.js +17 -0
  32. package/kits/physics-2d/engine/scene.js +12 -3
  33. package/kits/physics-2d/engine/spriteField.jsx +2 -15
  34. package/kits/physics-2d/engine/tap.js +90 -0
  35. package/kits/physics-2d/engine/text.js +94 -0
  36. package/kits/physics-2d/engine/ui.jsx +20 -0
  37. package/kits/physics-2d/engine/ui.module.css +10 -2
  38. package/kits/physics-3d/castle.json +4 -2
  39. package/package.json +7 -3
  40. package/dist/shell/assets/index-BWOEraUy.js +0 -447
  41. package/dist/shell/assets/index-BkVF1OXc.css +0 -1
@@ -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
@@ -1,20 +1,18 @@
1
- // The deck's font.
1
+ // The deck's fonts.
2
2
  //
3
- // Two files hold it, and the split is the whole point:
3
+ // Two files hold them, and the split is the whole point:
4
4
  //
5
- // theme.style { "font": "Piazzolla" } the RECORD -- readable, hand-editable
6
- // fonts.generated.js a one-line re-export the BYTES
5
+ // theme.style { "font": "Piazzolla" } the RECORD -- primary face
6
+ // fonts.generated.js static re-exports the BYTES
7
7
  //
8
8
  // `theme.style` is data the engine reads at RUNTIME, so a font name in it is
9
9
  // just a string, and turning a runtime string into font bytes means a lookup
10
10
  // keyed by that name -- which no bundler can narrow, so it would inline all
11
- // nine Castle faces (495 KB) into every physics-2d deck, whose whole bundle is
12
- // under 900 KB. The generated module names its face in a static import
13
- // instead, so exactly one face ships. See castle-web-fonts' README.
11
+ // nine Castle faces (495 KB) into every physics-2d deck. The generated module
12
+ // names each face in a static import instead, so only the faces in use ship.
14
13
  //
15
- // The two can drift when someone edits `theme.style` by hand. Edit mode heals
16
- // that (editors/deckFont.js); a published deck is always consistent, because
17
- // the module is on disk when `save-deck` bundles it.
14
+ // Faces in use = the theme primary plus every `Text.font` on actors in the
15
+ // deck's scenes/blueprints. Edit mode keeps the module honest (editors/deckFont.js).
18
16
 
19
17
  import { FACES, faceInfo } from 'castle-web-fonts';
20
18
 
@@ -34,11 +32,19 @@ export const DEFAULT_FONT_FAMILY = 'sans-serif';
34
32
  const generated = import.meta.glob('/fonts.generated.js', { eager: true });
35
33
  const deckFontModule = generated['/fonts.generated.js'];
36
34
 
37
- /** The face the generated module actually carries, or null when there is none.
38
- * This is what the deck RENDERS in, as against what `theme.style` asks for. */
35
+ /** The face the generated module actually carries as primary, or null. */
39
36
  export const generatedFontName =
40
37
  typeof deckFontModule?.deckFont === 'string' ? deckFontModule.deckFont : null;
41
38
 
39
+ /** Every face the generated module ships (primary + Text extras), ordered. */
40
+ export const generatedFontNames = (() => {
41
+ const listed = deckFontModule?.deckFonts;
42
+ if (Array.isArray(listed) && listed.length) {
43
+ return listed.filter((name) => typeof name === 'string' && faceInfo(name));
44
+ }
45
+ return generatedFontName ? [generatedFontName] : [];
46
+ })();
47
+
42
48
  /** A CSS `font-family` value that is always safe to use: the deck's face when
43
49
  * it has one, a plain sans stack when it doesn't. */
44
50
  export const deckFontFamily = generatedFontName ?? DEFAULT_FONT_FAMILY;
@@ -50,30 +56,126 @@ export function themeFontName(themeData) {
50
56
  return typeof raw === 'string' && faceInfo(raw) ? raw : null;
51
57
  }
52
58
 
59
+ /** Resolve a Text.font prop to a CSS family: empty/unknown → deck primary. */
60
+ export function resolveTextFont(font) {
61
+ if (typeof font === 'string' && font && faceInfo(font)) return font;
62
+ return deckFontFamily;
63
+ }
64
+
65
+ /** Unique official face names from every Text actor in scene/blueprint files. */
66
+ export function collectTextFontsFromFiles(files) {
67
+ const found = new Set();
68
+ for (const [path, text] of Object.entries(files ?? {})) {
69
+ if (!path.endsWith('.scene') || typeof text !== 'string') continue;
70
+ let data;
71
+ try {
72
+ data = JSON.parse(text);
73
+ } catch {
74
+ continue;
75
+ }
76
+ for (const actor of data?.actors ?? []) {
77
+ const name = actor?.components?.Text?.font;
78
+ if (typeof name === 'string' && faceInfo(name)) found.add(name);
79
+ }
80
+ }
81
+ return [...found];
82
+ }
83
+
84
+ /** Ordered unique official faces: primary first, then extras. */
85
+ export function orderedDeckFonts(primary, extras = []) {
86
+ const out = [];
87
+ const seen = new Set();
88
+ for (const name of [primary, ...extras]) {
89
+ if (!name || !faceInfo(name) || seen.has(name)) continue;
90
+ seen.add(name);
91
+ out.push(name);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Source for `fonts.generated.js` carrying exactly `fonts` (primary = deckFont). */
97
+ export function fontsGeneratedSource(primary, fonts) {
98
+ const list = orderedDeckFonts(primary, fonts);
99
+ if (!list.length) {
100
+ return `// Generated by the Theme editor. Do not edit.
101
+ //
102
+ // This deck has no font of its own; text renders in the default sans stack.
103
+ export const deckFont = null;
104
+ export const deckFonts = [];
105
+ export const installDeckFonts = [];
106
+ `;
107
+ }
108
+ const primaryName = list.includes(primary) ? primary : list[0];
109
+ const imports = list.map((name) => `install${name}`).join(', ');
110
+ const installers = list.map((name) => `install${name}`).join(', ');
111
+ const namesLit = list.map((name) => `'${name}'`).join(', ');
112
+ return `// Generated by the Theme editor / Text font picker. Do not edit.
113
+ //
114
+ // \`theme.style\` records the deck's primary font as ${primaryName}; Text actors
115
+ // may use additional faces. This module is what puts their bytes in the
116
+ // published bundle — each face must be named in a static import so the bundler
117
+ // can tree-shake the rest.
118
+ import { ${imports} } from 'castle-web-fonts';
119
+ export { install${primaryName} as installDeckFont };
120
+ export const deckFont = '${primaryName}';
121
+ export const deckFonts = [${namesLit}];
122
+ export const installDeckFonts = [${installers}];
123
+ `;
124
+ }
125
+
53
126
  let ready = null;
54
127
 
55
128
  /** Resolve once every face this deck registered can be drawn with -- the deck
56
- * font, and any a behavior imported for itself.
129
+ * font(s) in fonts.generated.js, and any a behavior imported for itself.
57
130
  *
58
131
  * Wait for this before the first frame. Canvas 2D does not trigger a font load,
59
132
  * so a `draw` hook setting `ctx.font` before the face is ready silently falls
60
- * back to the default -- and never repaints when the font lands, so the deck
61
- * looks right after a warm reload and wrong on a cold first paint.
62
- *
63
- * `document.fonts.ready` is what covers the extras, and it is the only thing
64
- * that can: a deck wanting a display face beside the deck font imports it
65
- * itself, and nothing here knows its name. A behavior module's `installBore()`
66
- * runs while the module graph loads, which is before the player mounts, so that
67
- * load is already pending by the time this is first called.
133
+ * back to the default -- and never repaints when the font lands.
68
134
  *
69
135
  * Never rejects: a deck whose font failed to load should still play. */
70
136
  export function fontsReady() {
71
137
  if (!ready) {
72
- const install = deckFontModule?.installDeckFont;
138
+ const list = deckFontModule?.installDeckFonts;
139
+ const installers = Array.isArray(list) && list.length
140
+ ? list
141
+ : deckFontModule?.installDeckFont
142
+ ? [deckFontModule.installDeckFont]
143
+ : [];
73
144
  ready = Promise.all([
74
- install ? install() : null,
145
+ ...installers.map((install) => (typeof install === 'function' ? install() : null)),
75
146
  typeof document === 'undefined' ? null : document.fonts?.ready,
76
147
  ]).catch(() => null);
77
148
  }
78
149
  return ready;
79
150
  }
151
+
152
+ // Edit-time loads for faces not yet in the eager `fonts.generated.js` module
153
+ // (e.g. the author just picked Text.font). Same woff2 URLs the font picker
154
+ // uses -- served from node_modules while editing, never inlined into a publish.
155
+ const faceLoads = new Map();
156
+
157
+ /** Install one Castle face into `document.fonts` so canvas text can use it
158
+ * without a panel reload. No-op for unknown / empty names. Idempotent. */
159
+ export function ensureOfficialFace(name) {
160
+ if (!name || !faceInfo(name)) return Promise.resolve(false);
161
+ if (typeof FontFace === 'undefined' || typeof document === 'undefined') {
162
+ return Promise.resolve(false);
163
+ }
164
+ const pending = faceLoads.get(name);
165
+ if (pending) return pending;
166
+ const load = (async () => {
167
+ try {
168
+ const face = new FontFace(
169
+ name,
170
+ `url("/node_modules/castle-web-fonts/woff2/${name}.woff2") format("woff2")`
171
+ );
172
+ await face.load();
173
+ document.fonts.add(face);
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ })();
179
+ faceLoads.set(name, load);
180
+ return load;
181
+ }