@caperjs/core 0.6.1 → 0.7.0

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 (53) hide show
  1. package/build/index.d.mts +6 -0
  2. package/build/index.mjs +7 -0
  3. package/build/plugins/solid.mjs +30 -0
  4. package/cli/doctor.mjs +128 -0
  5. package/cli/doctor.test.mjs +154 -0
  6. package/extras/llms.txt +192 -4
  7. package/extras/skills/caper/SKILL.md +51 -3
  8. package/lib/{CaptionsPlugin-BtVJiQs_.js → CaptionsPlugin-Boj-gm3A.js} +5 -5
  9. package/lib/{CaptionsPlugin-BtVJiQs_.js.map → CaptionsPlugin-Boj-gm3A.js.map} +1 -1
  10. package/lib/{DataAdapter-BtNz4yi2.js → DataAdapter-Cra9dJ6W.js} +9 -9
  11. package/lib/{DataAdapter-BtNz4yi2.js.map → DataAdapter-Cra9dJ6W.js.map} +1 -1
  12. package/lib/{DebugRenderer-DK8FAED1.js → DebugRenderer-19S58S4h.js} +2 -2
  13. package/lib/{DebugRenderer-DK8FAED1.js.map → DebugRenderer-19S58S4h.js.map} +1 -1
  14. package/lib/{DevToolsPlugin-BS9idGRi.js → DevToolsPlugin-BTSkRsdS.js} +2 -2
  15. package/lib/{DevToolsPlugin-BS9idGRi.js.map → DevToolsPlugin-BTSkRsdS.js.map} +1 -1
  16. package/lib/{GSAPPlugin-DVDbDVP8.js → GSAPPlugin-BcL7vjyw.js} +5 -5
  17. package/lib/{GSAPPlugin-DVDbDVP8.js.map → GSAPPlugin-BcL7vjyw.js.map} +1 -1
  18. package/lib/{LayoutPlugin-qQJ5qJud.js → LayoutPlugin-BDhSngz_.js} +4 -4
  19. package/lib/{LayoutPlugin-qQJ5qJud.js.map → LayoutPlugin-BDhSngz_.js.map} +1 -1
  20. package/lib/ScreenDebugPlugin-C6pxs_3O.js +103 -0
  21. package/lib/ScreenDebugPlugin-C6pxs_3O.js.map +1 -0
  22. package/lib/{SpinePlugin-CPNRM2J1.js → SpinePlugin-CrQhaK6r.js} +2 -2
  23. package/lib/{SpinePlugin-CPNRM2J1.js.map → SpinePlugin-CrQhaK6r.js.map} +1 -1
  24. package/lib/{StatsPlugin-BHihicBl.js → StatsPlugin-Cu625dmk.js} +2 -2
  25. package/lib/{StatsPlugin-BHihicBl.js.map → StatsPlugin-Cu625dmk.js.map} +1 -1
  26. package/lib/{VoiceOverPlugin-BdTHxgku.js → VoiceOverPlugin-D4rfHFyJ.js} +5 -5
  27. package/lib/{VoiceOverPlugin-BdTHxgku.js.map → VoiceOverPlugin-D4rfHFyJ.js.map} +1 -1
  28. package/lib/caper.mjs +98 -97
  29. package/lib/caper.mjs.map +1 -1
  30. package/lib/{const-BQItSP39.js → const-BQ2iMCtc.js} +180 -180
  31. package/lib/{const-BQItSP39.js.map → const-BQ2iMCtc.js.map} +1 -1
  32. package/lib/core/Application.d.ts.map +1 -1
  33. package/lib/plugins/ScreenDebugPlugin.d.ts +22 -0
  34. package/lib/plugins/ScreenDebugPlugin.d.ts.map +1 -0
  35. package/lib/plugins/ScreenDebugPlugin.test.d.ts +2 -0
  36. package/lib/plugins/ScreenDebugPlugin.test.d.ts.map +1 -0
  37. package/lib/plugins/index.d.ts +2 -0
  38. package/lib/plugins/index.d.ts.map +1 -1
  39. package/lib/plugins/screenDebugFlag.d.ts +2 -0
  40. package/lib/plugins/screenDebugFlag.d.ts.map +1 -0
  41. package/lib/plugins/screenDebugFlag.test.d.ts +2 -0
  42. package/lib/plugins/screenDebugFlag.test.d.ts.map +1 -0
  43. package/lib/{registries-DlqHRO90.js → registries-BmDw_GV1.js} +237 -221
  44. package/lib/registries-BmDw_GV1.js.map +1 -0
  45. package/package.json +1 -1
  46. package/src/core/Application.ts +8 -0
  47. package/src/plugins/ScreenDebugPlugin.test.ts +61 -0
  48. package/src/plugins/ScreenDebugPlugin.ts +174 -0
  49. package/src/plugins/index.ts +2 -0
  50. package/src/plugins/screenDebugFlag.test.ts +45 -0
  51. package/src/plugins/screenDebugFlag.ts +26 -0
  52. package/src/version.ts +1 -1
  53. package/lib/registries-DlqHRO90.js.map +0 -1
