pixelkiln 0.34.0 → 0.35.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.
@@ -0,0 +1,2118 @@
1
+ let snap = INITIAL;
2
+ const STATE_TONE = {
3
+ ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
4
+ failed: 'bad', 'in-flight': 'cool', recoverable: 'cool', missing: 'dim', undeclared: 'dim',
5
+ };
6
+ const STATE_ORDER = ['ok','stale','orphaned','untracked','in-flight','recoverable','blocked','failed','missing','undeclared'];
7
+ const ui = {
8
+ q: '', states: new Set(), providers: new Set(), generators: new Set(), projects: new Set(),
9
+ sort: 'key', group: 'style', open: null, member: 0, zoom: 'auto', playing: null,
10
+ /** Cards rendered per pass; a project with thousands of assets opts into the rest. */
11
+ limit: 600,
12
+ /** Item id whose edit form is open, or 'new:<project>:<style>' for an add form. */
13
+ editing: null,
14
+ /** One-shot confirmation shown in the drawer after a save. */
15
+ notice: null,
16
+ /** Record ids picked for side-by-side comparison, in pick order (max 4). */
17
+ compare: [],
18
+ /** Zoom for the comparison panel. */
19
+ compareZoom: 'auto',
20
+ /** Jobs expanded to show their log. */
21
+ logs: new Set(),
22
+ /** Job ids already seen finished, so a completion refreshes exactly once. */
23
+ settled: new Set(),
24
+ };
25
+ /** Generation status from /api/jobs: jobs, session budget, spend so far. */
26
+ let GEN = { jobs: [], budget: { byProvider: {} }, spent: {}, units: {} };
27
+ const $ = (id) => document.getElementById(id);
28
+ const el = (tag, cls, text) => {
29
+ const n = document.createElement(tag);
30
+ if (cls) n.className = cls;
31
+ if (text !== undefined && text !== null) n.textContent = String(text);
32
+ return n;
33
+ };
34
+ const fmtCost = (unit, amount) => {
35
+ if (unit === 'free') return 'free';
36
+ if (unit === 'usd') return '$' + Number(amount).toFixed(2);
37
+ if (unit === 'generations') return amount + ' generation' + (amount === 1 ? '' : 's');
38
+ return amount + ' ' + unit;
39
+ };
40
+ const fmtSpend = (spend) => Object.entries(spend).filter(([, n]) => n).sort()
41
+ .map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + ') || 'no spend recorded';
42
+ const fmtBytes = (n) => n == null ? '—' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(2) + ' MB';
43
+ const fmtWhen = (iso) => {
44
+ if (!iso) return null;
45
+ const d = new Date(iso);
46
+ return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
47
+ };
48
+ const isFrameSet = (item) => item.generator === 'frames' ||
49
+ (item.outputs.length > 1 && item.outputs.every((o) => o.role && /^frame/i.test(o.role)));
50
+ const shortPath = (p) => {
51
+ if (p.length <= 64) return p;
52
+ const parts = p.split('/');
53
+ return parts.length > 4 ? '…/' + parts.slice(-3).join('/') : p;
54
+ };
55
+ const displayScale = (w, h, boxW, boxH) => {
56
+ const s = Math.min(boxW / w, boxH / h);
57
+ return s >= 1 ? Math.floor(s) : s;
58
+ };
59
+
60
+ const projectOf = (item) => snap.workspace
61
+ ? snap.workspace.projects.find((pr) => pr.id === item.project)
62
+ : snap.project;
63
+
64
+ // The only write this page ever makes: one manifest edit, quoting the
65
+ // manifest hash it was rendered from so a concurrent hand edit is refused
66
+ // rather than overwritten. The server answers with the rebuilt gallery.
67
+ async function postEdit(body) {
68
+ const res = await fetch('/api/edit', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
71
+ body: JSON.stringify(body),
72
+ });
73
+ if (!res.ok) {
74
+ const err = new Error(await res.text());
75
+ err.status = res.status;
76
+ throw err;
77
+ }
78
+ return res.json();
79
+ }
80
+ const field = (label, input, hint) => {
81
+ const w = el('label', 'field');
82
+ w.append(el('span', null, label), input);
83
+ if (hint) w.append(el('small', null, hint));
84
+ return w;
85
+ };
86
+ const numberInput = (value, placeholder) => {
87
+ const i = el('input'); i.type = 'number'; i.min = '16'; i.step = '1';
88
+ i.value = value == null ? '' : String(value); i.placeholder = placeholder || '';
89
+ return i;
90
+ };
91
+ const numberOrNull = (input) => input.value.trim() === '' ? null : Number(input.value);
92
+ const splitTags = (value) => value.split(',').map((t) => t.trim()).filter(Boolean);
93
+
94
+ // ---- generation (only when the server holds a session budget) -------------
95
+
96
+ const ACTIVE_PHASES = new Set(['queued', 'submitting', 'polling', 'fetching', 'review']);
97
+ const PHASE_TONE = { queued: 'cool', submitting: 'cool', polling: 'cool', fetching: 'cool', review: 'warn', done: 'ok', failed: 'bad' };
98
+ // The job still working on this record, if any: what the drawer and its card
99
+ // show while a regeneration, restore, or pull is in flight.
100
+ const activeJobFor = (item) => GEN.jobs.find((job) => ACTIVE_PHASES.has(job.phase) && job.project === item.project && job.keys.includes(item.key)) || null;
101
+ const JOB_VERB = { generate: 'regenerating', resume: 'resuming', refresh: 'pulling upstream changes for', restore: 'restoring', revert: 'bringing back a previous generation of' };
102
+ const PHASE_TEXT = { queued: 'queued', submitting: 'submitting to the provider', polling: 'waiting for the provider to finish', fetching: 'downloading the result', review: 'candidates are ready to review' };
103
+ const remainingBudget = (provider) => {
104
+ const keyed = GEN.budget.byProvider[provider];
105
+ const ceiling = keyed !== undefined ? keyed : GEN.budget.amount;
106
+ return ceiling === undefined ? null : Math.max(0, ceiling - (GEN.spent[provider] || 0));
107
+ };
108
+ async function postGenerate(body) {
109
+ const res = await fetch('/api/generate', {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
112
+ body: JSON.stringify(body),
113
+ });
114
+ if (!res.ok) { const err = new Error(await res.text()); err.status = res.status; throw err; }
115
+ return res.json();
116
+ }
117
+ let jobsTimer = null;
118
+ async function pollJobs() {
119
+ if (!GENERATION) return;
120
+ try {
121
+ const res = await fetch('/api/jobs', { cache: 'no-store' });
122
+ if (!res.ok) throw new Error(await res.text());
123
+ GEN = await res.json();
124
+ } catch (err) {
125
+ $('note').textContent = ' Job status unavailable: ' + err.message;
126
+ return;
127
+ }
128
+ renderJobs();
129
+ renderHeader();
130
+ let changed = false;
131
+ for (const job of GEN.jobs) {
132
+ if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) {
133
+ ui.settled.add(job.id); changed = true;
134
+ // The open record hears how its job ended, where the strip was.
135
+ const open = ui.open && snap.items.find((i) => i.id === ui.open);
136
+ if (open && job.project === open.project && job.keys.includes(open.key)) {
137
+ ui.notice = { id: open.id, text: job.phase === 'failed'
138
+ ? (JOB_VERB[job.mode] || job.mode).replace(/ing$/, 'ing') + ' this asset failed: ' + (job.error || 'see the log above')
139
+ : job.mode === 'generate' ? 'Regenerated. The new result is on disk and recorded as this generation.'
140
+ : job.mode === 'restore' ? 'Restored. The recorded bytes are back on disk.'
141
+ : job.mode === 'revert' ? 'Brought back generation #' + job.revert + '. The one it replaced is now #1 in the list below.'
142
+ : job.mode === 'refresh' ? (job.counts.downloaded ? 'Pulled the upstream change; the lockfile records the new bytes.' : 'Nothing changed upstream; the file was left alone.')
143
+ : 'Done.' };
144
+ }
145
+ }
146
+ if (job.phase === 'review' && !ui.settled.has(job.id + ':review')) { ui.settled.add(job.id + ':review'); changed = true; }
147
+ }
148
+ if (changed) refresh();
149
+ else {
150
+ // A job in flight: keep the open record's strip and the card badges honest without a full re-render.
151
+ if (ui.open && !$('dialog-host').childNodes.length) renderDrawer();
152
+ for (const c of document.querySelectorAll('.card')) {
153
+ const item = snap.items.find((i) => i.id === c.dataset.key);
154
+ const job = item && activeJobFor(item);
155
+ const badge = c.querySelector('.busy-badge');
156
+ if (job && !badge) c.replaceWith(card(item));
157
+ else if (!job && badge) badge.remove();
158
+ }
159
+ }
160
+ const active = GEN.jobs.some((job) => ACTIVE_PHASES.has(job.phase) && job.phase !== 'review');
161
+ clearTimeout(jobsTimer);
162
+ if (active) jobsTimer = setTimeout(pollJobs, 2000);
163
+ }
164
+ function renderJobs() {
165
+ const host = $('jobs');
166
+ host.textContent = '';
167
+ if (!GENERATION) return;
168
+ for (const job of GEN.jobs) {
169
+ if (ui.settled.has(job.id + ':dismissed')) continue;
170
+ const row = el('div', 'job');
171
+ const ph = el('span', 'ph');
172
+ ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
173
+ const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'restore' ? 'restore ' : job.mode === 'revert' ? 'bring back generation #' + job.revert + ' of ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
174
+ (job.project ? ' in ' + job.project : '');
175
+ const last = el('span', 'last', what + (job.messages.length ? ' — ' + job.messages[job.messages.length - 1].trim() : ''));
176
+ last.title = job.keys.join('\n');
177
+ const acts = el('div', 'acts');
178
+ if (job.phase === 'review' && job.review.length) {
179
+ const b = el('button', 'primary', 'Review ' + job.review.length);
180
+ b.type = 'button'; b.onclick = () => openReview(job.id);
181
+ acts.append(b);
182
+ }
183
+ const logBtn = el('button', null, ui.logs.has(job.id) ? 'Hide log' : 'Log');
184
+ logBtn.type = 'button';
185
+ logBtn.onclick = () => { ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id); renderJobs(); };
186
+ acts.append(logBtn);
187
+ if (!ACTIVE_PHASES.has(job.phase)) {
188
+ const d = el('button', null, 'Dismiss');
189
+ d.type = 'button'; d.onclick = () => { ui.settled.add(job.id + ':dismissed'); renderJobs(); };
190
+ acts.append(d);
191
+ }
192
+ row.append(ph, last, acts);
193
+ if (ui.logs.has(job.id)) row.append(el('pre', null, job.messages.join('\n')));
194
+ host.append(row);
195
+ }
196
+ }
197
+ // ---- the in-browser editor (only when the server serves one) -------------
198
+ // The build is a 46 MB web export fetched once into a user-level cache and
199
+ // verified against hashes this PixelKiln release carries; the page only
200
+ // reports and asks, the server downloads. Nothing is fetched without a click.
201
+
202
+ let ED = null;
203
+ let edTimer = null;
204
+ const fmtMb = (bytes) => (bytes / 1e6).toFixed(1) + ' MB';
205
+ async function pollEditor() {
206
+ if (!EDITOR) return;
207
+ try {
208
+ const res = await fetch('/api/editor', { cache: 'no-store' });
209
+ if (!res.ok) throw new Error(await res.text());
210
+ ED = await res.json();
211
+ } catch (err) {
212
+ ED = { error: 'status unavailable: ' + err.message, installed: false, installing: null, missing: [], totalBytes: 0 };
213
+ }
214
+ renderTools();
215
+ clearTimeout(edTimer);
216
+ if (ED.installing) edTimer = setTimeout(pollEditor, 1000);
217
+ else if (ui.open) renderDrawer();
218
+ }
219
+ async function installEditor() {
220
+ try {
221
+ const res = await fetch('/api/editor/install', {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
224
+ body: '{}',
225
+ });
226
+ if (!res.ok) throw new Error(await res.text());
227
+ ED = await res.json();
228
+ } catch (err) {
229
+ ED = Object.assign({}, ED, { error: err.message });
230
+ }
231
+ renderTools();
232
+ clearTimeout(edTimer);
233
+ edTimer = setTimeout(pollEditor, 500);
234
+ }
235
+ function renderTools() {
236
+ const host = $('tools');
237
+ host.textContent = '';
238
+ if (!EDITOR || !ED) return;
239
+ const row = el('div', 'tool');
240
+ const ph = el('span', 'ph');
241
+ const last = el('span', 'last');
242
+ const acts = el('div', 'acts');
243
+ const name = 'Browser editor' + (ED.pixelorama ? ' (Pixelorama ' + ED.pixelorama + ')' : '');
244
+ if (ED.installing) {
245
+ const p = ED.installing;
246
+ ph.append(el('i', 'dot cool'), document.createTextNode('installing'));
247
+ last.textContent = name + ': fetching ' + (p.file || 'the build') + ' — ' + fmtMb(p.fetchedBytes) + ' of ' + fmtMb(p.totalBytes);
248
+ row.append(ph, last, acts);
249
+ const bar = el('progress');
250
+ if (p.totalBytes) { bar.max = p.totalBytes; bar.value = p.fetchedBytes; }
251
+ row.append(bar);
252
+ } else if (ED.installed) {
253
+ ph.append(el('i', 'dot ok'), document.createTextNode('ready'));
254
+ last.textContent = name + ' is installed and verified; a record’s Hand edit section opens sprites in it.';
255
+ last.title = ED.dir || '';
256
+ const b = el('button', null, 'Open editor');
257
+ b.type = 'button'; b.onclick = () => window.open(ED.url, '_blank', 'noopener');
258
+ acts.append(b);
259
+ row.append(ph, last, acts);
260
+ } else if (ED.error) {
261
+ ph.append(el('i', 'dot bad'), document.createTextNode('failed'));
262
+ last.textContent = name + ': ' + ED.error;
263
+ const b = el('button', 'primary', 'Retry install');
264
+ b.type = 'button'; b.onclick = installEditor;
265
+ acts.append(b);
266
+ row.append(ph, last, acts);
267
+ } else if (!ED.release) {
268
+ ph.append(el('i', 'dot dim'), document.createTextNode('unavailable'));
269
+ last.textContent = name + ': this PixelKiln version pins no published build.';
270
+ row.append(ph, last, acts);
271
+ } else {
272
+ ph.append(el('i', 'dot dim'), document.createTextNode('not installed'));
273
+ last.textContent = name + ': ' + fmtMb(ED.totalBytes - (ED.installedBytes || 0)) + ' fetched once from the PixelKiln release ' +
274
+ ED.release + ', verified against the hashes this version carries, and kept outside the project.';
275
+ const b = el('button', 'primary', 'Install editor');
276
+ b.type = 'button'; b.onclick = installEditor;
277
+ acts.append(b);
278
+ row.append(ph, last, acts);
279
+ }
280
+ host.append(row);
281
+ }
282
+
283
+ function budgetLine() {
284
+ const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
285
+ const parts = [];
286
+ for (const provider of [...providers].sort()) {
287
+ const left = remainingBudget(provider);
288
+ const unit = GEN.units[provider] || 'generations';
289
+ if (left !== null) parts.push(provider + ': ' + fmtCost(unit, Math.round(left * 100) / 100) + ' left');
290
+ }
291
+ // An unkeyed ceiling belongs to whichever single provider first spends it.
292
+ if (GEN.budget.amount !== undefined && !Object.keys(GEN.spent).length) parts.push(GEN.budget.amount + ' available');
293
+ return parts.length ? 'session budget — ' + parts.join(' · ') : '';
294
+ }
295
+
296
+ // The confirm step: what will be sent, what it is estimated to cost, and what
297
+ // this session may still spend. Mirrors gen's "Spend … on N asset(s)?" prompt.
298
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false, restore = false, revert = null, generation = null } = {}) {
299
+ const host = $('dialog-host');
300
+ host.textContent = '';
301
+ const wrap = el('div', 'dialog');
302
+ const form = el('form');
303
+ const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
304
+ const free = resume || refresh || restore || revert !== null;
305
+ const replacing = items.filter((i) => i.state === 'orphaned' && i.outputs.every((o) => o.exists) || i.state === 'untracked');
306
+ form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
307
+ : revert !== null ? 'Bring back generation #' + revert + ' of ' + items[0].key
308
+ : restore ? 'Restore ' + noun
309
+ : resume ? 'Resume ' + noun
310
+ : (force ? 'Regenerate ' : 'Generate ') + noun));
311
+ const table = el('table');
312
+ const thead = el('tr'); thead.append(el('th', null, 'asset'), el('th', null, 'state'), el('th', 'n', 'candidates'), el('th', 'n', 'estimate'));
313
+ table.append(thead);
314
+ const byProvider = new Map();
315
+ for (const item of items) {
316
+ const tr = el('tr');
317
+ tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '—'),
318
+ el('td', 'n', free ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
319
+ table.append(tr);
320
+ if (!free) {
321
+ const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
322
+ g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
323
+ }
324
+ }
325
+ form.append(table);
326
+ const sum = el('div', 'sum');
327
+ if (refresh) {
328
+ sum.append(el('div', null, 'Re-downloads each object from the provider and replaces the local file only when the object changed upstream — for example after editing it in the provider’s own editor. Unchanged objects are left alone; a file you changed locally is refused. Nothing is submitted.'));
329
+ } else if (revert !== null) {
330
+ sum.append(el('div', null, 'The current generation moves into this asset’s history and generation #' + revert + ' becomes current again: its object, hashes, and cost are recorded as this generation, and its bytes are written back ' +
331
+ (generation && generation.cached ? 'from the local cache.' : 'by re-downloading them from the provider.') + ' Nothing is generated; nothing upstream changes. Undo it from the same list.'));
332
+ if (generation && generation.promptDiffers) sum.append(el('div', 'warn', 'That generation was made with a different prompt than the current one; the record will show it as stale until the manifest matches or it is regenerated.'));
333
+ } else if (restore) {
334
+ sum.append(el('div', null, 'Puts the recorded bytes back on disk from the local cache or the provider object. Nothing is generated or submitted.'));
335
+ if (force && replacing.length) sum.append(el('div', 'warn', 'The file on disk was changed after PixelKiln wrote it. Restoring discards that change. To keep it instead, cancel and use Edit by hand or Edit in browser first: the edit is copied beside the generated file, and the record stays intact.'));
336
+ } else if (resume) {
337
+ sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
338
+ } else {
339
+ for (const [provider, g] of byProvider) {
340
+ const left = remainingBudget(provider);
341
+ const line = el('div');
342
+ line.append(el('b', null, provider + ': ' + fmtCost(g.unit, Math.round(g.cost * 100) / 100)));
343
+ line.append(document.createTextNode(left === null ? ' — no session budget for this provider'
344
+ : ' · ' + fmtCost(g.unit, Math.round(left * 100) / 100) + ' of the session budget left'));
345
+ if (left !== null && g.cost > left) line.className = 'warn';
346
+ sum.append(line);
347
+ }
348
+ if (force && replacing.length) sum.append(el('div', 'warn', (replacing.length === 1 ? 'This file' : replacing.length + ' of these files') + ' changed on disk after PixelKiln wrote it (or was never recorded). Generating replaces it and that change is lost. To keep it, cancel and use Edit by hand or Edit in browser first.'));
349
+ else if (force) sum.append(el('div', 'warn', 'Regenerating replaces the current art. The previous file is replaced when the new result is fetched; the provider objects it came from are not deleted.'));
350
+ sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
351
+ }
352
+ form.append(sum);
353
+ const actions = el('div', 'actions');
354
+ const go = el('button', 'primary', refresh ? 'Pull changes' : revert !== null ? 'Bring it back' : restore ? (force && replacing.length ? 'Restore and discard my change' : 'Restore') : resume ? 'Resume' : force && replacing.length ? 'Generate and replace' : 'Generate');
355
+ go.type = 'submit';
356
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
357
+ const msg = el('span', 'msg');
358
+ actions.append(go, cancel, msg);
359
+ form.append(actions);
360
+ form.onsubmit = async (e) => {
361
+ e.preventDefault();
362
+ go.disabled = true; msg.textContent = '';
363
+ try {
364
+ const body = { keys: items.map((i) => i.key) };
365
+ if (project) body.project = project;
366
+ if (force) body.force = true;
367
+ if (resume) body.resume = true;
368
+ if (refresh) body.refresh = true;
369
+ if (restore) body.restore = true;
370
+ if (revert !== null) body.revert = revert;
371
+ const job = await postGenerate(body);
372
+ host.textContent = '';
373
+ GEN.jobs.unshift(job);
374
+ renderJobs();
375
+ pollJobs();
376
+ } catch (err) {
377
+ go.disabled = false;
378
+ msg.textContent = err.message;
379
+ }
380
+ };
381
+ wrap.append(form);
382
+ host.append(wrap);
383
+ setTimeout(() => go.focus(), 0);
384
+ }
385
+
386
+ function openReview(jobId) {
387
+ const host = $('dialog-host');
388
+ host.textContent = '';
389
+ const scrim = el('div', 'sheet-scrim');
390
+ scrim.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
391
+ const panel = el('div', 'sheet review-host');
392
+ const bar = el('div', 'rbar');
393
+ const close = el('button', null, 'Back to gallery'); close.type = 'button';
394
+ close.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
395
+ bar.append(close, el('span', null, 'Choose from the candidates below. Apply selections writes the lockfile and downloads what you chose; unchosen rows stay in review.'));
396
+ const frame = el('iframe');
397
+ frame.src = '/review/' + encodeURIComponent(jobId);
398
+ frame.title = 'Candidate review';
399
+ panel.append(bar, frame);
400
+ host.append(scrim, panel);
401
+ }
402
+ window.addEventListener('message', (e) => {
403
+ if (e.origin !== location.origin || !e.data || typeof e.data.type !== 'string') return;
404
+ if (e.data.type === 'pixelkiln:review-applied') {
405
+ setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
406
+ return;
407
+ }
408
+ if (SHEET && e.source === SHEET.frame.contentWindow) onEditorMessage(e.data);
409
+ });
410
+
411
+ // ---- the in-browser editor sheet -------------------------------------------
412
+ // The page is the protocol host: it hands the editor the PNG and palette,
413
+ // asks it for the image back, and does the authenticated write itself. The
414
+ // iframe never sees the session token, and the server validates, sizes, and
415
+ // declares the file the same way pixelkiln edit does.
416
+
417
+ let SHEET = null;
418
+ // What the editor gets: the edit files when they are all there, else the
419
+ // generated outputs (one PNG, or every frame of a set).
420
+ const editorSources = (item) => {
421
+ if (item.edits.length && item.edits.every((e) => e.exists)) return { files: item.edits, fromEdit: true };
422
+ if (editableOutputs(item)) return { files: item.outputs, fromEdit: false };
423
+ return null;
424
+ };
425
+ const editorSource = (item) => { const s = editorSources(item); return s ? s.files[0] : null; };
426
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSources(item);
427
+ const toBase64 = (buf) => new Promise((resolve, reject) => {
428
+ const r = new FileReader();
429
+ r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
430
+ r.onerror = () => reject(r.error);
431
+ r.readAsDataURL(new Blob([buf]));
432
+ });
433
+ function sheetStatus(tone, text) {
434
+ if (!SHEET) return;
435
+ SHEET.status.textContent = '';
436
+ SHEET.status.append(el('i', 'dot ' + tone), document.createTextNode(text));
437
+ }
438
+ function sheetButtons() {
439
+ if (!SHEET) return;
440
+ const busy = !SHEET.opened || SHEET.pending !== null;
441
+ SHEET.save.disabled = busy || !SHEET.dirty;
442
+ SHEET.saveClose.disabled = busy;
443
+ }
444
+ function openEditorSheet(item) {
445
+ if (SHEET) return;
446
+ const src = editorSource(item);
447
+ const host = $('dialog-host');
448
+ host.textContent = '';
449
+ const scrim = el('div', 'sheet-scrim');
450
+ scrim.onclick = () => closeEditorSheet();
451
+ const panel = el('div', 'sheet editor-host');
452
+ const bar = el('div', 'rbar');
453
+ const close = el('button', null, 'Close'); close.type = 'button';
454
+ close.onclick = () => closeEditorSheet();
455
+ const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' · ' + item.width + '×' + item.height + (item.outputs.length > 1 ? ' · ' + item.outputs.length + ' ' + memberNoun(item, item.outputs.length) : ''));
456
+ const status = el('span', 'st');
457
+ const acts = el('div', 'acts');
458
+ const msg = el('span', 'msg');
459
+ const compare = el('label', 'chip'); compare.hidden = true;
460
+ compare.title = 'The generated art on a locked layer at 50%, for comparison; never part of the saved file';
461
+ const compareBox = el('input'); compareBox.type = 'checkbox'; compareBox.checked = true;
462
+ compareBox.onchange = () => showReference(compareBox.checked);
463
+ compare.append(compareBox, document.createTextNode(' show generated'));
464
+ const save = el('button', 'primary', 'Save to project'); save.type = 'button';
465
+ save.onclick = () => requestEditorSave(false);
466
+ const saveClose = el('button', null, 'Save & close'); saveClose.type = 'button';
467
+ saveClose.onclick = () => requestEditorSave(true);
468
+ acts.append(msg, compare, save, saveClose);
469
+ bar.append(close, title, status, acts);
470
+ const stage = el('div', 'stage');
471
+ const frame = el('iframe');
472
+ frame.title = 'Pixelorama';
473
+ frame.src = ED.url;
474
+ const loading = el('div', 'loading');
475
+ const loadText = el('div');
476
+ loadText.append(el('b', null, 'Loading the editor'), document.createTextNode('The first open compiles a 40 MB build; later opens come from the browser cache.'));
477
+ loading.append(loadText);
478
+ stage.append(frame, loading);
479
+ panel.append(bar, stage);
480
+ host.append(scrim, panel);
481
+ SHEET = { item, frame, loading, status, msg, save, saveClose, compare, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false, sentReference: false, reference: false, roles: null };
482
+ sheetStatus('cool', 'loading');
483
+ sheetButtons();
484
+ close.focus({ preventScroll: true });
485
+ }
486
+ // The edit files are what the editor gets; when a browser save kept the
487
+ // layered project beside them, that goes along too and the editor restores
488
+ // the layers, falling back to the flattened PNGs if the file cannot be read.
489
+ // A frame set is sent as frames, one per member, and comes back the same way.
490
+ async function sendOpen() {
491
+ const { item } = SHEET;
492
+ const sources = editorSources(item);
493
+ const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
494
+ const projectUrl = sources.fromEdit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
495
+ const set = sources.files.length > 1;
496
+ if (set && SHEET.ready.version < 3) {
497
+ SHEET.msg.textContent = 'This editor build cannot open sets; reinstall it with pixelkiln tools install editor.';
498
+ sheetStatus('bad', 'unsupported');
499
+ return;
500
+ }
501
+ // When an edit is being opened, the generated art rides along as a
502
+ // reference layer: what the editor shows through at 50% for comparison.
503
+ const referenceFiles = sources.fromEdit && SHEET.ready.version >= 4 && item.outputs.every((o) => o.exists && o.url) ? item.outputs : null;
504
+ const pngs = [];
505
+ const reference = [];
506
+ let pxo = null;
507
+ try {
508
+ for (const file of sources.files) {
509
+ const res = await fetch(file.url, { cache: 'no-store' });
510
+ if (!res.ok) throw new Error(await res.text());
511
+ pngs.push(await res.arrayBuffer());
512
+ }
513
+ if (projectUrl) {
514
+ const p = await fetch(projectUrl, { cache: 'no-store' });
515
+ if (p.ok) pxo = await p.arrayBuffer();
516
+ }
517
+ for (const file of referenceFiles || []) {
518
+ const res = await fetch(file.url, { cache: 'no-store' });
519
+ if (res.ok) reference.push({ role: file.role || null, png: await res.arrayBuffer() });
520
+ }
521
+ } catch (err) {
522
+ if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
523
+ return;
524
+ }
525
+ if (!SHEET) return;
526
+ const request = 'open-' + (++SHEET.requests);
527
+ SHEET.sentProject = !!pxo;
528
+ SHEET.roles = set ? item.outputs.map((o) => o.role || null) : null;
529
+ const message = {
530
+ type: 'pixelkiln:open', request,
531
+ asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
532
+ png: pngs[0], palette: style ? style.palette : [],
533
+ };
534
+ const transfer = [pngs[0]];
535
+ if (set) {
536
+ message.frames = pngs.map((png, i) => ({ role: SHEET.roles[i], png }));
537
+ // A tile set is not an animation; a slow rate keeps its timeline still-ish.
538
+ message.fps = isFrameSet(item) ? (item.fps || 12) : 1;
539
+ for (const png of pngs.slice(1)) transfer.push(png);
540
+ // The first frame is both png (older editors) and frames[0]; copy it so both transfer.
541
+ message.png = pngs[0].slice(0);
542
+ transfer.push(message.png);
543
+ }
544
+ if (pxo) { message.pxo = pxo; transfer.push(pxo); }
545
+ if (reference.length === (referenceFiles || []).length && reference.length) {
546
+ message.reference = reference;
547
+ for (const r of reference) transfer.push(r.png);
548
+ }
549
+ SHEET.sentReference = !!message.reference;
550
+ SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
551
+ sheetStatus('cool', 'opening');
552
+ }
553
+ function showReference(visible) {
554
+ if (!SHEET || !SHEET.opened || !SHEET.reference) return;
555
+ SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:reference', visible }, location.origin);
556
+ }
557
+ function requestEditorSave(close) {
558
+ if (!SHEET || !SHEET.opened || SHEET.pending) return;
559
+ const request = 'save-' + (++SHEET.requests);
560
+ SHEET.pending = { request, close };
561
+ SHEET.msg.textContent = '';
562
+ sheetStatus('cool', 'saving');
563
+ sheetButtons();
564
+ SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:request-save', request }, location.origin);
565
+ }
566
+ async function onEditorMessage(m) {
567
+ switch (m.type) {
568
+ case 'pixelkiln:ready':
569
+ SHEET.ready = m;
570
+ if (m.version !== ED.protocol) {
571
+ SHEET.msg.textContent = 'The editor speaks protocol ' + m.version + '; this gallery expects ' + ED.protocol + '. Reinstall it with pixelkiln tools install editor.';
572
+ sheetStatus('bad', 'mismatch');
573
+ return;
574
+ }
575
+ sendOpen();
576
+ break;
577
+ case 'pixelkiln:opened':
578
+ SHEET.opened = true;
579
+ SHEET.loading.remove();
580
+ SHEET.reference = !!m.reference;
581
+ SHEET.compare.hidden = !SHEET.reference;
582
+ if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file' + (m.frames > 1 ? 's' : '') + ' with ' + (m.layers === 1 ? 'the layer' : m.layers + ' layers') + (m.frames > 1 ? ' and ' + m.frames + ' ' + memberNoun(SHEET.item, m.frames) : '') + ' restored');
583
+ else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file' + (m.frames > 1 ? 's' : '') + '; the layer file could not be opened');
584
+ else if (m.source === 'frames') sheetStatus('ok', (SHEET.item.edits.length ? 'editing the edit files' : 'editing a copy of the generated ' + memberNoun(SHEET.item, m.frames)) + ' as ' + m.frames + ' frames' + (isFrameSet(SHEET.item) ? '' : ', one per ' + memberNoun(SHEET.item, 1)));
585
+ else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
586
+ if (SHEET.reference) SHEET.status.append(document.createTextNode(' · generated art shown through at 50%'));
587
+ sheetButtons();
588
+ break;
589
+ case 'pixelkiln:dirty':
590
+ SHEET.dirty = !!m.dirty;
591
+ if (SHEET.opened && !SHEET.pending) sheetStatus(SHEET.dirty ? 'warn' : 'ok', SHEET.dirty ? 'unsaved changes' : 'saved');
592
+ sheetButtons();
593
+ break;
594
+ case 'pixelkiln:save': {
595
+ const pending = SHEET.pending;
596
+ if (!pending || pending.request !== m.request) return;
597
+ try {
598
+ const body = { editor: SHEET.ready.editor, protocol: SHEET.ready.version };
599
+ if (SHEET.roles) {
600
+ if (!Array.isArray(m.frames)) throw new Error('the editor returned no frames');
601
+ body.frames = [];
602
+ for (const f of m.frames) body.frames.push({ role: f.role === undefined ? null : f.role, png: await toBase64(f.png) });
603
+ } else {
604
+ body.png = await toBase64(m.png);
605
+ }
606
+ if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
607
+ await postHandEdit(SHEET.item, 'save-edit', body);
608
+ const saved = snap.items.find((i) => i.id === SHEET.item.id);
609
+ if (saved) SHEET.item = saved;
610
+ SHEET.dirty = false;
611
+ SHEET.pending = null;
612
+ ui.notice = { id: SHEET.item.id, text: 'Saved ' + (saved && saved.edits.length > 1 ? saved.edits.length + ' ' + memberNoun(saved, saved.edits.length) + ' to ' + saved.source : saved && saved.edit ? saved.edit.path : 'the edit') + '. mount and pack place ' + (saved && saved.edits.length > 1 ? 'them' : 'it') + ' in place of the generated art.' };
613
+ render();
614
+ if (pending.close) { closeEditorSheet(true); return; }
615
+ sheetStatus('ok', 'saved');
616
+ } catch (err) {
617
+ SHEET.pending = null;
618
+ SHEET.msg.textContent = err.message + (err.status === 409 ? ' — press Refresh, then save again.' : '');
619
+ sheetStatus('bad', 'not saved');
620
+ }
621
+ sheetButtons();
622
+ break;
623
+ }
624
+ case 'pixelkiln:error':
625
+ SHEET.msg.textContent = m.message || 'The editor reported an error.';
626
+ if (SHEET.pending) { SHEET.pending = null; sheetStatus('bad', 'not saved'); sheetButtons(); }
627
+ break;
628
+ }
629
+ }
630
+ function closeEditorSheet(force) {
631
+ if (!SHEET) return;
632
+ if (SHEET.dirty && !force && !confirm('Discard unsaved changes in the editor?')) return;
633
+ SHEET = null;
634
+ $('dialog-host').textContent = '';
635
+ refresh();
636
+ }
637
+ window.addEventListener('beforeunload', (e) => {
638
+ if (SHEET && SHEET.dirty) { e.preventDefault(); e.returnValue = ''; }
639
+ });
640
+
641
+ function visibleItems() {
642
+ const q = ui.q.trim().toLowerCase();
643
+ let items = snap.items.filter((item) => {
644
+ if (ui.states.size && !ui.states.has(item.state)) return false;
645
+ if (ui.providers.size && !ui.providers.has(item.provider)) return false;
646
+ if (ui.generators.size && !ui.generators.has(item.generator)) return false;
647
+ if (ui.projects.size && !ui.projects.has(item.project)) return false;
648
+ if (!q) return true;
649
+ const hay = [item.id, item.prompt, item.currentPrompt, item.jobId, item.objectId,
650
+ item.recordedSpecHash, item.currentSpecHash, item.category, ...(item.tags || []),
651
+ ...item.outputs.flatMap((o) => [o.path, o.sha256])].filter(Boolean).join('\n').toLowerCase();
652
+ return hay.includes(q);
653
+ });
654
+ const when = (item) => item.downloadedAt || item.submittedAt || '';
655
+ const sorters = {
656
+ key: (a, b) => a.id.localeCompare(b.id),
657
+ newest: (a, b) => when(b).localeCompare(when(a)) || a.id.localeCompare(b.id),
658
+ oldest: (a, b) => when(a).localeCompare(when(b)) || a.id.localeCompare(b.id),
659
+ cost: (a, b) => (b.cost - a.cost) || a.id.localeCompare(b.id),
660
+ size: (a, b) => (b.width * b.height - a.width * a.height) || a.id.localeCompare(b.id),
661
+ };
662
+ items.sort(sorters[ui.sort] || sorters.key);
663
+ return items;
664
+ }
665
+
666
+ function renderHeader() {
667
+ $('editing').hidden = !EDITABLE;
668
+ $('foot-edit').hidden = EDITABLE;
669
+ $('foot-editing').hidden = !EDITABLE;
670
+ $('foot-gen').hidden = !GENERATION;
671
+ const p = $('project');
672
+ p.textContent = '';
673
+ if (snap.workspace) {
674
+ p.append(el('b', null, 'workspace'), ' ', document.createTextNode(shortPath(snap.workspace.path)));
675
+ p.title = snap.workspace.path;
676
+ } else {
677
+ p.append(el('b', null, snap.project.name), ' ', document.createTextNode(shortPath(snap.project.manifest)));
678
+ p.title = snap.project.manifest + '\nlockfile: ' + snap.project.lock;
679
+ }
680
+
681
+ const t = $('totals');
682
+ t.textContent = '';
683
+ if (snap.workspace) {
684
+ const broken = snap.workspace.projects.filter((pr) => pr.error).length;
685
+ const w = el('span', null, '');
686
+ w.append(el('b', null, snap.workspace.projects.length), document.createTextNode(' project' +
687
+ (snap.workspace.projects.length === 1 ? '' : 's') + (broken ? ' (' + broken + ' unreadable)' : '')));
688
+ t.append(w);
689
+ }
690
+ const n = snap.totals.entries;
691
+ t.append(el('span', null, ''));
692
+ t.lastChild.append(el('b', null, n), document.createTextNode(' lock ' + (n === 1 ? 'entry' : 'entries')));
693
+ const declared = snap.items.filter((i) => i.declared).length;
694
+ t.append(el('span', null, declared + ' declared by ' + (snap.workspace ? 'a manifest' : 'the manifest')));
695
+ const recorded = fmtSpend(snap.totals.spendByUnit);
696
+ t.append(el('span', null, recorded === 'no spend recorded' ? recorded : recorded + ' recorded'));
697
+ if (snap.filter.styles.length || snap.filter.assets.length) {
698
+ t.append(el('span', 'state-warn', 'filtered: ' +
699
+ [snap.filter.styles.length ? '--style ' + snap.filter.styles.join(',') : '',
700
+ snap.filter.assets.length ? '--only ' + snap.filter.assets.join(',') : ''].filter(Boolean).join(' ')));
701
+ }
702
+ t.append(el('span', null, 'snapshot ' + fmtWhen(snap.generatedAt)));
703
+ if (GENERATION) {
704
+ const line = budgetLine();
705
+ if (line) t.append(el('span', 'budget', line));
706
+ }
707
+
708
+ const chips = $('chips');
709
+ chips.textContent = '';
710
+ const counts = (pick) => {
711
+ const m = new Map();
712
+ for (const item of snap.items) { const k = pick(item); m.set(k, (m.get(k) || 0) + 1); }
713
+ return m;
714
+ };
715
+ const chip = (label, count, set, key, tone) => {
716
+ const b = el('button', 'chip' + (set.has(key) ? ' on' : ''));
717
+ b.type = 'button';
718
+ b.setAttribute('aria-pressed', String(set.has(key)));
719
+ if (tone) b.append(el('i', 'dot ' + tone));
720
+ b.append(document.createTextNode(label), el('span', 'n', count));
721
+ b.onclick = () => { set.has(key) ? set.delete(key) : set.add(key); render(); };
722
+ return b;
723
+ };
724
+ if (snap.workspace) {
725
+ const byProject = counts((i) => i.project);
726
+ for (const pr of snap.workspace.projects) {
727
+ if (byProject.has(pr.id) || ui.projects.has(pr.id)) chips.append(chip(pr.id, byProject.get(pr.id) || 0, ui.projects, pr.id));
728
+ }
729
+ chips.append(el('span', 'sep'));
730
+ }
731
+ const byState = counts((i) => i.state);
732
+ for (const s of STATE_ORDER) if (byState.has(s)) chips.append(chip(s, byState.get(s), ui.states, s, STATE_TONE[s]));
733
+ const byProvider = counts((i) => i.provider);
734
+ if (byProvider.size > 1 || ui.providers.size) {
735
+ chips.append(el('span', 'sep'));
736
+ for (const [k, v] of [...byProvider].sort()) chips.append(chip(k, v, ui.providers, k));
737
+ }
738
+ const byGen = counts((i) => i.generator);
739
+ if (byGen.size > 1 || ui.generators.size) {
740
+ chips.append(el('span', 'sep'));
741
+ for (const [k, v] of [...byGen].sort()) chips.append(chip(k, v, ui.generators, k));
742
+ }
743
+ if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.q) {
744
+ const clear = el('button', 'chip', 'clear filters');
745
+ clear.type = 'button';
746
+ clear.onclick = () => clearFilters();
747
+ chips.append(el('span', 'sep'), clear);
748
+ }
749
+ }
750
+
751
+ function clearFilters() {
752
+ ui.states.clear(); ui.providers.clear(); ui.generators.clear(); ui.projects.clear();
753
+ ui.q = ''; $('q').value = '';
754
+ render();
755
+ }
756
+
757
+ function thumb(item) {
758
+ const cell = el('div', 'cell');
759
+ // A hand edit is what ships, so it is what the card shows.
760
+ const shown = item.edit && item.editStatus === 'edited' && item.edits.every((e) => e.url) ? item.edits : item.outputs.filter((o) => o.url);
761
+ if (!shown.length) {
762
+ cell.classList.add('none');
763
+ cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
764
+ : item.state === 'in-flight' ? (item.status === 'review' ? 'awaiting review' : 'awaiting provider')
765
+ : item.state === 'recoverable' ? 'ready to fetch'
766
+ : item.state === 'failed' ? 'generation failed'
767
+ : item.state === 'blocked' ? 'waiting on its parent'
768
+ : item.outputs.length ? 'file missing on disk' : 'no output recorded'));
769
+ return cell;
770
+ }
771
+ const w = item.width, h = item.height;
772
+ if (shown.length > 1 && !isFrameSet(item)) {
773
+ const multi = el('div', 'multi');
774
+ for (const o of shown.slice(0, 4)) {
775
+ const box = el('div');
776
+ const img = el('img');
777
+ img.src = o.url; img.alt = o.role || item.assetId; img.loading = 'lazy';
778
+ const s = displayScale(w, h, 66, 52);
779
+ if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
780
+ box.append(img); multi.append(box);
781
+ }
782
+ cell.append(multi, el('span', 'badge', shown.length + ' outputs'));
783
+ return cell;
784
+ }
785
+ const img = el('img');
786
+ img.src = shown[0].url; img.alt = item.assetId; img.loading = 'lazy';
787
+ const s = displayScale(w, h, 156, 116);
788
+ if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
789
+ cell.append(img);
790
+ if (isFrameSet(item)) cell.append(el('span', 'badge', '▸ ' + shown.length + ' frames'));
791
+ else if (s >= 1 && s !== 1) cell.append(el('span', 'badge', s + '×'));
792
+ return cell;
793
+ }
794
+
795
+ function card(item) {
796
+ const slot = ui.compare.indexOf(item.id);
797
+ const c = el('button', 'card' + (item.outputs.some((o) => o.url) ? '' : ' ghost') + (ui.open === item.id ? ' active' : '') + (slot >= 0 ? ' compared' : ''));
798
+ c.type = 'button';
799
+ c.dataset.key = item.id;
800
+ c.setAttribute('aria-label', item.id + ', ' + item.state + (slot >= 0 ? ', in comparison' : ''));
801
+ const body = el('div', 'cbody');
802
+ body.append(el('div', 'aid', item.assetId));
803
+ const meta = el('div', 'cmeta');
804
+ const st = el('span', 'st');
805
+ st.append(el('i', 'dot ' + STATE_TONE[item.state]), document.createTextNode(item.state));
806
+ meta.append(st, el('span', null, item.width + '×' + item.height));
807
+ if (item.cost) meta.append(el('span', null, fmtCost(item.costUnit, item.cost)));
808
+ body.append(meta);
809
+ const cell = thumb(item);
810
+ if (slot >= 0) cell.append(el('span', 'slot', String(slot + 1)));
811
+ if (item.editStatus === 'edited' || item.editStatus === 'regenerated-since') {
812
+ const pen = el('span', 'pen', '✎'); pen.title = 'hand-edited'; cell.append(pen);
813
+ }
814
+ const job = activeJobFor(item);
815
+ if (job) {
816
+ const badge = el('span', 'busy-badge');
817
+ badge.append(el('i', 'dot busy'), document.createTextNode(job.phase === 'review' ? 'review' : job.mode === 'generate' ? 'generating' : job.mode));
818
+ badge.title = (JOB_VERB[job.mode] || job.mode) + ' this asset — ' + (PHASE_TEXT[job.phase] || job.phase);
819
+ cell.append(badge);
820
+ }
821
+ c.append(cell, body);
822
+ c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
823
+ return c;
824
+ }
825
+
826
+ function renderMain(items) {
827
+ const root = $('root');
828
+ root.textContent = '';
829
+ if (!snap.items.length) {
830
+ const e = el('div', 'empty');
831
+ e.append(el('p', null, 'Nothing has been generated for this project yet.'));
832
+ e.append(el('p', null, 'pixelkiln plan shows what the manifest would make; pixelkiln gen makes it.'));
833
+ root.append(e);
834
+ return;
835
+ }
836
+ if (!items.length) {
837
+ const e = el('div', 'empty');
838
+ e.append(el('p', null, 'No generations match these filters.'));
839
+ const b = el('button', null, 'Clear filters');
840
+ b.type = 'button';
841
+ b.onclick = clearFilters;
842
+ e.append(b);
843
+ root.append(e);
844
+ return;
845
+ }
846
+ // Building every card for a very large project on each keystroke is the one
847
+ // thing that makes this page feel slow, so a render is capped and the rest
848
+ // is one click away. Filters usually narrow well below the cap anyway.
849
+ const hidden = Math.max(0, items.length - ui.limit);
850
+ const shown = hidden ? items.slice(0, ui.limit) : items;
851
+ // A workspace always breaks on project: lock keys repeat across projects,
852
+ // so a flat wall of "anvil" cards would be ambiguous. Style grouping stays
853
+ // optional inside each project, exactly as it is for one project.
854
+ const unfiltered = !ui.projects.size && !ui.q && !ui.states.size && !ui.providers.size && !ui.generators.size;
855
+ const projects = snap.workspace
856
+ ? snap.workspace.projects.filter((pr) => ui.projects.size
857
+ ? ui.projects.has(pr.id)
858
+ : unfiltered || shown.some((i) => i.project === pr.id))
859
+ : [null];
860
+ const sections = [];
861
+ for (const pr of projects) {
862
+ const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
863
+ const styleSections = ui.group === 'style'
864
+ ? snap.styles.filter((s) => !pr || s.project === pr.id)
865
+ .map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length)
866
+ : (inProject.length ? [{ style: null, items: inProject }] : []);
867
+ if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
868
+ sections.push(...styleSections);
869
+ }
870
+ for (const sec of sections) {
871
+ if (sec.project) {
872
+ root.append(projectHeader(sec.project, sec.count));
873
+ continue;
874
+ }
875
+ const wrap = el('section', 'style');
876
+ if (sec.style) {
877
+ const s = sec.style;
878
+ const head = el('div', 'shead');
879
+ head.append(el('h2', null, s.id));
880
+ const meta = el('div', 'meta');
881
+ meta.append(el('span', null, s.provider), el('span', null, s.generator));
882
+ if (s.outDir) meta.append(el('span', 'mono', s.outDir));
883
+ if (s.palette.length) {
884
+ const sw = el('span', 'swatches');
885
+ for (const c of s.palette) { const i = el('i'); i.style.background = c; i.title = c; sw.append(i); }
886
+ meta.append(sw);
887
+ }
888
+ if (s.quality) meta.append(el('span', null, 'quality profile'));
889
+ meta.append(el('span', null, sec.items.length + ' of ' + s.items + (s.items === 1 ? ' asset' : ' assets')));
890
+ if (Object.keys(s.spendByUnit).length) meta.append(el('span', null, fmtSpend(s.spendByUnit)));
891
+ head.append(meta);
892
+ const addKey = 'new:' + (s.project || '') + ':' + s.id;
893
+ const tools = el('div', 'tools');
894
+ if (s.outDir && s.candidates !== null) tools.append(candidatesControl(s));
895
+ if (GENERATION && s.actionable.keys.length) {
896
+ const g = el('button', 'primary', 'Generate ' + s.actionable.keys.length + ' · ' + fmtCost(s.actionable.costUnit, Math.round(s.actionable.cost * 100) / 100));
897
+ g.type = 'button';
898
+ g.onclick = () => generateDialog(snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
899
+ tools.append(g);
900
+ }
901
+ const refreshable = snap.items.filter((i) => i.project === s.project && i.styleId === s.id && i.refreshable && i.upstreamUrl);
902
+ if (GENERATION && refreshable.length) {
903
+ const r = el('button', null, 'Pull upstream ' + refreshable.length); r.type = 'button';
904
+ r.title = 'Re-download objects edited in the provider’s own editor; no cost';
905
+ r.onclick = () => generateDialog(refreshable, { project: s.project, refresh: true });
906
+ tools.append(r);
907
+ }
908
+ const styleKey = 'style:' + (s.project || '') + ':' + s.id;
909
+ if (EDITABLE && s.outDir) {
910
+ const es = el('button', null, ui.editing === styleKey ? 'Cancel' : 'Edit style');
911
+ es.type = 'button';
912
+ es.onclick = () => { ui.editing = ui.editing === styleKey ? null : styleKey; ui.notice = null; render(); };
913
+ tools.append(es);
914
+ const add = el('button', 'add', ui.editing === addKey ? 'Cancel' : '+ Add asset');
915
+ add.type = 'button';
916
+ add.onclick = () => { ui.editing = ui.editing === addKey ? null : addKey; render(); };
917
+ tools.append(add);
918
+ }
919
+ if (tools.childNodes.length) head.append(tools);
920
+ wrap.append(head);
921
+ if (ui.notice && ui.notice.id === styleKey) wrap.append(el('div', 'notice', ui.notice.text));
922
+ if (ui.editing === styleKey) wrap.append(styleForm(s));
923
+ if (ui.editing === addKey) wrap.append(addAssetForm(s));
924
+ }
925
+ const grid = el('div', 'grid');
926
+ for (const item of sec.items) grid.append(card(item));
927
+ wrap.append(grid);
928
+ root.append(wrap);
929
+ }
930
+ if (hidden) {
931
+ const more = el('div', 'empty');
932
+ more.append(el('p', null, hidden + ' more ' + (hidden === 1 ? 'generation is' : 'generations are') + ' not shown.'));
933
+ const b = el('button', null, 'Show all ' + items.length);
934
+ b.type = 'button';
935
+ b.onclick = () => { ui.limit = Infinity; render(); };
936
+ more.append(b);
937
+ root.append(more);
938
+ }
939
+ }
940
+
941
+ function projectHeader(pr, count) {
942
+ const head = el('section', 'project');
943
+ const line = el('div', 'phead');
944
+ line.append(el('h2', null, pr.id));
945
+ const meta = el('div', 'meta');
946
+ if (pr.name !== pr.id) meta.append(el('span', null, pr.name));
947
+ meta.append(el('span', 'mono', shortPath(pr.manifest)));
948
+ if (pr.account) meta.append(el('span', null, pr.account));
949
+ if (pr.error) {
950
+ meta.append(el('span', 'state-bad', 'unreadable: ' + pr.error));
951
+ } else {
952
+ meta.append(el('span', null, count + ' of ' + pr.items + ' shown · ' + pr.entries + ' lock ' + (pr.entries === 1 ? 'entry' : 'entries')));
953
+ if (Object.keys(pr.spendByUnit).length) meta.append(el('span', null, fmtSpend(pr.spendByUnit)));
954
+ }
955
+ line.append(meta);
956
+ head.append(line);
957
+ return head;
958
+ }
959
+
960
+ // How many images one generation returns for this style. A provider option
961
+ // where the adapter has one; a fact of the generator where it does not.
962
+ function candidatesControl(style) {
963
+ const wrap = el('span', 'cand');
964
+ const editKey = 'cand:' + (style.project || '') + ':' + style.id;
965
+ const declared = snap.items.filter((i) => i.project === style.project && i.styleId === style.id && i.declared).length;
966
+ if (!(EDITABLE && style.candidatesEditable) || ui.editing !== editKey) {
967
+ wrap.append(el('span', null, style.candidates + (style.candidates === 1 ? ' candidate' : ' candidates') + ' per generation'));
968
+ if (EDITABLE && style.candidatesEditable) {
969
+ const b = el('button', null, 'change'); b.type = 'button';
970
+ b.onclick = () => { ui.editing = editKey; render(); };
971
+ wrap.append(b);
972
+ } else if (style.provider === 'pixellab') {
973
+ wrap.title = 'PixelLab: map and pixflux return one image per generation; a 1dir style returns 4–64 for its size.';
974
+ }
975
+ return wrap;
976
+ }
977
+ const input = el('input'); input.type = 'number'; input.min = '1'; input.max = '64'; input.value = String(style.candidates);
978
+ const save = el('button', 'primary', 'Save'); save.type = 'button';
979
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
980
+ const note = el('span', null, 'affects ' + declared + (declared === 1 ? ' asset' : ' assets') + ' — they become stale');
981
+ save.onclick = async () => {
982
+ save.disabled = true;
983
+ try {
984
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
985
+ const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch: { candidates: Number(input.value) } };
986
+ if (style.project) body.project = style.project;
987
+ snap = await postEdit(body);
988
+ ui.editing = null;
989
+ render();
990
+ } catch (err) {
991
+ save.disabled = false;
992
+ note.textContent = err.message;
993
+ note.className = 'state-bad';
994
+ }
995
+ };
996
+ wrap.append(input, save, cancel, note);
997
+ setTimeout(() => input.focus(), 0);
998
+ return wrap;
999
+ }
1000
+
1001
+ // The generations this one replaced: what the lockfile still remembers, with
1002
+ // thumbnails from the content cache while the bytes are there, and a way back.
1003
+ function historySection(item) {
1004
+ const limit = projectOf(item)?.historyLimit;
1005
+ if (!item.history.length && !(limit > 0 && item.status === 'downloaded')) return null;
1006
+ const s = el('section', 'meta');
1007
+ s.append(el('h3', null, item.history.length ? 'Previous generations (' + item.history.length + ')' : 'Previous generations'));
1008
+ if (!item.history.length) {
1009
+ s.append(el('div', 'state-dim', 'None yet. A regeneration keeps the generation it replaces — up to ' + limit + ' per asset (PIXELKILN_HISTORY, or the manifest’s history) — so it can be brought back from here.'));
1010
+ return s;
1011
+ }
1012
+ const list = el('div', 'versions');
1013
+ const job = GENERATION ? activeJobFor(item) : null;
1014
+ for (const g of item.history) {
1015
+ const v = el('div', 'version');
1016
+ const thumb = el('div', 'thumb');
1017
+ const first = g.outputs[0];
1018
+ if (first && first.url) { const img = el('img'); img.src = first.url; img.alt = 'generation #' + g.index; thumb.append(img); }
1019
+ else thumb.append(el('span', null, g.cached ? '' : 'not cached'));
1020
+ const facts = el('div', 'facts');
1021
+ const head = el('div');
1022
+ head.append(el('b', null, '#' + g.index), document.createTextNode(' · ' + fmtWhen(g.downloadedAt || g.submittedAt) + ' · ' + fmtCost(g.costUnit, g.cost) +
1023
+ (g.outputs.length > 1 ? ' · ' + g.outputs.length + ' files' : '')));
1024
+ facts.append(head);
1025
+ const prompt = el('div', 'prompt' + (g.promptDiffers ? ' differs' : ''), (g.promptDiffers ? 'prompt: ' : 'same prompt: ') + g.prompt);
1026
+ prompt.title = g.prompt;
1027
+ facts.append(prompt);
1028
+ const meta = el('div', 'meta');
1029
+ meta.append(document.createTextNode((first ? first.sha256.slice(0, 12) + '…' : '') + (g.cached ? ' · bytes cached locally' : ' · not in the local cache; a restore re-downloads it')));
1030
+ if (g.upstreamUrl) { const a = el('a', null, 'open in ' + item.provider + ' ↗'); a.href = g.upstreamUrl; a.target = '_blank'; a.rel = 'noopener'; meta.append(document.createTextNode(' · '), a); }
1031
+ facts.append(meta);
1032
+ const acts = el('div', 'acts');
1033
+ if (GENERATION) {
1034
+ const b = el('button', null, 'Restore this one · no cost'); b.type = 'button';
1035
+ b.disabled = !!job;
1036
+ b.title = job ? 'Wait for the running job to finish' : 'The current generation moves into history; this one becomes current and its bytes are written back';
1037
+ b.onclick = () => generateDialog([item], { project: item.project, revert: String(g.index), generation: g });
1038
+ acts.append(b);
1039
+ }
1040
+ v.append(thumb, facts, acts);
1041
+ list.append(v);
1042
+ }
1043
+ s.append(list);
1044
+ s.append(el('div', 'state-dim', 'Keeping up to ' + limit + ' per asset; a restore moves the current generation into this list, so it can be undone the same way.' +
1045
+ (GENERATION ? '' : ' Restoring needs the gallery started with --budget (any amount; nothing is spent).')));
1046
+ return s;
1047
+ }
1048
+
1049
+ function upstreamSection(item) {
1050
+ if (!item.upstreamUrl && !(GENERATION && item.refreshable)) return null;
1051
+ const s = el('section', 'meta');
1052
+ s.append(el('h3', null, 'Upstream'));
1053
+ const dl = el('dl');
1054
+ if (item.upstreamUrl) {
1055
+ const a = el('a', null, 'Open in ' + item.provider + ' ↗');
1056
+ a.href = item.upstreamUrl; a.target = '_blank'; a.rel = 'noopener';
1057
+ const tiles = item.generator === 'tiles';
1058
+ row(dl, tiles ? 'tile set' : 'object', a);
1059
+ row(dl, 'note', item.provider === 'pixellab'
1060
+ ? (tiles ? 'The tile set this generation was chosen from. Edit it there with PixelLab’s editor, then pull the changes back here; the lockfile records the new bytes as this generation.'
1061
+ : 'The account object this generation came from. Edit it there with PixelLab’s editor, then pull the changes back here; the lockfile records the new bytes as this generation.')
1062
+ : 'The provider’s page for this object.');
1063
+ }
1064
+ s.append(dl);
1065
+ if (GENERATION && item.refreshable) {
1066
+ const acts = el('div', 'hand-actions');
1067
+ const b = el('button', null, 'Pull upstream changes · no cost'); b.type = 'button';
1068
+ b.onclick = () => generateDialog([item], { project: item.project, refresh: true });
1069
+ acts.append(b);
1070
+ s.append(acts);
1071
+ }
1072
+ return s;
1073
+ }
1074
+
1075
+ // What a job is doing to this record right now. The record's state line still
1076
+ // says what is on disk; this says what is about to replace it.
1077
+ function jobStrip(item, job) {
1078
+ const strip = el('div', 'job-strip');
1079
+ const ph = el('span', 'ph');
1080
+ ph.append(el('i', 'dot busy'), document.createTextNode(job.phase));
1081
+ const last = el('span', 'last', job.messages.length ? job.messages[job.messages.length - 1].trim() : '');
1082
+ last.title = job.messages.join('\n');
1083
+ const what = el('span', 'what', (JOB_VERB[job.mode] || job.mode) + ' this asset — ' + (PHASE_TEXT[job.phase] || job.phase) +
1084
+ (job.mode === 'generate' && job.phase !== 'review' ? '. The current file stays until the new result is downloaded.' : '.'));
1085
+ strip.append(ph, last, what);
1086
+ const acts = el('div', 'acts');
1087
+ if (job.phase === 'review' && job.review.includes(item.key)) {
1088
+ const b = el('button', 'primary', 'Review candidates'); b.type = 'button'; b.onclick = () => openReview(job.id);
1089
+ acts.append(b);
1090
+ }
1091
+ const logBtn = el('button', null, ui.logs.has(job.id) ? 'Hide log' : 'Log'); logBtn.type = 'button';
1092
+ logBtn.onclick = () => { ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id); renderDrawer(); };
1093
+ acts.append(logBtn);
1094
+ strip.append(acts);
1095
+ if (ui.logs.has(job.id)) { const pre = el('pre', null, job.messages.join('\n')); pre.style.gridColumn = '1 / -1'; strip.append(pre); }
1096
+ return strip;
1097
+ }
1098
+
1099
+ function generateActions(item) {
1100
+ const row = el('div', 'gen');
1101
+ if (!GENERATION || !item.declared || item.currentSpecHash === null) return row;
1102
+ const cost = item.estimatedCost === null ? '' : ' · ' + fmtCost(item.costUnit, item.estimatedCost);
1103
+ const add = (label, cls, opts) => {
1104
+ const b = el('button', cls, label); b.type = 'button';
1105
+ b.onclick = () => generateDialog([item], { project: item.project, ...opts });
1106
+ row.append(b);
1107
+ };
1108
+ if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
1109
+ else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
1110
+ else if (item.state === 'orphaned' && item.outputs.every((o) => o.exists)) {
1111
+ // The file changed after download. Putting the recorded bytes back and
1112
+ // regenerating both discard that change, so both say so and ask.
1113
+ add('Restore recorded bytes · no cost', null, { restore: true, force: true });
1114
+ add('Regenerate' + cost, null, { force: true });
1115
+ } else if (item.state === 'orphaned') add('Restore · no cost', 'primary', { restore: true });
1116
+ else if (item.state === 'untracked') add('Generate' + cost + ' · replaces the file', null, { force: true });
1117
+ if (item.state === 'in-flight' || item.state === 'recoverable') {
1118
+ // A job already parked this asset in review: go straight to its sheet.
1119
+ const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
1120
+ if (holder) {
1121
+ const b = el('button', 'primary', 'Review candidates'); b.type = 'button';
1122
+ b.onclick = () => openReview(holder.id);
1123
+ row.append(b);
1124
+ } else {
1125
+ add(item.status === 'review' ? 'Resume review' : 'Resume · no cost', 'primary', { resume: true });
1126
+ }
1127
+ }
1128
+ return row;
1129
+ }
1130
+
1131
+ // ---- compare ---------------------------------------------------------------
1132
+
1133
+ function toggleCompare(id) {
1134
+ const at = ui.compare.indexOf(id);
1135
+ if (at >= 0) ui.compare.splice(at, 1);
1136
+ else if (ui.compare.length >= 4) { $('note').textContent = ' Compare holds four records; remove one first.'; return; }
1137
+ else ui.compare.push(id);
1138
+ $('note').textContent = '';
1139
+ render();
1140
+ }
1141
+ function renderTray() {
1142
+ let tray = $('tray');
1143
+ if (!ui.compare.length) { if (tray) tray.remove(); return; }
1144
+ if (!tray) { tray = el('div', 'tray'); tray.id = 'tray'; document.body.append(tray); }
1145
+ tray.textContent = '';
1146
+ const chips = el('div', 'chipset');
1147
+ for (const id of ui.compare) {
1148
+ const item = snap.items.find((i) => i.id === id);
1149
+ if (!item) continue;
1150
+ const pick = el('span', 'pick');
1151
+ const shown = item.outputs.find((o) => o.url);
1152
+ if (shown) { const t = el('img'); t.src = shown.url; t.alt = ''; pick.append(t); }
1153
+ pick.append(document.createTextNode((item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId));
1154
+ const x = el('button', null, '×'); x.type = 'button'; x.title = 'Remove from comparison';
1155
+ x.onclick = () => toggleCompare(id);
1156
+ pick.append(x);
1157
+ chips.append(pick);
1158
+ }
1159
+ const go = el('button', 'primary', 'Compare ' + ui.compare.length); go.type = 'button';
1160
+ go.disabled = ui.compare.length < 2;
1161
+ go.onclick = () => openCompare();
1162
+ const clear = el('button', null, 'Clear'); clear.type = 'button';
1163
+ clear.onclick = () => { ui.compare = []; render(); };
1164
+ tray.append(chips, go, clear);
1165
+ }
1166
+ function openCompare() {
1167
+ const items = ui.compare.map((id) => snap.items.find((i) => i.id === id)).filter(Boolean);
1168
+ if (items.length < 2) return;
1169
+ const host = $('dialog-host');
1170
+ host.textContent = '';
1171
+ const scrim = el('div', 'sheet-scrim'); scrim.onclick = () => { host.textContent = ''; };
1172
+ const panel = el('div', 'sheet compare-host');
1173
+ const bar = el('div', 'rbar');
1174
+ const close = el('button', null, 'Back to gallery'); close.type = 'button'; close.onclick = () => { host.textContent = ''; };
1175
+ bar.append(close, el('span', null, 'Fields that differ are tinted. Shift-click cards in the gallery to change the set.'));
1176
+ const zoomBar = el('div', 'zoom');
1177
+ zoomBar.append(el('span', null, 'zoom'));
1178
+ const columnWidth = Math.max(160, Math.floor((Math.min(window.innerWidth * 0.96, 1500) - 140) / items.length) - 24);
1179
+ const largest = Math.max(...items.map((i) => Math.max(i.width, i.height)));
1180
+ const auto = displayScale(largest, largest, columnWidth, Math.min(window.innerHeight * 0.5, 520));
1181
+ for (const z of ['fit', 1, 2, 4, 8]) {
1182
+ const b = el('button', ui.compareZoom === String(z) || (ui.compareZoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '×');
1183
+ b.type = 'button'; b.onclick = () => { ui.compareZoom = String(z); openCompare(); };
1184
+ zoomBar.append(b);
1185
+ }
1186
+ bar.append(zoomBar);
1187
+ const zoom = ui.compareZoom === 'auto' ? auto : ui.compareZoom === 'fit' ? Math.min(auto, 1) : Number(ui.compareZoom);
1188
+
1189
+ const body = el('div', 'compare-body');
1190
+ const table = el('table');
1191
+ const thead = el('thead'); const hr = el('tr'); hr.append(el('th', null, ''));
1192
+ for (const item of items) {
1193
+ const th = el('th');
1194
+ const head = el('div', 'chead');
1195
+ const badges = el('div', 'badges');
1196
+ if (item.project) badges.append(el('span', 'sid project', item.project));
1197
+ badges.append(el('span', 'sid', item.styleId));
1198
+ const aid = el('div', 'aid', item.assetId);
1199
+ const openBtn = el('button', 'linkish', 'open record'); openBtn.type = 'button';
1200
+ openBtn.onclick = () => { host.textContent = ''; openItem(item.id); };
1201
+ head.append(badges, aid, openBtn);
1202
+ th.append(head);
1203
+ hr.append(th);
1204
+ }
1205
+ thead.append(hr); table.append(thead);
1206
+ const tbody = el('tbody');
1207
+
1208
+ // Images first, at one shared zoom, so like is compared with like.
1209
+ const imgRow = el('tr'); imgRow.append(el('td', null, 'art'));
1210
+ for (const item of items) {
1211
+ const td = el('td');
1212
+ const box = el('div', 'cimg');
1213
+ const shown = item.outputs.find((o) => o.url);
1214
+ if (shown) {
1215
+ const img = el('img'); img.src = shown.url; img.alt = item.assetId;
1216
+ if (zoom >= 1) { img.width = Math.round(item.width * zoom); img.height = Math.round(item.height * zoom); }
1217
+ else { img.style.maxWidth = '100%'; img.style.height = 'auto'; }
1218
+ box.append(img);
1219
+ } else box.append(el('span', 'none', 'no art on disk'));
1220
+ td.append(box);
1221
+ if (item.outputs.filter((o) => o.url).length > 1) td.append(el('div', 'state-dim', item.outputs.length + ' outputs; first shown'));
1222
+ imgRow.append(td);
1223
+ }
1224
+ tbody.append(imgRow);
1225
+
1226
+ const rows = [
1227
+ ['state', (i) => i.state + (i.reason ? ' — ' + i.reason : ''), (i) => i.state],
1228
+ ['provider', (i) => i.provider],
1229
+ ['generator', (i) => i.generator + (i.tileFeature ? ' · ' + i.tileFeature : '')],
1230
+ ['candidates', (i) => i.candidates ?? '—'],
1231
+ ['size', (i) => i.width + ' × ' + i.height],
1232
+ ['cost', (i) => i.status ? fmtCost(i.costUnit, i.cost) : (i.estimatedCost !== null ? 'est. ' + fmtCost(i.costUnit, i.estimatedCost) : '—')],
1233
+ ['prompt', (i) => i.prompt, null, 'prompt'],
1234
+ ['submitted', (i) => fmtWhen(i.submittedAt) || '—'],
1235
+ ['downloaded', (i) => fmtWhen(i.downloadedAt) || '—'],
1236
+ ['quality', (i) => i.quality ? i.quality.state + (i.quality.review && i.quality.review.status === 'approved' ? ' by ' + i.quality.review.reviewer : '') : '—'],
1237
+ ['spec hash', (i) => i.recordedSpecHash ? i.recordedSpecHash.slice(0, 16) + '…' : '—', null, 'mono'],
1238
+ ['sha256', (i) => i.outputs[0] && i.outputs[0].sha256 ? i.outputs[0].sha256.slice(0, 16) + '…' : '—', null, 'mono'],
1239
+ ['tags', (i) => (i.tags || []).filter((t) => !/^(pixelkiln|asset|style):/.test(t)).join(', ') || '—'],
1240
+ ];
1241
+ for (const [label, show, keyOf, cls] of rows) {
1242
+ const tr = el('tr'); tr.append(el('td', null, label));
1243
+ const keys = items.map((i) => String((keyOf || show)(i)));
1244
+ const differs = new Set(keys).size > 1;
1245
+ for (const item of items) {
1246
+ const td = el('td', (differs ? 'diff' : '') + (cls === 'mono' ? ' mono' : ''));
1247
+ if (cls === 'prompt') td.append(el('div', 'prompt', show(item))); else td.textContent = String(show(item));
1248
+ tr.append(td);
1249
+ }
1250
+ tbody.append(tr);
1251
+ }
1252
+ table.append(tbody);
1253
+ body.append(table);
1254
+ panel.append(bar, body);
1255
+ host.append(scrim, panel);
1256
+ close.focus({ preventScroll: true });
1257
+ }
1258
+
1259
+ // ---- hand edits -------------------------------------------------------------
1260
+
1261
+ const EDIT_STATUS_TEXT = {
1262
+ same: 'The generated art’s pixels, not changed yet. Open it in your editor and save.',
1263
+ edited: 'Differs from the generated art. mount and pack place this file; the generated file stays as the record.',
1264
+ 'regenerated-since': 'The generated art changed after this edit was saved — the edit is based on an older generation.',
1265
+ missing: 'The declared file is not on disk.',
1266
+ };
1267
+ async function postHandEdit(item, action, extra) {
1268
+ const pr = projectOf(item);
1269
+ const body = { action, assetId: item.assetId, styleId: item.styleId, expectedSha256: pr.manifestSha256, ...extra };
1270
+ if (item.project) body.project = item.project;
1271
+ snap = await postEdit(body);
1272
+ }
1273
+ // A single PNG or a set of them — frames, tiles — is hand-editable, one file
1274
+ // per member; a set with a GIF in it is not.
1275
+ const editableOutputs = (item) => item.outputs.length && item.outputs.every((o) => o.exists && o.mediaType === 'image/png');
1276
+ // What the members of a set are called: frames for an animation, tiles for a tile set.
1277
+ const memberNoun = (item, n) => isFrameSet(item) ? (n === 1 ? 'frame' : 'frames')
1278
+ : item.outputs.every((o) => o.role && /^tile/i.test(o.role)) ? (n === 1 ? 'tile' : 'tiles') : (n === 1 ? 'member' : 'members');
1279
+ function handEditSection(item) {
1280
+ const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editableOutputs(item);
1281
+ if (!item.edit && !canStart) return null;
1282
+ const set = item.edits.length > 1;
1283
+ const s = el('section', 'meta');
1284
+ s.append(el('h3', null, set ? 'Hand edit (' + item.edits.length + ' ' + memberNoun(item, item.edits.length) + ')' : 'Hand edit'));
1285
+ if (item.edit) {
1286
+ const pair = el('div', 'pair');
1287
+ const shared = displayScale(item.width, item.height, 200, 160);
1288
+ const fig = (out, label) => {
1289
+ const f = el('figure');
1290
+ if (out && out.url) {
1291
+ const img = el('img'); img.src = out.url; img.alt = label;
1292
+ if (shared >= 1) { img.width = item.width * shared; img.height = item.height * shared; }
1293
+ f.append(img);
1294
+ } else f.append(el('span', 'state-dim', 'not on disk'));
1295
+ f.append(el('figcaption', null, label));
1296
+ return f;
1297
+ };
1298
+ // For a set, the member the preview is on; the strip there steps through them.
1299
+ const at = set ? Math.min(ui.member, item.edits.length - 1) : 0;
1300
+ const which = set ? ' · ' + (item.outputs[at].role || memberNoun(item, 1) + ' ' + (at + 1)) : '';
1301
+ pair.append(fig(item.outputs[at], 'generated' + which), fig(item.edits[at], 'edit' + which));
1302
+ s.append(pair);
1303
+ const dl = el('dl');
1304
+ const tone = item.editStatus === 'edited' ? 'ok' : item.editStatus === 'same' ? 'dim' : 'warn';
1305
+ const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone), document.createTextNode(' ' + item.editStatus));
1306
+ row(dl, 'status', st);
1307
+ row(dl, 'why', EDIT_STATUS_TEXT[item.editStatus] || '');
1308
+ if (set) {
1309
+ row(dl, 'files', item.source + ' — one per ' + memberNoun(item, 1) + ': ' + item.edits.map((e) => e.path.split('/').pop()).join(', '), { mono: true, copy: item.edits.map((e) => e.absolutePath).join('\n') });
1310
+ const differing = item.editChanged.filter(Boolean).length;
1311
+ row(dl, 'changed', differing + ' of ' + item.edits.length + ' ' + memberNoun(item, item.edits.length) + ' differ from the generated art' +
1312
+ (differing ? ': ' + item.edits.filter((e, i) => item.editChanged[i]).map((e) => item.outputs[item.edits.indexOf(e)].role || e.path.split('/').pop()).join(', ') : ''));
1313
+ } else {
1314
+ row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
1315
+ if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '…', { mono: true, copy: item.edit.sha256 });
1316
+ }
1317
+ row(dl, 'saved', fmtWhen(item.edits.reduce((latest, e) => e.modifiedAt && (!latest || e.modifiedAt > latest) ? e.modifiedAt : latest, null)));
1318
+ if (item.editMeta) {
1319
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' — a file changed since' : ''));
1320
+ if (item.editMeta.basedOn && !set) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '…', { mono: true, copy: item.editMeta.basedOn });
1321
+ if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
1322
+ }
1323
+ s.append(dl);
1324
+ } else {
1325
+ s.append(el('div', 'state-dim', item.outputs.length > 1
1326
+ ? 'Touch the ' + memberNoun(item, 2) + ' up in your own editor or in the browser, where they open as ' + (isFrameSet(item) ? 'one animation' : 'the frames of one project') + '. The generated files stay untouched; the edit is one sibling file per ' + memberNoun(item, 1) + ' that the manifest points at, and mount, pack, and export place those instead.'
1327
+ : 'Touch it up in your own editor. The generated file stays untouched; the edit is a sibling file the manifest points at, and mount and pack place that instead.'));
1328
+ }
1329
+ if (EDITABLE && item.declared && projectOf(item)?.manifestSha256) {
1330
+ const acts = el('div', 'hand-actions');
1331
+ const msg = el('span', 'msg');
1332
+ const go = async (label, action, extra, done) => {
1333
+ const b = el('button', label === 'Detach edit' || (EDITOR && ED && ED.installed) ? null : 'primary', label); b.type = 'button';
1334
+ b.onclick = async () => {
1335
+ b.disabled = true; msg.className = 'msg'; msg.textContent = '…';
1336
+ try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
1337
+ catch (err) { b.disabled = false; msg.className = 'msg bad'; msg.textContent = err.message + (err.status === 409 ? ' — press Refresh.' : ''); }
1338
+ };
1339
+ acts.append(b);
1340
+ };
1341
+ if (EDITOR && ED && (item.edit || canStart) && editorSource(item)) {
1342
+ if (ED.installed) {
1343
+ const b = el('button', 'primary', 'Edit in browser'); b.type = 'button';
1344
+ b.title = 'Open in Pixelorama here; Save writes the edit file and declares it';
1345
+ b.onclick = () => openEditorSheet(item);
1346
+ acts.append(b);
1347
+ } else if (ED.release && !ED.installing) {
1348
+ const b = el('button', null, 'Install editor to edit in browser'); b.type = 'button';
1349
+ b.onclick = () => { installEditor(); window.scrollTo({ top: 0 }); };
1350
+ acts.append(b);
1351
+ }
1352
+ }
1353
+ if (item.edit) {
1354
+ go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
1355
+ go('Detach edit', 'detach-edit', {}, 'Detached. The file' + (set ? 's are' : ' is') + ' still at ' + item.source + '; the generated art is placed again.');
1356
+ } else if (canStart) {
1357
+ const many = item.outputs.length > 1;
1358
+ go('Edit by hand', 'start-edit', { open: true }, 'Created the edit file' + (many ? 's and opened the first ' + memberNoun(item, 1) : ' and opened it') + ' in your editor. Save there, then Refresh to see it here.');
1359
+ go('Create edit file' + (many ? 's' : '') + ' only', 'start-edit', { open: false }, 'Created the edit file' + (many ? 's' : '') + ' and declared ' + (many ? 'them' : 'it') + ' in the manifest.');
1360
+ }
1361
+ acts.append(msg);
1362
+ s.append(acts);
1363
+ }
1364
+ return s;
1365
+ }
1366
+
1367
+ // ---- editing (only when the server minted a session) ---------------------
1368
+
1369
+ // Styles a parent edit reaches: every descendant (via extends) that does not
1370
+ // declare the field itself. Child-wins means an omitted field is inherited,
1371
+ // so the count follows the manifest's own rule rather than guessing.
1372
+ function inheritorsOf(style, field) {
1373
+ const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
1374
+ const out = [];
1375
+ for (const other of byId.values()) {
1376
+ if (other.id === style.id) continue;
1377
+ let cur = other, blocked = false, hops = 0;
1378
+ while (cur && cur.id !== style.id && hops++ < 32) {
1379
+ if (cur.ownFields.includes(field)) { blocked = true; break; }
1380
+ cur = cur.extends ? byId.get(cur.extends) : null;
1381
+ }
1382
+ if (cur && cur.id === style.id && !blocked) out.push(other.id);
1383
+ }
1384
+ return out;
1385
+ }
1386
+ // noBackground reaches the request only for pixflux and non-PixelLab
1387
+ // providers; elsewhere a change is recorded but alters nothing.
1388
+ const fieldReaches = (field, style) =>
1389
+ field !== 'noBackground' || style.generator === 'pixflux' || style.provider !== 'pixellab';
1390
+ function blastRadius(style, fields) {
1391
+ const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
1392
+ const styleIds = new Set();
1393
+ for (const field of fields) {
1394
+ for (const id of [style.id, ...inheritorsOf(style, field)]) {
1395
+ if (fieldReaches(field, byId.get(id) || style)) styleIds.add(id);
1396
+ }
1397
+ }
1398
+ const affected = snap.items.filter((i) => i.project === style.project && i.declared && styleIds.has(i.styleId));
1399
+ const units = new Map();
1400
+ for (const i of affected) if (i.estimatedCost !== null) units.set(i.costUnit, (units.get(i.costUnit) || 0) + i.estimatedCost);
1401
+ return {
1402
+ styles: [...styleIds],
1403
+ assets: affected.length,
1404
+ cost: [...units].map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + '),
1405
+ };
1406
+ }
1407
+ const parsePalette = (value) => value.split(/[\s,]+/).map((c) => c.trim()).filter(Boolean)
1408
+ .map((c) => '#' + c.replace(/^#/, '').toLowerCase());
1409
+ const paletteValid = (colors) => colors.every((c) => /^#[0-9a-f]{6}$/.test(c));
1410
+
1411
+ function styleForm(style) {
1412
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
1413
+ const own = (field) => style.ownFields.includes(field);
1414
+ const provenance = (field) => own(field) ? 'set on this style'
1415
+ : style.extends ? 'inherited from ' + style.extends + ' — saving sets a value on this style itself; ' + style.extends + ' is unchanged'
1416
+ : 'default — saving sets a value on this style itself';
1417
+ const form = el('form', 'edit style-form');
1418
+ form.append(el('h3', null, 'Edit style ' + style.id));
1419
+ const prefix = el('input'); prefix.type = 'text'; prefix.value = style.promptPrefix; prefix.placeholder = 'none';
1420
+ const suffix = el('input'); suffix.type = 'text'; suffix.value = style.promptSuffix; suffix.placeholder = 'none';
1421
+ const palette = el('input'); palette.type = 'text'; palette.value = style.palette.join(', '); palette.placeholder = '#rrggbb, #rrggbb — leave empty for no forced palette';
1422
+ const view = el('input'); view.type = 'text'; view.value = style.view || ''; view.placeholder = 'provider default';
1423
+ view.setAttribute('list', 'view-options');
1424
+ const noBg = el('select');
1425
+ noBg.append(new Option(style.extends ? 'inherit from ' + style.extends : 'default (on)', 'default'), new Option('on — strip the generated background', 'on'), new Option('off — keep the background (scenes, banners)', 'off'));
1426
+ noBg.value = own('noBackground') ? (style.noBackground ? 'on' : 'off') : 'default';
1427
+ const swatches = el('div', 'pal');
1428
+ const drawSwatches = () => {
1429
+ swatches.textContent = '';
1430
+ for (const c of parsePalette(palette.value)) { const i = el('i'); i.style.background = c; i.title = c; swatches.append(i); }
1431
+ };
1432
+ drawSwatches();
1433
+ form.append(
1434
+ field('prompt prefix', prefix, 'Prepended to every asset prompt in this style. ' + provenance('promptPrefix')),
1435
+ field('prompt suffix', suffix, 'Appended to every asset prompt in this style. ' + provenance('promptSuffix')),
1436
+ field('palette', palette, 'Forced colours where the provider supports them (pixflux). ' + provenance('palette')),
1437
+ swatches,
1438
+ );
1439
+ const row = el('div', 'row');
1440
+ row.append(
1441
+ field('view', view, 'PixelLab accepts low top-down, high top-down, side; Retro Diffusion reads sidescroller. ' + provenance('view')),
1442
+ field('background', noBg, (fieldReaches('noBackground', style) ? 'Sent for this style. ' : 'Not sent for ' + style.provider + ' ' + style.generator + '; recorded only. ') + provenance('noBackground')),
1443
+ );
1444
+ form.append(row);
1445
+ const blast = el('div', 'blast');
1446
+ const changed = () => {
1447
+ const out = [];
1448
+ if (prefix.value !== style.promptPrefix) out.push('promptPrefix');
1449
+ if (suffix.value !== style.promptSuffix) out.push('promptSuffix');
1450
+ if (parsePalette(palette.value).join(',') !== style.palette.map((c) => c.toLowerCase()).join(',')) out.push('palette');
1451
+ if (view.value.trim() !== (style.view || '')) out.push('view');
1452
+ const bgNow = own('noBackground') ? (style.noBackground ? 'on' : 'off') : 'default';
1453
+ if (noBg.value !== bgNow) out.push('noBackground');
1454
+ return out;
1455
+ };
1456
+ const updateBlast = () => {
1457
+ const fields = changed();
1458
+ drawSwatches();
1459
+ if (!fields.length) { blast.className = 'blast'; blast.textContent = 'No changes yet. A style change alters the request for every asset that uses it.'; return; }
1460
+ const r = blastRadius(style, fields);
1461
+ if (!r.assets) {
1462
+ blast.className = 'blast';
1463
+ blast.textContent = 'Recorded in the manifest only: nothing in ' + style.id + ' sends this field, so no request changes.';
1464
+ return;
1465
+ }
1466
+ blast.className = 'blast hot';
1467
+ blast.textContent = '';
1468
+ const others = r.styles.filter((id) => id !== style.id);
1469
+ blast.append(el('b', null, 'Affects ' + r.assets + (r.assets === 1 ? ' asset' : ' assets')));
1470
+ blast.append(document.createTextNode(others.length
1471
+ ? ' in ' + style.id + ' and ' + others.length + (others.length === 1 ? ' style that inherits it (' : ' styles that inherit it (') + others.join(', ') + ').'
1472
+ : ' in ' + style.id + '.'));
1473
+ blast.append(document.createTextNode(' Generated ones become stale; regenerating all of them is about ' + (r.cost || 'nothing') + '. Nothing is spent until you generate.'));
1474
+ };
1475
+ for (const input of [prefix, suffix, palette, view]) input.addEventListener('input', updateBlast);
1476
+ noBg.addEventListener('change', updateBlast);
1477
+ updateBlast();
1478
+ form.append(blast);
1479
+ const actions = el('div', 'actions');
1480
+ const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
1481
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
1482
+ const msg = el('span', 'msg');
1483
+ actions.append(save, cancel, msg);
1484
+ form.append(actions);
1485
+ form.onsubmit = async (e) => {
1486
+ e.preventDefault();
1487
+ const fields = changed();
1488
+ if (!fields.length) { ui.editing = null; render(); return; }
1489
+ const colors = parsePalette(palette.value);
1490
+ if (fields.includes('palette') && !paletteValid(colors)) { msg.className = 'msg bad'; msg.textContent = 'palette must be six-digit hex colours'; return; }
1491
+ const patch = {};
1492
+ if (fields.includes('promptPrefix')) patch.promptPrefix = prefix.value;
1493
+ if (fields.includes('promptSuffix')) patch.promptSuffix = suffix.value;
1494
+ if (fields.includes('palette')) patch.palette = colors;
1495
+ if (fields.includes('view')) patch.view = view.value.trim();
1496
+ if (fields.includes('noBackground')) patch.noBackground = noBg.value === 'default' ? null : noBg.value === 'on';
1497
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving…';
1498
+ const r = blastRadius(style, fields);
1499
+ try {
1500
+ const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch };
1501
+ if (style.project) body.project = style.project;
1502
+ snap = await postEdit(body);
1503
+ ui.editing = null;
1504
+ ui.notice = { id: 'style:' + (style.project || '') + ':' + style.id,
1505
+ text: r.assets
1506
+ ? 'Saved. The request for ' + r.assets + (r.assets === 1 ? ' asset' : ' assets') + ' changed' + (r.cost ? ' — about ' + r.cost + ' to regenerate.' : '.') + ' Nothing is spent until you generate.'
1507
+ : 'Saved. Recorded in the manifest; no request changed.' };
1508
+ render();
1509
+ } catch (err) {
1510
+ save.disabled = false;
1511
+ msg.className = 'msg bad';
1512
+ msg.textContent = err.message + (err.status === 409 ? ' — press Refresh.' : '');
1513
+ }
1514
+ };
1515
+ setTimeout(() => prefix.focus(), 0);
1516
+ return form;
1517
+ }
1518
+
1519
+ function addAssetForm(style) {
1520
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
1521
+ const siblings = snap.styles.filter((x) => x.project === style.project);
1522
+ const form = el('form', 'edit');
1523
+ form.append(el('h3', null, 'New asset in ' + style.id));
1524
+ const id = el('input'); id.type = 'text'; id.placeholder = 'asset-id'; id.required = true; id.autocomplete = 'off';
1525
+ id.pattern = '[^/\\]+';
1526
+ const prompt = el('textarea'); prompt.placeholder = 'What to generate. The style adds its prefix and suffix.'; prompt.required = true;
1527
+ const width = numberInput(null, 'style default'), height = numberInput(null, 'style default');
1528
+ const category = el('input'); category.type = 'text'; category.placeholder = 'optional subfolder';
1529
+ const only = el('input'); only.type = 'checkbox'; only.checked = siblings.length > 1;
1530
+ const onlyField = el('label', 'field check'); onlyField.append(only, el('span', null, 'only in ' + style.id));
1531
+ const row1 = el('div', 'row'); row1.append(field('id', id), field('category', category));
1532
+ const row2 = el('div', 'row'); row2.append(field('width', width), field('height', height));
1533
+ form.append(row1, field('prompt', prompt), row2);
1534
+ if (siblings.length > 1) form.append(onlyField);
1535
+ const actions = el('div', 'actions');
1536
+ const save = el('button', 'primary', 'Add asset'); save.type = 'submit';
1537
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
1538
+ const msg = el('span', 'msg');
1539
+ actions.append(save, cancel, msg);
1540
+ form.append(actions);
1541
+ form.onsubmit = async (e) => {
1542
+ e.preventDefault();
1543
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving…';
1544
+ const asset = { prompt: prompt.value };
1545
+ if (numberOrNull(width) !== null) asset.width = numberOrNull(width);
1546
+ if (numberOrNull(height) !== null) asset.height = numberOrNull(height);
1547
+ if (category.value.trim()) asset.category = category.value.trim();
1548
+ if (siblings.length > 1 && only.checked) asset.styles = [style.id];
1549
+ try {
1550
+ const body = { action: 'add-asset', assetId: id.value.trim(), expectedSha256: pr.manifestSha256, asset };
1551
+ if (style.project) body.project = style.project;
1552
+ snap = await postEdit(body);
1553
+ const newId = (style.project ? style.project + ':' : '') + style.id + '/' + id.value.trim();
1554
+ ui.editing = null;
1555
+ ui.notice = { id: newId, text: 'Added to the manifest. Nothing is generated until you run pixelkiln gen.' };
1556
+ render();
1557
+ if (snap.items.some((i) => i.id === newId)) openItem(newId);
1558
+ } catch (err) {
1559
+ save.disabled = false;
1560
+ msg.className = 'msg bad';
1561
+ msg.textContent = err.message + (err.status === 409 ? ' — press Refresh.' : '');
1562
+ }
1563
+ };
1564
+ setTimeout(() => id.focus(), 0);
1565
+ return form;
1566
+ }
1567
+
1568
+ function editForm(item) {
1569
+ const pr = projectOf(item);
1570
+ const a = item.asset;
1571
+ const styled = !!(a.promptByStyle && Object.hasOwn(a.promptByStyle, item.styleId));
1572
+ const form = el('form', 'edit');
1573
+ form.append(el('h3', null, 'Edit intent'));
1574
+ const scope = el('select');
1575
+ scope.append(new Option('Prompt for every style', 'all'), new Option('Prompt only for ' + item.styleId, 'style'));
1576
+ scope.value = styled ? 'style' : 'all';
1577
+ const prompt = el('textarea');
1578
+ prompt.value = styled ? a.promptByStyle[item.styleId] : a.prompt;
1579
+ scope.onchange = () => {
1580
+ prompt.value = scope.value === 'style' ? (a.promptByStyle?.[item.styleId] ?? a.prompt) : a.prompt;
1581
+ };
1582
+ const width = numberInput(a.width, 'style default'), height = numberInput(a.height, 'style default'), size = numberInput(a.size, 'style default');
1583
+ const category = el('input'); category.type = 'text'; category.value = a.category || '';
1584
+ const tags = el('input'); tags.type = 'text'; tags.value = (a.tags || []).join(', '); tags.placeholder = 'comma-separated';
1585
+ form.append(field('prompt', prompt, 'The style adds its prefix and suffix; the sent prompt is shown below.'), field('applies to', scope));
1586
+ const row = el('div', 'row'); row.append(field('width', width), field('height', height), field('size', size));
1587
+ form.append(row);
1588
+ const row2 = el('div', 'row');
1589
+ row2.append(field('category', category, 'Output subfolder. The existing file stays put; restore or gen writes the new path.'), field('tags', tags));
1590
+ form.append(row2);
1591
+ const actions = el('div', 'actions');
1592
+ const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
1593
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; renderDrawer(); };
1594
+ const msg = el('span', 'msg');
1595
+ actions.append(save, cancel, msg);
1596
+ form.append(actions);
1597
+ form.onsubmit = async (e) => {
1598
+ e.preventDefault();
1599
+ const patch = {};
1600
+ if (scope.value === 'style') {
1601
+ if (!styled || prompt.value !== a.promptByStyle[item.styleId]) patch.promptForStyle = { styleId: item.styleId, prompt: prompt.value };
1602
+ } else {
1603
+ if (prompt.value !== a.prompt) patch.prompt = prompt.value;
1604
+ if (styled) patch.promptForStyle = { styleId: item.styleId, prompt: null };
1605
+ }
1606
+ for (const [key, input] of [['width', width], ['height', height], ['size', size]]) {
1607
+ const next = numberOrNull(input);
1608
+ if (next !== (a[key] ?? null)) patch[key] = next;
1609
+ }
1610
+ const nextCategory = category.value.trim() || null;
1611
+ if (nextCategory !== (a.category || null)) patch.category = nextCategory;
1612
+ const nextTags = splitTags(tags.value);
1613
+ if (nextTags.join('\u0000') !== (a.tags || []).join('\u0000')) patch.tags = nextTags;
1614
+ if (!Object.keys(patch).length) { ui.editing = null; renderDrawer(); return; }
1615
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving…';
1616
+ try {
1617
+ const body = { action: 'patch-asset', assetId: item.assetId, expectedSha256: pr.manifestSha256, patch };
1618
+ if (item.project) body.project = item.project;
1619
+ snap = await postEdit(body);
1620
+ const after = snap.items.find((i) => i.id === item.id);
1621
+ ui.editing = null;
1622
+ ui.notice = {
1623
+ id: item.id,
1624
+ text: after
1625
+ ? 'Saved. plan now reports ' + after.state +
1626
+ (after.estimatedCost !== null && (after.state === 'stale' || after.state === 'missing')
1627
+ ? ' — ' + fmtCost(after.costUnit, after.estimatedCost) + ' to generate. Nothing is spent until you run pixelkiln gen.'
1628
+ : '.')
1629
+ : 'Saved.',
1630
+ };
1631
+ render();
1632
+ } catch (err) {
1633
+ save.disabled = false;
1634
+ msg.className = 'msg bad';
1635
+ msg.textContent = err.message + (err.status === 409 ? ' — press Refresh.' : '');
1636
+ }
1637
+ };
1638
+ setTimeout(() => prompt.focus(), 0);
1639
+ return form;
1640
+ }
1641
+
1642
+ // ---- detail drawer -------------------------------------------------------
1643
+
1644
+ function row(dl, label, value, opts = {}) {
1645
+ if (value === null || value === undefined || value === '') return;
1646
+ dl.append(el('dt', null, label));
1647
+ const dd = el('dd', opts.mono ? 'mono' : null);
1648
+ if (value instanceof Node) dd.append(value); else dd.textContent = String(value);
1649
+ if (opts.copy) {
1650
+ const b = el('button', 'copy', 'copy');
1651
+ b.type = 'button';
1652
+ b.onclick = async () => {
1653
+ try { await navigator.clipboard.writeText(String(opts.copy)); b.textContent = 'copied'; }
1654
+ catch { b.textContent = 'select to copy'; }
1655
+ setTimeout(() => (b.textContent = 'copy'), 1200);
1656
+ };
1657
+ dd.append(b);
1658
+ }
1659
+ dl.append(dd);
1660
+ }
1661
+ function section(title) {
1662
+ const s = el('section', 'meta');
1663
+ s.append(el('h3', null, title));
1664
+ const dl = el('dl');
1665
+ s.append(dl);
1666
+ return { s, dl };
1667
+ }
1668
+ function stateNode(state, reason) {
1669
+ const box = el('div', 'status');
1670
+ const st = el('span', 'st state-' + STATE_TONE[state]);
1671
+ st.append(el('i', 'dot ' + STATE_TONE[state]), document.createTextNode(state));
1672
+ box.append(st, el('span', 'why', reason));
1673
+ return box;
1674
+ }
1675
+ function sibling(item, key) {
1676
+ return snap.items.find((i) => i.key === key && i.project === item.project);
1677
+ }
1678
+ function keyLink(item, key, label) {
1679
+ const b = el('button', 'linkish', label || key);
1680
+ b.type = 'button';
1681
+ const target = sibling(item, key);
1682
+ if (!target) { b.disabled = true; b.title = 'not in this snapshot'; }
1683
+ b.onclick = () => openItem(target.id);
1684
+ return b;
1685
+ }
1686
+ function jsonDetails(title, value) {
1687
+ const d = el('details');
1688
+ d.append(el('summary', null, title), el('pre', null, JSON.stringify(value, null, 2)));
1689
+ return d;
1690
+ }
1691
+
1692
+ function stopPlayback() {
1693
+ if (ui.playing) { clearInterval(ui.playing); ui.playing = null; }
1694
+ }
1695
+
1696
+ function renderPreview(item, host) {
1697
+ host.textContent = '';
1698
+ stopPlayback();
1699
+ const shown = item.outputs.filter((o) => o.url);
1700
+ const preview = el('div', 'preview');
1701
+ if (!shown.length) {
1702
+ preview.append(el('div', 'none', item.outputs.length ? 'The recorded file is not on disk. pixelkiln restore re-downloads it without spending.' : 'No output to show.'));
1703
+ host.append(preview);
1704
+ return;
1705
+ }
1706
+ const member = shown[Math.min(ui.member, shown.length - 1)] || shown[0];
1707
+ const frameSet = isFrameSet(item);
1708
+ const w = item.width, h = item.height;
1709
+ const box = Math.min(window.innerWidth, 540) - 62;
1710
+ const auto = displayScale(w, h, box, Math.min(window.innerHeight * 0.55, 560));
1711
+ const zoom = ui.zoom === 'auto' ? auto : ui.zoom === 'fit' ? Math.min(auto, 1) : Number(ui.zoom);
1712
+ const img = el('img');
1713
+ img.src = member.url; img.alt = member.role || item.assetId;
1714
+ if (zoom >= 1) { img.width = Math.round(w * zoom); img.height = Math.round(h * zoom); }
1715
+ else { img.style.maxWidth = '100%'; img.style.maxHeight = '55vh'; img.style.width = 'auto'; img.style.height = 'auto'; }
1716
+ preview.append(img);
1717
+
1718
+ const zoomBar = el('div', 'zoom');
1719
+ zoomBar.append(el('span', null, 'zoom'));
1720
+ for (const z of ['fit', 1, 2, 4, 8]) {
1721
+ const b = el('button', ui.zoom === String(z) || (ui.zoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '×');
1722
+ b.type = 'button';
1723
+ b.onclick = () => { ui.zoom = String(z); renderPreview(item, host); };
1724
+ zoomBar.append(b);
1725
+ }
1726
+ const label = el('span', 'grow', w + '×' + h + (zoom >= 1 ? ' at ' + zoom + '×' : ' fitted'));
1727
+ zoomBar.append(label);
1728
+ if (frameSet && shown.length > 1) {
1729
+ const fps = item.fps || 12;
1730
+ const play = el('button', null, 'Play ' + fps + ' fps');
1731
+ play.type = 'button';
1732
+ let idx = shown.indexOf(member);
1733
+ play.onclick = () => {
1734
+ if (ui.playing) { stopPlayback(); play.textContent = 'Play ' + fps + ' fps'; return; }
1735
+ play.textContent = 'Pause';
1736
+ ui.playing = setInterval(() => {
1737
+ if (document.hidden) return;
1738
+ idx = (idx + 1) % shown.length;
1739
+ img.src = shown[idx].url;
1740
+ label.textContent = (shown[idx].role || 'frame ' + (idx + 1)) + ' · ' + w + '×' + h + (zoom >= 1 ? ' at ' + zoom + '×' : '');
1741
+ for (const [i, b] of [...host.querySelectorAll('.members button')].entries()) b.classList.toggle('on', i === idx);
1742
+ }, Math.max(16, Math.round(1000 / fps)));
1743
+ };
1744
+ zoomBar.append(play);
1745
+ }
1746
+ host.append(preview, zoomBar);
1747
+
1748
+ if (shown.length > 1) {
1749
+ const strip = el('div', 'members');
1750
+ shown.forEach((o, i) => {
1751
+ const b = el('button', o === member ? 'on' : null);
1752
+ b.type = 'button';
1753
+ const t = el('img'); t.src = o.url; t.alt = o.role || 'output ' + (i + 1); t.loading = 'lazy';
1754
+ b.append(t, el('span', null, o.role || String(i + 1)));
1755
+ b.onclick = () => { ui.member = i; renderPreview(item, host); };
1756
+ strip.append(b);
1757
+ });
1758
+ host.append(strip);
1759
+ }
1760
+ }
1761
+
1762
+ function renderDrawer() {
1763
+ const host = $('drawer-host');
1764
+ host.textContent = '';
1765
+ stopPlayback();
1766
+ const item = ui.open && snap.items.find((i) => i.id === ui.open);
1767
+ if (!item) { ui.open = null; return; }
1768
+ const items = visibleItems();
1769
+ const pos = items.findIndex((i) => i.id === item.id);
1770
+
1771
+ const scrim = el('div', 'scrim');
1772
+ scrim.onclick = () => closeItem();
1773
+ const drawer = el('aside', 'drawer');
1774
+ drawer.setAttribute('role', 'dialog');
1775
+ drawer.setAttribute('aria-label', item.id);
1776
+
1777
+ const head = el('div', 'dhead');
1778
+ const title = el('div', 'title');
1779
+ if (item.project) title.append(el('span', 'sid project', item.project), ' ');
1780
+ title.append(el('span', 'sid', item.styleId), el('div', 'aid', item.assetId));
1781
+ const nav = el('div', 'nav');
1782
+ const prev = el('button', null, '←'); prev.type = 'button'; prev.title = 'Previous (←)';
1783
+ prev.disabled = pos <= 0; prev.onclick = () => step(-1);
1784
+ const next = el('button', null, '→'); next.type = 'button'; next.title = 'Next (→)';
1785
+ next.disabled = pos < 0 || pos >= items.length - 1; next.onclick = () => step(1);
1786
+ const close = el('button', null, 'Close'); close.type = 'button'; close.onclick = () => closeItem();
1787
+ const cmp = el('button', null, ui.compare.includes(item.id) ? 'Remove from compare' : 'Compare +');
1788
+ cmp.type = 'button'; cmp.onclick = () => toggleCompare(item.id);
1789
+ nav.append(cmp);
1790
+ const canEdit = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256;
1791
+ if (canEdit) {
1792
+ const edit = el('button', null, ui.editing === item.id ? 'Cancel edit' : 'Edit');
1793
+ edit.type = 'button';
1794
+ edit.onclick = () => { ui.editing = ui.editing === item.id ? null : item.id; ui.notice = null; renderDrawer(); };
1795
+ nav.append(edit);
1796
+ }
1797
+ nav.append(prev, next, close);
1798
+ head.append(title, nav);
1799
+
1800
+ const body = el('div', 'dbody');
1801
+ const previewHost = el('div');
1802
+ renderPreview(item, previewHost);
1803
+ body.append(previewHost);
1804
+ if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
1805
+ body.append(stateNode(item.state, item.reason));
1806
+ const job = GENERATION ? activeJobFor(item) : null;
1807
+ if (job) body.append(jobStrip(item, job));
1808
+ else if (GENERATION) body.append(generateActions(item));
1809
+ // Writes are opt-in flags on the command line; say so where the buttons would be.
1810
+ if ((!EDITABLE || !GENERATION) && item.declared) {
1811
+ const missing = [];
1812
+ if (!EDITABLE) missing.push('--edit to change its prompt, size, and tags or open it in the browser editor');
1813
+ if (!GENERATION) missing.push('--budget <n> to generate or regenerate it from here');
1814
+ const hint = el('div', 'state-dim readonly-hint');
1815
+ hint.append(document.createTextNode('This gallery is read-only. Restart it with '), el('code', null, 'pixelkiln gallery'), document.createTextNode(' plus ' + missing.join(', and ') + '.'));
1816
+ body.append(hint);
1817
+ }
1818
+ if (canEdit && ui.editing === item.id) body.append(editForm(item));
1819
+ // Plan already quotes the error as the reason for a failed entry; only a
1820
+ // stale or superseded failure needs its own line.
1821
+ if (item.error && !item.reason.includes(item.error)) body.append(stateNode('failed', item.error));
1822
+
1823
+ // Generation — what was sent and what it cost.
1824
+ {
1825
+ const { s, dl } = section('Generation');
1826
+ row(dl, 'provider', item.provider);
1827
+ row(dl, 'generator', item.generator + (item.tileFeature ? ' · ' + item.tileFeature : ''));
1828
+ row(dl, 'lock status', item.status || 'none — nothing submitted');
1829
+ const pr = el('div', 'prompt', item.prompt); row(dl, item.status ? 'prompt sent' : 'prompt', pr);
1830
+ if (item.currentPrompt) row(dl, 'prompt now', el('div', 'prompt state-warn', item.currentPrompt));
1831
+ row(dl, 'size', item.width + ' × ' + item.height + ' px');
1832
+ if (item.status) row(dl, 'cost', fmtCost(item.costUnit, item.cost));
1833
+ if (item.estimatedCost !== null && (item.estimatedCost !== item.cost || !item.status)) {
1834
+ row(dl, 'estimate now', fmtCost(item.costUnit, item.estimatedCost) +
1835
+ (item.candidates && item.candidates > 1 ? ' for ' + item.candidates + ' candidates' : ''));
1836
+ }
1837
+ if (item.candidateIndex !== null) row(dl, 'candidate', '#' + (item.candidateIndex + 1) + ' chosen in review');
1838
+ row(dl, 'submitted', fmtWhen(item.submittedAt));
1839
+ row(dl, 'downloaded', fmtWhen(item.downloadedAt));
1840
+ row(dl, 'job id', item.jobId, { mono: true, copy: item.jobId });
1841
+ row(dl, 'object id', item.objectId, { mono: true, copy: item.objectId });
1842
+ row(dl, 'review object', item.reviewObjectId, { mono: true, copy: item.reviewObjectId });
1843
+ body.append(s);
1844
+ }
1845
+
1846
+ // Outputs — every file with its hash and whether it is really there.
1847
+ {
1848
+ const s = el('section', 'meta');
1849
+ s.append(el('h3', null, item.outputs.length === 1 ? 'Output' : 'Outputs (' + item.outputs.length + ')'));
1850
+ if (!item.outputs.length) s.append(el('div', 'state-dim', item.source ? 'placed from ' + item.source : 'none recorded'));
1851
+ for (const o of item.outputs) {
1852
+ const box = el('div', 'out');
1853
+ const role = el('div', 'role');
1854
+ role.append(document.createTextNode(o.role || (item.outputs.length > 1 ? 'primary' : 'file')));
1855
+ if (!o.exists) role.append(el('span', 'miss', 'missing on disk'));
1856
+ if (o.url) { const a = el('a', null, 'open'); a.href = o.url.split('?')[0]; a.target = '_blank'; a.rel = 'noopener'; role.append(a); }
1857
+ box.append(role);
1858
+ const dl = el('dl');
1859
+ row(dl, 'path', o.path, { mono: true, copy: o.absolutePath });
1860
+ row(dl, 'sha256', o.sha256 ? o.sha256.slice(0, 16) + '…' : (o.exists ? 'not recorded' : null), { mono: true, copy: o.sha256 || undefined });
1861
+ row(dl, 'bytes', o.exists ? fmtBytes(o.bytes) : null);
1862
+ row(dl, 'type', o.mediaType);
1863
+ box.append(dl);
1864
+ s.append(box);
1865
+ }
1866
+ body.append(s);
1867
+ }
1868
+
1869
+ const hand = handEditSection(item);
1870
+ if (hand) body.append(hand);
1871
+ const upstream = upstreamSection(item);
1872
+ if (upstream) body.append(upstream);
1873
+ const versions = historySection(item);
1874
+ if (versions) body.append(versions);
1875
+
1876
+ if (item.revision || item.revisionParentKey) {
1877
+ const { s, dl } = section('Lineage');
1878
+ row(dl, 'mode', item.revision ? item.revision.mode : (item.asset && item.asset.revision && item.asset.revision.mode));
1879
+ if (item.revisionParentKey) row(dl, 'parent', keyLink(item, item.revisionParentKey));
1880
+ if (item.revision) {
1881
+ row(dl, 'parent sha256', item.revision.sourceSha256.slice(0, 16) + '…', { mono: true, copy: item.revision.sourceSha256 });
1882
+ if (item.revision.maskSha256) row(dl, 'mask sha256', item.revision.maskSha256.slice(0, 16) + '…', { mono: true, copy: item.revision.maskSha256 });
1883
+ if (item.revision.strength !== undefined) row(dl, 'strength', item.revision.strength);
1884
+ }
1885
+ body.append(s);
1886
+ }
1887
+ const children = snap.items.filter((i) => i.revisionParentKey === item.key && i.project === item.project);
1888
+ if (children.length) {
1889
+ const { s, dl } = section('Revisions from this asset');
1890
+ for (const c of children) row(dl, c.revision ? c.revision.mode : 'child', keyLink(item, c.key));
1891
+ body.append(s);
1892
+ }
1893
+
1894
+ if (item.quality) {
1895
+ const q = item.quality;
1896
+ const { s, dl } = section('Quality');
1897
+ const tone = q.state === 'approved' ? 'ok' : q.state === 'blocked' ? 'bad' : 'warn';
1898
+ const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone + ' '), document.createTextNode(' ' + q.state));
1899
+ row(dl, 'state', st);
1900
+ row(dl, 'why', q.reason);
1901
+ if (q.review) {
1902
+ row(dl, 'review', q.review.status === 'approved'
1903
+ ? 'approved by ' + q.review.reviewer + ' · ' + fmtWhen(q.review.approvedAt)
1904
+ : 'pending human 1× review');
1905
+ if (q.review.status === 'approved' && q.review.note) row(dl, 'note', q.review.note);
1906
+ }
1907
+ if (q.palette) {
1908
+ const pal = el('div', 'pal');
1909
+ for (const c of q.palette) { const i = el('i'); i.style.background = c; i.title = c; pal.append(i); }
1910
+ row(dl, 'palette', pal);
1911
+ row(dl, 'colors', q.palette.length + ' declared' + (q.audit ? ', ' + q.audit.colorCount + ' used' : ''));
1912
+ }
1913
+ if (q.nativeGrid) {
1914
+ row(dl, 'native grid', q.nativeGrid.sourceWidth + '×' + q.nativeGrid.sourceHeight + ' → ' +
1915
+ q.nativeGrid.nativeWidth + '×' + q.nativeGrid.nativeHeight + ' (step ' + q.nativeGrid.stepX + '×' + q.nativeGrid.stepY +
1916
+ ', ' + q.nativeGrid.confidence + ' confidence)');
1917
+ }
1918
+ if (q.audit) row(dl, 'audit', (q.audit.safe ? 'passed' : 'failed') + ' · ' + Math.round(q.audit.transparency * 100) + '% transparent' +
1919
+ (q.audit.reasons.length ? ' · ' + q.audit.reasons.join('; ') : ''));
1920
+ if (q.frameSet) row(dl, 'frame set', q.frameSet.count + ' frames at ' + q.frameSet.fps + ' fps');
1921
+ row(dl, 'output', q.output, { mono: true });
1922
+ row(dl, 'record', q.record + (q.recordExists ? '' : ' (not written yet)'), { mono: true });
1923
+ if (q.check && !q.check.safe) row(dl, 'check', el('div', 'state-warn', q.check.reasons.join('\n')));
1924
+ body.append(s);
1925
+ if (q.outputs.some((o) => o.url)) {
1926
+ const strip = el('div', 'members');
1927
+ for (const o of q.outputs.filter((o) => o.url)) {
1928
+ const b = el('button'); b.type = 'button';
1929
+ const t = el('img'); t.src = o.url; t.alt = 'refined ' + (o.role || item.assetId);
1930
+ b.append(t, el('span', null, 'refined')); b.onclick = () => window.open(o.url.split('?')[0], '_blank');
1931
+ strip.append(b);
1932
+ }
1933
+ s.append(strip);
1934
+ }
1935
+ }
1936
+
1937
+ {
1938
+ const { s, dl } = section('Identity');
1939
+ if (item.project) row(dl, 'project', item.project);
1940
+ row(dl, 'lock key', item.key, { mono: true, copy: item.key });
1941
+ if (item.recordedSpecHash) {
1942
+ row(dl, 'spec hash', item.recordedSpecHash.slice(0, 16) + '…', { mono: true, copy: item.recordedSpecHash });
1943
+ }
1944
+ if (item.currentSpecHash && item.currentSpecHash !== item.recordedSpecHash) {
1945
+ row(dl, item.recordedSpecHash ? 'spec hash now' : 'spec hash',
1946
+ el('span', item.recordedSpecHash ? 'state-warn' : null, item.currentSpecHash.slice(0, 16) + '…'), { mono: true, copy: item.currentSpecHash });
1947
+ }
1948
+ row(dl, 'declared', item.declared ? 'yes' : 'no — the manifest no longer has this asset in this style');
1949
+ row(dl, 'category', item.category);
1950
+ row(dl, 'tags', item.tags && item.tags.length ? item.tags.join(', ') : null);
1951
+ row(dl, 'source art', item.source, { mono: true });
1952
+ body.append(s);
1953
+ }
1954
+
1955
+ if (item.asset) {
1956
+ // Show the asset as the author would write it: schema defaults that are
1957
+ // empty add nothing but noise to a record.
1958
+ const declared = Object.fromEntries(Object.entries(item.asset).filter(([, v]) =>
1959
+ !(Array.isArray(v) && !v.length) && !(v && typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length)));
1960
+ body.append(jsonDetails('Manifest asset', declared));
1961
+ }
1962
+ if (item.providerMetadata && Object.keys(item.providerMetadata).length) {
1963
+ body.append(jsonDetails('Provider metadata', item.providerMetadata));
1964
+ }
1965
+
1966
+ drawer.append(head, body);
1967
+ host.append(scrim, drawer);
1968
+ close.focus({ preventScroll: true });
1969
+ }
1970
+
1971
+ // View state lives in the URL — filters and sort in the query, the open
1972
+ // record in the hash — so a link to "every failed generation" or to one
1973
+ // specific record can be pasted into a review thread and land there.
1974
+ function readUrlState() {
1975
+ const params = new URLSearchParams(location.search);
1976
+ const list = (name) => (params.get(name) || '').split(',').map((v) => v.trim()).filter(Boolean);
1977
+ ui.q = params.get('q') || '';
1978
+ ui.states = new Set(list('state'));
1979
+ ui.providers = new Set(list('provider'));
1980
+ ui.generators = new Set(list('generator'));
1981
+ ui.projects = new Set(list('project'));
1982
+ ui.compare = list('compare').slice(0, 4);
1983
+ if (['key', 'newest', 'oldest', 'cost', 'size'].includes(params.get('sort'))) ui.sort = params.get('sort');
1984
+ if (['style', 'none'].includes(params.get('group'))) ui.group = params.get('group');
1985
+ }
1986
+ function writeUrlState() {
1987
+ const params = new URLSearchParams();
1988
+ if (ui.q) params.set('q', ui.q);
1989
+ if (ui.states.size) params.set('state', [...ui.states].join(','));
1990
+ if (ui.providers.size) params.set('provider', [...ui.providers].join(','));
1991
+ if (ui.generators.size) params.set('generator', [...ui.generators].join(','));
1992
+ if (ui.projects.size) params.set('project', [...ui.projects].join(','));
1993
+ if (ui.compare.length) params.set('compare', ui.compare.join(','));
1994
+ if (ui.sort !== 'key') params.set('sort', ui.sort);
1995
+ if (ui.group !== 'style') params.set('group', ui.group);
1996
+ const query = params.toString();
1997
+ const next = location.pathname + (query ? '?' + query : '') + (ui.open ? '#' + encodeURIComponent(ui.open) : '');
1998
+ if (next !== location.pathname + location.search + location.hash) history.replaceState(null, '', next);
1999
+ }
2000
+ function syncHash() { writeUrlState(); }
2001
+ function keyFromHash() {
2002
+ try { return location.hash.length > 1 ? decodeURIComponent(location.hash.slice(1)) : null; }
2003
+ catch { return null; }
2004
+ }
2005
+ // Opening a record must not rebuild the grid: at a few thousand cards that
2006
+ // is a visible pause, and nothing in the grid changes except one highlight.
2007
+ function markActive(key) {
2008
+ document.querySelector('.card.active')?.classList.remove('active');
2009
+ const card = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
2010
+ if (card) card.classList.add('active');
2011
+ return card;
2012
+ }
2013
+ function openItem(key) {
2014
+ if (ui.open !== key) { ui.editing = null; if (ui.notice && ui.notice.id !== key) ui.notice = null; }
2015
+ ui.open = key; ui.member = 0; ui.zoom = 'auto';
2016
+ syncHash();
2017
+ const card = markActive(key);
2018
+ renderDrawer();
2019
+ if (card) card.scrollIntoView({ block: 'nearest' });
2020
+ }
2021
+ function closeItem() {
2022
+ const key = ui.open;
2023
+ ui.open = null;
2024
+ ui.editing = null;
2025
+ ui.notice = null;
2026
+ syncHash();
2027
+ const card = markActive(null);
2028
+ renderDrawer();
2029
+ const focus = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
2030
+ if (focus) focus.focus({ preventScroll: true });
2031
+ }
2032
+ window.addEventListener('hashchange', () => {
2033
+ const key = keyFromHash();
2034
+ if (key && key !== ui.open && snap.items.some((i) => i.id === key)) openItem(key);
2035
+ else if (!key && ui.open) closeItem();
2036
+ });
2037
+ function step(delta) {
2038
+ const items = visibleItems();
2039
+ const pos = items.findIndex((i) => i.id === ui.open);
2040
+ const next = items[pos + delta];
2041
+ if (next) openItem(next.id);
2042
+ }
2043
+
2044
+ function render() {
2045
+ writeUrlState();
2046
+ renderHeader();
2047
+ renderMain(visibleItems());
2048
+ renderDrawer();
2049
+ renderTray();
2050
+ }
2051
+
2052
+ // ---- refresh --------------------------------------------------------------
2053
+
2054
+ let refreshing = false;
2055
+ async function refresh() {
2056
+ if (refreshing) return;
2057
+ refreshing = true;
2058
+ const b = $('refresh');
2059
+ b.disabled = true;
2060
+ try {
2061
+ const res = await fetch('/api/gallery.json', { cache: 'no-store' });
2062
+ if (!res.ok) throw new Error(await res.text());
2063
+ snap = await res.json();
2064
+ $('note').textContent = '';
2065
+ render();
2066
+ } catch (err) {
2067
+ $('note').textContent = ' Refresh failed: ' + err.message + ' — is pixelkiln gallery still running?';
2068
+ } finally {
2069
+ refreshing = false;
2070
+ b.disabled = false;
2071
+ }
2072
+ }
2073
+ $('refresh').onclick = refresh;
2074
+ let autoTimer = null;
2075
+ $('auto').onchange = (e) => {
2076
+ if (autoTimer) clearInterval(autoTimer);
2077
+ autoTimer = e.target.checked ? setInterval(() => { if (!document.hidden) refresh(); }, 5000) : null;
2078
+ };
2079
+
2080
+ // ---- input ----------------------------------------------------------------
2081
+
2082
+ let searchTimer = null;
2083
+ $('q').addEventListener('input', (e) => {
2084
+ ui.q = e.target.value;
2085
+ clearTimeout(searchTimer);
2086
+ searchTimer = setTimeout(render, 90);
2087
+ });
2088
+ $('sort').onchange = (e) => { ui.sort = e.target.value; render(); };
2089
+ $('group').onchange = (e) => { ui.group = e.target.value; render(); };
2090
+ document.addEventListener('keydown', (e) => {
2091
+ const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName || '');
2092
+ if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
2093
+ if (e.key === 'Escape') {
2094
+ if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
2095
+ if (SHEET) { e.preventDefault(); closeEditorSheet(); return; }
2096
+ if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
2097
+ if (ui.open) { e.preventDefault(); closeItem(); }
2098
+ return;
2099
+ }
2100
+ if (ui.open && !typing) {
2101
+ if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
2102
+ else if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
2103
+ }
2104
+ });
2105
+ document.addEventListener('visibilitychange', () => { if (document.hidden) stopPlayback(); });
2106
+
2107
+ readUrlState();
2108
+ $('q').value = ui.q;
2109
+ $('sort').value = ui.sort;
2110
+ $('group').value = ui.group;
2111
+ const initialKey = keyFromHash();
2112
+ if (initialKey && snap.items.some((i) => i.id === initialKey)) ui.open = initialKey;
2113
+ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
2114
+ render();
2115
+ if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
2116
+ if (GENERATION) pollJobs();
2117
+ if (EDITOR) pollEditor();
2118
+ if (ui.compare.length >= 2 && !ui.open) openCompare();