afterimage-cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - **The pill is the viewer's own bar.** It sits at the bottom of the page and reads Version N of N, Highlight, Compare and Save. Each one opens the viewer straight in that state, on the page and scroll you are on, so Highlight looks like the outlines switching on in place. There is no History step and no close button: turn Highlight off, leave Compare or close the list (or press Esc) and you are back on the live page, on the route you left the viewer on.
6
+ - **No flash when it opens.** Pointing at the pill loads the viewer out of sight, and it only shows once it is on the right page. Before, a dark layer covered the page while the viewer loaded.
7
+ - **Hash-routed apps compare page with page.** In an app that changes page by its hash (`#/orders`), the older version now follows to the same page. Before, it stayed on the first page, and the two different pages counted as a thousand changes. Change counts are kept per page.
8
+ - **Moves caused by an edit are not changes of their own.** When a version has real edits, the elements that only moved or resized because of them (the cells beside a column that got wider, the rows under one that got taller) are no longer outlined or counted. A version whose only difference is a move still shows it. Counts the version list cached under the old rule are worked out again.
9
+
3
10
  ## 0.2.0
4
11
 
5
12
  - **The pill.** Afterimage now sits on the prototype itself. Save keeps the current state as the next version, and History opens every version over the page, in the same tab; Esc or the close button returns to the prototype. On a deployed site the same pill shows everyone the versions, without Save. It draws in a shadow root, so the app's CSS can't reach it, and it stays out of saved versions.
package/README.md CHANGED
@@ -35,7 +35,7 @@ Run your prototype:
35
35
  npm run dev
36
36
  ```
37
37
 
38
- The pill sits at the bottom right of the page. Save keeps the current state as the next version. History opens every version over the page, with what changed between any two, and Esc closes it. Once you deploy, the same pill on the live site shows everyone the versions, without Save.
38
+ The pill sits at the bottom of the page and is the same bar the viewer has. Highlight outlines what changed since the version before, on the page you are on; Compare shows the two side by side; Version N of N lists every version. Turn it off again, or press Esc, and you are back on the live page. Save keeps the current state as the next version. Once you deploy, the same pill on the live site shows everyone the versions, without Save.
39
39
 
40
40
  You can also save from the command line, or have your agent do it:
41
41
 
@@ -5,7 +5,7 @@ alwaysApply: false
5
5
 
6
6
  # Afterimage
7
7
 
8
- Afterimage keeps a version history of this prototype. Each save builds the prototype and stores the built page as the next version (v1, v2, v3 and so on) in `afterimage/`. A pill sits on the running prototype: Save keeps the current state as the next version, and History opens every version over the page, with what changed between any two. On the deployed site the same pill shows the history to everyone who opens it. A version is the built page, kept to compare against and to share. It is not a backup of the source: the source belongs in git. Run every command from the project root, where `afterimage.config.json` is.
8
+ Afterimage keeps a version history of this prototype. Each save builds the prototype and stores the built page as the next version (v1, v2, v3 and so on) in `afterimage/`. A pill sits on the running prototype: Save keeps the current state as the next version; Highlight outlines what changed since the version before, right on the page; Compare and Version N of N open the rest of the history in place. On the deployed site the same pill shows the history to everyone who opens it. A version is the built page, kept to compare against and to share. It is not a backup of the source: the source belongs in git. Run every command from the project root, where `afterimage.config.json` is.
9
9
 
10
10
  The user works on the prototype, not in a terminal. Do the steps yourself and reply in a line or two, with a link when there is one. Never hand the user a command to run.
11
11
 
@@ -91,7 +91,7 @@ With dev on its default port, a comparison is `http://localhost:4321/afterimage/
91
91
  - **"Save a version called Remember me with a note that it keeps people signed in for 30 days."** Run `npx afterimage save "Remember me" --note "Keeps people signed in for 30 days."` and tell the user the id it printed, in one line.
92
92
  - **"What changed since v2?"** Read `afterimage/versions.json`. If v2 is the latest version, tell the user nothing has changed since v2, because it is the newest version, and don't open a comparison. If v2 isn't listed, say so. Otherwise make sure dev is running and give the user the history's URL with `?version=<latest>&mode=compare&base=v2` added. The viewer shows the changes, so don't write a text diff. It compares saved versions: if the prototype has changed since the last save, say that those changes aren't in the comparison.
