castle-web-cli 0.4.122 → 0.4.124

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 (60) hide show
  1. package/dist/agent-prompts.js +6 -2
  2. package/dist/castleJson.d.ts +2 -0
  3. package/dist/castleJson.js +39 -0
  4. package/dist/diffText.d.ts +14 -0
  5. package/dist/diffText.js +138 -0
  6. package/dist/headlessCover.d.ts +13 -0
  7. package/dist/headlessCover.js +99 -0
  8. package/dist/ide.d.ts +2 -1
  9. package/dist/ide.js +100 -43
  10. package/dist/imports.js +42 -2
  11. package/dist/index.js +49 -11
  12. package/dist/init.d.ts +1 -0
  13. package/dist/init.js +45 -1
  14. package/dist/localPaths.d.ts +1 -0
  15. package/dist/localPaths.js +5 -0
  16. package/dist/preview.d.ts +0 -1
  17. package/dist/preview.js +0 -58
  18. package/dist/save-deck.js +52 -1
  19. package/dist/serve.js +16 -0
  20. package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
  21. package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
  22. package/dist/shell/assets/index-BfOPkSej.css +1 -0
  23. package/dist/shell/assets/index-u0nYFqbF.js +434 -0
  24. package/dist/shell/index.html +2 -2
  25. package/dist/versionStore.d.ts +54 -0
  26. package/dist/versionStore.js +281 -0
  27. package/dist/versions.d.ts +82 -0
  28. package/dist/versions.js +446 -0
  29. package/kits/physics-2d/CLAUDE.md +2 -2
  30. package/kits/physics-2d/castle.json +10 -2
  31. package/kits/physics-2d/docs/pxart-format.md +33 -26
  32. package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
  33. package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
  34. package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
  35. package/kits/physics-2d/editors/pathOverlay.js +1 -1
  36. package/kits/physics-2d/editors/pathTools.js +9 -1
  37. package/kits/physics-2d/editors/pixelGeometry.js +14 -13
  38. package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
  39. package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
  40. package/kits/physics-2d/editors/pxArtTools.js +3 -43
  41. package/kits/physics-2d/editors/styleEditor.module.css +105 -0
  42. package/kits/physics-2d/editors/styleTheme.js +16 -0
  43. package/kits/physics-2d/engine/files.js +2 -1
  44. package/kits/physics-2d/engine/liveReload.js +4 -3
  45. package/kits/physics-2d/engine/palettes.js +636 -0
  46. package/kits/physics-2d/engine/pxart.js +6 -6
  47. package/kits/physics-2d/engine/svgImport.js +1056 -0
  48. package/kits/physics-2d/engine/ui.jsx +2 -0
  49. package/kits/physics-2d/engine/ui.module.css +54 -9
  50. package/kits/physics-2d/package-lock.json +1 -1
  51. package/kits/physics-2d/package.json +1 -0
  52. package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
  53. package/kits/physics-2d/scripts/draw.mjs +5 -3
  54. package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
  55. package/kits/physics-2d/scripts/palette.mjs +10 -0
  56. package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
  57. package/kits/physics-2d/theme.style +3 -0
  58. package/package.json +1 -1
  59. package/dist/shell/assets/index-BkJ87APM.css +0 -1
  60. package/dist/shell/assets/index-DXBpj3-y.js +0 -434