package/build/index.d.mts CHANGED
@@ -19,6 +19,12 @@ export interface CaperOptions {
19
19
  autoRegister?: boolean;
20
20
  update?: 'prompt' | 'auto' | 'manual';
21
21
  };
22
+ /**
23
+ * Compile `.tsx` with `@caperjs/solid`'s Solid JSX plugin, resolved from the
24
+ * app's own `node_modules`. Absent or `false` imports nothing. An object
25
+ * forwards `include` (default `['**\/*.tsx']`).
26
+ */
27
+ solid?: boolean | { include?: string[] };
22
28
  }
23
29
 
24
30
  /**
package/build/index.mjs CHANGED
@@ -25,6 +25,7 @@ import { entityListPlugin, pluginListPlugin, popupListPlugin, sceneListPlugin, u
25
25
  import { pngFallbackPrunePlugin } from './plugins/pruneFallbacks.mjs';
26
26
  import { caperPwaPlugins } from './plugins/pwa.mjs';
27
27
  import { createCaperRuntimePlugin } from './plugins/runtime.mjs';
28
+ import { caperSolidPlugin } from './plugins/solid.mjs';
28
29
  import { createCaperViewportPlugin } from './plugins/viewport.mjs';
29
30
 
30
31
  const buildFlags = readCaperBuildFlags();
@@ -96,6 +97,9 @@ function caperPluginList({ assets = {}, pwa } = {}) {
96
97
  * DOM update banner; 'auto' reloads the page as soon as a new build lands;
97
98
  * 'manual' installs no UI at all and leaves it to the game, which listens to
98
99
  * `app.onPwaUpdateAvailable`).
100
+ * @property {boolean|{include?: string[]}} [solid] Compile `.tsx` with
101
+ * `@caperjs/solid`'s Solid JSX plugin. Absent or `false` imports nothing.
102
+ * An object forwards `include` (default `['**\/*.tsx']`).
99
103
  */
100
104
 
101
105
  /**
@@ -108,6 +112,9 @@ export function caper(options = {}) {
108
112
  name: 'caper:defaults',
109
113
  config: (userConfig, env) => caperDefaults(userConfig, env),
110
114
  },
115
+ // Vite awaits promises in the plugins array, so the lazy import stays off
116
+ // `caper()`'s own signature — projects keep writing `plugins: [caper()]`.
117
+ ...(options.solid ? [caperSolidPlugin(options.solid)] : []),
111
118
  ...caperPluginList(options),
112
119
  ];
113
120
  }
@@ -0,0 +1,30 @@
1
+ import { createRequire } from 'node:module';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ /**
6
+ * `caper({ solid: true })` — pull `@caperjs/solid`'s Vite plugin into the preset.
7
+ *
8
+ * The dependency arrow only ever points from the app: core knows the specifier,
9
+ * never the package. Nothing is imported unless the app opts in.
10
+ *
11
+ * Resolution starts at the app root, not at this file. Under pnpm (and any other
12
+ * non-hoisted layout) `@caperjs/solid` is not a sibling of `@caperjs/core`, so a
13
+ * bare `import('@caperjs/solid/vite')` from here would fail even when the app has
14
+ * the package installed.
15
+ *
16
+ * @param {true|{ include?: string[] }} solid The preset's `solid` option.
17
+ * @param {string} [from] Directory to resolve from; the app root by default.
18
+ * @returns {Promise<import('vite').PluginOption>}
19
+ */
20
+ export async function caperSolidPlugin(solid, from = process.cwd()) {
21
+ const req = createRequire(path.join(from, 'package.json'));
22
+ let entry;
23
+ try {
24
+ entry = req.resolve('@caperjs/solid/vite');
25
+ } catch {
26
+ throw new Error('[caper] solid: true requires @caperjs/solid to be installed (pnpm add @caperjs/solid solid-js)');
27
+ }
28
+ const { caperSolid } = await import(pathToFileURL(entry).href);
29
+ return caperSolid(typeof solid === 'object' ? solid : undefined);
30
+ }
package/cli/doctor.mjs CHANGED
@@ -2,6 +2,7 @@ import { dim, green, red, yellow } from 'kleur/colors';
2
2
 
3
3
  import { execFile } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
+ import { createRequire } from 'node:module';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
 
@@ -61,10 +62,110 @@ const npmLatestVersion = (pkg, timeoutMs = 5000) =>
61
62
  });
62
63
  });
63
64
 
