@arraypress/extension-kit 1.8.0 → 1.9.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.
package/README.md CHANGED
@@ -206,6 +206,19 @@ scale. Chrome draws an SVG image with HTML inside (`foreignObject`) without tain
206
206
  (checked in Chromium, `tests/frame-chromium.test.js`). Images in it must be `data:` URLs
207
207
  (`letterIcon` makes one). `THEMES` holds the light and dark colours.
208
208
 
209
+ A plain macOS window (1.9.0) draws the same way: `macWindowSvg({ title, theme, w })` is its title
210
+ bar as macOS 26 and later draw it (measured from AppKit on macOS 27.2), `w` wide and
211
+ `MAC_WINDOW.titleBar` (32px) high: 14px traffic lights (the same markup as Chrome's strip), the
212
+ title after them, a hairline below. The canvas draws the rest: corners of `MAC_WINDOW.radius`
213
+ (16px, `NSThemeFrame`'s) and the theme's shadow, `THEMES[theme].windowShadow`
214
+ (`{ y, blur, color }` for a canvas's `shadowOffsetY`, `shadowBlur`, `shadowColor`; `THEMES[theme].shadow`
215
+ is the same shadow as CSS, the store frame's).
216
+
217
+ ```js
218
+ context.shadowColor = THEMES.light.windowShadow.color; // and its blur and y, at the scale
219
+ context.roundRect(x, y, w * scale, (MAC_WINDOW.titleBar + contentHeight) * scale, MAC_WINDOW.radius * scale);
220
+ ```
221
+
209
222
  ## Sites
210
223
 
211
224
  ```js
@@ -221,6 +234,28 @@ withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com'], what a list
221
234
  A site is http or https with a dot in its host (or `localhost`); text without a scheme is https;
222
235
  `localhost:8080` is a host and port. Pure, so a content script can load `sites/sites.js` alone.
223
236
 
237
+ ## A page's side
238
+
239
+ ```js
240
+ // In the worker or a page: call content/main.js's `scan` in the tab's frames (main.js and what it
241
+ // imports listed in web_accessible_resources). The module stays loaded between calls.
242
+ const frames = await callInPage(chrome.scripting, { tabId, allFrames: true }, 'content/main.js', 'scan', [options]);
243
+ for (const { frameId, result } of frames) show(frameId, result);
244
+
245
+ // From the toolbar click: the tab's own side panel, panel/panel.html?tab=<id>, nothing awaited first.
246
+ chrome.action.onClicked.addListener((tab) => openSidePanel(chrome.sidePanel, tab.id, 'panel/panel.html'));
247
+
248
+ // In the content script: a layer the page's styles can't touch (a closed shadow root).
249
+ const { root, remove } = pageOverlay(document, { name: 'my-inspector', css: ':host { position: fixed; inset: 0; pointer-events: none; }' });
250
+ ```
251
+
252
+ `callInPage(scripting, target, module, name, args, {immediately})` resolves to each frame's
253
+ `{frameId, result}` (frames Chrome couldn't run in left out) and rejects when it can run in none;
254
+ `immediately` (default true) doesn't wait for the page to be idle. `openSidePanel` sets the tab's
255
+ panel to `path?tab=<id>` (`sidePanelPath`) and opens it in the same moment: Chrome opens a panel
256
+ only while the click counts. `pageOverlay` puts a host element of its own name (`all: initial`)
257
+ on the root element, replacing one a run before left; `root` is its closed shadow root, `css` first.
258
+
224
259
  ## A content script on every site
225
260
 
226
261
  ```js
@@ -309,7 +344,8 @@ const stop = watchChanges(document.body, {
309
344
 
310
345
  `inSlices(root, work, {blockChars, sliceMs, pause})` cuts a page into blocks (`blocksOf`) and
311
346
  hands the main thread back whenever a slice has run its time, so a 2,000-item listing never
312
- freezes. `watchChanges` hands over what was added or changed a little after the page stops
347
+ freezes. `eachInSlices(items, work, {sliceMs, pause})` does the same for any list (each element's
348
+ style read, each text measured): `work(item, index)`. `watchChanges` hands over what was added or changed a little after the page stops
313
349
  changing (at most `maxWaitMs` while it keeps on); an element with a few children (`regroupChildren`,
314
350
  default 6) whose children changed is handed over whole. Both are pure DOM, so a content script
315
351
  imports them directly (list them in `web_accessible_resources`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/extension-kit",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Shared pieces for Chrome extensions: Chrome's own page styles and components, a Chrome Sync settings store, the light/dark toolbar glyph, Chrome's on-device model, content-script helpers, About links, and build and test helpers.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -28,7 +28,7 @@ const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
28
28
  const KIT = '@arraypress/extension-kit';
29
29
 
30
30
  /** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
31
- export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'ai', 'commands', 'content', 'dom', 'frame', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
31
+ export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'ai', 'commands', 'content', 'dom', 'frame', 'panel', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
32
32
 
33
33
  /** The kit's stylesheet, an absolute path in the installed package. */
34
34
  export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A content script's layer over the page: a host element of its own name on
3
+ * the page's root element, holding a closed shadow root with the script's
4
+ * styles, so the page's styles and scripts can't reach in and its styles
5
+ * can't reach out. Pickers, toasts, bars and inspectors draw in it.
6
+ *
7
+ * @module content/overlay
8
+ */
9
+ export type PageOverlay = {
10
+ /**
11
+ * The host element, on the page's root element.
12
+ */
13
+ host: HTMLElement;
14
+ /**
15
+ * Its closed shadow root, the styles first: append to it.
16
+ */
17
+ root: ShadowRoot;
18
+ /**
19
+ * Take it off the page.
20
+ */
21
+ remove: () => void;
22
+ };
23
+ /**
24
+ * A page overlay.
25
+ *
26
+ * @typedef {object} PageOverlay
27
+ * @property {HTMLElement} host The host element, on the page's root element.
28
+ * @property {ShadowRoot} root Its closed shadow root, the styles first: append to it.
29
+ * @property {() => void} remove Take it off the page.
30
+ */
31
+ /**
32
+ * Put an overlay on the page, replacing one of the same name a run before left.
33
+ * The host's own styles are reset (`all: initial`) before `css`, so the page's
34
+ * rules for unknown elements can't move or hide it; `css` styles `:host` and what's inside.
35
+ *
36
+ * @param {Document} doc - The page.
37
+ * @param {{name: string, css: string}} options - `name`: the host's element name, with a hyphen
38
+ * (never registered: a plain element); `css`: the overlay's styles.
39
+ * @returns {PageOverlay} The overlay.
40
+ * @example
41
+ * const { root, remove } = pageOverlay(document, { name: 'better-fonts-inspector', css: ':host { position: fixed; inset: 0; pointer-events: none; }' });
42
+ * root.append(card);
43
+ */
44
+ export declare function pageOverlay(doc: Document, { name, css }: {
45
+ name: string;
46
+ css: string;
47
+ }): PageOverlay;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A content script's layer over the page: a host element of its own name on
3
+ * the page's root element, holding a closed shadow root with the script's
4
+ * styles, so the page's styles and scripts can't reach in and its styles
5
+ * can't reach out. Pickers, toasts, bars and inspectors draw in it.
6
+ *
7
+ * @module content/overlay
8
+ */
9
+
10
+ /**
11
+ * A page overlay.
12
+ *
13
+ * @typedef {object} PageOverlay
14
+ * @property {HTMLElement} host The host element, on the page's root element.
15
+ * @property {ShadowRoot} root Its closed shadow root, the styles first: append to it.
16
+ * @property {() => void} remove Take it off the page.
17
+ */
18
+
19
+ /**
20
+ * Put an overlay on the page, replacing one of the same name a run before left.
21
+ * The host's own styles are reset (`all: initial`) before `css`, so the page's
22
+ * rules for unknown elements can't move or hide it; `css` styles `:host` and what's inside.
23
+ *
24
+ * @param {Document} doc - The page.
25
+ * @param {{name: string, css: string}} options - `name`: the host's element name, with a hyphen
26
+ * (never registered: a plain element); `css`: the overlay's styles.
27
+ * @returns {PageOverlay} The overlay.
28
+ * @example
29
+ * const { root, remove } = pageOverlay(document, { name: 'better-fonts-inspector', css: ':host { position: fixed; inset: 0; pointer-events: none; }' });
30
+ * root.append(card);
31
+ */
32
+ export function pageOverlay(doc, { name, css }) {
33
+ for (const old of doc.querySelectorAll(name)) old.remove();
34
+ const host = doc.createElement(name);
35
+ host.style.setProperty('all', 'initial', 'important');
36
+ const root = host.attachShadow({ mode: 'closed' });
37
+ const style = doc.createElement('style');
38
+ style.textContent = css;
39
+ root.append(style);
40
+ doc.documentElement.append(host);
41
+ return { host, root, remove: () => host.remove() };
42
+ }
@@ -48,6 +48,21 @@ export type SliceOptions = {
48
48
  * @property {() => Promise<void>} [pause] Hands the main thread back. Default: a macrotask.
49
49
  * @property {() => number} [now] The clock. Default `performance.now`.
50
50
  */
51
+ /**
52
+ * Work through any list in slices: `work(item, index)` on each, the main
53
+ * thread handed back whenever a slice has run `sliceMs`. For a content
54
+ * script's work that isn't cut by blocks of the page (each element's style
55
+ * read, each text measured).
56
+ *
57
+ * @template T
58
+ * @param {Iterable<T>} items - The items.
59
+ * @param {(item: T, index: number) => void} work - What to do with one.
60
+ * @param {Pick<SliceOptions, 'sliceMs' | 'pause' | 'now'>} [options] - Slice length, how to pause, the clock.
61
+ * @returns {Promise<void>} Once every item is done.
62
+ * @example
63
+ * await eachInSlices(elements, (element) => styles.push(getComputedStyle(element).fontFamily));
64
+ */
65
+ export declare function eachInSlices<T>(items: Iterable<T>, work: (item: T, index: number) => void, { sliceMs, pause, now }?: Pick<SliceOptions, 'sliceMs' | 'pause' | 'now'>): Promise<void>;
51
66
  /**
52
67
  * Work through a part of the page in slices: `work(block)` on each block
53
68
  * still in the page, the main thread handed back whenever a slice has run
@@ -60,4 +75,4 @@ export type SliceOptions = {
60
75
  * @example
61
76
  * await inSlices(document.body, (block) => scanner.scan(block));
62
77
  */
63
- export declare function inSlices(root: Node, work: (block: Node) => void, { blockChars, sliceMs, pause, now }?: SliceOptions): Promise<void>;
78
+ export declare function inSlices(root: Node, work: (block: Node) => void, { blockChars, ...options }?: SliceOptions): Promise<void>;
@@ -55,6 +55,32 @@ const nextTask = () => new Promise((resolve) => { setTimeout(resolve, 0); });
55
55
  * @property {() => number} [now] The clock. Default `performance.now`.
56
56
  */
57
57
 
58
+ /**
59
+ * Work through any list in slices: `work(item, index)` on each, the main
60
+ * thread handed back whenever a slice has run `sliceMs`. For a content
61
+ * script's work that isn't cut by blocks of the page (each element's style
62
+ * read, each text measured).
63
+ *
64
+ * @template T
65
+ * @param {Iterable<T>} items - The items.
66
+ * @param {(item: T, index: number) => void} work - What to do with one.
67
+ * @param {Pick<SliceOptions, 'sliceMs' | 'pause' | 'now'>} [options] - Slice length, how to pause, the clock.
68
+ * @returns {Promise<void>} Once every item is done.
69
+ * @example
70
+ * await eachInSlices(elements, (element) => styles.push(getComputedStyle(element).fontFamily));
71
+ */
72
+ export async function eachInSlices(items, work, { sliceMs = SLICE_MS, pause = nextTask, now = () => performance.now() } = {}) {
73
+ let started = now();
74
+ let index = 0;
75
+ for (const item of items) {
76
+ work(item, index++);
77
+ if (now() - started > sliceMs) {
78
+ await pause();
79
+ started = now();
80
+ }
81
+ }
82
+ }
83
+
58
84
  /**
59
85
  * Work through a part of the page in slices: `work(block)` on each block
60
86
  * still in the page, the main thread handed back whenever a slice has run
@@ -67,14 +93,7 @@ const nextTask = () => new Promise((resolve) => { setTimeout(resolve, 0); });
67
93
  * @example
68
94
  * await inSlices(document.body, (block) => scanner.scan(block));
69
95
  */
70
- export async function inSlices(root, work, { blockChars = BLOCK_CHARS, sliceMs = SLICE_MS, pause = nextTask, now = () => performance.now() } = {}) {
71
- let started = now();
72
- for (const block of blocksOf(root, blockChars)) {
73
- if (!block.isConnected) continue;
74
- work(block);
75
- if (now() - started > sliceMs) {
76
- await pause();
77
- started = now();
78
- }
79
- }
96
+ export function inSlices(root, work, { blockChars = BLOCK_CHARS, ...options } = {}) {
97
+ // A block the work before took out of the page is skipped.
98
+ return eachInSlices(blocksOf(root, blockChars), (block) => { if (block.isConnected) work(block); }, options);
80
99
  }
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Chrome's window around a page: the macOS tab strip and toolbar (light or dark, the default
3
- * theme), drawn as HTML. Shared by the store screenshots (`build/store-frame`, which lays it on a
4
- * 1280×800 canvas in Playwright) and by extension pages, which draw it on a canvas around a
5
- * capture through {@link windowChromeSvg} (an SVG image with the HTML inside, which Chrome draws
6
- * without tainting the canvas). The markup is XML-clean so both can use it.
2
+ * Windows around a page, drawn as HTML: Chrome's (the macOS tab strip and toolbar) and a plain
3
+ * macOS window's title bar, light or dark, sharing the traffic lights, the colours and the
4
+ * shadow. Chrome's is shared by the store screenshots (`build/store-frame`, which lays it on a
5
+ * 1280×800 canvas in Playwright) and by extension pages, which draw either on a canvas around a
6
+ * capture through {@link windowChromeSvg} or {@link macWindowSvg} (an SVG image with the HTML
7
+ * inside, which Chrome draws without tainting the canvas). The markup is XML-clean so both can
8
+ * use it.
7
9
  *
8
10
  * Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
9
- * toolbar with a pill omnibox).
11
+ * toolbar with a pill omnibox) and AppKit's titled window as macOS 26 and later draw it, measured
12
+ * on macOS 27.2 (`NSThemeFrame`'s `cornerRadius` 16, a 32pt title bar, the window buttons 14pt
13
+ * at 9, 32 and 55 from the left and 9 from the top, the title in the 13pt headline font at 85,
14
+ * after the buttons).
10
15
  *
11
16
  * @module frame/window
12
17
  */
@@ -15,6 +20,11 @@ export declare const WINDOW_BARS: Readonly<{
15
20
  strip: 42;
16
21
  toolbar: 46;
17
22
  }>;
23
+ /** A plain macOS window (macOS 26 and later): its title bar's height and its corners' radius (px). */
24
+ export declare const MAC_WINDOW: Readonly<{
25
+ titleBar: 32;
26
+ radius: 16;
27
+ }>;
18
28
  /** The window's default width (px): the store screenshot's. */
19
29
  export declare const WINDOW_W = 1152;
20
30
  /**
@@ -26,7 +36,25 @@ export declare const WINDOW_W = 1152;
26
36
  * @returns {string} A data: URL of the SVG.
27
37
  */
28
38
  export declare const letterIcon: (letter: string, bg: string, fg?: string) => string;
29
- /** Chrome's light and dark window colours, and the canvas behind. */
39
+ export type WindowShadow = {
40
+ /**
41
+ * How far down it falls.
42
+ */
43
+ y: number;
44
+ /**
45
+ * Its blur.
46
+ */
47
+ blur: number;
48
+ /**
49
+ * Its colour.
50
+ */
51
+ color: string;
52
+ };
53
+ /**
54
+ * Chrome's light and dark window colours, a macOS title bar's (`titleBar`, and `titleLine` below
55
+ * it, as AppKit draws them), the canvas behind, and the window's shadow (`windowShadow` for a
56
+ * canvas's `shadow*`, `shadow` for CSS).
57
+ */
30
58
  export declare const THEMES: {
31
59
  light: {
32
60
  bg: string;
@@ -43,6 +71,9 @@ export declare const THEMES: {
43
71
  url: string;
44
72
  urlDim: string;
45
73
  contentLine: string;
74
+ titleBar: string;
75
+ titleLine: string;
76
+ windowShadow: WindowShadow;
46
77
  shadow: string;
47
78
  };
48
79
  dark: {
@@ -60,6 +91,9 @@ export declare const THEMES: {
60
91
  url: string;
61
92
  urlDim: string;
62
93
  contentLine: string;
94
+ titleBar: string;
95
+ titleLine: string;
96
+ windowShadow: WindowShadow;
63
97
  shadow: string;
64
98
  };
65
99
  };
@@ -118,3 +152,31 @@ export declare function windowChrome({ tabs, url, urlHost, theme, w, ext }: {
118
152
  * const svg = windowChromeSvg({ tabs: [{ title: 'Example', icon: letterIcon('E', '#1a73e8'), active: true }], url: 'example.com', w: 1280 });
119
153
  */
120
154
  export declare function windowChromeSvg(options: Parameters<typeof windowChrome>[0]): string;
155
+ /**
156
+ * A plain macOS window's title bar, as HTML: the traffic lights at the left, the title after
157
+ * them (macOS 26 on), a hairline below.
158
+ *
159
+ * @param {object} options - What to draw.
160
+ * @param {string} options.title - The window's title.
161
+ * @param {Theme} [options.theme] - Default light.
162
+ * @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
163
+ * @returns {string} The HTML, with its own styles.
164
+ */
165
+ declare function macTitleBar({ title, theme, w }: {
166
+ title: string;
167
+ theme?: Theme;
168
+ w?: number;
169
+ }): string;
170
+ /**
171
+ * A plain macOS window's title bar as a standalone SVG image, `w` wide and `MAC_WINDOW.titleBar`
172
+ * high (scale it when drawing), for an extension page to draw on a canvas as
173
+ * {@link windowChromeSvg}'s bars are drawn. The window's corners (`MAC_WINDOW.radius`) and its
174
+ * shadow (`THEMES[theme].windowShadow`) are the canvas's to draw.
175
+ *
176
+ * @param {Parameters<typeof macTitleBar>[0]} options - As {@link macTitleBar}.
177
+ * @returns {string} The SVG markup.
178
+ * @example
179
+ * const svg = macWindowSvg({ title: 'Example', theme: 'dark', w: 1280 });
180
+ */
181
+ export declare function macWindowSvg(options: Parameters<typeof macTitleBar>[0]): string;
182
+ export {};
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Chrome's window around a page: the macOS tab strip and toolbar (light or dark, the default
3
- * theme), drawn as HTML. Shared by the store screenshots (`build/store-frame`, which lays it on a
4
- * 1280×800 canvas in Playwright) and by extension pages, which draw it on a canvas around a
5
- * capture through {@link windowChromeSvg} (an SVG image with the HTML inside, which Chrome draws
6
- * without tainting the canvas). The markup is XML-clean so both can use it.
2
+ * Windows around a page, drawn as HTML: Chrome's (the macOS tab strip and toolbar) and a plain
3
+ * macOS window's title bar, light or dark, sharing the traffic lights, the colours and the
4
+ * shadow. Chrome's is shared by the store screenshots (`build/store-frame`, which lays it on a
5
+ * 1280×800 canvas in Playwright) and by extension pages, which draw either on a canvas around a
6
+ * capture through {@link windowChromeSvg} or {@link macWindowSvg} (an SVG image with the HTML
7
+ * inside, which Chrome draws without tainting the canvas). The markup is XML-clean so both can
8
+ * use it.
7
9
  *
8
10
  * Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
9
- * toolbar with a pill omnibox).
11
+ * toolbar with a pill omnibox) and AppKit's titled window as macOS 26 and later draw it, measured
12
+ * on macOS 27.2 (`NSThemeFrame`'s `cornerRadius` 16, a 32pt title bar, the window buttons 14pt
13
+ * at 9, 32 and 55 from the left and 9 from the top, the title in the 13pt headline font at 85,
14
+ * after the buttons).
10
15
  *
11
16
  * @module frame/window
12
17
  */
@@ -14,6 +19,9 @@
14
19
  /** The tab strip's and the toolbar's heights (px): the content starts below both. */
15
20
  export const WINDOW_BARS = Object.freeze({ strip: 42, toolbar: 46 });
16
21
 
22
+ /** A plain macOS window (macOS 26 and later): its title bar's height and its corners' radius (px). */
23
+ export const MAC_WINDOW = Object.freeze({ titleBar: 32, radius: 16 });
24
+
17
25
  /** The window's default width (px): the store screenshot's. */
18
26
  export const WINDOW_W = 1152;
19
27
 
@@ -60,22 +68,69 @@ const icon = (name, size = 20) =>
60
68
  export const letterIcon = (letter, bg, fg = '#fff') =>
61
69
  `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="${bg}"/><text x="8" y="12" font-family="-apple-system,Helvetica,Arial" font-size="11" font-weight="700" text-anchor="middle" fill="${fg}">${letter}</text></svg>`)}`;
62
70
 
63
- /** Chrome's light and dark window colours, and the canvas behind. */
71
+ /**
72
+ * A window's shadow: soft and dropped below it, as macOS draws one (px; `blur` as CSS's and a
73
+ * canvas's `shadowBlur`, twice the blur's standard deviation).
74
+ *
75
+ * @typedef {object} WindowShadow
76
+ * @property {number} y How far down it falls.
77
+ * @property {number} blur Its blur.
78
+ * @property {string} color Its colour.
79
+ */
80
+
81
+ /** @type {{light: WindowShadow, dark: WindowShadow}} */
82
+ const SHADOWS = {
83
+ light: { y: 24, blur: 60, color: 'rgba(31,45,70,.20)' },
84
+ dark: { y: 24, blur: 60, color: 'rgba(0,0,0,.55)' },
85
+ };
86
+
87
+ /**
88
+ * A window's shadow and its hairline rim as a CSS `box-shadow`.
89
+ *
90
+ * @param {WindowShadow} shadow - The shadow.
91
+ * @param {string} rim - The rim's colour.
92
+ * @returns {string} The `box-shadow` value.
93
+ */
94
+ const boxShadow = (shadow, rim) => `0 ${shadow.y}px ${shadow.blur}px ${shadow.color}, 0 0 0 .5px ${rim}`;
95
+
96
+ /**
97
+ * Chrome's light and dark window colours, a macOS title bar's (`titleBar`, and `titleLine` below
98
+ * it, as AppKit draws them), the canvas behind, and the window's shadow (`windowShadow` for a
99
+ * canvas's `shadow*`, `shadow` for CSS).
100
+ */
64
101
  export const THEMES = {
65
102
  light: {
66
103
  bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
67
104
  frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
68
105
  toolbar: '#ffffff', toolbarIcon: '#474747', omnibox: '#e9eef6', url: '#1f1f1f', urlDim: '#5e5e5e',
69
- contentLine: '#d3d7dc', shadow: '0 24px 60px rgba(31,45,70,.20), 0 0 0 .5px rgba(0,0,0,.18)',
106
+ contentLine: '#d3d7dc', titleBar: '#ffffff', titleLine: '#eaeaea',
107
+ windowShadow: SHADOWS.light, shadow: boxShadow(SHADOWS.light, 'rgba(0,0,0,.18)'),
70
108
  },
71
109
  dark: {
72
110
  bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
73
111
  frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
74
112
  toolbar: '#3c3c3c', toolbarIcon: '#c7c7c7', omnibox: '#282828', url: '#e3e3e3', urlDim: '#a8a8a8',
75
- contentLine: '#2a2a2a', shadow: '0 24px 60px rgba(0,0,0,.55), 0 0 0 .5px rgba(255,255,255,.14)',
113
+ contentLine: '#2a2a2a', titleBar: '#282828', titleLine: '#3c3c3c',
114
+ windowShadow: SHADOWS.dark, shadow: boxShadow(SHADOWS.dark, 'rgba(255,255,255,.14)'),
76
115
  },
77
116
  };
78
117
 
118
+ /** The traffic lights' colours: close, minimise, zoom. */
119
+ const LIGHTS = ['#ff5f57', '#febc2e', '#28c840'];
120
+
121
+ /** The traffic lights' styles: circles with a faint edge, their size and spacing set where they're placed. */
122
+ const LIGHTS_CSS = `.lights { position: absolute; display: flex; }
123
+ .lights i { width: var(--light); height: var(--light); border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }`;
124
+
125
+ /**
126
+ * macOS's traffic lights (styled by {@link LIGHTS_CSS}).
127
+ *
128
+ * @param {{left: number, top: number, size: number, gap: number}} place - The first one's left edge, their top edge, their size and the space between them (px).
129
+ * @returns {string} The HTML.
130
+ */
131
+ const trafficLights = ({ left, top, size, gap }) =>
132
+ `<span class="lights" style="left:${left}px;top:${top}px;gap:${gap}px;--light:${size}px">${LIGHTS.map((color) => `<i style="background:${color}"></i>`).join('')}</span>`;
133
+
79
134
  /** @typedef {keyof typeof THEMES} Theme */
80
135
 
81
136
  /**
@@ -139,7 +194,7 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
139
194
  const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px"/>`).join('');
140
195
  return `
141
196
  <div class="strip">
142
- <span class="lights"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i></span>
197
+ ${trafficLights({ left: 16, top: 15, size: 12, gap: 8 })}
143
198
  ${tabHtml}
144
199
  <span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
145
200
  </div>
@@ -151,8 +206,7 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
151
206
  </div>
152
207
  <style>
153
208
  .strip { position: relative; height: ${WINDOW_BARS.strip}px; background: ${t.frame}; }
154
- .lights { position: absolute; left: 16px; top: 15px; display: flex; gap: 8px; }
155
- .lights i { width: 12px; height: 12px; border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }
209
+ ${LIGHTS_CSS}
156
210
  .tab { position: absolute; bottom: 0; height: ${TAB_H}px; display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; box-sizing: border-box; color: ${t.tabTextInactive}; font: 12px/16px system-ui; }
157
211
  .tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
158
212
  .tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
@@ -189,9 +243,57 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
189
243
  */
190
244
  export function windowChromeSvg(options) {
191
245
  const w = options.w ?? WINDOW_W;
192
- const h = WINDOW_BARS.strip + WINDOW_BARS.toolbar;
246
+ return svgImage(windowChrome({ ...options, w }), w, WINDOW_BARS.strip + WINDOW_BARS.toolbar);
247
+ }
248
+
249
+ /**
250
+ * A plain macOS window's title bar, as HTML: the traffic lights at the left, the title after
251
+ * them (macOS 26 on), a hairline below.
252
+ *
253
+ * @param {object} options - What to draw.
254
+ * @param {string} options.title - The window's title.
255
+ * @param {Theme} [options.theme] - Default light.
256
+ * @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
257
+ * @returns {string} The HTML, with its own styles.
258
+ */
259
+ function macTitleBar({ title, theme = 'light', w = WINDOW_W }) {
260
+ const t = THEMES[theme];
261
+ return `
262
+ <div class="bar" style="width:${w}px">${trafficLights({ left: 9, top: 9, size: 14, gap: 9 })}<span class="title">${esc(title)}</span></div>
263
+ <style>
264
+ .bar { position: relative; height: ${MAC_WINDOW.titleBar}px; background: ${t.titleBar}; border-bottom: 1px solid ${t.titleLine}; box-sizing: border-box; }
265
+ ${LIGHTS_CSS}
266
+ .title { position: absolute; left: 85px; right: 16px; top: 0; color: ${t.tabText}; font: 600 13px/${MAC_WINDOW.titleBar - 1}px system-ui; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
267
+ </style>`;
268
+ }
269
+
270
+ /**
271
+ * A plain macOS window's title bar as a standalone SVG image, `w` wide and `MAC_WINDOW.titleBar`
272
+ * high (scale it when drawing), for an extension page to draw on a canvas as
273
+ * {@link windowChromeSvg}'s bars are drawn. The window's corners (`MAC_WINDOW.radius`) and its
274
+ * shadow (`THEMES[theme].windowShadow`) are the canvas's to draw.
275
+ *
276
+ * @param {Parameters<typeof macTitleBar>[0]} options - As {@link macTitleBar}.
277
+ * @returns {string} The SVG markup.
278
+ * @example
279
+ * const svg = macWindowSvg({ title: 'Example', theme: 'dark', w: 1280 });
280
+ */
281
+ export function macWindowSvg(options) {
282
+ const w = options.w ?? WINDOW_W;
283
+ return svgImage(macTitleBar({ ...options, w }), w, MAC_WINDOW.titleBar);
284
+ }
285
+
286
+ /**
287
+ * HTML as a standalone SVG image, `w` × `h`.
288
+ *
289
+ * @param {string} html - The HTML (XML-clean).
290
+ * @param {number} w - Its width (px).
291
+ * @param {number} h - Its height.
292
+ * @returns {string} The SVG markup.
293
+ */
294
+ function svgImage(html, w, h) {
193
295
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
194
296
  + `<foreignObject x="0" y="0" width="${w}" height="${h}">`
195
- + `<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;margin:0;-webkit-font-smoothing:antialiased">${windowChrome({ ...options, w })}</div>`
297
+ + `<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;margin:0;-webkit-font-smoothing:antialiased">${html}</div>`
196
298
  + '</foreignObject></svg>';
197
299
  }
package/src/index.d.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * The kit's browser half: what an extension's pages, offscreen document,
3
3
  * service worker and content scripts load, Chrome's page components (the ⋮
4
4
  * menu, the text dialog, the toast with Undo, a settings card's site list,
5
- * the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model, and
6
- * a content script's slices and change watcher included. Every function takes the `chrome`
5
+ * the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model,
6
+ * a content script's slices, change watcher and overlay, a module's function called in a page,
7
+ * and a tab's side panel opened from a click. Every function takes the `chrome`
7
8
  * APIs it uses as arguments, so tests run the real logic against fakes.
8
9
  *
9
10
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -15,13 +16,16 @@
15
16
  export { githubAboutLinks, isPlaceholder } from './about/links.js';
16
17
  export { MODEL_MESSAGES, chromeLanguageModel, createModelSession, describeModel, downloadModel, modelAvailability, } from './ai/model.js';
17
18
  export { setShortcuts } from './commands/shortcuts.js';
18
- export { BLOCK_CHARS, SLICE_MS, blocksOf, inSlices } from './content/slices.js';
19
+ export { BLOCK_CHARS, SLICE_MS, blocksOf, eachInSlices, inSlices } from './content/slices.js';
20
+ export { pageOverlay } from './content/overlay.js';
19
21
  export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
20
22
  export { focusedElement } from './dom/focus.js';
21
- export { THEMES, WINDOW_BARS, WINDOW_W, letterIcon, windowChrome, windowChromeSvg } from './frame/window.js';
23
+ export { MAC_WINDOW, THEMES, WINDOW_BARS, WINDOW_W, letterIcon, macWindowSvg, windowChrome, windowChromeSvg } from './frame/window.js';
22
24
  export { localize, translator } from './dom/i18n.js';
23
25
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
24
26
  export { setUpPage } from './dom/setup.js';
27
+ export { openSidePanel, sidePanelPath } from './panel/side-panel.js';
28
+ export { callInPage, importAndCall } from './scripting/call.js';
25
29
  export { contentScriptSync } from './scripting/registration.js';
26
30
  export { createSettingsSaver } from './settings/saver.js';
27
31
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
@@ -46,11 +50,14 @@ export type ModelAnswer = import('./ai/model.js').ModelAnswer;
46
50
  export type ModelSession = import('./ai/model.js').ModelSession;
47
51
  export type ModelStatus = import('./ai/model.js').ModelStatus;
48
52
  export type SliceOptions = import('./content/slices.js').SliceOptions;
53
+ export type PageOverlay = import('./content/overlay.js').PageOverlay;
49
54
  export type WatchHandlers = import('./content/watch.js').WatchHandlers;
50
55
  export type WindowTab = import('./frame/window.js').Tab;
51
56
  export type WindowTheme = import('./frame/window.js').Theme;
57
+ export type WindowShadow = import('./frame/window.js').WindowShadow;
52
58
  export type ModelRow = import('./ui/model-row.js').ModelRow;
53
59
  export type ContentScript = import('./scripting/registration.js').ContentScript;
60
+ export type FrameResult = import('./scripting/call.js').FrameResult<unknown>;
54
61
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
55
62
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
56
63
  export type MenuItem = import('./ui/menu.js').MenuItem;
@@ -72,11 +79,14 @@ export type Toast = import('./ui/toast.js').Toast;
72
79
  * @typedef {import('./ai/model.js').ModelSession} ModelSession
73
80
  * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
74
81
  * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
82
+ * @typedef {import('./content/overlay.js').PageOverlay} PageOverlay
75
83
  * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
76
84
  * @typedef {import('./frame/window.js').Tab} WindowTab
77
85
  * @typedef {import('./frame/window.js').Theme} WindowTheme
86
+ * @typedef {import('./frame/window.js').WindowShadow} WindowShadow
78
87
  * @typedef {import('./ui/model-row.js').ModelRow} ModelRow
79
88
  * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
89
+ * @typedef {import('./scripting/call.js').FrameResult<unknown>} FrameResult
80
90
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
81
91
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
82
92
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
package/src/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
  * The kit's browser half: what an extension's pages, offscreen document,
3
3
  * service worker and content scripts load, Chrome's page components (the ⋮
4
4
  * menu, the text dialog, the toast with Undo, a settings card's site list,
5
- * the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model, and
6
- * a content script's slices and change watcher included. Every function takes the `chrome`
5
+ * the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model,
6
+ * a content script's slices, change watcher and overlay, a module's function called in a page,
7
+ * and a tab's side panel opened from a click. Every function takes the `chrome`
7
8
  * APIs it uses as arguments, so tests run the real logic against fakes.
8
9
  *
9
10
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -18,13 +19,16 @@ export {
18
19
  MODEL_MESSAGES, chromeLanguageModel, createModelSession, describeModel, downloadModel, modelAvailability,
19
20
  } from './ai/model.js';
20
21
  export { setShortcuts } from './commands/shortcuts.js';
21
- export { BLOCK_CHARS, SLICE_MS, blocksOf, inSlices } from './content/slices.js';
22
+ export { BLOCK_CHARS, SLICE_MS, blocksOf, eachInSlices, inSlices } from './content/slices.js';
23
+ export { pageOverlay } from './content/overlay.js';
22
24
  export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
23
25
  export { focusedElement } from './dom/focus.js';
24
- export { THEMES, WINDOW_BARS, WINDOW_W, letterIcon, windowChrome, windowChromeSvg } from './frame/window.js';
26
+ export { MAC_WINDOW, THEMES, WINDOW_BARS, WINDOW_W, letterIcon, macWindowSvg, windowChrome, windowChromeSvg } from './frame/window.js';
25
27
  export { localize, translator } from './dom/i18n.js';
26
28
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
27
29
  export { setUpPage } from './dom/setup.js';
30
+ export { openSidePanel, sidePanelPath } from './panel/side-panel.js';
31
+ export { callInPage, importAndCall } from './scripting/call.js';
28
32
  export { contentScriptSync } from './scripting/registration.js';
29
33
  export { createSettingsSaver } from './settings/saver.js';
30
34
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
@@ -57,11 +61,14 @@ export { TOAST_MS, createToast } from './ui/toast.js';
57
61
  * @typedef {import('./ai/model.js').ModelSession} ModelSession
58
62
  * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
59
63
  * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
64
+ * @typedef {import('./content/overlay.js').PageOverlay} PageOverlay
60
65
  * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
61
66
  * @typedef {import('./frame/window.js').Tab} WindowTab
62
67
  * @typedef {import('./frame/window.js').Theme} WindowTheme
68
+ * @typedef {import('./frame/window.js').WindowShadow} WindowShadow
63
69
  * @typedef {import('./ui/model-row.js').ModelRow} ModelRow
64
70
  * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
71
+ * @typedef {import('./scripting/call.js').FrameResult<unknown>} FrameResult
65
72
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
66
73
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
67
74
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Opening Chrome's side panel for one tab, from a click: the panel is the
3
+ * tab's own (no `side_panel` in the manifest, no global panel), so it shows
4
+ * only beside the page it's about and goes when the person switches tabs, as
5
+ * Chrome does for a tab's panel. Chrome opens a panel only while the click
6
+ * still counts, so nothing is awaited before `open`.
7
+ *
8
+ * @module panel/side-panel
9
+ */
10
+ export type SidePanelApi = Pick<typeof chrome.sidePanel, 'setOptions' | 'open'>;
11
+ /**
12
+ * The parts of `chrome.sidePanel` opening one uses.
13
+ *
14
+ * @typedef {Pick<typeof chrome.sidePanel, 'setOptions' | 'open'>} SidePanelApi
15
+ */
16
+ /**
17
+ * The panel page's address for a tab: `path?tab=<id>`, so the page knows its tab.
18
+ *
19
+ * @param {string} path - The panel page, `panel/panel.html`.
20
+ * @param {number} tabId - The tab.
21
+ * @returns {string} `panel/panel.html?tab=12`.
22
+ */
23
+ export declare const sidePanelPath: (path: string, tabId: number) => string;
24
+ /**
25
+ * Open the side panel beside a tab, from a click (the toolbar button's, a
26
+ * shortcut's, or a button on an extension page).
27
+ *
28
+ * @param {SidePanelApi} sidePanel - `chrome.sidePanel` (or a fake in tests).
29
+ * @param {number} tabId - The tab.
30
+ * @param {string} path - The panel page; it gets `?tab=<id>`.
31
+ * @returns {Promise<void>} Once it's open; rejects when Chrome refuses (no click behind it).
32
+ * @example
33
+ * chrome.action.onClicked.addListener((tab) => openSidePanel(chrome.sidePanel, tab.id, 'panel/panel.html'));
34
+ */
35
+ export declare function openSidePanel(sidePanel: SidePanelApi, tabId: number, path: string): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Opening Chrome's side panel for one tab, from a click: the panel is the
3
+ * tab's own (no `side_panel` in the manifest, no global panel), so it shows
4
+ * only beside the page it's about and goes when the person switches tabs, as
5
+ * Chrome does for a tab's panel. Chrome opens a panel only while the click
6
+ * still counts, so nothing is awaited before `open`.
7
+ *
8
+ * @module panel/side-panel
9
+ */
10
+
11
+ /**
12
+ * The parts of `chrome.sidePanel` opening one uses.
13
+ *
14
+ * @typedef {Pick<typeof chrome.sidePanel, 'setOptions' | 'open'>} SidePanelApi
15
+ */
16
+
17
+ /**
18
+ * The panel page's address for a tab: `path?tab=<id>`, so the page knows its tab.
19
+ *
20
+ * @param {string} path - The panel page, `panel/panel.html`.
21
+ * @param {number} tabId - The tab.
22
+ * @returns {string} `panel/panel.html?tab=12`.
23
+ */
24
+ export const sidePanelPath = (path, tabId) => `${path}?tab=${tabId}`;
25
+
26
+ /**
27
+ * Open the side panel beside a tab, from a click (the toolbar button's, a
28
+ * shortcut's, or a button on an extension page).
29
+ *
30
+ * @param {SidePanelApi} sidePanel - `chrome.sidePanel` (or a fake in tests).
31
+ * @param {number} tabId - The tab.
32
+ * @param {string} path - The panel page; it gets `?tab=<id>`.
33
+ * @returns {Promise<void>} Once it's open; rejects when Chrome refuses (no click behind it).
34
+ * @example
35
+ * chrome.action.onClicked.addListener((tab) => openSidePanel(chrome.sidePanel, tab.id, 'panel/panel.html'));
36
+ */
37
+ export function openSidePanel(sidePanel, tabId, path) {
38
+ // Not awaited: open() must be called while the click still counts.
39
+ sidePanel.setOptions({ tabId, path: sidePanelPath(path, tabId), enabled: true }).catch((error) => console.error('Setting the side panel up:', error));
40
+ return sidePanel.open({ tabId });
41
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Calling one of an extension module's functions in a page: `executeScript`
3
+ * runs a small function in the page's isolated world that imports the module
4
+ * (by its extension address, so it must be listed in `web_accessible_resources`)
5
+ * and calls the export named, with arguments that survive structured cloning.
6
+ * The module, and anything it keeps, stays loaded in that world between calls,
7
+ * so a content script can hold a session (a picker, an inspector) across them.
8
+ *
9
+ * @module scripting/call
10
+ */
11
+ export type CallScripting = Pick<typeof chrome.scripting, 'executeScript'>;
12
+ export type FrameResult<T> = {
13
+ /**
14
+ * The frame (0 is the top one).
15
+ */
16
+ frameId: number;
17
+ /**
18
+ * The function's result, once its promise settled.
19
+ */
20
+ result: T;
21
+ };
22
+ /**
23
+ * The parts of `chrome.scripting` a call uses.
24
+ *
25
+ * @typedef {Pick<typeof chrome.scripting, 'executeScript'>} CallScripting
26
+ */
27
+ /**
28
+ * What one frame's call gave back.
29
+ *
30
+ * @template T
31
+ * @typedef {object} FrameResult
32
+ * @property {number} frameId The frame (0 is the top one).
33
+ * @property {T} result The function's result, once its promise settled.
34
+ */
35
+ /**
36
+ * The function `executeScript` runs in the page: alone, without closures.
37
+ *
38
+ * @param {string} module - The module's path in the extension, `content/main.js`.
39
+ * @param {string} name - The export to call.
40
+ * @param {unknown[]} args - Its arguments.
41
+ * @returns {Promise<unknown>} What it returns.
42
+ */
43
+ export declare const importAndCall: (module: string, name: string, args: unknown[]) => Promise<unknown>;
44
+ /**
45
+ * Call `name` from the extension module `module` in the target's frames.
46
+ *
47
+ * @template [T=unknown]
48
+ * @param {CallScripting} scripting - `chrome.scripting` (or a fake in tests).
49
+ * @param {chrome.scripting.InjectionTarget} target - The tab, and which frames (`allFrames`, `frameIds`).
50
+ * @param {string} module - The module's path in the extension (listed in `web_accessible_resources`).
51
+ * @param {string} name - The exported function.
52
+ * @param {unknown[]} [args] - Its arguments (structured-cloneable).
53
+ * @param {{immediately?: boolean}} [options] - `immediately` (default true): don't wait for the
54
+ * page to be idle, which a page still loading ads or a player may not be for a long time.
55
+ * @returns {Promise<FrameResult<T>[]>} Each frame's result, frames Chrome couldn't run in left out;
56
+ * rejects when it can run in none (a page Chrome keeps to itself, no access).
57
+ * @example
58
+ * const [{ result }] = await callInPage(chrome.scripting, { tabId }, 'content/main.js', 'scan', [options]);
59
+ * await callInPage(chrome.scripting, { tabId, allFrames: true }, 'content/main.js', 'highlight', [key]);
60
+ */
61
+ export declare function callInPage<T = unknown>(scripting: CallScripting, target: chrome.scripting.InjectionTarget, module: string, name: string, args?: unknown[], { immediately }?: {
62
+ immediately?: boolean;
63
+ }): Promise<FrameResult<T>[]>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Calling one of an extension module's functions in a page: `executeScript`
3
+ * runs a small function in the page's isolated world that imports the module
4
+ * (by its extension address, so it must be listed in `web_accessible_resources`)
5
+ * and calls the export named, with arguments that survive structured cloning.
6
+ * The module, and anything it keeps, stays loaded in that world between calls,
7
+ * so a content script can hold a session (a picker, an inspector) across them.
8
+ *
9
+ * @module scripting/call
10
+ */
11
+
12
+ /**
13
+ * The parts of `chrome.scripting` a call uses.
14
+ *
15
+ * @typedef {Pick<typeof chrome.scripting, 'executeScript'>} CallScripting
16
+ */
17
+
18
+ /**
19
+ * What one frame's call gave back.
20
+ *
21
+ * @template T
22
+ * @typedef {object} FrameResult
23
+ * @property {number} frameId The frame (0 is the top one).
24
+ * @property {T} result The function's result, once its promise settled.
25
+ */
26
+
27
+ /**
28
+ * The function `executeScript` runs in the page: alone, without closures.
29
+ *
30
+ * @param {string} module - The module's path in the extension, `content/main.js`.
31
+ * @param {string} name - The export to call.
32
+ * @param {unknown[]} args - Its arguments.
33
+ * @returns {Promise<unknown>} What it returns.
34
+ */
35
+ export const importAndCall = (module, name, args) => import(chrome.runtime.getURL(module)).then((loaded) => loaded[name](...args));
36
+
37
+ /**
38
+ * Call `name` from the extension module `module` in the target's frames.
39
+ *
40
+ * @template [T=unknown]
41
+ * @param {CallScripting} scripting - `chrome.scripting` (or a fake in tests).
42
+ * @param {chrome.scripting.InjectionTarget} target - The tab, and which frames (`allFrames`, `frameIds`).
43
+ * @param {string} module - The module's path in the extension (listed in `web_accessible_resources`).
44
+ * @param {string} name - The exported function.
45
+ * @param {unknown[]} [args] - Its arguments (structured-cloneable).
46
+ * @param {{immediately?: boolean}} [options] - `immediately` (default true): don't wait for the
47
+ * page to be idle, which a page still loading ads or a player may not be for a long time.
48
+ * @returns {Promise<FrameResult<T>[]>} Each frame's result, frames Chrome couldn't run in left out;
49
+ * rejects when it can run in none (a page Chrome keeps to itself, no access).
50
+ * @example
51
+ * const [{ result }] = await callInPage(chrome.scripting, { tabId }, 'content/main.js', 'scan', [options]);
52
+ * await callInPage(chrome.scripting, { tabId, allFrames: true }, 'content/main.js', 'highlight', [key]);
53
+ */
54
+ export async function callInPage(scripting, target, module, name, args = [], { immediately = true } = {}) {
55
+ const frames = await scripting.executeScript({ target, injectImmediately: immediately, func: importAndCall, args: [module, name, args] });
56
+ return frames.map((frame) => ({ frameId: frame.frameId, result: /** @type {T} */ (frame.result) }));
57
+ }