@@ -0,0 +1,446 @@
1
+ // save-version / list-versions / restore-version.
2
+ //
3
+ // A version is a manual commit of the deck's files. Storage details are in
4
+ // versionStore.ts; this file is the behavior:
5
+ //
6
+ // save-version hashes the whole tree and records what moved
7
+ // save-version <paths...> records ONLY those paths -- a curated state, not
8
+ // the working tree at that moment
9
+ // list-versions [<id>] walks back from head (or from the given version)
10
+ // restore-version <id> puts the working tree back to that version,
11
+ // touching only paths some later version touched
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import { customAlphabet } from 'nanoid';
15
+ import { looksBinary, unifiedDiff } from './diffText.js';
16
+ import { COVER_FILE } from './localPaths.js';
17
+ import * as store from './versionStore.js';
18
+ // Version ids are alphanumeric on purpose: they are filenames, command-line
19
+ // positionals and (later) url segments, and nanoid's default alphabet includes
20
+ // `-`, which reads as a flag wherever an id is typed.
21
+ const newVersionId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 12);
22
+ function resolveProjectDir(dir) {
23
+ const projectDir = path.resolve(dir);
24
+ if (!fs.existsSync(projectDir))
25
+ throw new Error(`No such directory: ${projectDir}`);
26
+ return projectDir;
27
+ }
28
+ // A path on the command line may name a directory, which stands for every
29
+ // tracked file under it -- on disk AND in the parent manifest, so deleting a
30
+ // whole directory and saving it still records the deletions.
31
+ function expandSelection(projectDir, given, parentFiles) {
32
+ const workingFiles = store.walkDeckFiles(projectDir);
33
+ const selected = new Set();
34
+ for (const raw of given) {
35
+ const rel = store.toDeckRelative(projectDir, raw);
36
+ if (!rel)
37
+ throw new Error(`Path is outside the deck: ${raw}`);
38
+ if (store.isIgnoredPath(rel))
39
+ throw new Error(`Path is not tracked: ${rel}`);
40
+ const isDir = fs.existsSync(path.join(projectDir, rel)) && fs.statSync(path.join(projectDir, rel)).isDirectory();
41
+ if (isDir) {
42
+ const prefix = `${rel}/`;
43
+ for (const p of workingFiles)
44
+ if (p.startsWith(prefix))
45
+ selected.add(p);
46
+ for (const p of Object.keys(parentFiles))
47
+ if (p.startsWith(prefix))
48
+ selected.add(p);
49
+ continue;
50
+ }
51
+ if (!fs.existsSync(path.join(projectDir, rel)) && !(rel in parentFiles)) {
52
+ throw new Error(`No such file: ${rel}`);
53
+ }
54
+ selected.add(rel);
55
+ }
56
+ return [...selected].sort();
57
+ }
58
+ // The whole tree becomes the manifest. Content is stored as we go, so an
59
+ // unchanged file costs a read and a hash and writes nothing new.
60
+ function manifestFromTree(projectDir) {
61
+ const files = {};
62
+ for (const rel of store.walkDeckFiles(projectDir)) {
63
+ const hash = store.storeWorkingFile(projectDir, rel);
64
+ if (hash)
65
+ files[rel] = hash;
66
+ }
67
+ return files;
68
+ }
69
+ // The parent's manifest with only the selected paths overridden. Everything
70
+ // else keeps the parent's hash -- so the version describes a curated state, and
71
+ // the paths left out stay dirty on disk for the next save to pick up.
72
+ function manifestFromSelection(projectDir, parentFiles, selected) {
73
+ const files = { ...parentFiles };
74
+ for (const rel of selected) {
75
+ const hash = store.storeWorkingFile(projectDir, rel);
76
+ if (hash)
77
+ files[rel] = hash;
78
+ else
79
+ delete files[rel];
80
+ }
81
+ return files;
82
+ }
83
+ // The save itself, with no output -- what the CLI command and the editor's
84
+ // Save version button both run.
85
+ export function createVersion(projectDir, opts = {}) {
86
+ const parentId = store.readHead(projectDir);
87
+ const parentFiles = store.parentManifest(projectDir, parentId);
88
+ const given = opts.paths ?? [];
89
+ const selected = given.length > 0 ? expandSelection(projectDir, given, parentFiles) : null;
90
+ const files = selected
91
+ ? manifestFromSelection(projectDir, parentFiles, selected)
92
+ : manifestFromTree(projectDir);
93
+ const changes = store.diffManifests(parentFiles, files, selected ?? undefined);
94
+ if (Object.keys(changes).length === 0)
95
+ return { version: null, partial: selected !== null };
96
+ const version = {
97
+ id: newVersionId(),
98
+ parentId,
99
+ createdAt: new Date().toISOString(),
100
+ message: opts.message ?? '',
101
+ files,
102
+ changes,
103
+ };
104
+ store.writeVersion(projectDir, version);
105
+ store.writeHead(projectDir, version.id);
106
+ return { version, partial: selected !== null };
107
+ }
108
+ export function saveVersion(dir, opts = {}) {
109
+ const projectDir = resolveProjectDir(dir);
110
+ const { version, partial } = createVersion(projectDir, opts);
111
+ if (!version) {
112
+ console.log(partial ? 'No changes in the selected paths. Nothing saved.' : 'No changes. Nothing saved.');
113
+ return;
114
+ }
115
+ const changes = version.changes;
116
+ const count = Object.keys(changes).length;
117
+ console.log(`Saved version ${version.id} (${count} file${count === 1 ? '' : 's'} changed).`);
118
+ for (const [p, change] of Object.entries(changes)) {
119
+ const mark = change.before === null ? 'added' : change.after === null ? 'deleted' : 'changed';
120
+ console.log(` ${mark} ${p}`);
121
+ }
122
+ if (partial) {
123
+ console.log('Partial save: unselected files are still dirty on disk.');
124
+ }
125
+ }
126
+ // The deck cover is rewritten every time the deck is played, so it is
127
+ // permanently dirty. It is versioned and restored like any other file, but it
128
+ // never counts as a change worth reporting and never blocks a restore -- the
129
+ // old cover it writes back regenerates on the next play. Without this, every
130
+ // restore refused on a file nobody edited.
131
+ function isReportableChange(path) {
132
+ return path !== COVER_FILE;
133
+ }
134
+ function changeKind(change) {
135
+ if (change.before === null)
136
+ return 'added';
137
+ if (change.after === null)
138
+ return 'deleted';
139
+ return 'changed';
140
+ }
141
+ /**
142
+ * What has changed on disk since head, in the same shape a version's `changes`
143
+ * takes. Hashes the tree WITHOUT storing anything -- asking what is outstanding
144
+ * must not write to the store.
145
+ */
146
+ export function pendingChanges(projectDir) {
147
+ const headFiles = store.parentManifest(projectDir, store.readHead(projectDir));
148
+ const current = {};
149
+ for (const rel of store.walkDeckFiles(projectDir)) {
150
+ const hash = store.hashWorkingFile(projectDir, rel);
151
+ if (hash)
152
+ current[rel] = hash;
153
+ }
154
+ return Object.entries(store.diffManifests(headFiles, current))
155
+ .filter(([path]) => isReportableChange(path))
156
+ .map(([path, change]) => ({ path, kind: changeKind(change) }));
157
+ }
158
+ function toSummary(version, later) {
159
+ return {
160
+ id: version.id,
161
+ parentId: version.parentId,
162
+ createdAt: version.createdAt,
163
+ message: version.message,
164
+ changes: Object.entries(version.changes)
165
+ .filter(([path]) => isReportableChange(path))
166
+ .map(([path, change]) => ({ path, kind: changeKind(change) })),
167
+ later,
168
+ };
169
+ }
170
+ /**
171
+ * The deck's history newest first. From head (no `fromId`): the chain forward
172
+ * from head -- marked `later` -- then head, then its ancestors. From a named
173
+ * version: that version and its ancestors, as it has always been.
174
+ *
175
+ * The forward walk is why a restore no longer makes the versions after it
176
+ * vanish from the panel and from `list-versions`. It follows one chain of
177
+ * children, newest child at each step. After a save there is nothing to follow,
178
+ * because the new version is a leaf, so the forward rows drop away on their own
179
+ * -- the abandoned versions are still on disk, just no longer reachable forward
180
+ * from head.
181
+ *
182
+ * Naming a version asks a different question -- how did THIS version come to be
183
+ * -- so that form stays a walk back and is unchanged.
184
+ */
185
+ export function versionSummaries(projectDir, fromId) {
186
+ const startId = fromId ?? store.readHead(projectDir);
187
+ if (!startId)
188
+ return [];
189
+ if (!store.versionExists(projectDir, startId))
190
+ throw new Error(`No such version: ${startId}`);
191
+ const forward = fromId ? [] : store.forwardChain(projectDir, startId);
192
+ return [
193
+ // Nearest child first out of the walk, so reverse it: the furthest
194
+ // descendant is the newest thing there is.
195
+ ...[...forward].reverse().map((v) => toSummary(v, true)),
196
+ ...store.versionChain(projectDir, startId).map((v) => toSummary(v, false)),
197
+ ];
198
+ }
199
+ export function listVersions(dir, version) {
200
+ const summaries = versionSummaries(resolveProjectDir(dir), version);
201
+ if (summaries.length === 0) {
202
+ console.log('No versions yet.');
203
+ return;
204
+ }
205
+ for (const v of summaries) {
206
+ const count = v.changes.length;
207
+ const message = v.message ? ` ${v.message}` : '';
208
+ // The same history the panel shows, marked the same way, so the two never
209
+ // disagree about what happened.
210
+ const later = v.later ? ' (later)' : '';
211
+ console.log(`${v.id} ${v.createdAt} ${count} file${count === 1 ? '' : 's'}${later}${message}`);
212
+ }
213
+ }
214
+ // One file's side of a change, as text -- or null when it wasn't there (an add
215
+ // or a delete) and `binary: true` when it must not be rendered as a patch.
216
+ function blobText(projectDir, hash) {
217
+ if (hash === null)
218
+ return null;
219
+ const content = store.getBlob(projectDir, hash);
220
+ return looksBinary(content) ? { text: '', binary: true } : { text: content.toString('utf8'), binary: false };
221
+ }
222
+ function showFileChange(projectDir, filePath, change) {
223
+ const kind = changeKind(change);
224
+ const before = blobText(projectDir, change.before);
225
+ const after = blobText(projectDir, change.after);
226
+ // A sprite or an image must never come out as a garbled patch; a line saying
227
+ // what happened to it is the whole useful answer.
228
+ if (before?.binary || after?.binary) {
229
+ console.log(`${filePath} ${kind}, binary`);
230
+ return;
231
+ }
232
+ console.log(`${filePath} ${kind}`);
233
+ const hunks = unifiedDiff(before?.text ?? '', after?.text ?? '');
234
+ if (!hunks) {
235
+ console.log(' (too large to show as a diff)');
236
+ return;
237
+ }
238
+ for (const line of hunks)
239
+ console.log(line);
240
+ }
241
+ /**
242
+ * What one version changed, as diffs of the CHANGED FILES ONLY -- built from the
243
+ * two blobs each entry in `changes` already names, so nothing new is stored and
244
+ * the whole tree is never read. The CLI counterpart of the panel's row
245
+ * expansion, for an agent working out what a change did.
246
+ */
247
+ export function showVersion(dir, versionId) {
248
+ const projectDir = resolveProjectDir(dir);
249
+ if (!store.versionExists(projectDir, versionId))
250
+ throw new Error(`No such version: ${versionId}`);
251
+ const version = store.readVersion(projectDir, versionId);
252
+ const paths = Object.keys(version.changes).sort();
253
+ console.log(`${version.id} ${version.createdAt} ${paths.length} file${paths.length === 1 ? '' : 's'}`);
254
+ if (version.message)
255
+ console.log(version.message);
256
+ console.log('');
257
+ for (const filePath of paths) {
258
+ showFileChange(projectDir, filePath, version.changes[filePath]);
259
+ console.log('');
260
+ }
261
+ }
262
+ /**
263
+ * The versions from `from` back to `to`, exclusive of `to`, newest first -- or
264
+ * null when `to` is not an ancestor of `from`. Only `parentId` is stored, so
265
+ * this is the only direction the history can be walked directly.
266
+ */
267
+ function chainDownTo(projectDir, from, to) {
268
+ const chain = [];
269
+ const seen = new Set();
270
+ let cursor = from;
271
+ while (cursor && cursor !== to) {
272
+ if (seen.has(cursor))
273
+ throw new Error(`Version history loops at ${cursor}`);
274
+ seen.add(cursor);
275
+ const version = store.readVersion(projectDir, cursor);
276
+ chain.push(version);
277
+ cursor = version.parentId;
278
+ }
279
+ return cursor === to ? chain : null;
280
+ }
281
+ /**
282
+ * What a restore should write, and what it expects to find on disk first.
283
+ *
284
+ * The two directions are mirrors of each other over the SAME set of versions --
285
+ * the ones between head and the target -- read from opposite ends:
286
+ *
287
+ * backward (the target is an ancestor of head): put back the EARLIEST
288
+ * `before`, the state closest to the target; expect the LATEST `after`, the
289
+ * state closest to head.
290
+ * forward (the target is a descendant of head): put back the LATEST `after`,
291
+ * again the state closest to the target; expect the EARLIEST `before`,
292
+ * again the state closest to head.
293
+ *
294
+ * Either way a path no version between the two touched never enters the plan,
295
+ * so unsaved work on it survives.
296
+ */
297
+ function planRestore(projectDir, targetId) {
298
+ const head = store.readHead(projectDir);
299
+ if (!head)
300
+ throw new Error('No versions yet.');
301
+ const backward = chainDownTo(projectDir, head, targetId);
302
+ if (backward)
303
+ return planFromChain(backward, 'backward');
304
+ const forward = chainDownTo(projectDir, targetId, head);
305
+ if (forward)
306
+ return planFromChain(forward, 'forward');
307
+ throw new NotOnThisLine(targetId, head);
308
+ }
309
+ // `chain` is always newest first, so "first seen wins" reads the end nearest the
310
+ // newer version and "last write wins" reads the end nearest the older one.
311
+ function planFromChain(chain, direction) {
312
+ const plan = {
313
+ wanted: {},
314
+ expected: {},
315
+ offChain: direction === 'backward' ? chain.length : 0,
316
+ };
317
+ for (const version of chain) {
318
+ for (const [p, change] of Object.entries(version.changes)) {
319
+ if (direction === 'backward') {
320
+ plan.wanted[p] = change.before;
321
+ if (!(p in plan.expected))
322
+ plan.expected[p] = change.after;
323
+ }
324
+ else {
325
+ if (!(p in plan.wanted))
326
+ plan.wanted[p] = change.after;
327
+ plan.expected[p] = change.before;
328
+ }
329
+ }
330
+ }
331
+ return plan;
332
+ }
333
+ function removeEmptyParents(projectDir, relPath) {
334
+ let dir = path.dirname(path.join(projectDir, relPath));
335
+ while (dir.startsWith(projectDir) && dir !== projectDir) {
336
+ try {
337
+ if (fs.readdirSync(dir).length > 0)
338
+ return;
339
+ fs.rmdirSync(dir);
340
+ }
341
+ catch {
342
+ return;
343
+ }
344
+ dir = path.dirname(dir);
345
+ }
346
+ }
347
+ function applyRestore(projectDir, actions) {
348
+ for (const [rel, hash] of actions) {
349
+ const abs = path.join(projectDir, rel);
350
+ if (hash === null) {
351
+ fs.rmSync(abs, { force: true });
352
+ removeEmptyParents(projectDir, rel);
353
+ continue;
354
+ }
355
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
356
+ fs.writeFileSync(abs, store.getBlob(projectDir, hash));
357
+ }
358
+ }
359
+ // Work no version has recorded, which a restore would overwrite. The paths ARE
360
+ // the answer to "what would I lose", so they ride on the error and each caller
361
+ // says what to do about it -- the CLI offers `--force`, the panel has no such
362
+ // thing and says to save a version first.
363
+ export class UnsavedChanges extends Error {
364
+ paths;
365
+ constructor(paths) {
366
+ const count = paths.length;
367
+ super(`Unsaved changes would be lost in ${count} file${count === 1 ? '' : 's'}:\n` +
368
+ paths.map((p) => ` ${p}`).join('\n'));
369
+ this.name = 'UnsavedChanges';
370
+ this.paths = paths;
371
+ }
372
+ }
373
+ // A version on a different branch: neither an ancestor of head nor a descendant
374
+ // of it. Restore moves along one line in either direction; getting from here to
375
+ // there would mean merging, which is not something this build does silently.
376
+ export class NotOnThisLine extends Error {
377
+ constructor(targetId, head) {
378
+ super(`Version ${targetId} is on a different branch than ${head}. ` +
379
+ 'Restore moves back or forward along one line, not across a fork.');
380
+ this.name = 'NotOnThisLine';
381
+ }
382
+ }
383
+ // The restore itself, with no output. Throws on unsaved changes unless forced --
384
+ // the message names every conflicting path, since that list IS the answer to
385
+ // "what would I lose".
386
+ export function applyVersionRestore(projectDir, targetId, opts = {}) {
387
+ if (!store.versionExists(projectDir, targetId))
388
+ throw new Error(`No such version: ${targetId}`);
389
+ if (store.readHead(projectDir) === targetId) {
390
+ return { alreadyThere: true, applied: [], offChain: 0 };
391
+ }
392
+ const plan = planRestore(projectDir, targetId);
393
+ // Only paths whose content would actually move are checked and applied: a
394
+ // path already holding what the restore wants is neither a conflict nor work.
395
+ const actions = [];
396
+ const conflicts = [];
397
+ for (const [rel, wanted] of Object.entries(plan.wanted)) {
398
+ const current = store.hashWorkingFile(projectDir, rel);
399
+ if (current === wanted)
400
+ continue;
401
+ // The cover is exempt: it is dirty by construction, so refusing on it would
402
+ // refuse every restore. It still gets written back.
403
+ if (current !== plan.expected[rel] && isReportableChange(rel))
404
+ conflicts.push(rel);
405
+ actions.push([rel, wanted]);
406
+ }
407
+ if (conflicts.length > 0 && !opts.force)
408
+ throw new UnsavedChanges(conflicts.sort());
409
+ applyRestore(projectDir, actions.sort((a, b) => a[0].localeCompare(b[0])));
410
+ // Head moves to the target, so the next save continues from there and the
411
+ // versions we walked past fork off. They stay on disk and stay loadable by
412
+ // id -- they are just no longer on head's chain.
413
+ store.writeHead(projectDir, targetId);
414
+ return { alreadyThere: false, applied: actions, offChain: plan.offChain };
415
+ }
416
+ export function restoreVersion(dir, targetId, opts = {}) {
417
+ const projectDir = resolveProjectDir(dir);
418
+ let outcome;
419
+ try {
420
+ outcome = applyVersionRestore(projectDir, targetId, opts);
421
+ }
422
+ catch (e) {
423
+ // Only the command line has a --force, so only the command line mentions it.
424
+ if (e instanceof UnsavedChanges) {
425
+ throw new Error(`${e.message}\nSave a version first, or pass --force to restore anyway.`);
426
+ }
427
+ throw e;
428
+ }
429
+ if (outcome.alreadyThere) {
430
+ console.log(`Already at version ${targetId}. Nothing to restore.`);
431
+ return;
432
+ }
433
+ const applied = outcome.applied;
434
+ if (applied.length === 0) {
435
+ console.log(`The working tree already matched version ${targetId}.`);
436
+ }
437
+ else {
438
+ console.log(`Restored ${applied.length} file${applied.length === 1 ? '' : 's'} to version ${targetId}.`);
439
+ for (const [rel, hash] of applied)
440
+ console.log(` ${hash === null ? 'deleted' : 'restored'} ${rel}`);
441
+ }
442
+ // A silent head move is the thing people discover a week later. What the move
443
+ // left behind needs no announcement: `list-versions` shows those versions,
444
+ // marked `(later)`.
445
+ console.log(`head is now ${targetId}.`);
446
+ }
@@ -448,11 +448,11 @@ Physics is just this kit's use of it.
448
448
  Don't hand-write pixel grids. Generate sprites with the `draw` command: **emit a terse svg-rect** — a tiny `<svg viewBox="0 0 16 16">` with **one `<rect>` per pixel** — and pipe it to `npm run draw`. It quantizes every fill to the agent palette and writes a single-frame `drawings/<name>.sprite`.
