@arraypress/extension-kit 1.10.0 → 1.11.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.
Files changed (69) hide show
  1. package/README.md +103 -21
  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/vendor.d.ts +29 -8
  9. package/src/build/vendor.js +136 -40
  10. package/src/content/highlight.d.ts +15 -6
  11. package/src/content/highlight.js +42 -17
  12. package/src/content/overlay.d.ts +19 -4
  13. package/src/content/overlay.js +44 -6
  14. package/src/content/slices.d.ts +3 -1
  15. package/src/content/slices.js +29 -14
  16. package/src/content/watch.d.ts +4 -2
  17. package/src/content/watch.js +17 -3
  18. package/src/dom/focus.d.ts +11 -0
  19. package/src/dom/focus.js +15 -0
  20. package/src/frame/window.d.ts +1 -1
  21. package/src/frame/window.js +8 -7
  22. package/src/index.d.ts +10 -3
  23. package/src/index.js +8 -3
  24. package/src/panel/show-on-page.d.ts +82 -0
  25. package/src/panel/show-on-page.js +83 -0
  26. package/src/scripting/call.d.ts +29 -9
  27. package/src/scripting/call.js +35 -11
  28. package/src/scripting/context-menus.d.ts +45 -0
  29. package/src/scripting/context-menus.js +53 -0
  30. package/src/scripting/registration.d.ts +2 -1
  31. package/src/scripting/registration.js +2 -1
  32. package/src/scripting/tab.d.ts +17 -5
  33. package/src/scripting/tab.js +15 -7
  34. package/src/settings/saver.js +16 -6
  35. package/src/settings/storage.d.ts +33 -2
  36. package/src/settings/storage.js +73 -3
  37. package/src/settings/store.d.ts +17 -0
  38. package/src/settings/store.js +87 -20
  39. package/src/sites/sites.d.ts +6 -1
  40. package/src/sites/sites.js +20 -3
  41. package/src/styles/chrome-ui.css +59 -30
  42. package/src/testing/e2e.d.ts +35 -4
  43. package/src/testing/e2e.js +67 -5
  44. package/src/testing/fake-model.d.ts +9 -1
  45. package/src/testing/fake-model.js +44 -23
  46. package/src/testing/fake-storage.d.ts +41 -13
  47. package/src/testing/fake-storage.js +163 -51
  48. package/src/testing/index.d.ts +3 -3
  49. package/src/testing/index.js +5 -3
  50. package/src/testing/live-run.d.ts +23 -2
  51. package/src/testing/live-run.js +64 -18
  52. package/src/testing/messages.d.ts +7 -3
  53. package/src/testing/messages.js +50 -9
  54. package/src/testing/package.d.ts +56 -7
  55. package/src/testing/package.js +57 -18
  56. package/src/testing/prompt.d.ts +4 -2
  57. package/src/testing/prompt.js +7 -3
  58. package/src/time/pool.d.ts +2 -1
  59. package/src/time/pool.js +4 -2
  60. package/src/toolbar/theme.d.ts +10 -2
  61. package/src/toolbar/theme.js +12 -4
  62. package/src/ui/menu.d.ts +13 -3
  63. package/src/ui/menu.js +48 -11
  64. package/src/ui/model-row.js +5 -1
  65. package/src/ui/site-list.js +12 -3
  66. package/src/ui/text-dialog.d.ts +3 -2
  67. package/src/ui/text-dialog.js +10 -3
  68. package/src/ui/toast.d.ts +3 -0
  69. package/src/ui/toast.js +23 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Helpers for an extension's packaging test: list the folder, list the real
3
3
  * zip of it, and find every path the extension names that doesn't resolve to
4
- * a file inside it (a broken import, a stylesheet link, a manifest entry).
4
+ * a file the zip holds (a broken import, a stylesheet link, a manifest entry).
5
5
  *
6
6
  * @module testing/package
7
7
  */
