castle-web-cli 0.4.123 → 0.4.125

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 (49) hide show
  1. package/dist/castleJson.d.ts +2 -0
  2. package/dist/castleJson.js +39 -0
  3. package/dist/headlessCover.d.ts +13 -0
  4. package/dist/headlessCover.js +144 -0
  5. package/dist/ide.js +12 -42
  6. package/dist/imports.js +42 -2
  7. package/dist/index.js +1 -9
  8. package/dist/init.d.ts +1 -0
  9. package/dist/init.js +15 -1
  10. package/dist/preview.d.ts +0 -1
  11. package/dist/preview.js +0 -58
  12. package/dist/save-deck.js +52 -1
  13. package/dist/serve.js +16 -0
  14. package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
  15. package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
  16. package/dist/shell/assets/index-BfOPkSej.css +1 -0
  17. package/dist/shell/assets/index-u0nYFqbF.js +434 -0
  18. package/dist/shell/index.html +2 -2
  19. package/kits/physics-2d/CLAUDE.md +2 -2
  20. package/kits/physics-2d/castle.json +10 -2
  21. package/kits/physics-2d/docs/pxart-format.md +33 -26
  22. package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
  23. package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
  24. package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
  25. package/kits/physics-2d/editors/pathOverlay.js +1 -1
  26. package/kits/physics-2d/editors/pathTools.js +9 -1
  27. package/kits/physics-2d/editors/pixelGeometry.js +14 -13
  28. package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
  29. package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
  30. package/kits/physics-2d/editors/pxArtTools.js +3 -43
  31. package/kits/physics-2d/editors/styleEditor.module.css +105 -0
  32. package/kits/physics-2d/editors/styleTheme.js +16 -0
  33. package/kits/physics-2d/engine/files.js +2 -1
  34. package/kits/physics-2d/engine/liveReload.js +4 -3
  35. package/kits/physics-2d/engine/palettes.js +636 -0
  36. package/kits/physics-2d/engine/pxart.js +6 -6
  37. package/kits/physics-2d/engine/svgImport.js +1056 -0
  38. package/kits/physics-2d/engine/ui.jsx +2 -0
  39. package/kits/physics-2d/engine/ui.module.css +54 -9
  40. package/kits/physics-2d/package.json +1 -0
  41. package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
  42. package/kits/physics-2d/scripts/draw.mjs +5 -3
  43. package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
  44. package/kits/physics-2d/scripts/palette.mjs +10 -0
  45. package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
  46. package/kits/physics-2d/theme.style +3 -0
  47. package/package.json +1 -1
  48. package/dist/shell/assets/index-CARLHafh.css +0 -1
  49. package/dist/shell/assets/index-DWgt4KzC.js +0 -434
@@ -20,3 +20,5 @@ export declare const DEFAULT_MAIN = "main.jsx";
20
20
  export declare function deckMainFile(dir: string): string;
21
21
  export declare function readCastleJson(dir: string): CastleJson | null;
22
22
  export declare function readCastleJsonOrThrow(dir: string): CastleJson | null;
23
+ export declare function ensureVisiblePath(deckDir: string, rel: string): boolean;
24
+ export declare function ensureVisibleGlob(deckDir: string, glob: string, probe: string): boolean;
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import picomatch from 'picomatch';
3
4
  export const DEFAULT_STARTER_SCENE = 'scenes/main.scene';
