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.
- package/dist/agent-prompts.js +6 -2
- package/dist/castleJson.d.ts +2 -0
- package/dist/castleJson.js +39 -0
- package/dist/diffText.d.ts +14 -0
- package/dist/diffText.js +138 -0
- package/dist/headlessCover.d.ts +13 -0
- package/dist/headlessCover.js +99 -0
- package/dist/ide.d.ts +2 -1
- package/dist/ide.js +100 -43
- package/dist/imports.js +42 -2
- package/dist/index.js +49 -11
- package/dist/init.d.ts +1 -0
- package/dist/init.js +45 -1
- package/dist/localPaths.d.ts +1 -0
- package/dist/localPaths.js +5 -0
- package/dist/preview.d.ts +0 -1
- package/dist/preview.js +0 -58
- package/dist/save-deck.js +52 -1
- package/dist/serve.js +16 -0
- package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
- package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
- package/dist/shell/assets/index-BfOPkSej.css +1 -0
- package/dist/shell/assets/index-u0nYFqbF.js +434 -0
- package/dist/shell/index.html +2 -2
- package/dist/versionStore.d.ts +54 -0
- package/dist/versionStore.js +281 -0
- package/dist/versions.d.ts +82 -0
- package/dist/versions.js +446 -0
- package/kits/physics-2d/CLAUDE.md +2 -2
- package/kits/physics-2d/castle.json +10 -2
- package/kits/physics-2d/docs/pxart-format.md +33 -26
- package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
- package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
- package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
- package/kits/physics-2d/editors/pathOverlay.js +1 -1
- package/kits/physics-2d/editors/pathTools.js +9 -1
- package/kits/physics-2d/editors/pixelGeometry.js +14 -13
- package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
- package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
- package/kits/physics-2d/editors/pxArtTools.js +3 -43
- package/kits/physics-2d/editors/styleEditor.module.css +105 -0
- package/kits/physics-2d/editors/styleTheme.js +16 -0
- package/kits/physics-2d/engine/files.js +2 -1
- package/kits/physics-2d/engine/liveReload.js +4 -3
- package/kits/physics-2d/engine/palettes.js +636 -0
- package/kits/physics-2d/engine/pxart.js +6 -6
- package/kits/physics-2d/engine/svgImport.js +1056 -0
- package/kits/physics-2d/engine/ui.jsx +2 -0
- package/kits/physics-2d/engine/ui.module.css +54 -9
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/package.json +1 -0
- package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
- package/kits/physics-2d/scripts/draw.mjs +5 -3
- package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
- package/kits/physics-2d/scripts/palette.mjs +10 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
- package/kits/physics-2d/theme.style +3 -0
- package/package.json +1 -1
- package/dist/shell/assets/index-BkJ87APM.css +0 -1
- package/dist/shell/assets/index-DXBpj3-y.js +0 -434
package/dist/versions.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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-
|
|
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
|
-
###
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
picking
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
660
|
-
(`
|
|
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`
|