@@ -33,6 +33,10 @@ export type ManifestFiles = {
33
33
  options_ui?: {
34
34
  page?: string;
35
35
  };
36
+ /**
37
+ * The options page, the older way.
38
+ */
39
+ options_page?: string;
36
40
  /**
37
41
  * The popup and toolbar icon.
38
42
  */
@@ -56,6 +60,39 @@ export type ManifestFiles = {
56
60
  web_accessible_resources?: {
57
61
  resources: string[];
58
62
  }[];
63
+ /**
64
+ * Scripts and styles Chrome puts in pages.
65
+ */
66
+ content_scripts?: {
67
+ js?: string[];
68
+ css?: string[];
69
+ }[];
70
+ /**
71
+ * The locale whose `_locales/<locale>/messages.json` must be there.
72
+ */
73
+ default_locale?: string;
74
+ /**
75
+ * Rule files.
76
+ */
77
+ declarative_net_request?: {
78
+ rule_resources?: {
79
+ path: string;
80
+ }[];
81
+ };
82
+ /**
83
+ * Pages that replace Chrome's (the new tab page).
84
+ */
85
+ chrome_url_overrides?: Record<string, string>;
86
+ /**
87
+ * The DevTools page.
88
+ */
89
+ devtools_page?: string;
90
+ /**
91
+ * Sandboxed pages.
92
+ */
93
+ sandbox?: {
94
+ pages?: string[];
95
+ };
59
96
  };
60
97
  /**
61
98
  * The parts of a manifest that name files.
@@ -63,16 +100,25 @@ export type ManifestFiles = {
63
100
  * @typedef {object} ManifestFiles
64
101
  * @property {{service_worker?: string}} [background] The worker.
65
102
  * @property {{page?: string}} [options_ui] The options page.
103
+ * @property {string} [options_page] The options page, the older way.
66
104
  * @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
67
105
  * @property {{default_path?: string}} [side_panel] The side panel.
68
106
  * @property {Record<string, string>} [icons] The extension's icons.
69
107
  * @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
108
+ * @property {{js?: string[], css?: string[]}[]} [content_scripts] Scripts and styles Chrome puts in pages.
109
+ * @property {string} [default_locale] The locale whose `_locales/<locale>/messages.json` must be there.
110
+ * @property {{rule_resources?: {path: string}[]}} [declarative_net_request] Rule files.
111
+ * @property {Record<string, string>} [chrome_url_overrides] Pages that replace Chrome's (the new tab page).
112
+ * @property {string} [devtools_page] The DevTools page.
113
+ * @property {{pages?: string[]}} [sandbox] Sandboxed pages.
70
114
  */
71
115
  /**
72
116
  * The paths a manifest names, from the extension's root.
73
117
  *
74
118
  * @param {ManifestFiles} manifest - The parsed `manifest.json`.
75
- * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources (less patterns with `*`).
119
+ * @returns {string[]} Worker, pages, icons, side panel, content scripts and their styles, the default
120
+ * locale's messages, rule files, overridden and DevTools pages, and web-accessible resources
121
+ * (less patterns with `*`).
76
122
  */
77
123
  export declare function manifestReferences(manifest: ManifestFiles): string[];
78
124
  /**
@@ -84,11 +130,14 @@ export declare function manifestReferences(manifest: ManifestFiles): string[];
84
130
  */
85
131
  export declare function referencedPath(reference: string): string;
86
132
  /**
87
- * Every path the extension names that isn't a file inside it: relative
88
- * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
89
- * in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
90
- * or `?query` is ignored: `getURL('options.html#workspaces')` names
91
- * `options.html`.
133
+ * Every path the extension names that isn't a file it ships: relative imports in its scripts
134
+ * (`from`, `import()` and a bare `import './x.js'`), `chrome.runtime.getURL('…')` paths,
135
+ * `src`/`href` in its pages, `url(…)` and `@import` in its stylesheets, the manifest's entries
136
+ * ({@link manifestReferences}), and any `extra` paths. A file that's there but left out of the
137
+ * zip (`shipsFile`: an SVG under `icons/`, a declaration, a vendor stamp) is as missing as one
138
+ * that isn't: the store build won't have it. A `#fragment` or `?query` is ignored:
139
+ * `getURL('options.html#workspaces')` names `options.html`. A path starting `/` is from the
140
+ * extension's root. Files that don't ship aren't read.
92
141
  *
93
142
  * @param {string} dir - The extension folder.
94
143
  * @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Helpers for an extension's packaging test: list the folder, list the real
3
3
  * zip of it, and find every path the extension names that doesn't resolve to
4
- * a file inside it (a broken import, a stylesheet link, a manifest entry).
4
+ * a file the zip holds (a broken import, a stylesheet link, a manifest entry).
5
5
  *
6
6
  * @module testing/package
7
7
  */
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
12
12
  import { tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
- import { zipExtension } from '../build/zip.js';
14
+ import { shipsFile, zipExtension } from '../build/zip.js';
15
15
 
16
16
  /**
17
17
  * Every file under `dir`.
@@ -49,27 +49,43 @@ export async function zipEntries(from, exclude) {
49
49
  * @typedef {object} ManifestFiles
50
50
  * @property {{service_worker?: string}} [background] The worker.
51
51
  * @property {{page?: string}} [options_ui] The options page.
52
+ * @property {string} [options_page] The options page, the older way.
52
53
  * @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
53
54
  * @property {{default_path?: string}} [side_panel] The side panel.
54
55
  * @property {Record<string, string>} [icons] The extension's icons.
55
56
  * @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
57
+ * @property {{js?: string[], css?: string[]}[]} [content_scripts] Scripts and styles Chrome puts in pages.
58
+ * @property {string} [default_locale] The locale whose `_locales/<locale>/messages.json` must be there.
59
+ * @property {{rule_resources?: {path: string}[]}} [declarative_net_request] Rule files.
60
+ * @property {Record<string, string>} [chrome_url_overrides] Pages that replace Chrome's (the new tab page).
61
+ * @property {string} [devtools_page] The DevTools page.
62
+ * @property {{pages?: string[]}} [sandbox] Sandboxed pages.
56
63
  */
57
64
 
58
65
  /**
59
66
  * The paths a manifest names, from the extension's root.
60
67
  *
61
68
  * @param {ManifestFiles} manifest - The parsed `manifest.json`.
62
- * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources (less patterns with `*`).
69
+ * @returns {string[]} Worker, pages, icons, side panel, content scripts and their styles, the default
70
+ * locale's messages, rule files, overridden and DevTools pages, and web-accessible resources
71
+ * (less patterns with `*`).
63
72
  */
64
73
  export function manifestReferences(manifest) {
65
74
  const icon = manifest.action?.default_icon;
66
75
  return [
67
76
  manifest.background?.service_worker,
68
77
  manifest.options_ui?.page,
78
+ manifest.options_page,
69
79
  manifest.action?.default_popup,
70
80
  manifest.side_panel?.default_path,
81
+ manifest.devtools_page,
71
82
  ...Object.values(manifest.icons ?? {}),
72
83
  ...(typeof icon === 'string' ? [icon] : Object.values(icon ?? {})),
84
+ ...(manifest.content_scripts ?? []).flatMap((entry) => [...(entry.js ?? []), ...(entry.css ?? [])]),
85
+ ...(manifest.default_locale ? [`_locales/${manifest.default_locale}/messages.json`] : []),
86
+ ...(manifest.declarative_net_request?.rule_resources ?? []).map((entry) => entry.path),
87
+ ...Object.values(manifest.chrome_url_overrides ?? {}),
88
+ ...(manifest.sandbox?.pages ?? []),
73
89
  ...(manifest.web_accessible_resources ?? []).flatMap((entry) => entry.resources),
74
90
  ].filter((ref) => typeof ref === 'string').filter((ref) => !ref.includes('*'));
75
91
  }
@@ -88,12 +104,27 @@ export function referencedPath(reference) {
88
104
  /** A reference with a scheme (`https:`, `data:`, `mailto:`) or protocol-relative: not a file of the extension. */
89
105
  const ELSEWHERE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
90
106
 
107
+ /** A module a script imports: `… from './x.js'`, `import('./x.js')`, or `import './x.js'` alone on its line; either quote. */
108
+ const IMPORTS = [/(?:from|import\()\s*(['"])(\.{1,2}\/[^'"]+)\1/g, /^\s*import\s*(['"])(\.{1,2}\/[^'"]+)\1/gm];
109
+
110
+ /** `getURL('…')`, either quote. */
111
+ const GET_URL = /getURL\(\s*(['"])([^'"]+)\1\s*\)/g;
112
+
113
+ /** A page's `src="…"` or `href="…"`, either quote. */
114
+ const ATTRIBUTES = /\s(?:src|href)\s*=\s*(['"])([^'"]+)\1/g;
115
+
116
+ /** A stylesheet's `url(…)` and `@import "…"`. */
117
+ const STYLE_URLS = [/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, /@import\s+(['"])([^'"]+)\1/g];
118
+
91
119
  /**
92
- * Every path the extension names that isn't a file inside it: relative
93
- * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
94
- * in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
95
- * or `?query` is ignored: `getURL('options.html#workspaces')` names
96
- * `options.html`.
120
+ * Every path the extension names that isn't a file it ships: relative imports in its scripts
121
+ * (`from`, `import()` and a bare `import './x.js'`), `chrome.runtime.getURL('…')` paths,
122
+ * `src`/`href` in its pages, `url(…)` and `@import` in its stylesheets, the manifest's entries
123
+ * ({@link manifestReferences}), and any `extra` paths. A file that's there but left out of the
124
+ * zip (`shipsFile`: an SVG under `icons/`, a declaration, a vendor stamp) is as missing as one
125
+ * that isn't: the store build won't have it. A `#fragment` or `?query` is ignored:
126
+ * `getURL('options.html#workspaces')` names `options.html`. A path starting `/` is from the
127
+ * extension's root. Files that don't ship aren't read.
97
128
  *
98
129
  * @param {string} dir - The extension folder.
99
130
  * @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
@@ -107,18 +138,26 @@ export async function missingReferences(dir, extra = []) {
107
138
  const check = (from, target) => {
108
139
  const name = referencedPath(target);
109
140
  if (!name || ELSEWHERE.test(target)) return;
110
- const file = path.resolve(root, from, name);
111
- if (!file.startsWith(root) || !existsSync(file)) missing.push(`${from || 'root'} → ${target}`);
141
+ const file = name.startsWith('/') ? path.join(root, name) : path.resolve(root, from, name);
142
+ const inside = path.relative(root, file).split(path.sep).join('/');
143
+ if (inside.startsWith('..') || !existsSync(file) || !shipsFile(inside)) missing.push(`${from || 'root'} → ${target}`);
144
+ };
145
+ /** @param {string} source @param {readonly RegExp[]} patterns @param {string} from */
146
+ const each = (source, patterns, from) => {
147
+ for (const pattern of patterns) for (const match of source.matchAll(pattern)) check(from, match[2]);
112
148
  };
113
- for (const file of await filesUnder(root)) {
149
+ for (const file of (await filesUnder(root)).filter(shipsFile)) {
114
150
  const from = path.posix.dirname(file);
115
- if (file.endsWith('.js')) {
116
- const source = await readFile(path.join(root, file), 'utf8');
117
- for (const [, spec] of source.matchAll(/(?:from|import\()\s*'(\.{1,2}\/[^']+)'/g)) check(from, spec);
118
- for (const [, ref] of source.matchAll(/getURL\('([^']+)'\)/g)) check('', ref);
119
- } else if (file.endsWith('.html')) {
120
- const source = await readFile(path.join(root, file), 'utf8');
121
- for (const [, ref] of source.matchAll(/(?:src|href)="([^"]+)"/g)) check(from, ref);
151
+ const kind = path.posix.extname(file);
152
+ if (kind !== '.js' && kind !== '.html' && kind !== '.css') continue;
153
+ const source = await readFile(path.join(root, file), 'utf8');
154
+ if (kind === '.js') {
155
+ each(source, IMPORTS, from);
156
+ each(source, [GET_URL], '');
157
+ } else if (kind === '.html') {
158
+ each(source, [ATTRIBUTES], from);
159
+ } else {
160
+ each(source, STYLE_URLS, from);
122
161
  }
123
162
  }
124
163
  const manifest = JSON.parse(await readFile(path.join(root, 'manifest.json'), 'utf8'));
@@ -8,8 +8,10 @@
8
8
  * Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
9
9
  * recorded on `window.asked` (what was asked, and whether a click was behind it,
10
10
  * `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
11
- * kept), as a person clicking “Allow” or “Deny” would. Runs in the page: give it to
12
- * `page.evaluate` or `page.addInitScript`, so it takes no closures.
11
+ * kept), as a person clicking “Allow” or “Deny” would. A request with no click behind it is
12
+ * refused as Chrome refuses it (“This function must be called during a user gesture”) and takes
13
+ * no answer. Runs in the page: give it to `page.evaluate` or `page.addInitScript`, so it takes
14
+ * no closures.
13
15
  *
14
16
  * @param {boolean[]} answers - The answers, in order.
15
17
  * @example
@@ -9,8 +9,10 @@
9
9
  * Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
10
10
  * recorded on `window.asked` (what was asked, and whether a click was behind it,
11
11
  * `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
12
- * kept), as a person clicking “Allow” or “Deny” would. Runs in the page: give it to
13
- * `page.evaluate` or `page.addInitScript`, so it takes no closures.
12
+ * kept), as a person clicking “Allow” or “Deny” would. A request with no click behind it is
13
+ * refused as Chrome refuses it (“This function must be called during a user gesture”) and takes
14
+ * no answer. Runs in the page: give it to `page.evaluate` or `page.addInitScript`, so it takes
15
+ * no closures.
14
16
  *
15
17
  * @param {boolean[]} answers - The answers, in order.
16
18
  * @example
@@ -23,7 +25,9 @@ export function playPermissionPrompt(answers) {
23
25
  const asked = [];
24
26
  Object.assign(window, { asked });
25
27
  chrome.permissions.request = /** @type {typeof chrome.permissions.request} */ (/** @type {unknown} */ (async (/** @type {chrome.permissions.Permissions} */ permissions) => {
26
- asked.push({ permissions: permissions.permissions, origins: permissions.origins, gesture: navigator.userActivation.isActive });
28
+ const gesture = navigator.userActivation.isActive;
29
+ asked.push({ permissions: permissions.permissions, origins: permissions.origins, gesture });
30
+ if (!gesture) throw new Error('This function must be called during a user gesture');
27
31
  return answers.length > 1 ? answers.shift() : answers[0];
28
32
  }));
29
33
  }
@@ -13,7 +13,8 @@
13
13
  *
14
14
  * @template T
15
15
  * @param {readonly T[]} items - The items.
16
- * @param {number} limit - At most this many at once.
16
+ * @param {number} limit - At most this many at once (at least one: a limit of 0 or less, or one
17
+ * that isn't a number, runs them one at a time rather than not at all).
17
18
  * @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
18
19
  * `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
19
20
  * (a browser each).
package/src/time/pool.js CHANGED
@@ -14,7 +14,8 @@
14
14
  *
15
15
  * @template T
16
16
  * @param {readonly T[]} items - The items.
17
- * @param {number} limit - At most this many at once.
17
+ * @param {number} limit - At most this many at once (at least one: a limit of 0 or less, or one
18
+ * that isn't a number, runs them one at a time rather than not at all).
18
19
  * @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
19
20
  * `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
20
21
  * (a browser each).
@@ -24,7 +25,8 @@
24
25
  */
25
26
  export async function eachLimited(items, limit, work) {
26
27
  let next = 0;
27
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async (_, slot) => {
28
+ const slots = Math.max(1, Math.floor(limit) || 1);
29
+ await Promise.all(Array.from({ length: Math.min(slots, items.length) }, async (_, slot) => {
28
30
  while (next < items.length) {
29
31
  const index = next;
30
32
  next += 1;
@@ -132,9 +132,12 @@ export type OffscreenApi = {
132
132
  *
133
133
  * @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
134
134
  * @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
135
+ * @param {string} [justification] - What the document is for, when it does more than watch the
136
+ * scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
137
+ * with the document, so it has to be the whole truth.
135
138
  * @returns {Promise<boolean>} Whether it opened one (false: already open).
136
139
  */
137
- export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string): Promise<boolean>;
140
+ export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string, justification?: string): Promise<boolean>;
138
141
  export type ToolbarWorkerApi = {
139
142
  /**
140
143
  * `chrome.storage`.
@@ -179,9 +182,14 @@ export type ToolbarWorkerApi = {
179
182
  * worker wakes.
180
183
  *
181
184
  * @param {ToolbarWorkerApi} api - `chrome`, or a fake.
185
+ * @param {{url?: string, justification?: string}} [document] - The offscreen document and what
186
+ * it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
182
187
  * @returns {ToolbarTheme} The theme, for tests.
183
188
  * @example
184
189
  * // background.js
185
190
  * startToolbarTheme(chrome);
186
191
  */
187
- export declare function startToolbarTheme(api: ToolbarWorkerApi): ToolbarTheme;
192
+ export declare function startToolbarTheme(api: ToolbarWorkerApi, { url, justification }?: {
193
+ url?: string;
194
+ justification?: string;
195
+ }): ToolbarTheme;
@@ -116,6 +116,9 @@ export function createToolbarTheme({ storage, action, extensionId }) {
116
116
  /** @type {Promise<boolean> | null} */
117
117
  let opening = null;
118
118
 
119
+ /** Why the offscreen document is open, as Chrome is told. */
120
+ const SCHEME_JUSTIFICATION = 'Reads whether Chrome is in light or dark mode, to show the matching toolbar icon.';
121
+
119
122
  /**
120
123
  * Open the offscreen document that watches the scheme (reason MATCH_MEDIA),
121
124
  * unless it's open. It stays open: it's one small page in the extension's own
@@ -125,16 +128,19 @@ let opening = null;
125
128
  *
126
129
  * @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
127
130
  * @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
131
+ * @param {string} [justification] - What the document is for, when it does more than watch the
132
+ * scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
133
+ * with the document, so it has to be the whole truth.
128
134
  * @returns {Promise<boolean>} Whether it opened one (false: already open).
129
135
  */
130
- export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH) {
136
+ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH, justification = SCHEME_JUSTIFICATION) {
131
137
  opening ??= (async () => {
132
138
  const open = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
133
139
  if (open.length) return false;
134
140
  await offscreen.createDocument({
135
141
  url,
136
142
  reasons: [offscreen.Reason.MATCH_MEDIA],
137
- justification: 'Reads whether Chrome is in light or dark mode, to show the matching toolbar icon.',
143
+ justification,
138
144
  });
139
145
  return true;
140
146
  })().finally(() => { opening = null; });
@@ -159,15 +165,17 @@ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH)
159
165
  * worker wakes.
160
166
  *
161
167
  * @param {ToolbarWorkerApi} api - `chrome`, or a fake.
168
+ * @param {{url?: string, justification?: string}} [document] - The offscreen document and what
169
+ * it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
162
170
  * @returns {ToolbarTheme} The theme, for tests.
163
171
  * @example
164
172
  * // background.js
165
173
  * startToolbarTheme(chrome);
166
174
  */
167
- export function startToolbarTheme(api) {
175
+ export function startToolbarTheme(api, { url, justification } = {}) {
168
176
  const theme = createToolbarTheme({ storage: api.storage.local, action: api.action, extensionId: api.runtime.id });
169
177
  theme.restore();
170
- watchFromOffscreen(api).catch((error) => console.error('Opening the colour scheme watcher:', error));
178
+ watchFromOffscreen(api, url, justification).catch((error) => console.error('Opening the colour scheme watcher:', error));
171
179
  api.runtime.onMessage.addListener((message, sender) => {
172
180
  theme.onMessage(message, sender);
173
181
  return false;
package/src/ui/menu.d.ts CHANGED
@@ -99,8 +99,10 @@ export type Box = {
99
99
  *
100
100
  * @param {{width: number, height: number}} size - The menu's size.
101
101
  * @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
102
- * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The anchor's
103
- * box, or a point; `placement` says where it goes from an anchor (default `'over'`).
102
+ * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below', rtl?: boolean}} where - The
103
+ * anchor's box, or a point; `placement` says where it goes from an anchor (default `'over'`); `rtl`
104
+ * (a right-to-left page) lines its start up with the anchor's left edge and opens leftwards from a
105
+ * point, as Chrome mirrors its own.
104
106
  * @returns {{left: number, top: number}} The menu's top-left, in px.
105
107
  * @example
106
108
  * menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
@@ -112,11 +114,12 @@ export declare function menuPosition(size: {
112
114
  }, viewport: {
113
115
  width: number;
114
116
  height: number;
115
- }, { anchor, x, y, placement }: {
117
+ }, { anchor, x, y, placement, rtl }: {
116
118
  anchor?: Box;
117
119
  x?: number;
118
120
  y?: number;
119
121
  placement?: 'over' | 'below';
122
+ rtl?: boolean;
120
123
  }): {
121
124
  left: number;
122
125
  top: number;
@@ -163,6 +166,11 @@ export type ActionMenu = {
163
166
  * Close it, if open, and return the focus.
164
167
  */
165
168
  close: () => void;
169
+ /**
170
+ * Take it off the page and stop listening to the window (a content
171
+ * script's overlay being removed).
172
+ */
173
+ destroy: () => void;
166
174
  };
167
175
  /**
168
176
  * What {@link createMenu} takes.
@@ -182,6 +190,8 @@ export type ActionMenu = {
182
190
  * @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
183
191
  * and open it (closing it first if it's open).
184
192
  * @property {() => void} close Close it, if open, and return the focus.
193
+ * @property {() => void} destroy Take it off the page and stop listening to the window (a content
194
+ * script's overlay being removed).
185
195
  */
186
196
  /**
187
197
  * Make a page's action menu: one `<dialog class="menu">` that any number of
package/src/ui/menu.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * @module ui/menu
15
15
  */
16
16
 
17
- import { focusedElement, rootDocument } from '../dom/focus.js';
17
+ import { focusedElement, focusedIn, rootDocument } from '../dom/focus.js';
18
18
 
19
19
  /** The gap kept between a menu and the window's edges, in px. */
20
20
  export const MENU_EDGE = 8;
@@ -67,19 +67,21 @@ const BELOW_GAP = 4;
67
67
  *
68
68
  * @param {{width: number, height: number}} size - The menu's size.
69
69
  * @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
70
- * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The anchor's
71
- * box, or a point; `placement` says where it goes from an anchor (default `'over'`).
70
+ * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below', rtl?: boolean}} where - The
71
+ * anchor's box, or a point; `placement` says where it goes from an anchor (default `'over'`); `rtl`
72
+ * (a right-to-left page) lines its start up with the anchor's left edge and opens leftwards from a
73
+ * point, as Chrome mirrors its own.
72
74
  * @returns {{left: number, top: number}} The menu's top-left, in px.
73
75
  * @example
74
76
  * menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
75
77
  * // → {left: anchor.right - 160, top: anchor.top}
76
78
  */
77
- export function menuPosition(size, viewport, { anchor, x = 0, y = 0, placement = 'over' }) {
79
+ export function menuPosition(size, viewport, { anchor, x = 0, y = 0, placement = 'over', rtl = false }) {
78
80
  const { width, height } = size;
79
81
  const left = (/** @type {number} */ start) => Math.max(MENU_EDGE, Math.min(start, viewport.width - width - MENU_EDGE));
80
82
  const room = (/** @type {number} */ top) => top + height <= viewport.height - MENU_EDGE;
81
- if (!anchor) return { left: left(x), top: Math.max(MENU_EDGE, room(y) ? y : y - height) };
82
- const start = left(anchor.right - width);
83
+ if (!anchor) return { left: left(rtl ? x - width : x), top: Math.max(MENU_EDGE, room(y) ? y : y - height) };
84
+ const start = left(rtl ? anchor.left : anchor.right - width);
83
85
  if (placement === 'below') {
84
86
  const below = anchor.bottom + BELOW_GAP;
85
87
  return { left: start, top: Math.max(MENU_EDGE, room(below) ? below : anchor.top - height - BELOW_GAP) };
@@ -99,6 +101,20 @@ export function isInside(box, x, y) {
99
101
  return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
100
102
  }
101
103
 
104
+ /**
105
+ * The box a menu opens from: the anchor's, or, when the anchor has none (it isn't drawn: a row's
106
+ * buttons shown only while the row is hovered or holds the focus), its nearest ancestor's that has.
107
+ *
108
+ * @param {Element} anchor - The button that opened the menu.
109
+ * @returns {DOMRect | undefined} The box; undefined when nothing up to the root is drawn.
110
+ */
111
+ function anchorBox(anchor) {
112
+ for (let at = /** @type {Element | null} */ (anchor); at; at = at.parentElement) {
113
+ if (at.getClientRects().length) return at.getBoundingClientRect();
114
+ }
115
+ return undefined;
116
+ }
117
+
102
118
  /**
103
119
  * What {@link createMenu} takes.
104
120
  *
@@ -118,6 +134,8 @@ export function isInside(box, x, y) {
118
134
  * @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
119
135
  * and open it (closing it first if it's open).
120
136
  * @property {() => void} close Close it, if open, and return the focus.
137
+ * @property {() => void} destroy Take it off the page and stop listening to the window (a content
138
+ * script's overlay being removed).
121
139
  */
122
140
 
123
141
  /**
@@ -168,21 +186,32 @@ export function createMenu({ id, placement = 'over', root, doc = rootDocument(ro
168
186
 
169
187
  menu.addEventListener('keydown', (event) => {
170
188
  const list = items();
171
- const at = list.indexOf(/** @type {HTMLButtonElement} */ (focusedElement(doc)));
189
+ // Asked of the menu's own root: a closed shadow root's focused element can't be seen from the document.
190
+ const at = list.indexOf(/** @type {HTMLButtonElement} */ (focusedIn(menu)));
172
191
  /** @param {number} index */
173
192
  const go = (index) => {
174
- event.preventDefault();
193
+ handled();
175
194
  list[(index + list.length) % list.length]?.focus();
176
195
  };
196
+ /** The key is the menu's: the page under a content script's menu doesn't act on it too (its own shortcuts). */
197
+ const handled = () => {
198
+ event.preventDefault();
199
+ event.stopPropagation();
200
+ };
177
201
  if (event.key === 'ArrowDown') go(at + 1);
178
202
  else if (event.key === 'ArrowUp') go(at < 0 ? -1 : at - 1);
179
203
  else if (event.key === 'Home') go(0);
180
204
  else if (event.key === 'End') go(-1);
181
205
  else if (event.key === 'Escape' || event.key === 'Tab') {
182
- event.preventDefault();
206
+ handled();
183
207
  close();
184
208
  }
185
209
  });
210
+ // Closed any other way (the browser's own close, another script): the button no longer says it's open.
211
+ menu.addEventListener('close', () => {
212
+ expanded?.setAttribute('aria-expanded', 'false');
213
+ expanded = null;
214
+ });
186
215
  // Esc the browser handles itself (when the keydown above didn't see it).
187
216
  menu.addEventListener('cancel', (event) => {
188
217
  event.preventDefault();
@@ -219,22 +248,30 @@ export function createMenu({ id, placement = 'over', root, doc = rootDocument(ro
219
248
  if (label) menu.setAttribute('aria-label', label);
220
249
  else menu.removeAttribute('aria-label');
221
250
 
222
- const active = focusedElement(doc);
251
+ const active = focusedIn(menu) ?? focusedElement(doc);
223
252
  returnFocus = focusTo ?? anchor ?? (active instanceof view.HTMLElement ? active : null);
224
253
  if (anchor?.hasAttribute('aria-haspopup')) {
225
254
  anchor.setAttribute('aria-expanded', 'true');
226
255
  expanded = anchor;
227
256
  }
257
+ // Measured before the menu opens: it takes the focus, and a button shown only while its row
258
+ // holds the focus (`.sp-suffix`) has no box after that, so the menu would open in the corner.
259
+ const from = anchor && anchorBox(anchor);
228
260
  menu.showModal();
229
261
  const { width, height } = menu.getBoundingClientRect();
230
262
  const { left, top } = menuPosition({ width, height }, { width: view.innerWidth, height: view.innerHeight }, {
231
- anchor: anchor?.getBoundingClientRect(), x, y, placement,
263
+ anchor: from, x, y, placement, rtl: view.getComputedStyle(menu).direction === 'rtl',
232
264
  });
233
265
  menu.style.left = `${left}px`;
234
266
  menu.style.top = `${top}px`;
235
267
  items()[0]?.focus();
236
268
  },
237
269
  close,
270
+ destroy() {
271
+ shut();
272
+ view.removeEventListener('blur', close);
273
+ menu.remove();
274
+ },
238
275
  };
239
276
  }
240
277
 
@@ -94,7 +94,11 @@ export function createModelRow({ status, help, download, progress }, { options,
94
94
  progress.value = loaded;
95
95
  status.textContent = t(MODEL_MESSAGES.downloading, [String(Math.round(loaded * 100))]);
96
96
  },
97
- }).then(async () => show(await state()), () => show('downloadable', t(MODEL_MESSAGES.downloadFailed)));
97
+ }).then(
98
+ // The state as the code that uses it sees it; if it can't be asked (the worker didn't answer), as this page does.
99
+ async (here) => show(await state().catch(() => here)),
100
+ () => show('downloadable', t(MODEL_MESSAGES.downloadFailed)),
101
+ );
98
102
  });
99
103
 
100
104
  return {
@@ -27,6 +27,7 @@
27
27
  * @module ui/site-list
28
28
  */
29
29
 
30
+ import { focusedIn } from '../dom/focus.js';
30
31
  import { createMenu, moreActionsButton } from './menu.js';
31
32
  import { askText } from './text-dialog.js';
32
33
  import { MAX_HOST_LENGTH, siteLabel } from '../sites/sites.js';
@@ -177,9 +178,11 @@ export function createSiteList({
177
178
  form?.addEventListener('submit', async (event) => {
178
179
  event.preventDefault();
179
180
  const input = /** @type {HTMLInputElement} */ (form.querySelector('input'));
180
- const site = parse(input.value);
181
- if (!site) invalid?.(input.value);
182
- else if (await add(site) !== false) input.value = '';
181
+ const typed = input.value;
182
+ const site = parse(typed);
183
+ if (!site) invalid?.(typed);
184
+ // Cleared only if it's still what was added: adding can wait on Chrome (a permission prompt), and the next site may be typed meanwhile.
185
+ else if (await add(site) !== false && input.value === typed) input.value = '';
183
186
  });
184
187
  addButton?.addEventListener('click', async () => {
185
188
  const site = await ask(w.addSite, w.add);
@@ -219,6 +222,11 @@ export function createSiteList({
219
222
 
220
223
  return {
221
224
  render(sites) {
225
+ // The focus stays in the list across a redraw: on the same row's button (the one after a removed
226
+ // row, the last after the last), or on the way to add when no row is left. Without this, the
227
+ // row's button a dialog or menu gave the focus back to is replaced and the focus falls to the page.
228
+ const focused = focusedIn(list);
229
+ const at = focused && list.contains(focused) ? [...list.children].findIndex((row) => row.contains(focused)) : -1;
222
230
  list.replaceChildren(...sites.map((site) => {
223
231
  const row = doc.createElement('li');
224
232
  row.className = 'row';
@@ -229,6 +237,7 @@ export function createSiteList({
229
237
  return row;
230
238
  }));
231
239
  if (empty) empty.hidden = sites.length > 0;
240
+ if (at >= 0) (list.children[Math.min(at, sites.length - 1)]?.querySelector('button') ?? addButton ?? form?.querySelector('input'))?.focus();
232
241
  },
233
242
  };
234
243
  }
@@ -4,8 +4,9 @@
4
4
  * folder's name (`askText`); several, as chrome://settings/searchEngines asks
5
5
  * for a site search (`askFields`). The action button stays disabled while a
6
6
  * field is blank or isn't valid, with the reason under that field. Enter
7
- * means the action button, Esc and Cancel cancel, and the focus goes back to
8
- * what opened it. The page needs no markup: the dialog (`#text-dialog`) is
7
+ * means the action button, Esc and Cancel cancel (as does the dialog being
8
+ * closed any other way), and the focus goes back to what opened it. A reason
9
+ * is announced as it appears, and its field marked invalid. The page needs no markup: the dialog (`#text-dialog`) is
9
10
  * made the first time, its fields each time it's asked.
10
11
  *
11
12
  * @module ui/text-dialog