93
93
  - **"Get the version history ready to deploy."** Run `npx afterimage build`. If the project isn't a git repository, tell the user the history has to be committed to the repository the site deploys from, and stop. Otherwise run `git status --short -- afterimage afterimage.config.json package.json package-lock.json pnpm-lock.yaml yarn.lock bun.lock bun.lockb .gitattributes vercel.json netlify.toml` and tell the user which of those aren't committed yet, since the host only sees what is in git. The rest of the project is the user's business. Tell them the history will be at `/afterimage/` on the deployed site, behind the same pill. Don't commit, push or deploy unless the user asks.
94
- - **"Show me the version history."** Make sure dev is running, then tell the user to press History on the pill, and give them the history's URL too.
94
+ - **"Show me the version history."** Make sure dev is running, then tell the user to press Version N of N on the pill, and give them the history's URL too.
95
95
 
96
96
  ## Rules
97
97
 
@@ -5,7 +5,7 @@ description: Adds Afterimage (afterimage-cli) to this prototype and saves, names
5
5
 
6
6
  # Afterimage
7
7
 
8
- Afterimage keeps a version history of this prototype. Each save builds the prototype and stores the built page as the next version (v1, v2, v3 and so on) in `afterimage/`. A pill sits on the running prototype: Save keeps the current state as the next version, and History opens every version over the page, with what changed between any two. On the deployed site the same pill shows the history to everyone who opens it. A version is the built page, kept to compare against and to share. It is not a backup of the source: the source belongs in git. Run every command from the project root, where `afterimage.config.json` is.
8
+ Afterimage keeps a version history of this prototype. Each save builds the prototype and stores the built page as the next version (v1, v2, v3 and so on) in `afterimage/`. A pill sits on the running prototype: Save keeps the current state as the next version; Highlight outlines what changed since the version before, right on the page; Compare and Version N of N open the rest of the history in place. On the deployed site the same pill shows the history to everyone who opens it. A version is the built page, kept to compare against and to share. It is not a backup of the source: the source belongs in git. Run every command from the project root, where `afterimage.config.json` is.
9
9
 
10
10
  The user works on the prototype, not in a terminal. Do the steps yourself and reply in a line or two, with a link when there is one. Never hand the user a command to run.
11
11
 
@@ -91,7 +91,7 @@ With dev on its default port, a comparison is `http://localhost:4321/afterimage/
91
91
  - **"Save a version called Remember me with a note that it keeps people signed in for 30 days."** Run `npx afterimage save "Remember me" --note "Keeps people signed in for 30 days."` and tell the user the id it printed, in one line.
92
92
  - **"What changed since v2?"** Read `afterimage/versions.json`. If v2 is the latest version, tell the user nothing has changed since v2, because it is the newest version, and don't open a comparison. If v2 isn't listed, say so. Otherwise make sure dev is running and give the user the history's URL with `?version=<latest>&mode=compare&base=v2` added. The viewer shows the changes, so don't write a text diff. It compares saved versions: if the prototype has changed since the last save, say that those changes aren't in the comparison.
93
93
  - **"Get the version history ready to deploy."** Run `npx afterimage build`. If the project isn't a git repository, tell the user the history has to be committed to the repository the site deploys from, and stop. Otherwise run `git status --short -- afterimage afterimage.config.json package.json package-lock.json pnpm-lock.yaml yarn.lock bun.lock bun.lockb .gitattributes vercel.json netlify.toml` and tell the user which of those aren't committed yet, since the host only sees what is in git. The rest of the project is the user's business. Tell them the history will be at `/afterimage/` on the deployed site, behind the same pill. Don't commit, push or deploy unless the user asks.
94
- - **"Show me the version history."** Make sure dev is running, then tell the user to press History on the pill, and give them the history's URL too.
94
+ - **"Show me the version history."** Make sure dev is running, then tell the user to press Version N of N on the pill, and give them the history's URL too.
95
95
 
96
96
  ## Rules
97
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "afterimage-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A version history for prototypes, deployed with the prototype itself.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -8,8 +8,11 @@
8
8
  // How this copy was loaded says which, so a built site never asks for a helper, even served from this machine:
9
9
  // - a script tag dev served carries dev's stamp (window.__afterimageDev); any other script tag is a built site's copy.
10
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.
11
+ //
12
+ // The pill is the viewer's own bar, in the same place: Version N of N, Highlight, Compare, and Save. Pointing at it
13
+ // loads the viewer, hidden, over the page; a button asks the viewer for that state, on the page's route and scroll, and
14
+ // the viewer only shows once it is there, so the page seems to switch the outlines on in place. Turning everything off
15
+ // again (or Esc) hands the page back, where the visitor left the viewer. The messages are in viewer/hooks/usePageHost.js.
13
16
  //
14
17
  // It is plain JavaScript with no imports, so it runs as a classic script and as a module, and it does nothing where
15
18
  // 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.
@@ -35,6 +38,7 @@
35
38
  } catch { /* a script tag */ }
36
39
  const inDev = local && (scriptTag ? servedByDev : mode !== 'production');
37
40
  const helperPorts = Array.from({ length: 10 }, (_, i) => 4321 + i);
41
+ const OPEN_TIMEOUT_MS = 12000;
38
42
 
39
43
  const getJson = async (url, timeout = 1500) => {
40
44
  try {
@@ -72,44 +76,51 @@
72
76
  return null;
73
77
  }
74
78
 
79
+ // The viewer's pill (viewer/pill/pill.css and styles/tokens.css), so the two read as one control.
75
80
  const css = `
