afterimage-cli 0.1.1 → 0.2.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.
@@ -35,7 +35,10 @@ const skillHint = [
35
35
  ...Object.entries(agents).map(([name, agent]) => ` npx afterimage init --agent ${name} copies the ${agent.what} to ${agent.to}${agent.what === 'skill' ? '/' : ''}`)
36
36
  ].join('\n');
37
37
 
38
- const nextStep = 'Next: npx afterimage save "First version"';
38
+ // The dev script starts Afterimage beside the app when init could set it; otherwise the app's own command goes after --.
39
+ const nextStep = devReady => (devReady
40
+ ? 'Next: npm run dev, then use the Afterimage pill on your prototype to save versions and see them all.'
41
+ : 'Next: start your dev server through Afterimage with npx afterimage dev -- <your dev command>, then use the pill on your prototype to save versions and see them all.');
39
42
 
40
43
  async function isFolder(file) {
41
44
  try {
@@ -146,22 +149,39 @@ async function planConfig(cwd, flags, ctx) {
146
149
  };
147
150
  }
148
151
 
149
- async function planPackage(cwd) {
152
+ // Read and checked before anything else, so a bad package.json stops init before any question.
153
+ async function readPackage(cwd) {
150
154
  const file = path.join(cwd, 'package.json');
151
155
  const { bom, body: before } = await readJsonText(file);
152
156
  const manifest = before === null ? { private: true, scripts: {} } : parseJson(before, 'package.json');
153
157
  if (manifest.scripts !== undefined && (!manifest.scripts || typeof manifest.scripts !== 'object' || Array.isArray(manifest.scripts))) {
154
158
  throw new Error('package.json "scripts" must be an object.\nFix it, then run "afterimage init" again.');
155
159
  }
160
+ return { file, bom, before, manifest };
161
+ }
162
+
163
+ // The pill on the prototype needs afterimage dev running beside the app. So the project's own dev script starts it:
164
+ // "vite" becomes "afterimage dev -- vite", which runs both and stops both. A plain folder with no dev script gets
165
+ // "afterimage dev", which serves the folder itself. Run again, it changes nothing.
166
+ const devWrap = 'afterimage dev';
167
+ function devScript(existing, config) {
168
+ if (typeof existing.dev === 'string') return existing.dev.trim().startsWith(devWrap) ? null : `${devWrap} -- ${existing.dev}`;
169
+ return config.build ? null : devWrap;
170
+ }
171
+
172
+ function planPackage({ file, bom, before, manifest }, config) {
156
173
  const existing = manifest.scripts ?? {};
157
174
  const added = Object.keys(scripts).filter(name => !Object.hasOwn(existing, name));
158
175
  const notes = [];
159
176
  if (Object.hasOwn(existing, 'afterimage:build') && existing['afterimage:build'] !== scripts['afterimage:build']) {
160
177
  notes.push(`package.json already has "afterimage:build": "${existing['afterimage:build']}". The host config runs that script, so it should be "${scripts['afterimage:build']}".`);
161
178
  }
162
- if (!added.length) return { change: null, notes, apply: null };
163
- manifest.scripts = { ...existing, ...Object.fromEntries(added.map(name => [name, scripts[name]])) };
164
- const what = `added ${plural(added.length, 'script')}`;
179
+ const dev = devScript(existing, config);
180
+ if (!added.length && !dev) return { change: null, notes, apply: null };
181
+ const wraps = dev && Object.hasOwn(existing, 'dev');
182
+ manifest.scripts = { ...existing, ...Object.fromEntries(added.map(name => [name, scripts[name]])), ...(dev ? { dev } : {}) };
183
+ const count = added.length + (dev && !wraps ? 1 : 0);
184
+ const what = [count ? `added ${plural(count, 'script')}` : null, wraps ? 'dev now starts Afterimage too' : null].filter(Boolean).join('; ');
165
185
  return {
166
186
  change: `${before === null ? 'Created' : 'Updated'} package.json (${what})`,
167
187
  notes,
@@ -169,6 +189,95 @@ async function planPackage(cwd) {
169
189
  };
170
190
  }
171
191
 
192
+ // The overlay's one line. A plain page loads it from the history's own folder, which afterimage dev and the deployed
193
+ // site both serve; a Vite page imports it from the package, since Vite's dev server serves no /afterimage/.
194
+ const pillTag = '<script src="/afterimage/overlay.js" defer></script>';
195
+ const pillImport = "<script type=\"module\">import 'afterimage-cli/overlay';</script>";
196
+ const pillNotes = {
197
+ 'next-static': "Add the Afterimage pill to your app: create app/afterimage.tsx with\n 'use client';\n import 'afterimage-cli/overlay';\n export default function Afterimage() { return null; }\nand put <Afterimage /> inside <body> in app/layout.tsx.",
198
+ astro: "Add the Afterimage pill to your app: put <script>import 'afterimage-cli/overlay';</script> in the layout every page uses.",
199
+ 'sveltekit-static': "Add the Afterimage pill to your app: put import 'afterimage-cli/overlay'; in the <script> of src/routes/+layout.svelte.",
200
+ unknown: "Add the Afterimage pill to your app: import 'afterimage-cli/overlay' once in the code every page loads."
201
+ };
202
+
203
+ // Before the last </body>, or at the end of the file when it has none, on a line of its own. When </body> starts its
204
+ // line, the new line takes the indent of the line above it, so it sits with the page's other scripts.
205
+ function withLine(text, line) {
206
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
207
+ const at = text.search(/<\/body>(?![\s\S]*<\/body>)/i);
208
+ if (at === -1) return `${text}${text === '' || text.endsWith('\n') ? '' : eol}${line}${eol}`;
209
+ const lineStart = text.lastIndexOf('\n', at - 1) + 1;
210
+ if (!/^[ \t]*$/.test(text.slice(lineStart, at))) return `${text.slice(0, at)}${eol}${line}${eol}${text.slice(at)}`;
211
+ const above = text.slice(0, lineStart).replace(/\r?\n$/, '').split(/\r?\n/).pop();
212
+ return `${text.slice(0, lineStart)}${above.match(/^[ \t]*/)[0]}${line}${eol}${text.slice(lineStart)}`;
213
+ }
214
+
215
+ // Every file under `dir` whose name passes `want`, never going into hidden folders, installed packages or `skip`
216
+ // (absolute folders), which a real project's node_modules makes worth not reading at all.
217
+ async function walk(dir, want, skip) {
218
+ const found = [];
219
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
220
+ const full = path.join(dir, entry.name);
221
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
222
+ if (entry.isDirectory()) {
223
+ if (!skip.includes(full)) found.push(...await walk(full, want, skip));
224
+ } else if (entry.isFile() && want(entry.name)) {
225
+ found.push(full);
226
+ }
227
+ }
228
+ return found;
229
+ }
230
+
231
+ // Every .html page in the prototype's folder, minus the history and Afterimage's own output.
232
+ async function plainPages(cwd, config) {
233
+ const skip = [config.history, config.out].map(folder => path.resolve(cwd, folder));
234
+ const pages = await walk(path.resolve(cwd, config.source), name => /\.html?$/i.test(name), skip);
235
+ return pages.map(full => ({ full, name: path.relative(cwd, full).replace(/\\/g, '/') })).sort((a, b) => (a.name < b.name ? -1 : 1));
236
+ }
237
+
238
+ // Whether the app's own code already loads the overlay, so init stops asking once the line is in. Only source files
239
+ // are read: not installed packages, hidden folders, the history, or what a build wrote.
240
+ const codeFile = /\.(m?[jt]sx?|svelte|astro|vue|html?)$/i;
241
+ async function loadsPill(cwd, config) {
242
+ const skip = [config.history, config.out, config.source].filter(folder => folder !== '.').map(folder => path.resolve(cwd, folder));
243
+ for (const full of await walk(cwd, name => codeFile.test(name), skip)) {
244
+ const text = await readText(full);
245
+ if (text.includes('afterimage-cli/overlay') || text.includes('/afterimage/overlay.js')) return true;
246
+ }
247
+ return false;
248
+ }
249
+
250
+ async function planPill(cwd, config) {
251
+ let pages = [];
252
+ let line = pillTag;
253
+ const noteFor = async type => ({ change: null, notes: await loadsPill(cwd, config) ? [] : [pillNotes[type] ?? pillNotes.unknown], apply: null });
254
+ if (config.type === 'plain') {
255
+ pages = await plainPages(cwd, config);
256
+ } else if (config.type === 'vite') {
257
+ const index = path.join(cwd, 'index.html');
258
+ if (await readText(index) === null) return noteFor('unknown');
259
+ pages = [{ full: index, name: 'index.html' }];
260
+ line = pillImport;
261
+ } else {
262
+ return noteFor(config.type);
263
+ }
264
+ const writes = [];
265
+ for (const page of pages) {
266
+ const text = await readText(page.full);
267
+ if (text.includes('/afterimage/overlay.js') || text.includes('afterimage-cli/overlay')) continue;
268
+ writes.push({ ...page, text: withLine(text, line) });
269
+ }
270
+ if (!writes.length) return { change: null, notes: [], apply: null };
271
+ const names = writes.map(page => page.name);
272
+ return {
273
+ change: names.length === 1 ? `Added the Afterimage pill to ${names[0]}` : `Added the Afterimage pill to ${names.length} pages (${names.join(', ')})`,
274
+ notes: [],
275
+ apply: async () => {
276
+ for (const page of writes) await fs.writeFile(page.full, page.text);
277
+ }
278
+ };
279
+ }
280
+
172
281
  // Adds each line that is not already there, in the file's own line ending.
173
282
  async function planLines(cwd, name, lines, isPresent) {
174
283
  const file = path.join(cwd, name);
@@ -274,11 +383,12 @@ export async function run(args, ctx) {
274
383
  // and detection (which reads package.json) still sees the project as it was.
275
384
  let plans;
276
385
  try {
277
- const packagePlan = await planPackage(cwd);
386
+ const packageFile = await readPackage(cwd);
278
387
  const configPlan = await planConfig(cwd, flags, prompt);
279
388
  plans = [
280
389
  configPlan,
281
- packagePlan,
390
+ planPackage(packageFile, configPlan.config),
391
+ await planPill(cwd, configPlan.config),
282
392
  await planLines(cwd, '.gitattributes', [`${configPlan.config.history}/** -text`], hasTextRule),
283
393
  // The save's lock and stage folders too, which a killed save can leave in the history folder.
284
394
  await planLines(cwd, '.gitignore', ['.afterimage/', `${configPlan.config.history}/.afterimage-*`, 'node_modules/'], hasIgnore),
@@ -302,6 +412,7 @@ export async function run(args, ctx) {
302
412
  if (notes.length) stdout.write(`${changes.length ? '\n' : ''}${notes.join('\n\n')}\n`);
303
413
  // With --agent the copy is in place, and a fresh one needs loading; without it, say how to add it.
304
414
  const agentLine = agent ? plans.at(-1).change && agents[agent].loads : skillHint;
305
- stdout.write(`\n${agentLine ? `${agentLine}\n` : ''}${nextStep}\n`);
415
+ const dev = (await readPackage(cwd)).manifest.scripts?.dev;
416
+ stdout.write(`\n${agentLine ? `${agentLine}\n` : ''}${nextStep(typeof dev === 'string' && dev.trim().startsWith(devWrap))}\n`);
306
417
  return 0;
307
418
  }
@@ -31,11 +31,7 @@ export async function run(args, { cwd, stdout, stderr, buildStdio, largeHistory
31
31
  }
32
32
 
33
33
  const config = await readConfig(cwd);
34
- if (config.build) await runBuild(config.build, cwd, { stdio: buildStdio });
35
- const { version, unchanged, bytes, history } = await saveVersion(
36
- { historyDir: path.resolve(cwd, config.history), sourceDir: path.resolve(cwd, config.source), exclude: await sourceExcludes(cwd, config) },
37
- { name, notes: parsed.values.note ?? [] }
38
- );
34
+ const { version, unchanged, bytes, history } = await saveProject({ projectDir: cwd, config, name, notes: parsed.values.note ?? [], buildStdio });
39
35
  if (unchanged) {
40
36
  stdout.write(`Nothing changed since ${version.id} "${version.name}". Nothing was saved.\n`);
41
37
  return 0;
@@ -49,7 +45,17 @@ export async function run(args, { cwd, stdout, stderr, buildStdio, largeHistory
49
45
  return 0;
50
46
  }
51
47
 
52
- export const sizeDocs = 'https://github.com/uxlucas/Afterimage/blob/main/docs/limits.md#history-size';
48
+ // Builds the prototype when it has a build, then saves it as the next version. Shared by this command and the overlay's
49
+ // Save button, which reaches it through afterimage dev.
50
+ export async function saveProject({ projectDir, config, name, notes = [], buildStdio }) {
51
+ if (config.build) await runBuild(config.build, projectDir, { stdio: buildStdio });
52
+ return saveVersion(
53
+ { historyDir: path.resolve(projectDir, config.history), sourceDir: path.resolve(projectDir, config.source), exclude: await sourceExcludes(projectDir, config) },
54
+ { name, notes }
55
+ );
56
+ }
57
+
58
+ export const sizeDocs ='https://github.com/uxlucas/Afterimage/blob/main/docs/limits.md#history-size';
53
59
 
54
60
  // Decimal units, as macOS Finder and npm count them.
55
61
  export function formatSize(bytes) {
package/src/cli/site.js CHANGED
@@ -65,7 +65,7 @@ async function checkCollision(sourceDir, excluded) {
65
65
  // With includeApp false (the dev server) only the afterimage/ folder is made. The old out stays in place until the
66
66
  // new one is complete, so a failure anywhere leaves the previous site untouched.
67
67
  // Returns warnings for the caller to print. fsOps lets tests make the swap fail; it is not part of the public signature.
68
- export async function assembleSite({ projectDir, config, viewerDir = packagedViewerDir, runtimeDir = packagedRuntimeDir, includeApp = true, buildStdio, fsOps = {} }) {
68
+ export async function assembleSite({ projectDir, config, viewerDir = packagedViewerDir, runtimeDir = packagedRuntimeDir, includeApp = true, allowEmpty = false, buildStdio, fsOps = {} }) {
69
69
  const ops = { rename: fs.rename, rm: fs.rm, ...fsOps };
70
70
  const historyDir = path.resolve(projectDir, config.history);
71
71
  const sourceDir = path.resolve(projectDir, config.source);
@@ -73,7 +73,8 @@ export async function assembleSite({ projectDir, config, viewerDir = packagedVie
73
73
  const warnings = [];
74
74
 
75
75
  const manifest = await verifyHistory(historyDir);
76
- if (!manifest?.versions.length) throw new Error('Save a first version with: afterimage save "Name"');
76
+ // Dev may start before the first save, so the overlay can offer it; a deploy needs something to show.
77
+ if (!manifest?.versions.length && !allowEmpty) throw new Error('Save a first version with: afterimage save "Name"');
77
78
  try {
78
79
  await fs.access(path.join(viewerDir, 'index.html'));
79
80
  } catch {
@@ -130,8 +131,8 @@ export async function assembleSite({ projectDir, config, viewerDir = packagedVie
130
131
  }
131
132
  await fs.cp(viewerDir, served, { recursive: true });
132
133
  // Only what versions.json lists. The history folder also holds the save lock and stage folders.
133
- await fs.copyFile(path.join(historyDir, 'versions.json'), path.join(served, 'versions.json'));
134
- for (const { id } of manifest.versions) {
134
+ if (manifest) await fs.copyFile(path.join(historyDir, 'versions.json'), path.join(served, 'versions.json'));
135
+ for (const { id } of manifest?.versions ?? []) {
135
136
  await fs.cp(path.join(historyDir, 'versions', id), path.join(served, 'versions', id), { recursive: true });
136
137
  }
137
138
  // map.js is for node (history.js injects with it); frame.js and sw.js carry their own copies of what they need,
@@ -167,5 +168,5 @@ export async function assembleSite({ projectDir, config, viewerDir = packagedVie
167
168
  await fs.rm(staging, keep).catch(() => {});
168
169
  throw error;
169
170
  }
170
- return { outDir, versions: manifest.versions.length, warnings };
171
+ return { outDir, versions: manifest?.versions.length ?? 0, warnings };
171
172
  }
@@ -0,0 +1,266 @@
1
+ // The overlay: Afterimage's pill on the prototype itself, served at /afterimage/overlay.js and exported as
2
+ // afterimage-cli/overlay. One line adds it to an app: a script tag in plain HTML, or an import in a bundled app.
3
+ //
4
+ // It finds the history on its own and draws nothing when there is none:
5
+ // - on this machine, afterimage dev answers /afterimage/api/session, on the page's own origin (a plain prototype dev
6
+ // serves itself) or on ports 4321 to 4330 (beside the app's own dev server). There the pill can also save.
7
+ // - on a built site, /afterimage/versions.json is next to the app, and the pill shows the history only.
8
+ // How this copy was loaded says which, so a built site never asks for a helper, even served from this machine:
9
+ // - a script tag dev served carries dev's stamp (window.__afterimageDev); any other script tag is a built site's copy.
10
+ // - an import has no currentScript. Bundlers replace process.env.NODE_ENV, so a production build says it is one.
11
+ // History opens the viewer as a layer over the page, in the same tab; the close button, Esc, or the viewer itself
12
+ // (it posts afterimage:close when it is framed) close it again.
13
+ //
14
+ // It is plain JavaScript with no imports, so it runs as a classic script and as a module, and it does nothing where
15
+ // there is no document (a server render of a bundled app). It draws in a shadow root, so the app's CSS can't reach it.
16
+ // It stays out of saved versions, which are builds of the same app: frame.js marks those pages before any app code
17
+ // runs, and any page in a frame is left alone too.
18
+ (() => {
19
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
20
+ if (window.__afterimageOverlay || window.__afterimageVersion) return;
21
+ try {
22
+ if (window.top !== window) return;
23
+ } catch {
24
+ return;
25
+ }
26
+ window.__afterimageOverlay = true;
27
+
28
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname) || location.hostname.endsWith('.localhost');
29
+ const scriptTag = Boolean(document.currentScript);
30
+ const servedByDev = window.__afterimageDev === true;
31
+ let mode;
32
+ try {
33
+ // Written out in full so a bundler can replace it; with no bundler there is no process and this throws.
34
+ mode = process.env.NODE_ENV;
35
+ } catch { /* a script tag */ }
36
+ const inDev = local && (scriptTag ? servedByDev : mode !== 'production');
37
+ const helperPorts = Array.from({ length: 10 }, (_, i) => 4321 + i);
38
+
39
+ const getJson = async (url, timeout = 1500) => {
40
+ try {
41
+ const response = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(timeout) });
42
+ return response.ok ? await response.json() : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+ const session = async base => {
48
+ const body = await getJson(`${base}/afterimage/api/session`);
49
+ return body?.afterimage === 1 ? { base, ...body } : null;
50
+ };
51
+
52
+ // Where the history is, or null. On this machine a running afterimage dev comes first: some dev servers (Vite's)
53
+ // serve the project's own afterimage/versions.json at the same path a deployed site does, and that copy can't save.
54
+ // A host that answers every path with the app's own page fails the JSON checks.
55
+ async function find() {
56
+ if (inDev && servedByDev) {
57
+ const here = await session(location.origin);
58
+ if (here) return here;
59
+ } else if (inDev) {
60
+ // One port at a time from 4321, stopping at the first helper: usually that is the first ask, and a port held by
61
+ // some other server costs one quick refusal (which the browser logs) rather than ten at once.
62
+ for (const port of helperPorts) {
63
+ const found = await session(`http://${location.hostname}:${port}`);
64
+ if (found) return found;
65
+ }
66
+ }
67
+ const manifest = await getJson('/afterimage/versions.json');
68
+ if (Array.isArray(manifest?.versions) && manifest.versions.length) {
69
+ const latest = manifest.versions[0];
70
+ return { base: location.origin, versions: manifest.versions.length, latest: { id: latest.id, name: latest.name }, canSave: false };
71
+ }
72
+ return null;
73
+ }
74
+
75
+ const css = `
76
+ :host { all: initial; }
77
+ * { box-sizing: border-box; }
78
+ .dock { position: fixed; right: 16px; bottom: 16px; z-index: 2147483646; font: 13px/1 system-ui, -apple-system, 'Segoe UI', sans-serif;
79
+ -webkit-font-smoothing: antialiased; color: #ededed; }
80
+ .pill { display: flex; align-items: center; gap: 2px; padding: 5px; border-radius: 999px; background: #1b1b1b;
81
+ box-shadow: 0 0 0 1px #ffffff14, 0 1px 2px #0000004d, 0 10px 28px #00000040; user-select: none; -webkit-user-select: none; }
82
+ .mark { flex: none; width: 34px; height: 34px; display: grid; place-items: center; }
83
+ .label { padding: 0 8px 0 2px; color: #8e8e8e; white-space: nowrap; font-variant-numeric: tabular-nums; }
84
+ .label b { color: #ededed; font-weight: 500; }
85
+ .sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: #ffffff14; }
86
+ button { appearance: none; height: 34px; margin: 0; padding: 0 12px; border: 0; border-radius: 999px; background: transparent;
87
+ color: inherit; font: inherit; line-height: 1; white-space: nowrap; cursor: pointer; }
88
+ button:hover:not(:disabled) { background: #2a2a2a; }
89
+ button:disabled { color: #5c5c5c; cursor: default; }
90
+ button:focus-visible, input:focus-visible { outline: 2px solid #ededed8c; outline-offset: 1px; }
91
+ .primary { background: #ededed; color: #1b1b1b; }
92
+ .primary:hover:not(:disabled) { background: #ffffff; }
93
+ input { width: 180px; height: 34px; margin: 0 2px 0 0; padding: 0 12px; border: 0; border-radius: 999px; background: #2a2a2a;
94
+ color: #ededed; font: inherit; }
95
+ input::placeholder { color: #8e8e8e; }
96
+ .status { padding: 0 12px 0 2px; white-space: nowrap; }
97
+ .status.bad { color: #e0705f; white-space: normal; max-width: 320px; line-height: 1.35; padding: 6px 12px 6px 2px; }
98
+ .busy { display: inline-block; width: 6px; height: 6px; margin-right: 8px; border-radius: 50%; background: #e5a83b;
99
+ vertical-align: 1px; animation: pulse 1s ease-in-out infinite alternate; }
100
+ @keyframes pulse { from { opacity: 0.35; } to { opacity: 1; } }
101
+ .layer { position: fixed; inset: 0; z-index: 2147483647; background: #1b1b1b; opacity: 0; transition: opacity 160ms ease-out; }
102
+ .layer[data-shown] { opacity: 1; }
103
+ .layer iframe { display: block; width: 100%; height: 100%; border: 0; }
104
+ .close { position: absolute; top: 12px; right: 12px; width: 34px; padding: 0; display: grid; place-items: center; background: #1b1b1b;
105
+ box-shadow: 0 0 0 1px #ffffff14, 0 6px 16px #00000040; color: #ededed; }
106
+ .close:hover:not(:disabled) { background: #2a2a2a; }
107
+ .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
108
+ @media (prefers-reduced-motion: reduce) { .layer { transition: none; } .busy { animation: none; } }
109
+ `;
110
+ // The wipe mark from afterimage's own icon: the first version on the left, today's on the right.
111
+ const mark = '<svg width="18" height="18" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="#ededed"/><path d="M7 0h9v32H7a7 7 0 0 1-7-7V7a7 7 0 0 1 7-7z" fill="#8e8e8e"/><rect x="15" y="5" width="2" height="22" rx="1" fill="#1b1b1b"/></svg>';
112
+ const cross = '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
113
+
114
+ const host = document.createElement('afterimage-overlay');
115
+ const root = host.attachShadow({ mode: 'open' });
116
+ const make = (tag, props = {}) => Object.assign(document.createElement(tag), props);
117
+
118
+ let state = null;
119
+ let view = 'idle';
120
+ let message = '';
121
+ let timer = 0;
122
+ let layer = null;
123
+ let returnFocus = null;
124
+
125
+ const dock = make('div', { className: 'dock' });
126
+ const pill = make('div', { className: 'pill' });
127
+ pill.setAttribute('role', 'toolbar');
128
+ pill.setAttribute('aria-label', 'Afterimage');
129
+ dock.append(pill);
130
+ const live = make('div', { className: 'visually-hidden' });
131
+ live.setAttribute('aria-live', 'polite');
132
+ root.append(make('style', { textContent: css }), dock, live);
133
+
134
+ const say = text => { live.textContent = text; };
135
+ const later = (next, ms) => {
136
+ clearTimeout(timer);
137
+ timer = setTimeout(next, ms);
138
+ };
139
+ const button = (text, onClick, extra = {}) => {
140
+ const element = make('button', { type: 'button', textContent: text, ...extra });
141
+ element.addEventListener('click', onClick);
142
+ return element;
143
+ };
144
+
145
+ function render() {
146
+ pill.replaceChildren();
147
+ pill.insertAdjacentHTML('beforeend', `<span class="mark">${mark}</span>`);
148
+ const versions = state.versions;
149
+
150
+ if (view === 'naming') {
151
+ const input = make('input', { type: 'text', maxLength: 120, placeholder: `Version ${versions + 1}`, ariaLabel: 'Version name' });
152
+ input.addEventListener('keydown', event => {
153
+ if (event.key === 'Enter') save(input.value);
154
+ if (event.key === 'Escape') { event.stopPropagation(); back(); }
155
+ });
156
+ pill.append(input, button('Save', () => save(input.value), { className: 'primary' }), button('Cancel', back));
157
+ input.focus();
158
+ return;
159
+ }
160
+ if (view === 'saving') {
161
+ pill.append(make('span', { className: 'status', innerHTML: `<span class="busy"></span>Saving v${versions + 1}…` }));
162
+ return;
163
+ }
164
+ if (view === 'saved' || view === 'failed') {
165
+ pill.append(make('span', { className: `status${view === 'failed' ? ' bad' : ''}`, textContent: message }));
166
+ return;
167
+ }
168
+
169
+ const label = make('span', { className: 'label' });
170
+ if (versions) label.innerHTML = `<b>${state.latest.id}</b> · ${versions} ${versions === 1 ? 'version' : 'versions'}`;
171
+ else label.textContent = 'No versions yet';
172
+ pill.append(label, make('span', { className: 'sep' }));
173
+ if (versions) pill.append(button('History', open));
174
+ if (state.canSave) pill.append(button(versions ? 'Save' : 'Save first version', () => { view = 'naming'; render(); }, versions ? {} : { className: 'primary' }));
175
+ }
176
+
177
+ function back() {
178
+ view = 'idle';
179
+ render();
180
+ }
181
+
182
+ async function save(name) {
183
+ view = 'saving';
184
+ render();
185
+ say('Saving. Afterimage builds the prototype first.');
186
+ let result;
187
+ try {
188
+ const response = await fetch(`${state.base}/afterimage/api/save`, {
189
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim() })
190
+ });
191
+ result = await response.json().catch(() => ({ error: `Afterimage answered ${response.status}.` }));
192
+ if (!response.ok) throw new Error(result.error ?? `Afterimage answered ${response.status}.`);
193
+ } catch (error) {
194
+ view = 'failed';
195
+ message = `Couldn't save. ${error.message === 'Failed to fetch' ? 'Is afterimage dev still running?' : error.message}`;
196
+ render();
197
+ say(message);
198
+ later(back, 6000);
199
+ return;
200
+ }
201
+ if (result.saved) state = { ...state, versions: Number(result.id.slice(1)), latest: { id: result.id, name: result.name } };
202
+ view = 'saved';
203
+ message = result.saved ? `Saved ${result.id} "${result.name}"` : `No changes since ${result.id}`;
204
+ render();
205
+ say(message);
206
+ later(back, 2400);
207
+ }
208
+
209
+ function open(event) {
210
+ if (layer) return;
211
+ returnFocus = event?.currentTarget ?? null;
212
+ layer = make('div', { className: 'layer' });
213
+ layer.setAttribute('role', 'dialog');
214
+ layer.setAttribute('aria-modal', 'true');
215
+ layer.setAttribute('aria-label', 'Version history');
216
+ const frame = make('iframe', { src: `${state.base}/afterimage/`, title: 'Version history' });
217
+ const closer = button('', close, { className: 'close', ariaLabel: 'Close history' });
218
+ closer.innerHTML = cross;
219
+ layer.append(frame, closer);
220
+ root.append(layer);
221
+ dock.hidden = true;
222
+ document.documentElement.style.setProperty('overflow', 'hidden');
223
+ requestAnimationFrame(() => layer?.setAttribute('data-shown', ''));
224
+ frame.addEventListener('load', () => frame.focus(), { once: true });
225
+ closer.focus();
226
+ }
227
+
228
+ function close() {
229
+ if (!layer) return;
230
+ layer.remove();
231
+ layer = null;
232
+ dock.hidden = false;
233
+ document.documentElement.style.removeProperty('overflow');
234
+ returnFocus?.focus?.();
235
+ // A save the agent made from the terminal meanwhile shows up in the count.
236
+ refresh();
237
+ }
238
+
239
+ window.addEventListener('keydown', event => {
240
+ if (event.key === 'Escape' && layer) close();
241
+ });
242
+ window.addEventListener('message', event => {
243
+ if (layer && event.origin === new URL(state.base).origin && event.data?.type === 'afterimage:close') close();
244
+ });
245
+
246
+ async function refresh() {
247
+ if (!state || view !== 'idle') return;
248
+ const next = state.canSave ? await session(state.base) : null;
249
+ if (next && (next.versions !== state.versions)) {
250
+ state = next;
251
+ render();
252
+ }
253
+ }
254
+ // Saves the agent makes from the terminal show up when the person comes back to the page.
255
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refresh(); });
256
+
257
+ const mount = async () => {
258
+ state = await find();
259
+ if (!state) return;
260
+ render();
261
+ // On <html>, beside <body> rather than in it: frameworks that redraw the body (hydration, page swaps) leave it alone.
262
+ document.documentElement.append(host);
263
+ };
264
+ if (document.body) mount();
265
+ else document.addEventListener('DOMContentLoaded', mount, { once: true });
266
+ })();