65
+ const readJson = (file) => {
66
+ if (!fs.existsSync(file)) return null;
67
+ try {
68
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
69
+ } catch {
70
+ return null;
71
+ }
72
+ };
73
+
74
+ /** tsconfig.json is JSON with comments and trailing commas often enough to matter. */
75
+ const readJsonc = (file) => {
76
+ if (!fs.existsSync(file)) return null;
77
+ try {
78
+ const raw = fs
79
+ .readFileSync(file, 'utf-8')
80
+ .replace(/\/\*[\s\S]*?\*\//g, '')
81
+ .replace(/(^|[^:"'\\])\/\/.*$/gm, '$1')
82
+ .replace(/,(\s*[}\]])/g, '$1');
83
+ return JSON.parse(raw);
84
+ } catch {
85
+ return null;
86
+ }
87
+ };
88
+
64
89
  const push = (checks, id, status, label, hint) => {
65
90
  checks.push({ id, status, label, ...(hint ? { hint } : {}) });
66
91
  };
67
92
 
93
+ /**
94
+ * Resolve a tsconfig `extends` entry to an absolute config file path,
95
+ * TypeScript-style: relative specs resolve against the extending config's
96
+ * directory, everything else resolves node-module-style (bare package name,
97
+ * `+ .json`, `+ /tsconfig.json`) rooted at that same config file so an app's
98
+ * own node_modules is used. Returns null (never throws) when nothing resolves.
99
+ */
100
+ const resolveExtendsSpec = (spec, fromConfigFile) => {
101
+ const fromDir = path.dirname(fromConfigFile);
102
+ const isRelative = spec.startsWith('./') || spec.startsWith('../') || path.isAbsolute(spec);
103
+
104
+ if (isRelative) {
105
+ const base = path.isAbsolute(spec) ? spec : path.resolve(fromDir, spec);
106
+ if (fs.existsSync(base) && fs.statSync(base).isFile()) return base;
107
+ const withJson = base.endsWith('.json') ? base : `${base}.json`;
108
+ return fs.existsSync(withJson) ? withJson : null;
109
+ }
110
+
111
+ const require = createRequire(fromConfigFile);
112
+ const candidates = [spec, `${spec}.json`, `${spec}/tsconfig.json`];
113
+ for (const candidate of candidates) {
114
+ try {
115
+ return require.resolve(candidate);
116
+ } catch {
117
+ // try the next candidate
118
+ }
119
+ }
120
+ // Some bare specs are effectively relative paths, or node-module resolution
121
+ // fails for other plausible reasons — fall back to filesystem paths relative
122
+ // to the extending config's directory before giving up.
123
+ for (const candidate of candidates) {
124
+ const asPath = path.resolve(fromDir, candidate);
125
+ if (fs.existsSync(asPath) && fs.statSync(asPath).isFile()) return asPath;
126
+ }
127
+ return null;
128
+ };
129
+
130
+ /**
131
+ * Effective `compilerOptions` for a tsconfig, walking `extends` (string or
132
+ * array, TS 5 style) like `tsc` does: the own config's fields override
133
+ * wholesale, later array entries beat earlier ones, and each base can itself
134
+ * extend further (recursion). Cycle-guarded per ancestor chain and depth
135
+ * capped; unreadable/unresolvable bases are silently skipped so doctor never
136
+ * crashes on a weird config.
137
+ */
138
+ const resolveEffectiveCompilerOptions = (configFile, ancestors = new Set(), depth = 0) => {
139
+ if (depth > 10) return {};
140
+
141
+ let real;
142
+ try {
143
+ real = fs.realpathSync(configFile);
144
+ } catch {
145
+ real = configFile;
146
+ }
147
+ if (ancestors.has(real)) return {};
148
+
149
+ const config = readJsonc(configFile);
150
+ if (!config) return {};
151
+
152
+ const nextAncestors = new Set(ancestors);
153
+ nextAncestors.add(real);
154
+
155
+ let inherited = {};
156
+ if (config.extends) {
157
+ const specs = Array.isArray(config.extends) ? config.extends : [config.extends];
158
+ for (const spec of specs) {
159
+ const resolved = resolveExtendsSpec(spec, configFile);
160
+ if (!resolved) continue;
161
+ const baseOptions = resolveEffectiveCompilerOptions(resolved, nextAncestors, depth + 1);
162
+ inherited = { ...inherited, ...baseOptions };
163
+ }
164
+ }
165
+
166
+ return { ...inherited, ...(config.compilerOptions ?? {}) };
167
+ };
168
+
68
169
  export async function runChecks(cwd, { online = true } = {}) {
69
170
  const checks = [];
70
171
  const installedVersion = readInstalledVersion();
@@ -169,6 +270,33 @@ export async function runChecks(cwd, { online = true } = {}) {
169
270
  });
170
271
  push(checks, 'peers', peerStatus, `peer deps: ${peerResults.join(', ')}`, peerStatus !== 'ok' ? 'pnpm install' : undefined);
171
272
 
273
+ // Solid JSX only typechecks with three tsconfig settings, and none of them can
274
+ // be defaulted from caper's base config: `jsxFactory` is a type-lookup root
275
+ // that exists because a transitive @types/react would otherwise shadow the
276
+ // global JSX namespace. Apps without @caperjs/solid never see this check.
277
+ const appPkg = readJson(path.join(cwd, 'package.json'));
278
+ const appDeps = { ...appPkg?.dependencies, ...appPkg?.devDependencies };
279
+ if (appDeps['@caperjs/solid']) {
280
+ const tsconfigPath = path.join(cwd, 'tsconfig.json');
281
+ const tsconfig = readJsonc(tsconfigPath);
282
+ if (!tsconfig) {
283
+ push(checks, 'solid-tsconfig', 'fail', 'solid tsconfig unreadable', 'tsconfig.json is missing or not parseable');
284
+ } else {
285
+ const compilerOptions = resolveEffectiveCompilerOptions(tsconfigPath);
286
+ const missing = [];
287
+ if (compilerOptions.jsx !== 'preserve') missing.push('"jsx": "preserve"');
288
+ if (compilerOptions.jsxFactory !== 'CaperJSX.h') missing.push('"jsxFactory": "CaperJSX.h"');
289
+ if (!compilerOptions.types?.includes('@caperjs/solid/jsx')) missing.push('"@caperjs/solid/jsx" in types');
290
+ push(
291
+ checks,
292
+ 'solid-tsconfig',
293
+ missing.length ? 'fail' : 'ok',
294
+ missing.length ? 'solid tsconfig incomplete' : 'solid tsconfig',
295
+ missing.length ? `add ${missing.join(', ')} to tsconfig.json compilerOptions` : undefined,
296
+ );
297
+ }
298
+ }
299
+
172
300
  const caches = ['.assetpack', '.cache', 'dist'].filter((name) => fs.existsSync(path.join(cwd, name)));
173
301
  push(checks, 'caches', 'ok', `caches${caches.length ? `: ${caches.join(', ')}` : ' clean'}`, caches.length ? 'rm -rf them on weird asset/name mismatches' : undefined);
174
302
 
@@ -99,6 +99,160 @@ ${END_MARKER}`;
99
99
  expect(find(checks, 'agent').status).toBe('ok');
100
100
  });
101
101
 
102
+ it('skips the solid tsconfig check when the app does not use @caperjs/solid', async () => {
103
+ const cwd = makeTempDir();
104
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { '@caperjs/core': '^1' } }), 'utf-8');
105
+
106
+ const checks = await runChecks(cwd, { online: false });
107
+
108
+ expect(find(checks, 'solid-tsconfig')).toBeUndefined();
109
+ });
110
+
111
+ it('names every tsconfig setting a solid app is missing', async () => {
112
+ const cwd = makeTempDir();
113
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { '@caperjs/solid': '^1' } }), 'utf-8');
114
+ fs.writeFileSync(path.join(cwd, 'tsconfig.json'), '{\n // solid needs more than this\n "compilerOptions": { "jsx": "preserve" }\n}\n', 'utf-8');
115
+
116
+ const checks = await runChecks(cwd, { online: false });
117
+ const solid = find(checks, 'solid-tsconfig');
118
+
119
+ expect(solid.status).toBe('fail');
120
+ expect(solid.hint).toContain('"jsxFactory": "CaperJSX.h"');
121
+ expect(solid.hint).toContain('"@caperjs/solid/jsx" in types');
122
+ expect(solid.hint).not.toContain('"jsx": "preserve"');
123
+ });
124
+
125
+ it('passes a solid app whose tsconfig has all three settings', async () => {
126
+ const cwd = makeTempDir();
127
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ devDependencies: { '@caperjs/solid': '^1' } }), 'utf-8');
128
+ fs.writeFileSync(
129
+ path.join(cwd, 'tsconfig.json'),
130
+ JSON.stringify({
131
+ compilerOptions: { jsx: 'preserve', jsxFactory: 'CaperJSX.h', types: ['@caperjs/core/client', '@caperjs/solid/jsx'] },
132
+ }),
133
+ 'utf-8',
134
+ );
135
+
136
+ const checks = await runChecks(cwd, { online: false });
137
+
138
+ expect(find(checks, 'solid-tsconfig').status).toBe('ok');
139
+ });
140
+
141
+ it('resolves solid tsconfig settings inherited through a relative extends', async () => {
142
+ const cwd = makeTempDir();
143
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { '@caperjs/solid': '^1' } }), 'utf-8');
144
+ fs.writeFileSync(
145
+ path.join(cwd, 'base.tsconfig.json'),
146
+ JSON.stringify({
147
+ compilerOptions: { jsx: 'preserve', jsxFactory: 'CaperJSX.h', types: ['@caperjs/core/client', '@caperjs/solid/jsx'] },
148
+ }),
149
+ 'utf-8',
150
+ );
151
+ fs.writeFileSync(path.join(cwd, 'tsconfig.json'), JSON.stringify({ extends: './base.tsconfig.json' }), 'utf-8');
152
+
153
+ const checks = await runChecks(cwd, { online: false });
154
+
155
+ expect(find(checks, 'solid-tsconfig').status).toBe('ok');
156
+ });
157
+
158
+ it("lets the app's own tsconfig override a bad base setting", async () => {
159
+ const cwd = makeTempDir();
160
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { '@caperjs/solid': '^1' } }), 'utf-8');
161
+ fs.writeFileSync(
162
+ path.join(cwd, 'base.tsconfig.json'),
163
+ JSON.stringify({
164
+ compilerOptions: { jsx: 'preserve', jsxFactory: 'Wrong.h', types: ['@caperjs/core/client', '@caperjs/solid/jsx'] },
165
+ }),
166
+ 'utf-8',
167
+ );
168
+ fs.writeFileSync(
169
+ path.join(cwd, 'tsconfig.json'),
170
+ JSON.stringify({ extends: './base.tsconfig.json', compilerOptions: { jsxFactory: 'CaperJSX.h' } }),
171
+ 'utf-8',
172
+ );
173
+
174
+ const checks = await runChecks(cwd, { online: false });
175
+
176
+ expect(find(checks, 'solid-tsconfig').status).toBe('ok');
177
+ });
178
+
179
+ it("fails when the app's own tsconfig overrides a good base setting with a bad one", async () => {
180
+ const cwd = makeTempDir();
181
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { '@caperjs/solid': '^1' } }), 'utf-8');
182
+ fs.writeFileSync(
183
+ path.join(cwd, 'base.tsconfig.json'),
184
+ JSON.stringify({
185
+ compilerOptions: { jsx: 'preserve', jsxFactory: 'CaperJSX.h', types: ['@caperjs/core/client', '@caperjs/solid/jsx'] },
186
+ }),
187
+ 'utf-8',
188
+ );
189
+ fs.writeFileSync(
190
+ path.join(cwd, 'tsconfig.json'),
191
+ JSON.stringify({ extends: './base.tsconfig.json', compilerOptions: { jsxFactory: 'Wrong.h' } }),
192
+ 'utf-8',
193
+ );
194
+
195
+ const checks = await runChecks(cwd, { online: false });
196
+ const solid = find(checks, 'solid-tsconfig');
197
+
198
+ expect(solid.status).toBe('fail');
199
+ expect(solid.hint).toContain('"jsxFactory": "CaperJSX.h"');
200
+ });
201
+
202
+ it('resolves solid tsconfig settings from a later entry in an extends array', async () => {
203
+ const cwd = makeTempDir();
204
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { '@caperjs/solid': '^1' } }), 'utf-8');
205
+ fs.writeFileSync(path.join(cwd, 'first.tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true } }), 'utf-8');
206
+ fs.writeFileSync(
207
+ path.join(cwd, 'second.tsconfig.json'),
208
+ JSON.stringify({
209
+ compilerOptions: { jsx: 'preserve', jsxFactory: 'CaperJSX.h', types: ['@caperjs/core/client', '@caperjs/solid/jsx'] },
210
+ }),
211
+ 'utf-8',
212
+ );
213
+ fs.writeFileSync(path.join(cwd, 'tsconfig.json'), JSON.stringify({ extends: ['./first.tsconfig.json', './second.tsconfig.json'] }), 'utf-8');
214
+
215
+ const checks = await runChecks(cwd, { online: false });
216
+
217
+ expect(find(checks, 'solid-tsconfig').status).toBe('ok');
218
+ });
219
+
220
+ it('resolves solid tsconfig settings through a chained extends', async () => {
221
+ const cwd = makeTempDir();
222
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { '@caperjs/solid': '^1' } }), 'utf-8');
223
+ fs.writeFileSync(
224
+ path.join(cwd, 'grandparent.tsconfig.json'),
225
+ JSON.stringify({
226
+ compilerOptions: { jsx: 'preserve', jsxFactory: 'CaperJSX.h', types: ['@caperjs/core/client', '@caperjs/solid/jsx'] },
227
+ }),
228
+ 'utf-8',
229
+ );
230
+ fs.writeFileSync(path.join(cwd, 'parent.tsconfig.json'), JSON.stringify({ extends: './grandparent.tsconfig.json' }), 'utf-8');
231
+ fs.writeFileSync(path.join(cwd, 'tsconfig.json'), JSON.stringify({ extends: './parent.tsconfig.json' }), 'utf-8');
232
+
233
+ const checks = await runChecks(cwd, { online: false });
234
+
235
+ expect(find(checks, 'solid-tsconfig').status).toBe('ok');
236
+ });
237
+
238
+ it('does not throw on a broken extends path and falls back to the own tsconfig', async () => {
239
+ const cwd = makeTempDir();
240
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { '@caperjs/solid': '^1' } }), 'utf-8');
241
+ fs.writeFileSync(
242
+ path.join(cwd, 'tsconfig.json'),
243
+ JSON.stringify({ extends: './does-not-exist.json', compilerOptions: { jsx: 'preserve' } }),
244
+ 'utf-8',
245
+ );
246
+
247
+ const checks = await runChecks(cwd, { online: false });
248
+ const solid = find(checks, 'solid-tsconfig');
249
+
250
+ expect(solid.status).toBe('fail');
251
+ expect(solid.hint).toContain('"jsxFactory": "CaperJSX.h"');
252
+ expect(solid.hint).toContain('"@caperjs/solid/jsx" in types');
253
+ expect(solid.hint).not.toContain('"jsx": "preserve"');
254
+ });
255
+
102
256
  it('reports a linked package missing its build', async () => {
103
257
  const cwd = makeTempDir();
104
258
  const checkout = path.join(cwd, 'checkout');
package/extras/llms.txt CHANGED
@@ -40,7 +40,8 @@ produce working code.
40
40
  15. [Common recipes](#15-common-recipes)
41
41
  16. [Gotchas & verification loop](#16-gotchas--verification-loop)
42
42
  17. [Automation bridge — drive the running app](#17-automation-bridge--drive-the-running-app)
43
- 18. [API reference index](#18-api-reference-index)
43
+ 18. [Declarative layer — `@caperjs/solid`](#18-declarative-layer--caperjssolid)
44
+ 19. [API reference index](#19-api-reference-index)
44
45
 
45
46
  ---
46
47
 
@@ -464,6 +465,12 @@ Every allowed key in `caper.config.ts`. Defer shape details to
464
465
 
465
466
  Plus all standard PixiJS `ApplicationOptions` (`antialias`, `width`, `height`, etc.).
466
467
 
468
+ Screen viewport debugging is available through the built-in `screenDebug` overlay.
469
+ Open the game URL in a normal browser tab with `?caper-screen-debug=1` to enable it.
470
+ Then relaunch the installed PWA; the same-origin setting persists without an address bar.
471
+ Use `?caper-screen-debug=0` in a browser tab to disable it.
472
+ A screenshot containing the overlay and marker lines is the diagnostic artifact.
473
+
467
474
  **Scene/plugin/popup/entity registrations are NOT in this table** — they're
468
475
  discovered from disk. Plugins list above is the one exception.
469
476
 
@@ -1213,6 +1220,9 @@ Each is a separate npm package. Install → list in `plugins: [...]` → use via
1213
1220
  Install via pnpm/npm, then register in `caper.config.ts plugins: [...]`.
1214
1221
  Deep API lives in each plugin's own README on npm.
1215
1222
 
1223
+ `@caperjs/solid` is first-party too but is **not** a plugin — it is the optional
1224
+ declarative view layer and never appears in `plugins: [...]`. See §18.
1225
+
1216
1226
  Canonical registration shape:
1217
1227
 
1218
1228
  ```ts
@@ -1638,7 +1648,180 @@ Rules of thumb:
1638
1648
 
1639
1649
  ---
1640
1650
 
1641
- ## 18. API reference index
1651
+ ## 18. Declarative layer — `@caperjs/solid`
1652
+
1653
+ **Optional and purely additive.** `@caperjs/solid` is a separate package (npm
1654
+ `@caperjs/solid`, source `packages/plugin-solid/`) that lets a display object
1655
+ declare its members with SolidJS JSX instead of building them by hand. It is
1656
+ **not** an `IPlugin` — nothing goes in `plugins: [...]`, there is no `app.solid`.
1657
+ Core stays Solid-free; imperative Caper (§6) is unchanged and always available.
1658
+
1659
+ Reach for it when a screen is **UI-shaped**: state→view plumbing, lists that
1660
+ insert/remove, panels that show and hide. Keep gameplay (`update()` loops,
1661
+ physics, entity simulation) imperative — this layer does nothing for it.
1662
+
1663
+ ### 18.1 The `compose()` contract
1664
+
1665
+ A `Container` (or `Scene`) subclass extends `ComposableContainer` /
1666
+ `ComposableScene` and returns JSX from `compose()`:
1667
+
1668
+ - **Mount once.** The tree mounts the first time the object hits the stage,
1669
+ driven by Pixi's native `'added'` event — so a subclass that overrides
1670
+ `added()` without calling `super.added()` cannot break it.
1671
+ - **Dispose on `destroy()`.** The Solid root is torn down there; re-adding the
1672
+ object does **not** remount.
1673
+ - **Signals do every update after that.** Fine-grained, no VDOM, no re-render,
1674
+ no diff.
1675
+
1676
+ > `compose()` declares what exists, signals say when state changed, `update()`
1677
+ > moves things every frame.
1678
+
1679
+ ### 18.2 Install + build wiring
1680
+
1681
+ ```sh
1682
+ pnpm add @caperjs/solid solid-js
1683
+ ```
1684
+
1685
+ ```ts
1686
+ // vite.config.ts
1687
+ import { caper } from '@caperjs/core/vite';
1688
+
1689
+ export default defineConfig({ plugins: [caper({ solid: true })] });
1690
+ ```
1691
+
1692
+ `solid: true` makes the preset resolve `@caperjs/solid/vite` from the **app's**
1693
+ `node_modules` and add its Solid JSX plugin (universal renderer, `moduleName:
1694
+ '@caperjs/solid'`, `hot: false`). `vite-plugin-solid` ships as a dependency of
1695
+ `@caperjs/solid` — never install it directly. `{ solid: { include: [...] } }`
1696
+ narrows which files compile as JSX (default `['**/*.tsx']`). For a bare vite or
1697
+ vitest config, import `caperSolid()` from `@caperjs/solid/vite` instead.
1698
+
1699
+ ### 18.3 tsconfig contract (all three required)
1700
+
1701
+ ```jsonc
1702
+ {
1703
+ "compilerOptions": {
1704
+ "jsx": "preserve", // vite-plugin-solid does the real compile
1705
+ "jsxFactory": "CaperJSX.h", // type-lookup root only; nothing calls it
1706
+ "types": ["@caperjs/core/client", "@caperjs/solid/jsx"]
1707
+ }
1708
+ }
1709
+ ```
1710
+
1711
+ `jsxFactory` exists because a transitive `@types/react` shadows the global `JSX`
1712
+ namespace and `jsxImportSource` only applies in `react-jsx` mode. `caper doctor`
1713
+ reports a `solid-tsconfig` check with the exact missing keys whenever the app
1714
+ depends on `@caperjs/solid`; it walks the `extends` chain, so the three keys can
1715
+ live in a shared base config.
1716
+
1717
+ ### 18.4 Minimal example
1718
+
1719
+ ```tsx
1720
+ import { animated, ComposableContainer, type Composes } from '@caperjs/solid';
1721
+ import type { Graphics } from 'pixi.js';
1722
+ import { createSignal } from 'solid-js';
1723
+
1724
+ const WIDTH = 220;
1725
+ const HEIGHT = 22;
1726
+
1727
+ // Module-level so the reference is stable — see rule 2 in §18.6.
1728
+ const bg = (g: Graphics) => g.roundRect(0, 0, WIDTH, HEIGHT, 4).fill({ color: 0x22252a });
1729
+ const fill = (g: Graphics) => g.roundRect(0, 0, WIDTH, HEIGHT, 4).fill({ color: 0x9aa96b });
1730
+
1731
+ export class HealthBar extends ComposableContainer implements Composes {
1732
+ private health = createSignal(100);
1733
+
1734
+ /** Imperative API — an ordinary method that happens to write a signal. */
1735
+ public damage(amount: number) {
1736
+ this.health[1]((hp) => Math.max(0, hp - amount));
1737
+ }
1738
+
1739
+ public compose() {
1740
+ const hp = this.health[0];
1741
+ const hpA = animated(() => hp() / 100); // glides instead of snapping
1742
+
1743
+ return (
1744
+ <container>
1745
+ <graphics draw={bg} />
1746
+ <graphics draw={fill} scale={{ x: hpA(), y: 1 }} />
1747
+ <text text={`${hp()} HP`} anchor={{ x: 0.5, y: 0.5 }} x={WIDTH / 2} y={HEIGHT / 2} />
1748
+ </container>
1749
+ );
1750
+ }
1751
+ }
1752
+ ```
1753
+
1754
+ ### 18.5 Surface
1755
+
1756
+ | Name | One-line |
1757
+ | --- | --- |
1758
+ | `Composable(Base)` | Mixin adding the `compose()` contract to any display class. |
1759
+ | `ComposableContainer` / `ComposableScene` | Prebuilt `Composable(Container)` / `Composable(Scene)`. |
1760
+ | `Composes` | The interface a subclass `implements` to declare `compose()`. |
1761
+ | `asComponent(Ctor, defaults?)` | Lift an existing display class into JSX; constructs once, applies props reactively (incl. `ref`). |
1762
+ | `useTick(fn)` | `app.ticker.add` + `onCleanup` — `update()` for function components. |
1763
+ | `animated(source, opts?)` | Gliding accessor: reads like a signal, eases toward its source. `opts: { duration, ease }`. |
1764
+ | `<AnimatedShow when enter exit>` | `<Show>` that holds children on stage until the exit animation ends. |
1765
+
1766
+ Intrinsic elements: `container`, `sprite`, `text`, `graphics`, `flexContainer`.
1767
+ Anything else goes through `asComponent`. Props use the same resolvers as
1768
+ `this.add.*`, so `scale={2}`, `scale={{ x, y }}` and `scale={[x, y]}` all behave
1769
+ as in factory config; `on*` props attach Pixi listeners and flip `eventMode` to
1770
+ `'static'`; `draw` is `<graphics>`-only.
1771
+
1772
+ `enter` / `exit` on `<AnimatedShow>` are plain GSAP vars with target properties
1773
+ in a `pixi: {...}` block (GSAP's PixiPlugin, which core's `GSAPPlugin` registers
1774
+ at bootstrap). `animated()` and `<AnimatedShow>` must be called inside a
1775
+ reactive owner — a component body or a `compose()`.
1776
+
1777
+ ### 18.6 Authoring rules
1778
+
1779
+ 1. **Signals for event-rate state; `update()` / `useTick` + refs for per-frame
1780
+ bulk motion.** Hundreds of per-frame bindings are fine; moving a crowd every
1781
+ frame still belongs in an imperative loop.
1782
+ 2. **`draw` functions must be stable references.** Solid batches an element's
1783
+ dynamic props into one `!==`-guarded effect, so an inline `draw={dot(3)}`
1784
+ allocates a new closure whenever any prop in that batch changes and forces a
1785
+ full `clear()` + redraw. Hoist it.
1786
+ 3. **Never imperatively remove or reparent a child JSX created.** Solid owns
1787
+ those nodes. Building extra things alongside the composed tree is fine.
1788
+ 4. **Each `compose()` is its own reactive root.** Signals cross roots; Solid
1789
+ *context* does not cross the class boundary — pass props or instance fields.
1790
+ 5. **`text` elements default to `eventMode: 'none'`** so a label cannot swallow
1791
+ a sibling button's tap. An `on*` prop or an explicit `eventMode` overrides it.
1792
+ 6. **One-shot effects stay imperative via refs** — a JSX-mounted Caper container
1793
+ still carries the `Animated` mixin, so `bar.shake()` works (§12.3).
1794
+
1795
+ ### 18.7 Scene constraint — keep `src/scenes/` JSX-free
1796
+
1797
+ Discovery parses `src/scenes/**` with `jsx: false`. **Scene files must contain
1798
+ no JSX.** Put the view in a sibling directory and have `compose()` return a
1799
+ plain function call:
1800
+
1801
+ ```ts
1802
+ // src/scenes/HudScene.ts — plain .ts, no JSX
1803
+ import { SceneView } from '@/solid-demo/View';
1804
+ import { defineScene } from '@caperjs/core';
1805
+ import { ComposableScene, type Composes } from '@caperjs/solid';
1806
+
1807
+ export const scene = defineScene({ id: 'hud' });
1808
+
1809
+ export default class HudScene extends ComposableScene implements Composes {
1810
+ public compose() {
1811
+ return SceneView(this); // the JSX lives on the other side of this call
1812
+ }
1813
+ }
1814
+ ```
1815
+
1816
+ Canonical working example in the caper repo:
1817
+ `apps/kitchen-sink/src/scenes/SolidJsxScene.ts` (the JSX-free scene) plus
1818
+ `apps/kitchen-sink/src/solid-demo/View.tsx`, `HealthBar.tsx` (nested
1819
+ `compose()`) and `Orbiter.ts` (an imperative `update()` class mounted via
1820
+ `asComponent`). Deeper API notes live in the package's own README.
1821
+
1822
+ ---
1823
+
1824
+ ## 19. API reference index
1642
1825
 
1643
1826
  Flat jump table. Name → section → one-line.
1644
1827
 
@@ -1646,8 +1829,12 @@ Flat jump table. Name → section → one-line.
1646
1829
  | --- | --- | --- |
1647
1830
  | `Application` | §3.4 | Base app class; subclass for typed plugin getters. |
1648
1831
  | `AnimatedSprite` | §6.1, §10 | Pixi AnimatedSprite wrapper (`animations`, `animation`, `autoPlay`). |
1832
+ | `animated(source, opts?)` | §18.5 | `@caperjs/solid` — gliding accessor; eases toward its source. |
1833
+ | `<AnimatedShow>` | §18.5 | `@caperjs/solid` — `<Show>` that holds children until the exit ends. |
1834
+ | `asComponent(Ctor, defaults?)` | §18.5 | `@caperjs/solid` — lift a display class into JSX. |
1649
1835
  | `Button` | §7.2 | Four-state interactive button with sounds + actions. |
1650
1836
  | `Camera` | §10 | Camera controller for scene-level follow/constrain. |
1837
+ | `Composable(Base)` / `ComposableContainer` / `ComposableScene` | §18.1 | `@caperjs/solid` — the `compose()` contract. |
1651
1838
  | `Container` | §6, §10 | Caper base container (Animated + WithSignals + Factory). |
1652
1839
  | `create(config, el?, speak?)` | §3.3 | Programmatic app bootstrap. |
1653
1840
  | `defineActions(contexts, map)` | §4.3 | Typed action registry. |
@@ -1675,6 +1862,7 @@ Flat jump table. Name → section → one-line.
1675
1862
  | `Store` | §12.2 | Unified storage-adapter facade. |
1676
1863
  | `Toast` / `Toaster` | §7.4 | Toast notifications. |
1677
1864
  | `UICanvas` | §7.5 | HUD root layer. |
1865
+ | `useTick(fn)` | §18.5 | `@caperjs/solid` — per-frame callback inside a Solid component. |
1678
1866
  | `app.actions(id)` | §8 | Returns the action's signal. |
1679
1867
  | `app.assets.loadBundles(ids)` | §8, §11.5 | Load named bundles. |
1680
1868
  | `app.assets.loadSceneAssets(scene)` | §8 | Load a scene's declared bundles. |
@@ -1693,11 +1881,11 @@ Flat jump table. Name → section → one-line.
1693
1881
  | `app.store.save(adapter, key, data)` | §12.2 | Persist via storage adapter. |
1694
1882
  | `app.store.load(adapter, key)` | §12.2 | Read via storage adapter. |
1695
1883
  | `app.timers.delay(ms)` | §8 | Scene-scoped timer. |
1696
- | `Animated.animate(props)` | §12.3 | GSAP tween. |
1884
+ | `Animated.animate(props)` | §12.3 | GSAP animation. |
1697
1885
  | `Animated.shake()` / `.pulse()` / `.bob()` | §12.3 | Preset animations. |
1698
1886
  | `this.add.*` / `this.make.*` | §6 | Factory methods on any Container subclass. |
1699
1887
 
1700
1888
  ---
1701
1889
 
1702
- **Last updated:** with `@caperjs/core@0.5.x` (PixiJS 8.x); version in `../package.json`. When in doubt, read the
1890
+ **Last updated:** with `@caperjs/core@0.6.x` (PixiJS 8.x); version in `../package.json`. When in doubt, read the
1703
1891
  cited framework source file — signatures and defaults are authoritative there.