4
5
  export function deckStarterScene(dir) {
5
6
  const declared = readCastleJson(dir)?.starterScene;
@@ -32,3 +33,41 @@ export function readCastleJsonOrThrow(dir) {
32
33
  throw new Error(`Could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
33
34
  });
34
35
  }
36
+ // Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
37
+ // newly-made folder show in the curated Files tree.
38
+ export function ensureVisiblePath(deckDir, rel) {
39
+ return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
40
+ }
41
+ // Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
42
+ // ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
43
+ // everything is visible, and adding a glob would wrongly start hiding things.
44
+ // No-op if an existing glob already covers `probe`, a path the new glob would
45
+ // match. Returns whether it wrote.
46
+ export function ensureVisibleGlob(deckDir, glob, probe) {
47
+ const file = path.join(deckDir, 'castle.json');
48
+ let data;
49
+ try {
50
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ }
52
+ catch {
53
+ return false; // no castle.json yet (deck never saved) -> treat as not curated
54
+ }
55
+ const visible = data.editor && Array.isArray(data.editor.visiblePaths)
56
+ ? data.editor.visiblePaths.filter((v) => typeof v === 'string')
57
+ : null;
58
+ if (!visible || visible.length === 0)
59
+ return false; // not curated -> all visible
60
+ if (visible.includes(glob))
61
+ return false;
62
+ if (picomatch(visible)(probe))
63
+ return false; // already covered
64
+ visible.push(glob);
65
+ data.editor.visiblePaths = visible;
66
+ try {
67
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
68
+ return true;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
@@ -0,0 +1,13 @@
1
+ import { type PlaytestBrowserManager } from "./native/playtest-browser.js";
2
+ export interface HeadlessCoverResult {
3
+ ok: boolean;
4
+ outPath?: string;
5
+ error?: string;
6
+ installedMs?: number;
7
+ }
8
+ export declare function captureCoverHeadless(opts: {
9
+ serveUrl: string;
10
+ outPath: string;
11
+ manager?: PlaytestBrowserManager;
12
+ onProgress?: (message: string) => void;
13
+ }): Promise<HeadlessCoverResult>;
@@ -0,0 +1,144 @@
1
+ // Capture a deck's cover with a real browser instead of asking the page to
2
+ // draw itself.
3
+ //
4
+ // The in-page path (the SDK's html2canvas compositing) re-implements CSS
5
+ // rendering in JS, so it inherits a support list: a WebGL canvas is blank
6
+ // unless the deck happened to pass `preserveDrawingBuffer`, and any element
7
+ // using `filter` / `clip-path` / `mask-image` / a blend mode is dropped. Both
8
+ // fail SILENTLY -- the capture succeeds and returns a plausible image that is
9
+ // missing part of the game -- and both put the burden on the deck to know a
10
+ // platform quirk.
11
+ //
12
+ // Screenshotting through the browser removes that whole class: the engine
13
+ // composites the frame it was already going to composite, so whatever the deck
14
+ // legitimately renders is what lands in the cover. This is the same move the
15
+ // mobile app made for its exp-web snapshots (WKWebView takeSnapshot / PixelCopy)
16
+ // -- ask the platform for its pixels rather than re-deriving them.
17
+ //
18
+ // It buys correctness, not judgment: a cold headless load sees the deck's FIRST
19
+ // moments, where the in-page path saw whatever the creator had on screen. That
20
+ // is why this is the fallback for an unattended save rather than a replacement
21
+ // for the editor's capture button -- see `SETTLE_MS`.
22
+ import fs from "fs";
23
+ import path from "path";
24
+ import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
25
+ // The card is 5:7; this is the same fixed viewport the playtest tool uses, so a
26
+ // deck laid out for one is laid out for the other.
27
+ const VIEWPORT = { width: 500, height: 700 };
28
+ // Retina-ish, so a cover holds up scaled down in a feed and blown up on a deck
29
+ // page. 2x of a 500x700 card is 1000x1400.
30
+ const SCALE = 2;
31
+ const NAV_TIMEOUT_MS = 20_000;
32
+ // Long enough for a deck to boot, load assets and render real frames -- a cover
33
+ // of frame zero is a loading screen. Deliberately generous: this runs once per
34
+ // save, never in a player's path.
35
+ const SETTLE_MS = 3_500;
36
+ // A ceiling on the whole capture, because this runs inside somebody's "Push to
37
+ // Castle". The steps below are individually bounded, but a browser is a big
38
+ // dependency and this is a cover -- nothing here is worth making a creator
39
+ // watch a spinner for. Whatever is unfinished at this point is abandoned and
40
+ // the save proceeds without a cover.
41
+ const CAPTURE_BUDGET_MS = 60_000;
42
+ // The card the SDK mounts (`initCard`) or a deck marks itself. Falling back to
43
+ // the whole viewport keeps a deck that does neither from getting no cover at
44
+ // all -- the viewport IS the card in play mode.
45
+ const CARD_SELECTOR = "#castle-card, [data-castle-card]";
46
+ // Grab the deck's cover from `serveUrl` and write it to `<projectDir>/.castle`.
47
+ // Never throws; a failure is reported, not raised.
48
+ export async function captureCoverHeadless(opts) {
49
+ // A manager we made is a manager we must close. `withBrowser` deliberately
50
+ // leaves the browser RUNNING so the next call reuses it -- right for the
51
+ // agent, which holds one manager for its whole session, and fatal here:
52
+ // `save-deck` is one-shot, and a live browser connection keeps node's event
53
+ // loop alive, so the command finishes its work and then never exits. The
54
+ // caller waits out its own timeout on a process that is done. (The manager's
55
+ // idle timer is unref'd precisely so it isn't what holds a one-shot open --
56
+ // the browser connection is.)
57
+ //
58
+ // A manager passed IN belongs to the caller; closing it would kill a browser
59
+ // they are still using.
60
+ const owned = !opts.manager;
61
+ const manager = opts.manager ?? createPlaytestBrowserManager();
62
+ try {
63
+ const outcome = await withBudget(manager.withBrowser((browser) => shoot(browser, opts.serveUrl), {
64
+ onProgress: opts.onProgress,
65
+ }), CAPTURE_BUDGET_MS);
66
+ if (!outcome)
67
+ return { ok: false, error: `capture gave up after ${CAPTURE_BUDGET_MS / 1000}s` };
68
+ if (!outcome.ok)
69
+ return { ok: false, error: outcome.error };
70
+ if (!outcome.value.png) {
71
+ return { ok: false, error: outcome.value.error ?? "capture produced no image" };
72
+ }
73
+ try {
74
+ fs.mkdirSync(path.dirname(opts.outPath), { recursive: true });
75
+ fs.writeFileSync(opts.outPath, outcome.value.png);
76
+ }
77
+ catch (e) {
78
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
79
+ }
80
+ return { ok: true, outPath: opts.outPath, installedMs: outcome.installedMs };
81
+ }
82
+ finally {
83
+ if (owned)
84
+ await manager.shutdown().catch(() => undefined);
85
+ }
86
+ }
87
+ // Resolve with `work`, or with null if the budget runs out first. The loser is
88
+ // abandoned rather than cancelled -- the shutdown in the caller's `finally` is
89
+ // what actually stops the browser it was using.
90
+ function withBudget(work, ms) {
91
+ return new Promise((resolve) => {
92
+ const timer = setTimeout(() => resolve(null), ms);
93
+ timer.unref?.();
94
+ void work.then((v) => {
95
+ clearTimeout(timer);
96
+ resolve(v);
97
+ }, () => {
98
+ clearTimeout(timer);
99
+ resolve(null);
100
+ });
101
+ });
102
+ }
103
+ async function shoot(browser, serveUrl) {
104
+ const context = await browser.newContext({
105
+ viewport: { ...VIEWPORT },
106
+ deviceScaleFactor: SCALE,
107
+ // A cover is a still of a game: give it a touch-shaped context, since decks
108
+ // are phone-first and some branch their controls on it.
109
+ hasTouch: true,
110
+ isMobile: true,
111
+ });
112
+ try {
113
+ const page = await context.newPage();
114
+ // `/index.html`, NOT `/`: the serve's root is the editor shell, and the deck
115
+ // itself is what the shell loads into its iframe (see serve.ts). Pointing at
116
+ // `/` photographs the editor. `?edit=0` then asks for the play surface, so
117
+ // the deck renders in its card with no editor UI -- which is what a cover is.
118
+ const url = `${serveUrl.replace(/\/$/, "")}/index.html?edit=0`;
119
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
120
+ // Let it boot and actually draw. `waitForTimeout` rather than a load event:
121
+ // a deck is a game, and "loaded" says nothing about whether it has rendered
122
+ // anything worth showing.
123
+ await page.waitForTimeout(SETTLE_MS);
124
+ // A deck that failed to build renders vite's error overlay, and a browser
125
+ // screenshots that just as faithfully as it screenshots a game -- so without
126
+ // this, a broken deck publishes a cover of its own stack trace. The in-page
127
+ // path could not do this (nothing renders, so nothing is captured), so it is
128
+ // a failure mode this approach introduces and has to close itself.
129
+ const broken = await page.$("vite-error-overlay");
130
+ if (broken)
131
+ return { error: "the deck is showing a build error, so there is nothing to photograph" };
132
+ const card = await page.$(CARD_SELECTOR);
133
+ // Element screenshot clips to the card for us. Without a card, the viewport
134
+ // IS the card in play mode, so shoot that.
135
+ const png = card ? await card.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
136
+ return { png };
137
+ }
138
+ catch (e) {
139
+ return { error: e instanceof Error ? e.message : String(e) };
140
+ }
141
+ finally {
142
+ await context.close().catch(() => undefined);
143
+ }
144
+ }
package/dist/ide.js CHANGED
@@ -16,6 +16,7 @@ import headlessPkg from '@xterm/headless';
16
16
  import { SerializeAddon } from '@xterm/addon-serialize';
17
17
  import { WebSocketServer } from 'ws';
18
18
  import { IMPORTS_DIR, importStatuses, updateImport } from './imports.js';
19
+ import { ensureVisibleGlob, ensureVisiblePath } from './castleJson.js';
19
20
  import { readEditorConfig, resolveFileTypes, } from './editorConfig.js';
20
21
  import { UNSUPPORTED_MEDIA } from './unsupportedMedia.js';
21
22
  import { IMPORT_API_PREFIX, handleImportApi } from './importBrowse.js';
@@ -137,10 +138,17 @@ export const FAVICON_LINK_TAGS = [
137
138
  // hands those files to the kit iframe and keeps the builtin editor as the
138
139
  // default for everything else).
139
140
  export const FILES_API_PREFIX = '/__castle/files/';
140
- // The deck's local cover image. Its own endpoint rather than a files/upload,
141
- // because this file is written over and over (once per automatic capture) and
142
- // upload deliberately refuses to clobber. A cover is platform vocabulary, not a
143
- // kit's -- `save-deck` is what publishes preview.png -- so it belongs here.
141
+ // The deck's local cover image. Nothing in THIS shell posts here any more --
142
+ // covers are captured headlessly by `save-deck` (see headlessCover.ts), which
143
+ // photographs the deck with a real browser instead of asking the page to
144
+ // rasterize itself. The endpoint stays because the shell is baked into the
145
+ // sandbox image: a warm sandbox keeps running an older shell that still posts
146
+ // its automatic captures here, and accepting them is cheaper than making those
147
+ // requests 404. `save-deck` still reads preview.png as a last-resort fallback.
148
+ //
149
+ // Its own endpoint rather than a files/upload because this file is written over
150
+ // and over and upload deliberately refuses to clobber. A cover is platform
151
+ // vocabulary, not a kit's, so it belongs here.
144
152
  export const COVER_API_PATH = '/__castle/cover';
145
153
  // Re-exported so `save-deck` and anything else that already asks the serve for
146
154
  // the cover's name keeps working.
@@ -437,44 +445,6 @@ function handleFilesWrite(deckDir, req, res) {
437
445
  }
438
446
  });
439
447
  }
440
- // Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
441
- // newly-made folder show in the curated Files tree.
442
- function ensureVisiblePath(deckDir, rel) {
443
- return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
444
- }
445
- // Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
446
- // ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
447
- // everything is visible, and adding a glob would wrongly start hiding things.
448
- // No-op if an existing glob already covers `probe`, a path the new glob would
449
- // match. Returns whether it wrote.
450
- function ensureVisibleGlob(deckDir, glob, probe) {
451
- const file = path.join(deckDir, 'castle.json');
452
- let data;
453
- try {
454
- data = JSON.parse(fs.readFileSync(file, 'utf8'));
455
- }
456
- catch {
457
- return false; // no castle.json yet (deck never saved) -> treat as not curated
458
- }
459
- const visible = data.editor && Array.isArray(data.editor.visiblePaths)
460
- ? data.editor.visiblePaths.filter((v) => typeof v === 'string')
461
- : null;
462
- if (!visible || visible.length === 0)
463
- return false; // not curated -> all visible
464
- if (visible.includes(glob))
465
- return false;
466
- if (picomatch(visible)(probe))
467
- return false; // already covered
468
- visible.push(glob);
469
- data.editor.visiblePaths = visible;
470
- try {
471
- fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
472
- return true;
473
- }
474
- catch {
475
- return false;
476
- }
477
- }
478
448
  function handleFilesMkdir(deckDir, req, res) {
479
449
  withMutationPath(deckDir, req, res, (target) => {
480
450
  try {
package/dist/imports.js CHANGED
@@ -5,7 +5,7 @@ import * as api from './api.js';
5
5
  import { runTar } from './save-deck.js';
6
6
  import { normalizeDeckPackageJson } from './normalize.js';
7
7
  import { getKitsDir } from './localPaths.js';
8
- import { readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
8
+ import { ensureVisibleGlob, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
9
9
  // Adding another deck as a dependency (`castle-web add-import`; removing one is a
10
10
  // later command). Deliberately NOT `get-deck`: that one
11
11
  // replaces THIS deck's own source from the server (and carries guards for the
@@ -485,7 +485,7 @@ export async function addImportTo(dir, options = {}) {
485
485
  const declaredMain = tryReadCastleJson(importDir)?.main;
486
486
  let adoptedMain = null;
487
487
  if (typeof declaredMain === 'string' && declaredMain.trim()) {
488
- const { isPristineBareDeck, adoptImportEntry, writeStarterScene } = await import('./init.js');
488
+ const { isPristineBareDeck, adoptImportEntry, writeStarterScene, writeStarterTheme } = await import('./init.js');
489
489
  if (isPristineBareDeck(targetDir)) {
490
490
  adoptImportEntry(targetDir, alias, declaredMain.trim());
491
491
  adoptedMain = declaredMain.trim();
@@ -500,6 +500,14 @@ export async function addImportTo(dir, options = {}) {
500
500
  console.warn(`Adopted ${alias} but could not write its starter scene: ${e instanceof Error ? e.message : String(e)}`);
501
501
  }
502
502
  }
503
+ // Same for the kit's starter theme.style (skips on its own if the deck
504
+ // already has one or the kit ships none).
505
+ try {
506
+ writeStarterTheme(targetDir, importDir);
507
+ }
508
+ catch (e) {
509
+ console.warn(`Adopted ${alias} but could not write its starter theme.style: ${e instanceof Error ? e.message : String(e)}`);
510
+ }
503
511
  }
504
512
  }
505
513
  // An import's packages have to be ON DISK, not just named in package.json:
@@ -557,9 +565,41 @@ export async function addImport(dir, options = {}) {
557
565
  // `update-import` and the serve-start auto update come through here, so there
558
566
  // is one path that replaces an import's files rather than two that have to be
559
567
  // kept in step. `log` is how the caller labels the lines it produces.
568
+ // A kit update can introduce deck-level starter content that an existing deck
569
+ // predates. Same additive spirit as syncImportDependencies (which adds a kit's
570
+ // new package deps to the deck's own package.json on update): seed the kit's
571
+ // `theme.style` at the deck root when the deck has none. The engine reads it
572
+ // from the deck root only -- it is the creator's file, like scenes/ -- so
573
+ // without this, decks scaffolded before the kit shipped one would never get
574
+ // it. Never overwrites: a deck that has the file keeps it, whatever the kit
575
+ // ships. Also registers the file in a curated deck's editor.visiblePaths so it
576
+ // shows in the Files tree (the same courtesy mkdir/upload extend).
577
+ function seedKitTheme(deckDir, alias, log) {
578
+ const importDir = path.join(deckDir, IMPORTS_DIR, alias);
579
+ // Only kits (imports that declare an entry in castle.json `main`) seed deck
580
+ // content -- a plain deck import that happens to carry a theme.style of its
581
+ // own must not set this deck's palette.
582
+ const main = tryReadCastleJson(importDir)?.main;
583
+ if (typeof main !== 'string' || !main.trim())
584
+ return;
585
+ const src = path.join(importDir, 'theme.style');
586
+ const dest = path.join(deckDir, 'theme.style');
587
+ if (!fs.existsSync(src) || fs.existsSync(dest))
588
+ return;
589
+ try {
590
+ fs.copyFileSync(src, dest);
591
+ }
592
+ catch (e) {
593
+ console.warn(`Could not seed theme.style from ${alias}: ${e instanceof Error ? e.message : String(e)}`);
594
+ return;
595
+ }
596
+ ensureVisibleGlob(deckDir, 'theme.style', 'theme.style');
597
+ log(`Seeded theme.style from ${alias} (new in this version; the deck's to edit)`);
598
+ }
560
599
  async function applyImportUpdate(deckDir, alias, update, source, log) {
561
600
  await placeImport(deckDir, alias, update.deckId, source, update.via);
562
601
  log(`Updated ${alias} (${update.from} -> ${source.updatedAt})`);
602
+ seedKitTheme(deckDir, alias, log);
563
603
  // Its own dependencies may have moved too.
564
604
  const transitive = await addTransitiveImports(deckDir, alias, importedDeckIds(deckDir));
565
605
  for (const t of transitive)
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { addImport, updateImport } from './imports.js';
9
9
  import { listDecks } from './list-decks.js';
10
10
  import { getCliVersion, init } from './init.js';
11
11
  import { install } from './install.js';
12
- import { connectWS, savePreviewImage, takeScreenshot } from './preview.js';
12
+ import { connectWS, takeScreenshot } from './preview.js';
13
13
  import { listVersions, restoreVersion, saveVersion, showVersion } from './versions.js';
14
14
  const args = process.argv.slice(2);
15
15
  const command = args[0];
@@ -91,7 +91,6 @@ function usage() {
91
91
  castle-web serve [dir] [--port PORT] [--host HOST] [--open] [--detach]
92
92
  castle-web restart [--port PORT]
93
93
  castle-web screenshot [--out FILE] [--port PORT]
94
- castle-web save-preview-image [dir] [--port PORT] [--no-restart]
95
94
  castle-web save-deck [dir] [--title TITLE] [--caption TEXT] [--visibility unlisted|private]
96
95
  castle-web get-deck [dir] [--deck-id ID] [--force] (replaces the source if the deck is already there)
97
96
  castle-web list-decks [dir] [--kind mine|saved|kits] (decks this one could import; ids are what add-import takes)
@@ -242,13 +241,6 @@ async function main() {
242
241
  console.log(`Saved ${outFile}`);
243
242
  return;
244
243
  }
245
- case 'save-preview-image': {
246
- const dir = findPositionalDir();
247
- const wsPort = getWsPort(dir);
248
- const noRestart = args.includes('--no-restart');
249
- await savePreviewImage(dir, wsPort, noRestart);
250
- return;
251
- }
252
244
  case 'login':
253
245
  await login();
254
246
  break;
package/dist/init.d.ts CHANGED
@@ -9,6 +9,7 @@ export declare function isPristineBareDeck(dir: string): boolean;
9
9
  export declare function adoptImportEntry(deckDir: string, alias: string, mainFile: string): void;
10
10
  export declare function getCliVersion(): string;
11
11
  export declare function writeStarterScene(projectDir: string, kitDir: string, alias: string): void;
12
+ export declare function writeStarterTheme(projectDir: string, kitDir: string): void;
12
13
  export declare function init(dir: string, opts?: {
13
14
  kit?: string;
14
15
  serve?: boolean;
package/dist/init.js CHANGED
@@ -38,7 +38,7 @@ const DEFAULT_KIT = 'physics-2d';
38
38
  // Registry version of castle-web-sdk to inject when scaffolding from a
39
39
  // globally-installed castle-web (not from inside the workspace). Bumped
40
40
  // alongside cli/sdk version bumps.
41
- const PUBLISHED_SDK_VERSION = '0.4.13';
41
+ const PUBLISHED_SDK_VERSION = '0.4.14';
42
42
  // The account the first-party kits are (to be) published under, so a kit
43
43
  // imported from the CLI's copy is named the same as one fetched from the server.
44
44
  const KIT_AUTHOR = 'castle';
@@ -281,6 +281,19 @@ export function writeStarterScene(projectDir, kitDir, alias) {
281
281
  fs.mkdirSync(path.join(projectDir, 'scenes'), { recursive: true });
282
282
  writeJsonFile(path.join(projectDir, 'scenes', 'main.scene'), scene ? rewrite(scene) : { name: 'Main', actors: [] });
283
283
  }
284
+ // A kit may ship a starter `theme.style` (deck-level styling, e.g. the color
285
+ // palette). Like the starter scene, it is deck content: the engine reads it
286
+ // from the DECK root, and it is the creator's to edit -- so it gets seeded once
287
+ // at scaffold time rather than living read-only in the import. Copied verbatim
288
+ // (its values are palette ids/hexes, never kit file paths, so no ref
289
+ // rewriting). Exported for the same two scaffold paths as writeStarterScene.
290
+ export function writeStarterTheme(projectDir, kitDir) {
291
+ const src = path.join(kitDir, 'theme.style');
292
+ const dest = path.join(projectDir, 'theme.style');
293
+ if (!fs.existsSync(src) || fs.existsSync(dest))
294
+ return;
295
+ fs.copyFileSync(src, dest);
296
+ }
284
297
  function makeImportedKitClaudeMd(alias) {
285
298
  return `# Castle deck
286
299
 
@@ -354,6 +367,7 @@ function scaffoldFromKitImport(kit, projectDir) {
354
367
  const title = typeof kitConfig.title === 'string' ? kitConfig.title : kit;
355
368
  writeDeckIndexHtml(projectDir, alias, title, deckMainFile(kitDir));
356
369
  writeStarterScene(projectDir, kitDir, alias);
370
+ writeStarterTheme(projectDir, kitDir);
357
371
  // The deck's own castle.json: the kit's editor config (panel layout, file
358
372
  // filters) as a starting point -- it is the deck's to edit from here -- plus
359
373
  // the import pin.
package/dist/preview.d.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  import type { WebSocket as WSClient } from 'ws';
2
2
  export declare function connectWS(wsPort: number): Promise<WSClient>;
3
3
  export declare function takeScreenshot(ws: WSClient): Promise<string>;
4
- export declare function savePreviewImage(dir: string, wsPort: number, noRestart?: boolean): Promise<void>;
package/dist/preview.js CHANGED
@@ -1,7 +1,3 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
- import * as api from './api.js';
4
- import * as config from './config.js';
5
1
  export async function connectWS(wsPort) {
6
2
  const { default: WS } = await import('ws');
7
3
  return new Promise((resolve, reject) => {
@@ -45,57 +41,3 @@ export function takeScreenshot(ws) {
45
41
  ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
46
42
  });
47
43
  }
48
- function waitForRestart(ws) {
49
- return new Promise((resolve) => {
50
- ws.send(JSON.stringify({ type: 'restart' }));
51
- setTimeout(resolve, 2000);
52
- });
53
- }
54
- // The shared declaration (castleJson.ts) rather than a local copy of the two
55
- // fields this file reads, so it can't drift from what the rest of the CLI
56
- // believes castle.json contains.
57
- function readCastleJson(projectDir) {
58
- const castleJsonPath = path.join(projectDir, 'castle.json');
59
- if (!fs.existsSync(castleJsonPath))
60
- return null;
61
- return JSON.parse(fs.readFileSync(castleJsonPath, 'utf-8'));
62
- }
63
- async function captureAndUpload(projectDir, ws, castleJson) {
64
- // A deck has no cardId until its first save, and the cover IS that card's
65
- // background -- so there is nothing to set it on yet. Both callers reach here
66
- // right after a save, so this is the never-saved edge, not the normal path.
67
- // Reported back so a caller doesn't record a cover that was never set.
68
- const { cardId } = castleJson;
69
- if (!cardId) {
70
- ws.close();
71
- console.log('Preview skipped: save the deck first.');
72
- return false;
73
- }
74
- const base64 = await takeScreenshot(ws);
75
- ws.close();
76
- fs.writeFileSync(path.join(projectDir, 'preview.png'), Buffer.from(base64, 'base64'));
77
- console.log('Saved preview.png');
78
- const file = await api.uploadBase64(base64, 'preview.png');
79
- await api.updateCardCustomBackgroundImage(cardId, file.fileId);
80
- console.log('Set deck preview image.');
81
- return true;
82
- }
83
- export async function savePreviewImage(dir, wsPort, noRestart = false) {
84
- const projectDir = path.resolve(dir);
85
- const castleJson = readCastleJson(projectDir);
86
- if (!castleJson) {
87
- console.error('No castle.json found. Save the deck first.');
88
- process.exit(1);
89
- }
90
- if (!config.getToken()) {
91
- console.error('Not logged in. Run `castle-web login` first.');
92
- process.exit(1);
93
- }
94
- const ws = await connectWS(wsPort);
95
- if (!noRestart)
96
- await waitForRestart(ws);
97
- // Asking for this by name is choosing a cover, so it goes to the card's
98
- // CUSTOM slot -- the one that takes precedence over the cover a save
99
- // publishes. Clearing it is an editor action (Deck settings -> Clear cover).
100
- await captureAndUpload(projectDir, ws, castleJson);
101
- }
package/dist/save-deck.js CHANGED
@@ -79,8 +79,59 @@ async function uploadSource(projectDir, deckId) {
79
79
  //
80
80
  // Never fatal: a deck that saves but whose cover upload failed is a deck that
81
81
  // saved. Losing the push over a thumbnail would be the wrong trade.
82
+ // The deck's own serve, if one is running -- the URL a headless cover capture
83
+ // loads. No serve means no deck to photograph, which is a normal state (a save
84
+ // from a script), not an error.
85
+ function runningServeUrl(projectDir) {
86
+ try {
87
+ const raw = fs.readFileSync(path.join(projectDir, '.castle', 'serve.json'), 'utf8');
88
+ const port = JSON.parse(raw).port;
89
+ return typeof port === 'number' ? `http://localhost:${port}` : null;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ // Photograph the running deck with a real browser, into a scratch file.
96
+ //
97
+ // This OUTRANKS the editor's `preview.png`, and deliberately: both are the
98
+ // AUTOMATIC cover (a creator's hand-picked one lives in the card's custom slot
99
+ // and outranks both), so the question is only which automatic capture is better
100
+ // -- and the in-page one silently drops a WebGL canvas that lacks
101
+ // `preserveDrawingBuffer`, plus anything using `filter`/`clip-path`/`mask`. A
102
+ // browser compositing its own frame has no such list.
103
+ //
104
+ // `preview.png` does have one real advantage -- it is whatever the creator last
105
+ // had on screen, where this is a cold load -- so it stays the fallback. But a
106
+ // cover that is missing the game beats a cover of the opening seconds only if
107
+ // you never look at it, and nobody looks at an unattended capture. Writing to a
108
+ // scratch file rather than over `preview.png` keeps that fallback intact.
109
+ async function captureAutomaticCover(projectDir) {
110
+ const serveUrl = runningServeUrl(projectDir);
111
+ if (!serveUrl)
112
+ return null;
113
+ const outPath = path.join(projectDir, '.castle', 'headless-cover.png');
114
+ const { captureCoverHeadless } = await import('./headlessCover.js');
115
+ const result = await captureCoverHeadless({
116
+ serveUrl,
117
+ outPath,
118
+ onProgress: (message) => console.log(message),
119
+ });
120
+ if (!result.ok) {
121
+ // Never fatal, same as the upload below: a deck that saved without a cover
122
+ // is a deck that saved.
123
+ console.log(`Cover capture skipped: ${result.error}`);
124
+ return null;
125
+ }
126
+ return outPath;
127
+ }
82
128
  async function uploadLocalCover(projectDir) {
83
- const coverPath = path.join(projectDir, COVER_FILE);
129
+ const captured = await captureAutomaticCover(projectDir);
130
+ const coverPath = captured ?? path.join(projectDir, COVER_FILE);
131
+ if (captured)
132
+ console.log('Captured a cover from the running deck.');
133
+ else if (fs.existsSync(coverPath))
134
+ console.log('Using the cover captured in the editor.');
84
135
  if (!fs.existsSync(coverPath))
85
136
  return null;
86
137
  try {
package/dist/serve.js CHANGED
@@ -239,6 +239,22 @@ export async function serve(dir, options = {}) {
239
239
  console.warn(`auto-update: skipped -- ${e instanceof Error ? e.message : String(e)}`);
240
240
  });
241
241
  }
242
+ // Start pulling the headless browser now, in the background.
243
+ //
244
+ // `save-deck` photographs the deck with it to make the deck's cover, and the
245
+ // binary is a ~250MB one-time download. Left lazy, that download lands inside
246
+ // the creator's first "Push to Castle" -- the one moment they are watching a
247
+ // spinner and least want an unexplained wait. A serve means someone is editing
248
+ // a deck they will probably push, so this is the cheapest place to get ahead
249
+ // of it.
250
+ //
251
+ // Fire-and-forget and single-flight: it shares one download with the agent's
252
+ // playtest tool, so this is free when that already pulled it, and every
253
+ // failure is swallowed -- the capture re-detects and reports properly at the
254
+ // point it actually matters.
255
+ void import('./native/playtest-browser.js')
256
+ .then((m) => m.createPlaytestBrowserManager().prewarm())
257
+ .catch(() => undefined);
242
258
  // Close stdin when not attached to a TTY so the process can be cleanly
243
259
  // backgrounded with `&` (otherwise stdin reads can block or hold the parent).
244
260
  if (!process.stdin.isTTY) {