449
449
 
450
450
  - **Resolution is 16×16.** Use `viewBox="0 0 16 16"` and **one 1×1 `<rect>` per filled pixel** (`width="1" height="1"` at integer `x`/`y`). Leave background pixels out (omitted = transparent). Keep every rect 1×1 — the decoder infers cell size from the smallest rect, so mixing in larger blocks shrinks the whole grid.
451
- - **Palette is the agent 16-color subset** (a fixed subset of the editor's full Endesga-64 painting palette) — every `fill` must be one of:
451
+ - **Palette is the deck's generation palette.** The default (no `theme.style`, or Endesga-64) is this 16-color subset — every `fill` must be one of:
452
452
 
453
453
  `#e69c69` `#bf6f4a` `#8a4836` `#391f21` `#891e2b` `#ea323c` `#ffa214` `#ffeb57` `#5ac54f` `#1e6f50` `#134c4c` `#657392` `#c7cfdd` `#ffffff` `#0cf1ff` `#0098dc`
454
454
 
455
- (Off-palette fills are snapped to the nearest of these, so stay on-palette to keep what you intend. People editing in the app can paint with the full Endesga-64 palette; only this generation path is constrained to the 16.)
455
+ If the deck defines a palette in `theme.style`, that governs instead. `npm run palette` prints the active generation palette. Off-palette fills snap to the nearest of these, so stay on-palette to keep what you intend. People editing in the app paint from the deck palette (and the sprite's own colors); only this generation path is quantized.
456
456
  - **Run it** (sprite name as the arg; stdin is the svg):
457
457
 
458
458
  ```bash
@@ -23,7 +23,8 @@
23
23
  "scenes/**",
24
24
  "blueprints/**",
25
25
  "behaviors/**",
26
- "assets/**"
26
+ "assets/**",
27
+ "theme.style"
27
28
  ],
28
29
  "fileTypes": [
29
30
  {
@@ -55,6 +56,13 @@
55
56
  "new": "New behavior",
56
57
  "icon": "code"
57
58
  },
59
+ {
60
+ "ext": ".style",
61
+ "label": "Style",
62
+ "icon": "file",
63
+ "editor": "kit",
64
+ "data": true
65
+ },
58
66
  {
59
67
  "ext": ".png",
60
68
  "label": "Image",
@@ -123,5 +131,5 @@
123
131
  "main": "main.jsx",
124
132
  "autoUpdateWhenImported": true,
125
133
  "title": "physics-2d",
126
- "publishedVersion": "2026-08-16T04:02:06.732Z"
134
+ "publishedVersion": "2026-08-18T21:05:46.663Z"
127
135
  }
@@ -88,36 +88,42 @@ temporal versions — that the kit treats as one in-memory model:
88
88
  transparent.
89
89
  - Indexed-with-hex only: **not** RGBA-per-pixel, **not** grayscale.
90
90
 
91
- ### Two-tier palette system (Endesga-64)
92
-
93
- The kit uses a **two-tier** fixed palette, both tiers drawn from Lospec's
94
- [Endesga 64](https://lospec.com/palette-list/endesga-64). There is no free color
95
- picking at either tier.
96
-
97
- **Full palette Endesga 64 (`EDG64`, all 64 colors): what USERS paint with.**
98
- The kit's `PxArtEditor` exposes the **full 64-color swatch** (canonical Lospec
99
- order). The editor assigns each color a stable single-char key (`KEY_ALPHABET`,
100
- 64 distinct keys) plus `.` for transparent. When an existing sprite is opened for
101
- editing, each cell's resolved color is **snapped to the nearest Endesga-64
102
- color** by RGB distance, so editing always stays on the fixed swatch. (Sprites
103
- still *render* with their own stored palette via `renderSpriteFrame`; the snap
104
- only affects what the editor writes back.) The swatch UI renders as an 8-column
105
- grid (8 rows for the 64 colors) that scrolls within the inspector.
106
-
107
- **Agent subset — 16 colors (`AGENT_PALETTE_16`): what the LLM generates with.**
108
- The agent generation path (the `draw` svg-rect `.sprite` quantizer and the
109
- prompt the model receives) is constrained to a **fixed 16-color subset of
110
- Endesga-64**, so generated art stays coherent. Every color below is a member of
111
- `EDG64`. In order:
91
+ ### Deck palette + derived agent subset
92
+
93
+ Each sprite file carries its **own** ordered `{ key, hex }` palette. That list
94
+ is truth: opening a sprite for editing never remaps its colors. The editor
95
+ paints with those keys, and picking a new color **allocates** a key into the
96
+ sprite's palette (`KEY_ALPHABET`, capped at 64) rather than snapping existing
97
+ pixels onto a global swatch.
98
+
99
+ The **deck palette** is a picker source, not a normalization target. It lives
100
+ in `theme.style` at the deck root (`engine/palettes.js`). Missing or malformed
101
+ theme data falls back to Lospec's [Endesga 64](https://lospec.com/palette-list/endesga-64)
102
+ (`EDG64`). The inspector picker is an unlabeled row of the sprite's own colors
103
+ (when that palette is non-empty) plus an 8-column grid of official palettes,
104
+ paged with named arrows. The deck's `theme.style` palette is the default page;
105
+ paging does not write the theme. A custom `{ name, colors }` deck adds one
106
+ extra read-only page. Picking a hex still allocates onto the sprite.
107
+
108
+ **Agent subset what the LLM generates with.** The generation path (`draw`
109
+ svg-rect `.sprite`, and `import-svg`) quantizes to a derived subset of the
110
+ deck palette (`agentPaletteFor`):
111
+
112
+ - default Endesga-64 → the hand-curated `AGENT_PALETTE_16`
113
+ - custom palette larger than 16 → greedy farthest-point subset in Oklab
114
+ - 16 or fewer colors → used as-is
115
+
116
+ `npm run palette` prints both the deck palette and the derived generation
117
+ subset. The default 16, in agent-facing order (every entry is a member of
118
+ `EDG64`):
112
119
 
113
120
  ```
114
121
  #e69c69 #bf6f4a #8a4836 #391f21 #891e2b #ea323c #ffa214 #ffeb57
115
122
  #5ac54f #1e6f50 #134c4c #657392 #c7cfdd #ffffff #0cf1ff #0098dc
116
123
  ```
117
124
 
118
- So: the editor offers all 64 to people; the agent generation path snaps to these
119
- 16. Both constants (`EDG64`, `AGENT_PALETTE_16`) live in the kit's
120
- `engine/pxart.js`.
125
+ `EDG64` and `AGENT_PALETTE_16` live in `engine/pxart.js`; derivation and
126
+ theme resolution live in `engine/palettes.js`.
121
127
 
122
128
  ---
123
129
 
@@ -656,8 +662,9 @@ system treats it as a full (degenerate) Sprite.
656
662
  ### Single-layer / single-frame generation contract
657
663
 
658
664
  The **generation contract** for this kit is to emit the **compact shorthand**: a
659
- single 16×16 (default) `{ palette, grid }` sprite over the agent 16-color subset
660
- (`AGENT_PALETTE_16`).
665
+ single 16×16 (default) `{ palette, grid }` sprite quantized to the deck's
666
+ generation palette (`agentPaletteFor` of the `theme.style` palette, or
667
+ `AGENT_PALETTE_16` when the deck uses the Endesga-64 default).
661
668
  The CLI generation worker and the kit's `PxArtEditor` both produce/store this
662
669
  compact form when nothing is lost by it. Multi-layer / multi-frame structure is
663
670
  authored by tools later, not hand-generated — and the minimal `PxArtEditor`