76
81
  :host { all: initial; }
77
82
  * { 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;
83
+ .dock { position: fixed; left: 0; right: 0; bottom: 16px; z-index: 2147483646; display: flex; justify-content: center;
84
+ padding: 0 16px; pointer-events: none; font: 13px/1 system-ui, -apple-system, 'Segoe UI', sans-serif;
79
85
  -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; }
86
+ .pill { display: flex; align-items: center; gap: 2px; max-width: 100%; padding: 5px; border-radius: 999px; background: #1b1b1b;
87
+ white-space: nowrap; box-shadow: 0 0 0 1px #ffffff14, 0 1px 2px #0000004d, 0 10px 28px #00000040; pointer-events: auto;
88
+ user-select: none; -webkit-user-select: none; }
89
+ .num { font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; font-size: 12px; }
85
90
  .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; }
91
+ .label { padding: 0 12px; color: #8e8e8e; }
92
+ button { appearance: none; display: inline-flex; align-items: center; gap: 8px; height: 34px; margin: 0; padding: 0 12px;
93
+ border: 0; border-radius: 999px; background: transparent; color: inherit; font: inherit; line-height: 1; white-space: nowrap;
94
+ cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent;
95
+ transition: background-color 150ms ease, color 150ms ease, transform 120ms cubic-bezier(0.22, 1, 0.36, 1); }
96
+ button:active:not(:disabled) { transform: scale(0.97); }
89
97
  button:disabled { color: #5c5c5c; cursor: default; }
90
- button:focus-visible, input:focus-visible { outline: 2px solid #ededed8c; outline-offset: 1px; }
98
+ @media (hover: hover) { button:hover:not(:disabled) { background: #2a2a2a; } }
99
+ button:focus-visible, input:focus-visible { outline: 2px solid #ededed8c; outline-offset: -2px; }
100
+ .dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: #6fbf73; }
101
+ .field { display: inline-grid; place-items: center; min-width: 24px; height: 24px; padding: 0 6px; margin-left: -2px; border-radius: 6px;
102
+ background: #ffffff0d; color: #ededed; }
103
+ .of { color: #8e8e8e; margin-left: -4px; }
104
+ .of .num { margin-left: 4px; }
91
105
  .primary { background: #ededed; color: #1b1b1b; }
92
- .primary:hover:not(:disabled) { background: #ffffff; }
106
+ @media (hover: hover) { .primary:hover:not(:disabled) { background: #ffffff; } }
107
+ button[aria-busy='true']::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #e5a83b;
108
+ animation: pulse 0.8s ease-in-out infinite alternate; }
93
109
  input { width: 180px; height: 34px; margin: 0 2px 0 0; padding: 0 12px; border: 0; border-radius: 999px; background: #2a2a2a;
94
110
  color: #ededed; font: inherit; }
95
111
  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; }
112
+ .status { padding: 0 12px; }
113
+ .status.bad { color: #e0705f; white-space: normal; max-width: 320px; line-height: 1.35; padding: 6px 12px; }
98
114
  .busy { display: inline-block; width: 6px; height: 6px; margin-right: 8px; border-radius: 50%; background: #e5a83b;
99
115
  vertical-align: 1px; animation: pulse 1s ease-in-out infinite alternate; }
100
116
  @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; }
117
+ /* The viewer waits here, laid out but unseen, until it shows what was asked for. Not display:none, so it loads. */
118
+ .layer { position: fixed; inset: 0; z-index: 2147483647; opacity: 0; pointer-events: none; }
119
+ .layer[data-shown] { opacity: 1; pointer-events: auto; }
120
+ .layer iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
107
121
  .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; } }
122
+ @media (prefers-reduced-motion: reduce) { button { transition: none; } .busy, button[aria-busy='true']::before { animation: none; } }
109
123
  `;
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
124
 
114
125
  const host = document.createElement('afterimage-overlay');
115
126
  const root = host.attachShadow({ mode: 'open' });
@@ -119,7 +130,10 @@
119
130
  let view = 'idle';
120
131
  let message = '';
121
132
  let timer = 0;
122
- let layer = null;
133
+ // The viewer, once the pill was pointed at: { el, frame, origin, versions, listening, asked, shown }.
134
+ // asked is { view, button } from a click until the viewer is ready.
135
+ let viewer = null;
136
+ let openTimer = 0;
123
137
  let returnFocus = null;
124
138
 
125
139
  const dock = make('div', { className: 'dock' });
@@ -136,15 +150,15 @@
136
150
  clearTimeout(timer);
137
151
  timer = setTimeout(next, ms);
138
152
  };
139
- const button = (text, onClick, extra = {}) => {
140
- const element = make('button', { type: 'button', textContent: text, ...extra });
153
+ const button = (content, onClick, extra = {}) => {
154
+ const element = make('button', { type: 'button', ...extra });
155
+ element.append(...[content].flat());
141
156
  element.addEventListener('click', onClick);
142
157
  return element;
143
158
  };
144
159
 
145
160
  function render() {
146
161
  pill.replaceChildren();
147
- pill.insertAdjacentHTML('beforeend', `<span class="mark">${mark}</span>`);
148
162
  const versions = state.versions;
149
163
 
150
164
  if (view === 'naming') {
@@ -166,12 +180,26 @@
166
180
  return;
167
181
  }
168
182
 
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' }));
183
+ if (!versions) {
184
+ pill.append(make('span', { className: 'label', textContent: 'No versions yet' }));
185
+ if (state.canSave) pill.append(button('Save first version', () => { view = 'naming'; render(); }, { className: 'primary' }));
186
+ return;
187
+ }
188
+ // Version N of N opens the list of versions, as it does in the viewer. A first version has nothing to compare.
189
+ const number = Number(state.latest.id.slice(1)) || versions;
190
+ const single = versions < 2;
191
+ pill.append(
192
+ button([
193
+ make('span', { className: 'dot' }),
194
+ 'Version',
195
+ make('span', { className: 'field num', textContent: String(number) }),
196
+ make('span', { className: 'of', innerHTML: `of<span class="num">${versions}</span>` }),
197
+ ], event => open('versions', event), { ariaLabel: `Version ${number} of ${versions}, latest. Show versions` }),
198
+ make('span', { className: 'sep' }),
199
+ button('Highlight', event => open('highlight', event), { disabled: single, ariaLabel: single ? 'Highlight changes, not available on the first version' : 'Highlight changes' }),
200
+ button('Compare', event => open('compare', event), { disabled: single }),
201
+ );
202
+ if (state.canSave) pill.append(make('span', { className: 'sep' }), button('Save', () => { view = 'naming'; render(); }));
175
203
  }
176
204
 
177
205
  function back() {
@@ -206,42 +234,97 @@
206
234
  later(back, 2400);
207
235
  }
208
236
 
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);
237
+ // Loads the viewer behind the page, on the page's route, so a click finds it ready. A save since then made a version
238
+ // the loaded viewer doesn't know, so that one is replaced.
239
+ function prepare() {
240
+ if (!state?.versions || viewer?.shown || viewer?.asked) return;
241
+ if (viewer && viewer.versions === state.versions) return;
242
+ viewer?.el.remove();
243
+ const el = make('div', { className: 'layer' });
244
+ el.inert = true;
245
+ el.setAttribute('aria-hidden', 'true');
246
+ const frame = make('iframe', { src: `${state.base}/afterimage/${location.hash}`, title: 'Version history', tabIndex: -1 });
247
+ el.append(frame);
248
+ root.append(el);
249
+ viewer = { el, frame, origin: new URL(state.base).origin, versions: state.versions, listening: false, asked: null, shown: false };
250
+ }
251
+
252
+ // A click can come before the viewer has started; then it is asked as soon as it says it is listening.
253
+ function ask() {
254
+ if (!viewer?.listening || !viewer.asked) return;
255
+ viewer.frame.contentWindow.postMessage({ type: 'afterimage:open', view: viewer.asked.view, route: location.hash, scrollY: window.scrollY }, viewer.origin);
256
+ }
257
+
258
+ function open(name, event) {
259
+ if (viewer?.shown || viewer?.asked) return;
260
+ prepare();
261
+ const pressed = event?.currentTarget ?? null;
262
+ returnFocus = pressed;
263
+ pressed?.setAttribute('aria-busy', 'true');
264
+ viewer.asked = { view: name, button: pressed };
265
+ ask();
266
+ clearTimeout(openTimer);
267
+ openTimer = setTimeout(() => {
268
+ // The viewer never got there (the helper stopped, say): start from scratch next time.
269
+ viewer?.el.remove();
270
+ viewer = null;
271
+ view = 'failed';
272
+ message = "Couldn't open the history. Is afterimage dev still running?";
273
+ render();
274
+ say(message);
275
+ later(back, 6000);
276
+ }, OPEN_TIMEOUT_MS);
277
+ }
278
+
279
+ function show() {
280
+ if (!viewer?.asked) return;
281
+ clearTimeout(openTimer);
282
+ viewer.asked.button?.removeAttribute('aria-busy');
283
+ viewer.asked = null;
284
+ viewer.shown = true;
285
+ const { el } = viewer;
286
+ el.inert = false;
287
+ el.removeAttribute('aria-hidden');
288
+ el.setAttribute('role', 'dialog');
289
+ el.setAttribute('aria-modal', 'true');
290
+ el.setAttribute('aria-label', 'Version history');
291
+ el.setAttribute('data-shown', '');
221
292
  dock.hidden = true;
222
293
  document.documentElement.style.setProperty('overflow', 'hidden');
223
- requestAnimationFrame(() => layer?.setAttribute('data-shown', ''));
224
- frame.addEventListener('load', () => frame.focus(), { once: true });
225
- closer.focus();
294
+ viewer.frame.focus();
226
295
  }
227
296
 
228
- function close() {
229
- if (!layer) return;
230
- layer.remove();
231
- layer = null;
297
+ // The viewer stays loaded for next time. The page takes the route and scroll the visitor left the viewer on.
298
+ function hide({ route, scrollY } = {}) {
299
+ if (!viewer?.shown) return;
300
+ viewer.shown = false;
301
+ const { el } = viewer;
302
+ el.removeAttribute('data-shown');
303
+ el.removeAttribute('role');
304
+ el.removeAttribute('aria-modal');
305
+ el.setAttribute('aria-hidden', 'true');
306
+ el.inert = true;
232
307
  dock.hidden = false;
233
308
  document.documentElement.style.removeProperty('overflow');
234
- returnFocus?.focus?.();
309
+ if (typeof route === 'string' && route !== location.hash && (route || location.hash)) location.hash = route;
310
+ if (typeof scrollY === 'number') requestAnimationFrame(() => requestAnimationFrame(() => window.scrollTo(0, scrollY)));
311
+ returnFocus?.focus?.({ preventScroll: true });
235
312
  // A save the agent made from the terminal meanwhile shows up in the count.
236
313
  refresh();
237
314
  }
238
315
 
239
316
  window.addEventListener('keydown', event => {
240
- if (event.key === 'Escape' && layer) close();
317
+ if (event.key === 'Escape' && viewer?.shown) hide();
241
318
  });
242
319
  window.addEventListener('message', event => {
243
- if (layer && event.origin === new URL(state.base).origin && event.data?.type === 'afterimage:close') close();
320
+ if (!viewer || event.source !== viewer.frame.contentWindow || event.origin !== viewer.origin) return;
321
+ const type = event.data?.type;
322
+ if (type === 'afterimage:listening') { viewer.listening = true; ask(); }
323
+ if (type === 'afterimage:ready') show();
324
+ if (type === 'afterimage:close') hide(event.data);
244
325
  });
326
+ pill.addEventListener('pointerenter', prepare);
327
+ pill.addEventListener('focusin', prepare);
245
328
 
246
329
  async function refresh() {
247
330
  if (!state || view !== 'idle') return;