@arraypress/extension-kit 1.9.0 → 1.11.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.
Files changed (88) hide show
  1. package/README.md +145 -18
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/index.d.ts +2 -1
  9. package/src/build/index.js +2 -1
  10. package/src/build/store-shots.d.ts +177 -0
  11. package/src/build/store-shots.js +173 -0
  12. package/src/build/test-build.d.ts +13 -1
  13. package/src/build/test-build.js +41 -2
  14. package/src/build/vendor.d.ts +29 -8
  15. package/src/build/vendor.js +136 -40
  16. package/src/content/elements.d.ts +50 -0
  17. package/src/content/elements.js +88 -0
  18. package/src/content/highlight.d.ts +63 -0
  19. package/src/content/highlight.js +117 -0
  20. package/src/content/overlay.d.ts +31 -6
  21. package/src/content/overlay.js +56 -8
  22. package/src/content/page-toast.d.ts +26 -0
  23. package/src/content/page-toast.js +72 -0
  24. package/src/content/slices.d.ts +3 -1
  25. package/src/content/slices.js +29 -14
  26. package/src/content/watch.d.ts +4 -2
  27. package/src/content/watch.js +17 -3
  28. package/src/dom/copy.d.ts +36 -0
  29. package/src/dom/copy.js +84 -0
  30. package/src/dom/focus.d.ts +11 -0
  31. package/src/dom/focus.js +15 -0
  32. package/src/dom/i18n.d.ts +17 -0
  33. package/src/dom/i18n.js +15 -0
  34. package/src/dom/make.d.ts +53 -0
  35. package/src/dom/make.js +71 -0
  36. package/src/frame/window.d.ts +8 -1
  37. package/src/frame/window.js +11 -8
  38. package/src/index.d.ts +31 -6
  39. package/src/index.js +24 -6
  40. package/src/panel/show-on-page.d.ts +82 -0
  41. package/src/panel/show-on-page.js +83 -0
  42. package/src/scripting/call.d.ts +29 -9
  43. package/src/scripting/call.js +35 -11
  44. package/src/scripting/context-menus.d.ts +45 -0
  45. package/src/scripting/context-menus.js +53 -0
  46. package/src/scripting/registration.d.ts +2 -1
  47. package/src/scripting/registration.js +2 -1
  48. package/src/scripting/tab.d.ts +80 -0
  49. package/src/scripting/tab.js +89 -0
  50. package/src/settings/saver.js +16 -6
  51. package/src/settings/storage.d.ts +33 -2
  52. package/src/settings/storage.js +73 -3
  53. package/src/settings/store.d.ts +17 -0
  54. package/src/settings/store.js +87 -20
  55. package/src/sites/sites.d.ts +6 -1
  56. package/src/sites/sites.js +20 -3
  57. package/src/styles/chrome-ui.css +63 -30
  58. package/src/testing/e2e.d.ts +35 -4
  59. package/src/testing/e2e.js +67 -5
  60. package/src/testing/fake-model.d.ts +9 -1
  61. package/src/testing/fake-model.js +44 -23
  62. package/src/testing/fake-storage.d.ts +41 -13
  63. package/src/testing/fake-storage.js +163 -51
  64. package/src/testing/index.d.ts +8 -2
  65. package/src/testing/index.js +8 -2
  66. package/src/testing/live-run.d.ts +151 -0
  67. package/src/testing/live-run.js +206 -0
  68. package/src/testing/messages.d.ts +7 -3
  69. package/src/testing/messages.js +50 -9
  70. package/src/testing/package.d.ts +56 -7
  71. package/src/testing/package.js +57 -18
  72. package/src/testing/pages.d.ts +114 -0
  73. package/src/testing/pages.js +109 -0
  74. package/src/testing/prompt.d.ts +4 -2
  75. package/src/testing/prompt.js +7 -3
  76. package/src/time/pool.d.ts +2 -1
  77. package/src/time/pool.js +4 -2
  78. package/src/toolbar/theme.d.ts +10 -2
  79. package/src/toolbar/theme.js +12 -4
  80. package/src/ui/menu.d.ts +13 -3
  81. package/src/ui/menu.js +31 -11
  82. package/src/ui/model-row.js +5 -1
  83. package/src/ui/notice.d.ts +58 -0
  84. package/src/ui/notice.js +43 -0
  85. package/src/ui/site-list.js +12 -3
  86. package/src/ui/text-dialog.d.ts +3 -2
  87. package/src/ui/text-dialog.js +10 -3
  88. package/src/ui/toast.js +2 -0
package/src/ai/host.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Chrome's on-device model for a service worker, wherever Chrome puts the
3
+ * Prompt API. Chrome's documentation says `LanguageModel` isn't in workers
4
+ * (https://developer.chrome.com/docs/ai/prompt-api); Chrome 157 and Chromium
5
+ * 156 do have it in an extension's service worker (checked in both: an
6
+ * unpacked extension, `typeof LanguageModel` in the worker, an extension page
7
+ * and an offscreen document, `'function'` in all three). So neither is
8
+ * promised, and a worker that uses the model shouldn't depend on which holds:
9
+ * {@link createHostedModelSession} is a `ModelSession` that asks the worker's
10
+ * own `LanguageModel` when there is one, and otherwise the extension's
11
+ * offscreen document, which has a page's Prompt API and answers over
12
+ * `chrome.runtime` messages ({@link serveModelOffscreen}).
13
+ *
14
+ * The offscreen document is the one the worker already keeps for the toolbar
15
+ * glyph (an extension gets only one): its script calls both
16
+ * `watchSchemeOffscreen` and `serveModelOffscreen`. It's told the session's
17
+ * options and instructions with each request, so it needs none of the
18
+ * extension's own code.
19
+ *
20
+ * @module ai/host
21
+ */
22
+
23
+ import { within } from '../time/time.js';
24
+ import { chromeLanguageModel, createModelSession } from './model.js';
25
+
26
+ /** @import { AskOptions, Availability, InitialPrompt, LanguageModelOptions, LanguageModelStatic, ModelAnswer, ModelSession } from './model.js' */
27
+
28
+ /** The request's message type: `{type: MODEL_HOST, method, name, …}`. */
29
+ export const MODEL_HOST = 'model-host';
30
+
31
+ /** How long past an answer's time limit the worker waits for the offscreen document to say so itself (ms). */
32
+ export const HOST_GRACE_MS = 1000;
33
+
34
+ /** The model's states, to check an answer that came over a message. */
35
+ const STATES = ['available', 'downloadable', 'downloading', 'unavailable', 'missing'];
36
+
37
+ /** Why a question got no answer, to check the same. */
38
+ const REASONS = ['unavailable', 'late', 'aborted', 'failed'];
39
+
40
+ /**
41
+ * A request to the offscreen document.
42
+ *
43
+ * @typedef {object} HostRequest
44
+ * @property {typeof MODEL_HOST} type The message type.
45
+ * @property {'availability' | 'ask' | 'abort' | 'release'} method What's asked.
46
+ * @property {string} name The session's name (one base session each).
47
+ * @property {LanguageModelOptions} [options] What its sessions are made with.
48
+ * @property {InitialPrompt[]} [initialPrompts] Its instructions and examples.
49
+ * @property {string} [id] A question's id, for `abort`.
50
+ * @property {string} [prompt] The question.
51
+ * @property {number} [timeoutMs] Its time limit.
52
+ * @property {object} [responseConstraint] The answer's JSON Schema.
53
+ */
54
+
55
+ /**
56
+ * The parts of `chrome.runtime` the worker's side uses.
57
+ *
58
+ * @typedef {{sendMessage(message: unknown): Promise<unknown>}} HostSender
59
+ */
60
+
61
+ /**
62
+ * The parts of `chrome.runtime` the offscreen document's side uses.
63
+ *
64
+ * @typedef {object} HostRuntime
65
+ * @property {string} id This extension's id: requests from anyone else are ignored.
66
+ * @property {{addListener(listener: (message: unknown, sender: {id?: string, tab?: unknown}, respond: (answer: unknown) => void) => boolean): void}} onMessage
67
+ * `chrome.runtime.onMessage`.
68
+ */
69
+
70
+ /**
71
+ * @typedef {object} HostedSessionSetup
72
+ * @property {string} name The session's name, its own among the extension's sessions ('topics').
73
+ * @property {LanguageModelOptions} options What sessions are made (and availability asked) with.
74
+ * @property {InitialPrompt[]} [initialPrompts] The instructions and examples every question starts from.
75
+ * @property {HostSender} runtime `chrome.runtime`.
76
+ * @property {() => Promise<unknown>} open Makes sure the offscreen document is open
77
+ * (`() => watchFromOffscreen(chrome)`); only called when the worker has no Prompt API.
78
+ * @property {() => LanguageModelStatic | undefined} [model] The worker's own Prompt API, read when used.
79
+ * Default Chrome's.
80
+ */
81
+
82
+ /**
83
+ * A model session for a service worker: the worker's own Prompt API when it
84
+ * has one, else the offscreen document's. The same `ModelSession` either way.
85
+ * An answer's JSON Schema crosses to the offscreen document as JSON, so it
86
+ * must be a plain object there (not a RegExp).
87
+ *
88
+ * @param {HostedSessionSetup} setup - The session, and how to reach the offscreen document.
89
+ * @returns {ModelSession} The session.
90
+ * @example
91
+ * // background.js
92
+ * const words = createHostedModelSession({
93
+ * name: 'words', options: ENGLISH, initialPrompts: [{ role: 'system', content: 'You list words.' }],
94
+ * runtime: chrome.runtime, open: () => watchFromOffscreen(chrome),
95
+ * });
96
+ * const { text } = await words.ask('Topic: football', { timeoutMs: 20_000 });
97
+ */
98
+ export function createHostedModelSession({ name, options, initialPrompts = [], runtime, open, model = chromeLanguageModel }) {
99
+ const local = createModelSession({ model, options, initialPrompts });
100
+ let asked = 0;
101
+
102
+ /**
103
+ * Ask the offscreen document.
104
+ *
105
+ * @param {HostRequest['method']} method
106
+ * @param {Partial<HostRequest>} [more]
107
+ * @returns {Promise<unknown>} Its answer; rejects when there's no document to hear it.
108
+ */
109
+ const send = async (method, more = {}) => {
110
+ await open();
111
+ return runtime.sendMessage(/** @type {HostRequest} */ ({ type: MODEL_HOST, method, name, options, initialPrompts, ...more }));
112
+ };
113
+
114
+ return {
115
+ async availability() {
116
+ if (model()) return local.availability();
117
+ const state = await send('availability').catch(() => 'missing');
118
+ return STATES.includes(/** @type {string} */ (state)) ? /** @type {Availability} */ (state) : 'missing';
119
+ },
120
+
121
+ async ask(prompt, askOptions) {
122
+ if (model()) return local.ask(prompt, askOptions);
123
+ const { timeoutMs, signal, responseConstraint } = askOptions;
124
+ if (signal?.aborted) return { text: null, reason: 'aborted' };
125
+ const id = `${name}:${Date.now()}:${asked++}`;
126
+ const onAbort = () => { runtime.sendMessage({ type: MODEL_HOST, method: 'abort', name, id }).catch(() => {}); };
127
+ signal?.addEventListener('abort', onAbort, { once: true });
128
+ try {
129
+ // The document keeps the time limit; the worker's own is for a document that never answers.
130
+ const answer = /** @type {Partial<ModelAnswer> | 'gone' | 'late' | undefined} */ (await within(
131
+ send('ask', { id, prompt, timeoutMs, ...(responseConstraint && { responseConstraint }) }).catch(() => 'gone'),
132
+ timeoutMs + HOST_GRACE_MS,
133
+ 'late',
134
+ ));
135
+ if (answer === 'late') {
136
+ onAbort();
137
+ return { text: null, reason: signal?.aborted ? 'aborted' : 'late' };
138
+ }
139
+ if (answer && typeof answer === 'object') {
140
+ if (typeof answer.text === 'string' && answer.reason === 'answered') return { text: answer.text, reason: 'answered' };
141
+ if (REASONS.includes(/** @type {string} */ (answer.reason))) return { text: null, reason: /** @type {Exclude<ModelAnswer['reason'], 'answered'>} */ (answer.reason) };
142
+ }
143
+ return { text: null, reason: signal?.aborted ? 'aborted' : 'unavailable' };
144
+ } finally {
145
+ signal?.removeEventListener('abort', onAbort);
146
+ }
147
+ },
148
+
149
+ release() {
150
+ local.release();
151
+ if (!model()) runtime.sendMessage({ type: MODEL_HOST, method: 'release', name }).catch(() => {});
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Answer the worker's model requests, in the offscreen document: one base
158
+ * session per name, made with the options and instructions the request
159
+ * carries (and again if they change). Requests from another extension, or
160
+ * from a content script (a web page's side), are ignored.
161
+ *
162
+ * @param {HostRuntime} runtime - `chrome.runtime`.
163
+ * @param {{model?: () => LanguageModelStatic | undefined}} [options] - The document's Prompt API. Default Chrome's.
164
+ * @returns {void}
165
+ * @example
166
+ * // offscreen/offscreen.js
167
+ * watchSchemeOffscreen(chrome.runtime);
168
+ * serveModelOffscreen(chrome.runtime);
169
+ */
170
+ export function serveModelOffscreen(runtime, { model = chromeLanguageModel } = {}) {
171
+ /** Each name's session, and the setup it was made with. @type {Map<string, {setup: string, session: ModelSession}>} */
172
+ const sessions = new Map();
173
+ /** The questions being answered, to stop one. @type {Map<string, AbortController>} */
174
+ const asking = new Map();
175
+
176
+ /** @param {HostRequest} request @returns {ModelSession} */
177
+ const sessionFor = ({ name, options = {}, initialPrompts = [] }) => {
178
+ const setup = JSON.stringify([options, initialPrompts]);
179
+ const kept = sessions.get(name);
180
+ if (kept?.setup === setup) return kept.session;
181
+ kept?.session.release();
182
+ const session = createModelSession({ model, options, initialPrompts });
183
+ sessions.set(name, { setup, session });
184
+ return session;
185
+ };
186
+
187
+ /** @param {HostRequest} request @returns {Promise<unknown>} */
188
+ const answer = async (request) => {
189
+ switch (request.method) {
190
+ case 'availability':
191
+ return sessionFor(request).availability();
192
+ case 'ask': {
193
+ const stop = new AbortController();
194
+ const id = String(request.id);
195
+ asking.set(id, stop);
196
+ try {
197
+ return await sessionFor(request).ask(String(request.prompt ?? ''), /** @type {AskOptions} */ ({
198
+ timeoutMs: Number(request.timeoutMs) || 0,
199
+ signal: stop.signal,
200
+ ...(request.responseConstraint && { responseConstraint: request.responseConstraint }),
201
+ }));
202
+ } finally {
203
+ asking.delete(id);
204
+ }
205
+ }
206
+ case 'abort':
207
+ asking.get(String(request.id))?.abort();
208
+ return null;
209
+ case 'release':
210
+ sessions.get(request.name)?.session.release();
211
+ sessions.delete(request.name);
212
+ return null;
213
+ default:
214
+ return null;
215
+ }
216
+ };
217
+
218
+ runtime.onMessage.addListener((message, sender, respond) => {
219
+ const request = /** @type {HostRequest | null | undefined} */ (message);
220
+ if (request?.type !== MODEL_HOST || typeof request.name !== 'string') return false;
221
+ if (sender.id !== runtime.id || sender.tab) return false;
222
+ answer(request).then(respond, () => respond(null));
223
+ return true;
224
+ });
225
+ }
package/src/ai/model.d.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * only downloads from a user gesture) and which reports progress;
10
10
  * - one base session, with the instructions and examples, is made once and
11
11
  * kept; each question goes to a `clone()` of it, so answers don't leak into
12
- * each other;
12
+ * each other; a base session Chrome has let go since (it can't be cloned)
13
+ * is made again;
13
14
  * - each answer has a time limit: an `AbortSignal` stops the answer once
14
15
  * it's late, and the caller gets a reason instead of a hang;
15
16
  * - the first session after Chrome starts can take ~18 s while the model
package/src/ai/model.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * only downloads from a user gesture) and which reports progress;
10
10
  * - one base session, with the instructions and examples, is made once and
11
11
  * kept; each question goes to a `clone()` of it, so answers don't leak into
12
- * each other;
12
+ * each other; a base session Chrome has let go since (it can't be cloned)
13
+ * is made again;
13
14
  * - each answer has a time limit: an `AbortSignal` stops the answer once
14
15
  * it's late, and the caller gets a reason instead of a hang;
15
16
  * - the first session after Chrome starts can take ~18 s while the model
@@ -265,7 +266,14 @@ export function createModelSession({ model, options, initialPrompts = [] }) {
265
266
  * @returns {Promise<string>} The answer.
266
267
  */
267
268
  async function ask(api, prompt, signal, responseConstraint) {
268
- const session = await (await baseSession(api)).clone({ signal });
269
+ const made = baseSession(api);
270
+ const kept = await made;
271
+ const session = await kept.clone({ signal }).catch(async (error) => {
272
+ if (signal.aborted) throw error;
273
+ // Chrome let the base session go (the model was updated, or its process ended): a new one, once.
274
+ if (base === made) base = null;
275
+ return (await baseSession(api)).clone({ signal });
276
+ });
269
277
  try {
270
278
  return await session.prompt(prompt, { signal, ...(responseConstraint && { responseConstraint }) });
271
279
  } finally {
@@ -7,7 +7,8 @@
7
7
  export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
8
8
  export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
9
9
  export * as storeFrame from './store-frame.js';
10
- export { HOOK_FILE, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
10
+ export { PANEL_HEADER_H, PANEL_W, contextMenuHtml, pngUrl, storeShotHtml, storeShots } from './store-shots.js';
11
+ export { HOOK_FILE, PANEL_OPENER, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
11
12
  export { ICON_SIZES, TOOLBAR_COLORS, TOOLBAR_ICON_COLORS, TOOLBAR_ICON_SIZES, renderIcons, renderToolbarIcons, toolbarIconFile, } from './toolbar-icons.js';
12
13
  export { RUNTIME_ENTRIES, STYLESHEET, VENDOR_STAMP, checkVendored, packageDir, vendorKit, vendorPackage } from './vendor.js';
13
14
  export { ZIP_EXCLUDE, shipsFile, zipExtension } from './zip.js';
@@ -8,7 +8,8 @@
8
8
  export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
9
9
  export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
10
10
  export * as storeFrame from './store-frame.js';
11
- export { HOOK_FILE, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
11
+ export { PANEL_HEADER_H, PANEL_W, contextMenuHtml, pngUrl, storeShotHtml, storeShots } from './store-shots.js';
12
+ export { HOOK_FILE, PANEL_OPENER, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
12
13
  export {
13
14
  ICON_SIZES, TOOLBAR_COLORS, TOOLBAR_ICON_COLORS, TOOLBAR_ICON_SIZES, renderIcons, renderToolbarIcons, toolbarIconFile,
14
15
  } from './toolbar-icons.js';
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Store screenshots for the whole family, in one style: a caption band in sentence case over
3
+ * Chrome's window (light theme) with the extension pinned in the toolbar, real captures inside.
4
+ * What headless Chrome can't capture is composed around the real extension UI, as Chrome draws
5
+ * it: the toolbar popup hanging from the extension's button (the popup page captured on its own),
6
+ * the side panel beside the page (the panel page captured on its own) and the right-click menu
7
+ * (drawn, with the extension's real menu titles).
8
+ *
9
+ * {@link storeShotHtml} lays one out (pure); {@link storeShots} renders them in a Playwright
10
+ * page at 2× and writes 1280×800 PNGs. Captures are taken at 2× too
11
+ * (`deviceScaleFactor: 2`) and shown at their CSS size, so text stays sharp.
12
+ *
13
+ * @module build/store-shots
14
+ */
15
+ import type { Tab, Theme } from '../frame/window.js';
16
+ import type { Geometry } from './store-frame.js';
17
+ /** @import { Tab, Theme } from '../frame/window.js' */
18
+ /** @import { Geometry } from './store-frame.js' */
19
+ /** The side panel's width, as Chrome opens it (px). */
20
+ export declare const PANEL_W = 360;
21
+ /** The side panel's header: the extension's icon and name, and Chrome's close button (px). Capture a panel page `PANEL_HEADER_H` shorter than the content. */
22
+ export declare const PANEL_HEADER_H = 40;
23
+ export type MenuItem = {
24
+ /**
25
+ * Its words (none: a separator).
26
+ */
27
+ label?: string;
28
+ /**
29
+ * A 16px icon's URL (the extension's, beside its own item).
30
+ */
31
+ icon?: string;
32
+ /**
33
+ * It opens a submenu (an arrow).
34
+ */
35
+ submenu?: boolean;
36
+ /**
37
+ * The pointer is over it.
38
+ */
39
+ highlighted?: boolean;
40
+ /**
41
+ * Greyed out.
42
+ */
43
+ disabled?: boolean;
44
+ };
45
+ /**
46
+ * One row of the right-click menu.
47
+ *
48
+ * @typedef {object} MenuItem
49
+ * @property {string} [label] Its words (none: a separator).
50
+ * @property {string} [icon] A 16px icon's URL (the extension's, beside its own item).
51
+ * @property {boolean} [submenu] It opens a submenu (an arrow).
52
+ * @property {boolean} [highlighted] The pointer is over it.
53
+ * @property {boolean} [disabled] Greyed out.
54
+ */
55
+ /**
56
+ * The right-click menu as macOS draws Chrome's (a native menu): rounded, translucent, 13px rows,
57
+ * the row under the pointer in the accent colour; a submenu beside its highlighted row. Its class
58
+ * names start `ctx` (the window's own styles have `.sep`, `.tab`…).
59
+ *
60
+ * @param {object} menu - What to draw.
61
+ * @param {number} menu.x - Its left edge, in the window's content area (px).
62
+ * @param {number} menu.y - Its top edge, in the content area.
63
+ * @param {readonly MenuItem[]} menu.items - Its rows.
64
+ * @param {readonly MenuItem[]} [menu.submenu] - The highlighted row's submenu.
65
+ * @returns {string} The HTML, positioned in the content area.
66
+ */
67
+ export declare function contextMenuHtml({ x, y, items, submenu }: {
68
+ x: number;
69
+ y: number;
70
+ items: readonly MenuItem[];
71
+ submenu?: readonly MenuItem[];
72
+ }): string;
73
+ export type Shot = {
74
+ /**
75
+ * The caption, sentence case, a few words.
76
+ */
77
+ caption: string;
78
+ /**
79
+ * The window's tabs.
80
+ */
81
+ tabs: readonly Tab[];
82
+ /**
83
+ * The address bar's text.
84
+ */
85
+ url: string;
86
+ /**
87
+ * The start of `url` shown in full; the rest dimmed.
88
+ */
89
+ urlHost?: string;
90
+ /**
91
+ * The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
92
+ */
93
+ page: string;
94
+ /**
95
+ * The popup's capture and its size: hung from the extension's toolbar button.
96
+ */
97
+ popup?: {
98
+ image: string;
99
+ width: number;
100
+ height: number;
101
+ };
102
+ /**
103
+ * The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
104
+ */
105
+ panel?: {
106
+ image: string;
107
+ title: string;
108
+ icon: string;
109
+ };
110
+ /**
111
+ * A right-click menu over the page.
112
+ */
113
+ menu?: Parameters<typeof contextMenuHtml>[0];
114
+ /**
115
+ * The mouse pointer, in the content area.
116
+ */
117
+ pointer?: {
118
+ x: number;
119
+ y: number;
120
+ };
121
+ };
122
+ /**
123
+ * One store screenshot.
124
+ *
125
+ * @typedef {object} Shot
126
+ * @property {string} caption The caption, sentence case, a few words.
127
+ * @property {readonly Tab[]} tabs The window's tabs.
128
+ * @property {string} url The address bar's text.
129
+ * @property {string} [urlHost] The start of `url` shown in full; the rest dimmed.
130
+ * @property {string} page The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
131
+ * @property {{image: string, width: number, height: number}} [popup] The popup's capture and its size: hung from the extension's toolbar button.
132
+ * @property {{image: string, title: string, icon: string}} [panel] The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
133
+ * @property {Parameters<typeof contextMenuHtml>[0]} [menu] A right-click menu over the page.
134
+ * @property {{x: number, y: number}} [pointer] The mouse pointer, in the content area.
135
+ */
136
+ /**
137
+ * The HTML of one store screenshot: the caption, Chrome's window with the extension's toolbar
138
+ * button, and the captures; a popup hangs from the button (shown pressed), a side panel sits at
139
+ * the window's right, a menu over the page.
140
+ *
141
+ * @param {Shot & {toolbarIcon: string, theme?: Theme, geo?: Geometry}} shot - What to draw; the extension's toolbar icon (a data: URL).
142
+ * @returns {string} A whole HTML document, {@link W}×{@link H}.
143
+ */
144
+ export declare function storeShotHtml({ caption, tabs, url, urlHost, page, popup, panel, menu, pointer, toolbarIcon, theme, geo }: Shot & {
145
+ toolbarIcon: string;
146
+ theme?: Theme;
147
+ geo?: Geometry;
148
+ }): string;
149
+ /**
150
+ * A data: URL of a PNG.
151
+ *
152
+ * @param {Uint8Array} png - The PNG's bytes.
153
+ * @returns {string} The URL.
154
+ */
155
+ export declare const pngUrl: (png: Uint8Array) => string;
156
+ /**
157
+ * A store-screenshot writer: lays each shot out with {@link storeShotHtml} in a page of the
158
+ * given browser at 2×, waits for its images, and saves it as a {@link W}×{@link H} PNG.
159
+ *
160
+ * @param {object} options - Where and how.
161
+ * @param {import('playwright').Browser | import('playwright').BrowserContext} options.browser - A browser (or a context) to draw in.
162
+ * @param {string} options.dir - The folder to write to (made if missing).
163
+ * @param {string} options.toolbarIcon - The extension's toolbar icon, a data: URL.
164
+ * @returns {{geo: Geometry, shoot: (file: string, shot: Shot) => Promise<string>}} The window's geometry
165
+ * (capture pages at `geo.content`), and `shoot`, which writes one and returns its path.
166
+ * @example
167
+ * const { geo, shoot } = storeShots({ browser, dir: 'store/screenshots', toolbarIcon });
168
+ * await shoot('1-menu.png', { caption: 'Copy any page as Markdown', tabs, url, page: pngUrl(await page.screenshot()) });
169
+ */
170
+ export declare function storeShots({ browser, dir, toolbarIcon }: {
171
+ browser: import('playwright').Browser | import('playwright').BrowserContext;
172
+ dir: string;
173
+ toolbarIcon: string;
174
+ }): {
175
+ geo: Geometry;
176
+ shoot: (file: string, shot: Shot) => Promise<string>;
177
+ };
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Store screenshots for the whole family, in one style: a caption band in sentence case over
3
+ * Chrome's window (light theme) with the extension pinned in the toolbar, real captures inside.
4
+ * What headless Chrome can't capture is composed around the real extension UI, as Chrome draws
5
+ * it: the toolbar popup hanging from the extension's button (the popup page captured on its own),
6
+ * the side panel beside the page (the panel page captured on its own) and the right-click menu
7
+ * (drawn, with the extension's real menu titles).
8
+ *
9
+ * {@link storeShotHtml} lays one out (pure); {@link storeShots} renders them in a Playwright
10
+ * page at 2× and writes 1280×800 PNGs. Captures are taken at 2× too
11
+ * (`deviceScaleFactor: 2`) and shown at their CSS size, so text stays sharp.
12
+ *
13
+ * @module build/store-shots
14
+ */
15
+
16
+ import { mkdir, writeFile } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { esc } from '../frame/window.js';
19
+ import { H, W, WIN, canvas, cursor, geometry, windowChrome } from './store-frame.js';
20
+
21
+ /** @import { Tab, Theme } from '../frame/window.js' */
22
+ /** @import { Geometry } from './store-frame.js' */
23
+
24
+ /** The side panel's width, as Chrome opens it (px). */
25
+ export const PANEL_W = 360;
26
+
27
+ /** The side panel's header: the extension's icon and name, and Chrome's close button (px). Capture a panel page `PANEL_HEADER_H` shorter than the content. */
28
+ export const PANEL_HEADER_H = 40;
29
+
30
+ /**
31
+ * One row of the right-click menu.
32
+ *
33
+ * @typedef {object} MenuItem
34
+ * @property {string} [label] Its words (none: a separator).
35
+ * @property {string} [icon] A 16px icon's URL (the extension's, beside its own item).
36
+ * @property {boolean} [submenu] It opens a submenu (an arrow).
37
+ * @property {boolean} [highlighted] The pointer is over it.
38
+ * @property {boolean} [disabled] Greyed out.
39
+ */
40
+
41
+ /**
42
+ * The right-click menu as macOS draws Chrome's (a native menu): rounded, translucent, 13px rows,
43
+ * the row under the pointer in the accent colour; a submenu beside its highlighted row. Its class
44
+ * names start `ctx` (the window's own styles have `.sep`, `.tab`…).
45
+ *
46
+ * @param {object} menu - What to draw.
47
+ * @param {number} menu.x - Its left edge, in the window's content area (px).
48
+ * @param {number} menu.y - Its top edge, in the content area.
49
+ * @param {readonly MenuItem[]} menu.items - Its rows.
50
+ * @param {readonly MenuItem[]} [menu.submenu] - The highlighted row's submenu.
51
+ * @returns {string} The HTML, positioned in the content area.
52
+ */
53
+ export function contextMenuHtml({ x, y, items, submenu }) {
54
+ /** @param {readonly MenuItem[]} rows @returns {string} */
55
+ const list = (rows) => rows.map((row) => (row.label === undefined
56
+ ? '<div class="ctx-sep"></div>'
57
+ : `<div class="ctx-row${row.highlighted ? ' on' : ''}${row.disabled ? ' off' : ''}"><span class="ctx-ic">${row.icon ? `<img src="${row.icon}" width="16" height="16" alt="">` : ''}</span><span class="ctx-label">${esc(row.label)}</span>${row.submenu ? '<span class="ctx-arrow">›</span>' : ''}</div>`)).join('');
58
+ const at = items.findIndex((row) => row.highlighted && row.submenu);
59
+ const top = (/** @type {number} */ index) => 5 + items.slice(0, index).reduce((sum, row) => sum + (row.label === undefined ? 11 : 22), 0);
60
+ return `<div class="ctx" style="left:${x}px;top:${y}px">${list(items)}</div>`
61
+ + (submenu && at >= 0 ? `<div class="ctx" style="left:${x + 236}px;top:${y + top(at) - 5}px">${list(submenu)}</div>` : '')
62
+ + `<style>
63
+ .ctx { position: absolute; z-index: 10; width: 240px; box-sizing: border-box; padding: 5px; border-radius: 10px; background: rgba(246,246,246,.96);
64
+ box-shadow: 0 0 0 .5px rgba(0,0,0,.22), 0 10px 30px rgba(0,0,0,.22); font: 13px/22px system-ui; color: #1d1d1f; }
65
+ .ctx-row { display: flex; align-items: center; height: 22px; padding: 0 8px 0 4px; border-radius: 5px; white-space: nowrap; }
66
+ .ctx-row.on { background: #0a82ff; color: #fff; }
67
+ .ctx-row.off { color: #b0b0b0; }
68
+ .ctx-ic { width: 22px; flex: none; display: flex; }
69
+ .ctx-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
70
+ .ctx-arrow { flex: none; font-size: 15px; opacity: .7; }
71
+ .ctx-sep { height: 1px; margin: 5px 8px; background: rgba(0,0,0,.11); }
72
+ </style>`;
73
+ }
74
+
75
+ /**
76
+ * One store screenshot.
77
+ *
78
+ * @typedef {object} Shot
79
+ * @property {string} caption The caption, sentence case, a few words.
80
+ * @property {readonly Tab[]} tabs The window's tabs.
81
+ * @property {string} url The address bar's text.
82
+ * @property {string} [urlHost] The start of `url` shown in full; the rest dimmed.
83
+ * @property {string} page The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
84
+ * @property {{image: string, width: number, height: number}} [popup] The popup's capture and its size: hung from the extension's toolbar button.
85
+ * @property {{image: string, title: string, icon: string}} [panel] The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
86
+ * @property {Parameters<typeof contextMenuHtml>[0]} [menu] A right-click menu over the page.
87
+ * @property {{x: number, y: number}} [pointer] The mouse pointer, in the content area.
88
+ */
89
+
90
+ /**
91
+ * The HTML of one store screenshot: the caption, Chrome's window with the extension's toolbar
92
+ * button, and the captures; a popup hangs from the button (shown pressed), a side panel sits at
93
+ * the window's right, a menu over the page.
94
+ *
95
+ * @param {Shot & {toolbarIcon: string, theme?: Theme, geo?: Geometry}} shot - What to draw; the extension's toolbar icon (a data: URL).
96
+ * @returns {string} A whole HTML document, {@link W}×{@link H}.
97
+ */
98
+ export function storeShotHtml({ caption, tabs, url, urlHost, page, popup, panel, menu, pointer, toolbarIcon, theme = 'light', geo = geometry(1) }) {
99
+ const { width: cw, height: ch } = geo.content;
100
+ const pageW = panel ? cw - PANEL_W : cw;
101
+ const content = `<div style="position:relative;display:flex;width:${cw}px;height:${ch}px">
102
+ <img src="${page}" width="${pageW}" height="${ch}" alt="" style="display:block;flex:none">
103
+ ${panel ? `<div class="panel"><div class="panel-head"><img src="${panel.icon}" width="16" height="16" alt=""><span>${esc(panel.title)}</span><svg viewBox="0 0 24 24" width="18" height="18"><path fill="#474747" d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></div>
104
+ <img src="${panel.image}" width="${PANEL_W}" height="${ch - PANEL_HEADER_H}" alt="" style="display:block"></div>` : ''}
105
+ ${menu ? contextMenuHtml(menu) : ''}
106
+ ${pointer ? cursor(pointer.x, pointer.y) : ''}
107
+ </div>
108
+ <style>
109
+ .panel { flex: none; width: ${PANEL_W}px; height: ${ch}px; background: #fff; border-left: 1px solid #d3d7dc; box-sizing: content-box; }
110
+ .panel-head { height: ${PANEL_HEADER_H}px; display: flex; align-items: center; gap: 10px; padding: 0 12px 0 14px; font: 500 13px system-ui; color: #1f1f1f; }
111
+ .panel-head span { flex: 1; }
112
+ </style>`;
113
+ const overlays = popup ? `<div class="popup" style="width:${popup.width}px;height:${popup.height}px"><img src="${popup.image}" width="${popup.width}" height="${popup.height}" alt=""></div>
114
+ <style>
115
+ .popup { position: absolute; z-index: 30; border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 0 0 .5px rgba(0,0,0,.2), 0 4px 12px rgba(0,0,0,.14), 0 12px 32px rgba(0,0,0,.16); }
116
+ .popup img { display: block; }
117
+ .toolbar > img.pressed { border-radius: 50%; background: #e3e3e3; box-shadow: 0 0 0 9px #e3e3e3; }
118
+ </style>
119
+ <script>
120
+ // Hang the popup from the extension's button, as Chrome does: its right edge near the button's, just under the toolbar.
121
+ const button = document.querySelector('.toolbar > img');
122
+ button.classList.add('pressed');
123
+ const box = button.getBoundingClientRect();
124
+ const popupBox = document.querySelector('.popup');
125
+ popupBox.style.left = (box.right + 14 - ${popup.width}) + 'px';
126
+ popupBox.style.top = (${WIN.y + WIN.strip + WIN.toolbar} - 2) + 'px';
127
+ </script>` : '';
128
+ return canvas({ caption: esc(caption), theme, chrome: windowChrome({ tabs, url, urlHost, theme, w: geo.w, ext: [toolbarIcon] }), content, overlays, geo });
129
+ }
130
+
131
+ /**
132
+ * A data: URL of a PNG.
133
+ *
134
+ * @param {Uint8Array} png - The PNG's bytes.
135
+ * @returns {string} The URL.
136
+ */
137
+ export const pngUrl = (png) => `data:image/png;base64,${Buffer.from(png).toString('base64')}`;
138
+
139
+ /**
140
+ * A store-screenshot writer: lays each shot out with {@link storeShotHtml} in a page of the
141
+ * given browser at 2×, waits for its images, and saves it as a {@link W}×{@link H} PNG.
142
+ *
143
+ * @param {object} options - Where and how.
144
+ * @param {import('playwright').Browser | import('playwright').BrowserContext} options.browser - A browser (or a context) to draw in.
145
+ * @param {string} options.dir - The folder to write to (made if missing).
146
+ * @param {string} options.toolbarIcon - The extension's toolbar icon, a data: URL.
147
+ * @returns {{geo: Geometry, shoot: (file: string, shot: Shot) => Promise<string>}} The window's geometry
148
+ * (capture pages at `geo.content`), and `shoot`, which writes one and returns its path.
149
+ * @example
150
+ * const { geo, shoot } = storeShots({ browser, dir: 'store/screenshots', toolbarIcon });
151
+ * await shoot('1-menu.png', { caption: 'Copy any page as Markdown', tabs, url, page: pngUrl(await page.screenshot()) });
152
+ */
153
+ export function storeShots({ browser, dir, toolbarIcon }) {
154
+ const geo = geometry(1);
155
+ return {
156
+ geo,
157
+ shoot: async (file, shot) => {
158
+ const context = 'newContext' in browser ? await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2 }) : null;
159
+ const page = context ? await context.newPage() : await /** @type {import('playwright').BrowserContext} */ (browser).newPage();
160
+ try {
161
+ await page.setViewportSize({ width: W, height: H });
162
+ await page.setContent(storeShotHtml({ ...shot, toolbarIcon, geo }), { waitUntil: 'load' });
163
+ await page.evaluate(() => Promise.all([...document.images].map((img) => img.decode().catch(() => undefined))));
164
+ await mkdir(dir, { recursive: true });
165
+ const out = path.join(dir, file);
166
+ await writeFile(out, await page.screenshot({ type: 'png', scale: 'css', clip: { x: 0, y: 0, width: W, height: H } }));
167
+ return out;
168
+ } finally {
169
+ await (context ?? page).close();
170
+ }
171
+ },
172
+ };
173
+ }
@@ -54,6 +54,12 @@ export type TestBuild = {
54
54
  * Changes the copy's manifest in place.
55
55
  */
56
56
  manifest?: (manifest: TestManifest) => void;
57
+ /**
58
+ * A side panel page (`panel/panel.html`): adds {@link PANEL_OPENER}(`?tab=<id>`), whose button opens that panel beside the tab the way the toolbar click does
59
+ * (the kit's `openSidePanel`, during a real click), which Playwright can't click. Its title turns
60
+ * `opened` (or `error: …`). The kit must be vendored at `vendor/extension-kit/`.
61
+ */
62
+ panelOpener?: string;
57
63
  };
58
64
  /**
59
65
  * A Chrome extension manifest, as far as a test build changes it.
@@ -70,7 +76,13 @@ export type TestBuild = {
70
76
  * imports the real worker and exposes what a test can't reach headlessly (a menu click, the
71
77
  * toolbar button).
72
78
  * @property {(manifest: TestManifest) => void} [manifest] Changes the copy's manifest in place.
79
+ * @property {string} [panelOpener] A side panel page (`panel/panel.html`): adds {@link PANEL_OPENER}
80
+ * (`?tab=<id>`), whose button opens that panel beside the tab the way the toolbar click does
81
+ * (the kit's `openSidePanel`, during a real click), which Playwright can't click. Its title turns
82
+ * `opened` (or `error: …`). The kit must be vendored at `vendor/extension-kit/`.
73
83
  */
84
+ /** The page {@link TestBuild}'s `panelOpener` adds, from the build's root. */
85
+ export declare const PANEL_OPENER = "e2e/open-panel.html";
74
86
  /**
75
87
  * Copy an extension folder into a test build, with its own hook and manifest.
76
88
  *
@@ -82,4 +94,4 @@ export type TestBuild = {
82
94
  * manifest: (manifest) => { manifest.host_permissions = ['http://127.0.0.1/*']; },
83
95
  * });
84
96
  */
85
- export declare function buildTestCopy({ from, to, hook, manifest: change }: TestBuild): Promise<string>;
97
+ export declare function buildTestCopy({ from, to, hook, manifest: change, panelOpener }: TestBuild): Promise<string>;