@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
@@ -8,6 +8,15 @@
8
8
  * `chrome.storage.local` instead. Local also holds any write sync refused
9
9
  * (too many writes a minute, or an item over its size limit), kept under
10
10
  * `unsyncedKey`, read as if synced, and retried, so a change is never lost.
11
+ * Beside it (`<unsyncedKey>Was`) is what sync held for each when it was
12
+ * refused: once sync holds something else (the setting was changed since on
13
+ * another computer), the held change is the older one and is dropped, not
14
+ * read over the newer one or sent up over it.
15
+ *
16
+ * Holding a change is a read-modify-write of one local item, so it runs
17
+ * under a Web Lock the extension's pages and worker share: two refused
18
+ * writes at once keep both changes, and a retry can't put an older value
19
+ * back over a newer one.
11
20
  *
12
21
  * Each extension supplies its schema: the defaults and one normaliser that
13
22
  * coerces anything read from storage or a form into valid settings.
@@ -72,6 +81,11 @@ export type SettingsStore<S extends Record<string, unknown>> = {
72
81
  * Try again to put earlier refusals into sync.
73
82
  */
74
83
  retryUnsynced: (storage: SyncedStorage) => Promise<boolean>;
84
+ /**
85
+ * Whether trying again could help: something held is still the newest and small enough for sync
86
+ * (an item over sync's size limit stays on this computer however often it's tried).
87
+ */
88
+ retryable: (storage: SyncedStorage) => Promise<boolean>;
75
89
  /**
76
90
  * Save the settings `changes` names, normalised.
77
91
  */
@@ -116,6 +130,9 @@ export type SettingsStore<S extends Record<string, unknown>> = {
116
130
  * everything reached sync.
117
131
  * @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
118
132
  * Try again to put earlier refusals into sync.
133
+ * @property {(storage: SyncedStorage) => Promise<boolean>} retryable
134
+ * Whether trying again could help: something held is still the newest and small enough for sync
135
+ * (an item over sync's size limit stays on this computer however often it's tried).
119
136
  * @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
120
137
  * Save the settings `changes` names, normalised.
121
138
  * @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed
@@ -8,6 +8,15 @@
8
8
  * `chrome.storage.local` instead. Local also holds any write sync refused
9
9
  * (too many writes a minute, or an item over its size limit), kept under
10
10
  * `unsyncedKey`, read as if synced, and retried, so a change is never lost.
11
+ * Beside it (`<unsyncedKey>Was`) is what sync held for each when it was
12
+ * refused: once sync holds something else (the setting was changed since on
13
+ * another computer), the held change is the older one and is dropped, not
14
+ * read over the newer one or sent up over it.
15
+ *
16
+ * Holding a change is a read-modify-write of one local item, so it runs
17
+ * under a Web Lock the extension's pages and worker share: two refused
18
+ * writes at once keep both changes, and a retry can't put an older value
19
+ * back over a newer one.
11
20
  *
12
21
  * Each extension supplies its schema: the defaults and one normaliser that
13
22
  * coerces anything read from storage or a form into valid settings.
@@ -15,7 +24,7 @@
15
24
  * @module settings/store
16
25
  */
17
26
 
18
- import { MAX_SYNC_ITEM_BYTES, isEmpty, pick, syncItemBytes } from './storage.js';
27
+ import { MAX_SYNC_ITEM_BYTES, exclusive, isEmpty, pick, sameValue, syncItemBytes } from './storage.js';
19
28
 
20
29
  /** @import { StorageChange, SyncedStorage } from './storage.js' */
21
30
 
@@ -51,6 +60,9 @@ import { MAX_SYNC_ITEM_BYTES, isEmpty, pick, syncItemBytes } from './storage.js'
51
60
  * everything reached sync.
52
61
  * @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
53
62
  * Try again to put earlier refusals into sync.
63
+ * @property {(storage: SyncedStorage) => Promise<boolean>} retryable
64
+ * Whether trying again could help: something held is still the newest and small enough for sync
65
+ * (an item over sync's size limit stays on this computer however often it's tried).
54
66
  * @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
55
67
  * Save the settings `changes` names, normalised.
56
68
  * @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed
@@ -78,9 +90,36 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
78
90
  /** @param {Record<string, unknown> | null} [raw] @returns {S} */
79
91
  const normalize = (raw) => normalizeRaw(raw ?? {});
80
92
 
93
+ /** The local item holding what sync held for each refused setting: `{key: [value]}`, `[]` for nothing. */
94
+ const wasKey = `${unsyncedKey}Was`;
95
+
96
+ /**
97
+ * What an area holds for a key, in a form that tells nothing from `null`.
98
+ *
99
+ * @param {Record<string, unknown>} items - What a `get` gave.
100
+ * @param {string} key - The key.
101
+ * @returns {unknown[]} `[value]`, or `[]` when it holds nothing.
102
+ */
103
+ const seen = (items, key) => (key in items ? [items[key]] : []);
104
+
105
+ /**
106
+ * The held changes that are still the newest: sync holds what it did when each was refused.
107
+ * One held before `wasKey` existed has nothing to compare, and counts as newest.
108
+ *
109
+ * @param {Record<string, unknown>} held - The held changes.
110
+ * @param {Record<string, unknown[]>} was - What sync held when each was refused.
111
+ * @param {Record<string, unknown>} synced - What sync holds now.
112
+ * @returns {Record<string, unknown>} The ones to keep.
113
+ */
114
+ const newest = (held, was, synced) => Object.fromEntries(Object.entries(pick(held, syncedKeys))
115
+ .filter(([key]) => !(key in was) || sameValue(was[key], seen(synced, key))));
116
+
81
117
  /**
82
118
  * Start one sync write of `values` at once; resolves to what sync refused.
83
- * An item too big for sync isn't even tried.
119
+ * An item too big for sync isn't even tried. When Chrome refuses several
120
+ * together for anything but its write rate, each is tried on its own, so
121
+ * one item Chrome won't take (the whole of sync is full, or it weighs more
122
+ * there than here) doesn't keep the others on this computer.
84
123
  *
85
124
  * @param {SyncedStorage} storage
86
125
  * @param {Record<string, unknown>} values
@@ -91,10 +130,13 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
91
130
  const fits = Object.fromEntries(entries.filter(([key, value]) => syncItemBytes(key, value) <= MAX_SYNC_ITEM_BYTES));
92
131
  const refused = Object.fromEntries(entries.filter(([key]) => !(key in fits)));
93
132
  if (isEmpty(fits)) return Promise.resolve(refused);
94
- return storage.sync.set(fits).then(() => refused, (/** @type {unknown} */ error) => {
95
- console.warn('Settings: Chrome Sync refused the change; keeping it on this computer and trying again later.',
96
- /** @type {Error | undefined} */ (error)?.message ?? error);
97
- return { ...refused, ...fits };
133
+ return storage.sync.set(fits).then(() => refused, async (/** @type {unknown} */ error) => {
134
+ const message = String(/** @type {Error | undefined} */ (error)?.message ?? error);
135
+ console.warn('Settings: Chrome Sync refused the change; keeping it on this computer and trying again later.', message);
136
+ const keys = Object.keys(fits);
137
+ if (keys.length === 1 || /MAX_WRITE_OPERATIONS/.test(message)) return { ...refused, ...fits };
138
+ const alone = await Promise.all(keys.map((key) => storage.sync.set({ [key]: fits[key] }).then(() => [], () => [key])));
139
+ return { ...refused, ...pick(fits, alone.flat()) };
98
140
  });
99
141
  }
100
142
 
@@ -102,30 +144,54 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
102
144
  async function load(storage) {
103
145
  const [synced, local] = await Promise.all([
104
146
  storage.sync.get([...syncedKeys]),
105
- storage.local.get([...localKeys, unsyncedKey]),
147
+ storage.local.get([...localKeys, unsyncedKey, wasKey]),
106
148
  ]);
107
- const { [unsyncedKey]: unsynced = {}, ...here } = local;
108
- return normalize({ ...synced, ...pick(unsynced, syncedKeys), ...here });
149
+ const { [unsyncedKey]: unsynced = {}, [wasKey]: was = {}, ...here } = local;
150
+ return normalize({ ...synced, ...newest(unsynced, was, synced), ...here });
109
151
  }
110
152
 
111
153
  /**
112
154
  * One sync write for all of `values` (sync allows 120 a minute), then any
113
- * that sync refused before. What sync refuses is kept in local, where
114
- * `load` still reads it, and goes up with the next write or
115
- * `retryUnsynced`.
155
+ * that sync refused before and that are still the newest. What sync refuses
156
+ * is kept in local, where `load` still reads it, and goes up with the next
157
+ * write or `retryUnsynced`.
116
158
  *
117
159
  * @type {SettingsStore<S>['writeSynced']}
118
160
  */
119
- async function writeSynced(storage, values) {
161
+ function writeSynced(storage, values) {
120
162
  // Sent before anything is awaited, so a page that's closing still gets it out.
121
163
  const sending = send(storage, values);
122
- const { [unsyncedKey]: held } = await storage.local.get([unsyncedKey]);
123
- let refused = await sending;
124
- const older = Object.fromEntries(Object.entries(pick(held ?? {}, syncedKeys)).filter(([key]) => !(key in values)));
125
- if (!isEmpty(older)) refused = { ...(await send(storage, older)), ...refused };
126
- if (!isEmpty(refused)) await storage.local.set({ [unsyncedKey]: refused });
127
- else if (held) await storage.local.remove(unsyncedKey);
128
- return isEmpty(refused);
164
+ return exclusive(`settings:${unsyncedKey}`, async (waited) => {
165
+ let refused = await sending;
166
+ const sent = Object.keys(values).filter((key) => !(key in refused));
167
+ if (waited && sent.length) {
168
+ // The work waited for may have been a retry that sent an older value after this one: put this one back.
169
+ const now = await storage.sync.get(sent);
170
+ const undone = pick(values, sent.filter((key) => !sameValue(seen(now, key), [values[key]])));
171
+ if (!isEmpty(undone)) refused = { ...refused, ...(await send(storage, undone)) };
172
+ }
173
+ const { [unsyncedKey]: held = {}, [wasKey]: was = {} } = await storage.local.get([unsyncedKey, wasKey]);
174
+ const heldKeys = Object.keys(pick(held, syncedKeys)).filter((key) => !(key in values));
175
+ const synced = await storage.sync.get([...heldKeys, ...Object.keys(refused)]);
176
+ const older = pick(newest(held, was, synced), heldKeys);
177
+ const stillOlder = isEmpty(older) ? {} : await send(storage, older);
178
+ const keep = { ...stillOlder, ...refused };
179
+ const keepWas = Object.fromEntries(Object.keys(keep).map((key) => [key, key in refused || !(key in was) ? seen(synced, key) : was[key]]));
180
+ if (isEmpty(keep)) {
181
+ if (!isEmpty(held) || !isEmpty(was)) await storage.local.remove([unsyncedKey, wasKey]);
182
+ } else if (!sameValue(keep, held) || !sameValue(keepWas, was)) {
183
+ await storage.local.set({ [unsyncedKey]: keep, [wasKey]: keepWas });
184
+ }
185
+ return isEmpty(keep);
186
+ });
187
+ }
188
+
189
+ /** @type {SettingsStore<S>['retryable']} */
190
+ async function retryable(storage) {
191
+ const { [unsyncedKey]: held = {}, [wasKey]: was = {} } = await storage.local.get([unsyncedKey, wasKey]);
192
+ const keys = Object.keys(pick(held, syncedKeys));
193
+ if (!keys.length) return false;
194
+ return Object.entries(newest(held, was, await storage.sync.get(keys))).some(([key, value]) => syncItemBytes(key, value) <= MAX_SYNC_ITEM_BYTES);
129
195
  }
130
196
 
131
197
  /**
@@ -166,6 +232,7 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
166
232
  normalize,
167
233
  load,
168
234
  writeSynced,
235
+ retryable,
169
236
  retryUnsynced: (/** @type {SyncedStorage} */ storage) => writeSynced(storage, {}),
170
237
  save,
171
238
  changed,
@@ -7,7 +7,11 @@
7
7
  * taken out or edited. Pure.
8
8
  *
9
9
  * A site is a web host: http or https, with a dot in its name (or
10
- * `localhost`). Text without a scheme is read as https.
10
+ * `localhost`). Text without a scheme is read as https. Only a real host
11
+ * name is a site: a `*` typed inside one would make a match pattern Chrome
12
+ * refuses (`*://*.*.example.com/*`), and with it the whole
13
+ * `registerContentScripts` call, so the extension would stop following its
14
+ * settings (checked in Chromium 156: "Invalid host wildcard").
11
15
  *
12
16
  * @module sites/sites
13
17
  */
@@ -35,6 +39,7 @@ export declare function siteOrigin(input: unknown): string | null;
35
39
  * @example
36
40
  * siteHost(' https://www.GitHub.com/a '); // 'github.com'
37
41
  * siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
42
+ * siteHost('*.example.com'); // 'example.com': a list covers subdomains anyway
38
43
  * siteHost('not a site'); // null
39
44
  */
40
45
  export declare function siteHost(input: unknown): string | null;
@@ -7,7 +7,11 @@
7
7
  * taken out or edited. Pure.
8
8
  *
9
9
  * A site is a web host: http or https, with a dot in its name (or
10
- * `localhost`). Text without a scheme is read as https.
10
+ * `localhost`). Text without a scheme is read as https. Only a real host
11
+ * name is a site: a `*` typed inside one would make a match pattern Chrome
12
+ * refuses (`*://*.*.example.com/*`), and with it the whole
13
+ * `registerContentScripts` call, so the extension would stop following its
14
+ * settings (checked in Chromium 156: "Invalid host wildcard").
11
15
  *
12
16
  * @module sites/sites
13
17
  */
@@ -18,19 +22,31 @@ export const MAX_HOST_LENGTH = 253;
18
22
  /** A scheme at the start of what was typed; `localhost:8080` is a host and port, not one. */
19
23
  const SCHEME = /^[a-z][a-z\d+.-]*:(?!\d)/i;
20
24
 
25
+ /** A match pattern's wildcards at the start of what was typed: `*://` for any scheme, `*.` for subdomains. A listed site covers its subdomains anyway. */
26
+ const PATTERN_START = /^(?:\*:\/\/)?((?:[a-z][a-z\d+.-]*:\/\/)?)\*\./i;
27
+
28
+ /**
29
+ * A host name as the URL parser gives it (lower case, punycode), and nothing else it lets through:
30
+ * labels of letters, digits, `-` and `_`, none empty, a dot at the end allowed. `*`, commas and
31
+ * empty labels pass the parser, and a `*` goes on to make a match pattern Chrome refuses.
32
+ */
33
+ const HOST_NAME = /^[a-z\d_](?:[a-z\d_-]*[a-z\d_])?(?:\.[a-z\d_](?:[a-z\d_-]*[a-z\d_])?)*\.?$/;
34
+
21
35
  /**
22
- * What was typed (or stored, or a page's address) as a web URL.
36
+ * What was typed (or stored, or a page's address) as a web URL. A match pattern's wildcards at
37
+ * the start (`*.example.com`, `*://*.example.com/*`) are dropped: the site is `example.com`.
23
38
  *
24
39
  * @param {unknown} input - Anything.
25
40
  * @returns {URL | null} The URL, or null when it isn't a web site's.
26
41
  */
27
42
  function webUrl(input) {
28
43
  // Only text: a stray number would otherwise read as an IP address (3 → "0.0.0.3").
29
- const text = typeof input === 'string' ? input.trim() : '';
44
+ const text = typeof input === 'string' ? input.trim().replace(PATTERN_START, '$1').replace(/^\*:\/\//, '') : '';
30
45
  if (!text || /\s/.test(text)) return null;
31
46
  try {
32
47
  const url = new URL(SCHEME.test(text) ? text : `https://${text}`);
33
48
  if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
49
+ if (!HOST_NAME.test(url.hostname)) return null;
34
50
  return url.hostname.includes('.') || url.hostname === 'localhost' ? url : null;
35
51
  } catch {
36
52
  return null;
@@ -63,6 +79,7 @@ export function siteOrigin(input) {
63
79
  * @example
64
80
  * siteHost(' https://www.GitHub.com/a '); // 'github.com'
65
81
  * siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
82
+ * siteHost('*.example.com'); // 'example.com': a list covers subdomains anyway
66
83
  * siteHost('not a site'); // null
67
84
  */
68
85
  export function siteHost(input) {
@@ -307,7 +307,7 @@ button.link:hover { background: none; text-decoration: underline; }
307
307
 
308
308
  /* settings-subpage's header, at the top of its card: the back button, then the subpage's title.
309
309
  <div class="subpage-head"><button class="icon-button" aria-label="Back"><span class="icon icon-arrow-back"></span></button><h2>Cookies</h2></div> */
310
- .subpage-head { display: flex; align-items: center; gap: 8px; min-height: 56px; padding: 0 20px 0 8px; }
310
+ .subpage-head { display: flex; align-items: center; gap: 8px; min-height: 56px; padding-inline: 8px 20px; }
311
311
  .subpage-head h2 { margin: 0; font-size: 15px; font-weight: 400; color: var(--text); overflow-wrap: anywhere; }
312
312
  .subpage-head .icon-button { color: var(--icon-muted); }
313
313
 
@@ -323,13 +323,17 @@ select {
323
323
  width: 200px;
324
324
  max-width: 100%;
325
325
  height: 36px;
326
- padding: 0 20px 0 10px;
326
+ padding-block: 0;
327
+ padding-inline: 10px 20px;
327
328
  border: 1px solid var(--field-border);
328
329
  border-radius: var(--radius);
329
330
  background: var(--select-arrow) calc(100% - 10px) center / 7px no-repeat transparent;
330
331
  cursor: pointer;
331
332
  }
332
333
 
334
+ /* Right to left: the arrow at the other end, as Chrome mirrors its own. */
335
+ select:dir(rtl) { background-position: 10px center; }
336
+
333
337
  select:hover { background-color: var(--hover-subtle); }
334
338
  select:focus-visible { outline: 2px solid var(--primary); outline-offset: -1px; }
335
339
  select:disabled { opacity: 0.38; cursor: default; }
@@ -414,13 +418,13 @@ input.switch::after {
414
418
  content: "";
415
419
  position: absolute;
416
420
  top: 50%;
417
- left: 7px;
421
+ inset-inline-start: 7px;
418
422
  width: 8px;
419
423
  height: 8px;
420
424
  border-radius: 50%;
421
425
  background: var(--switch-outline-off);
422
426
  transform: translate(-50%, -50%);
423
- transition: left 80ms linear, width 80ms linear, height 80ms linear, background-color 80ms linear;
427
+ transition: inset-inline-start 80ms linear, width 80ms linear, height 80ms linear, background-color 80ms linear;
424
428
  }
425
429
 
426
430
  /* The 20px hover halo around the knob. */
@@ -428,7 +432,7 @@ input.switch:hover::before {
428
432
  content: "";
429
433
  position: absolute;
430
434
  top: 50%;
431
- left: 7px;
435
+ inset-inline-start: 7px;
432
436
  width: 20px;
433
437
  height: 20px;
434
438
  border-radius: 50%;
@@ -438,8 +442,10 @@ input.switch:hover::before {
438
442
 
439
443
  input.switch:active::after { width: 10px; height: 10px; }
440
444
  input.switch:checked { background: var(--primary); border-color: var(--primary); }
441
- input.switch:checked::after { left: 17px; width: 12px; height: 12px; background: var(--on-primary); }
442
- input.switch:checked:hover::before { left: 17px; }
445
+ input.switch:checked::after { inset-inline-start: 17px; width: 12px; height: 12px; background: var(--on-primary); }
446
+ input.switch:checked:hover::before { inset-inline-start: 17px; }
447
+ /* Right to left: the knob is measured from the right, so it's centred on its point the other way. */
448
+ input.switch:dir(rtl)::after, input.switch:dir(rtl):hover::before { transform: translate(50%, -50%); }
443
449
  input.switch:checked:active::after { width: 14px; height: 14px; }
444
450
  input.switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
445
451
  input.switch:disabled { opacity: 0.38; cursor: default; }
@@ -458,10 +464,10 @@ input.switch:disabled::before { display: none; }
458
464
  grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--sidebar-width);
459
465
  align-items: center;
460
466
  height: var(--toolbar-height);
461
- padding-right: 12px;
467
+ padding-inline-end: 12px;
462
468
  }
463
469
 
464
- .toolbar-start { display: flex; align-items: center; gap: 12px; padding-left: 20px; min-width: 0; }
470
+ .toolbar-start { display: flex; align-items: center; gap: 12px; padding-inline-start: 20px; min-width: 0; }
465
471
  .toolbar-start h1, .toolbar > h1 {
466
472
  font-size: 22px;
467
473
  font-weight: 500;
@@ -481,7 +487,8 @@ input.switch:disabled::before { display: none; }
481
487
  width: 100%;
482
488
  max-width: 680px;
483
489
  height: 36px;
484
- padding: 0 6px 0 10px;
490
+ padding-block: 0;
491
+ padding-inline: 10px 6px;
485
492
  border-radius: 100px;
486
493
  background: var(--search-bg);
487
494
  color: var(--icon);
@@ -496,7 +503,8 @@ input.switch:disabled::before { display: none; }
496
503
  min-width: 0;
497
504
  height: 100%;
498
505
  min-height: 0;
499
- padding: 0 0 0 4px;
506
+ padding: 0;
507
+ padding-inline-start: 4px;
500
508
  border: 0;
501
509
  border-radius: 0;
502
510
  background: none;
@@ -621,11 +629,13 @@ label.row, .row[actionable] { cursor: pointer; }
621
629
  flex: none;
622
630
  width: 20px;
623
631
  height: 20px;
624
- margin-right: -2px;
632
+ margin-inline-end: -2px;
625
633
  background: var(--icon-muted);
626
634
  mask: var(--icon-arrow-right) center / 20px no-repeat;
627
635
  }
628
636
 
637
+ .link-row:dir(rtl)::after { transform: scaleX(-1); }
638
+
629
639
  .link-row.external::after { mask-image: var(--icon-open-in-new); }
630
640
 
631
641
  /* A list inside a card: each item is a row. */
@@ -653,9 +663,13 @@ label.row, .row[actionable] { cursor: pointer; }
653
663
  align-items: center;
654
664
  gap: 20px;
655
665
  min-height: 40px;
656
- margin: 0 2px 0 1px;
657
- padding: 10px 16px 10px 23px;
658
- border-radius: 0 100px 100px 0;
666
+ margin-block: 0;
667
+ margin-inline: 1px 2px;
668
+ padding-block: 10px;
669
+ padding-inline: 23px 16px;
670
+ border-radius: 0;
671
+ border-start-end-radius: 100px;
672
+ border-end-end-radius: 100px;
659
673
  color: var(--text);
660
674
  font-size: 14px;
661
675
  font-weight: 500;
@@ -678,7 +692,7 @@ label.row, .row[actionable] { cursor: pointer; }
678
692
  .manager { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
679
693
  .layout { flex: 1; min-height: 0; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); }
680
694
  .sidebar { overflow: auto; padding: 8px 0 24px; }
681
- .main { overflow: auto; padding: 24px 32px 96px 17px; min-width: 0; }
695
+ .main { overflow: auto; padding-block: 24px 96px; padding-inline: 17px 32px; min-width: 0; }
682
696
 
683
697
  /* A folder in the sidebar (bookmarks-folder-node): the same pill as the settings menu. */
684
698
  .tree { list-style: none; margin: 0; padding: 0; }
@@ -689,9 +703,13 @@ label.row, .row[actionable] { cursor: pointer; }
689
703
  align-items: center;
690
704
  gap: 8px;
691
705
  min-height: 40px;
692
- margin: 0 3px 0 1px;
693
- padding: 0 4px 0 calc(8px + var(--depth) * 20px);
694
- border-radius: 0 100px 100px 0;
706
+ margin-block: 0;
707
+ margin-inline: 1px 3px;
708
+ padding-block: 0;
709
+ padding-inline: calc(8px + var(--depth) * 20px) 4px;
710
+ border-radius: 0;
711
+ border-start-end-radius: 100px;
712
+ border-end-end-radius: 100px;
695
713
  color: var(--folder-text);
696
714
  font-size: 14px;
697
715
  font-weight: 500;
@@ -724,7 +742,8 @@ label.row, .row[actionable] { cursor: pointer; }
724
742
  align-items: center;
725
743
  gap: 20px;
726
744
  height: 40px;
727
- padding: 0 8px 0 20px;
745
+ padding-block: 0;
746
+ padding-inline: 20px 8px;
728
747
  user-select: none;
729
748
  }
730
749
 
@@ -798,7 +817,8 @@ label.row, .row[actionable] { cursor: pointer; }
798
817
  `hidden`: the popover defaults undone either way. */
799
818
  .toast {
800
819
  position: fixed;
801
- inset: auto auto 0 0;
820
+ inset-block: auto 0;
821
+ inset-inline: 0 auto;
802
822
  display: flex;
803
823
  align-items: center;
804
824
  gap: 32px;
@@ -822,7 +842,7 @@ label.row, .row[actionable] { cursor: pointer; }
822
842
 
823
843
  .toast button {
824
844
  height: auto;
825
- margin-right: -8px;
845
+ margin-inline-end: -8px;
826
846
  padding: 8px;
827
847
  border: 0;
828
848
  background: none;
@@ -836,7 +856,8 @@ label.row, .row[actionable] { cursor: pointer; }
836
856
 
837
857
  .drawer {
838
858
  position: fixed;
839
- inset: 0 auto 0 0;
859
+ inset-block: 0;
860
+ inset-inline: 0 auto;
840
861
  width: 256px;
841
862
  max-width: calc(100vw - 56px);
842
863
  height: 100%;
@@ -851,9 +872,11 @@ label.row, .row[actionable] { cursor: pointer; }
851
872
 
852
873
  .drawer[open] { animation: cr-drawer-in 200ms ease; }
853
874
  .drawer::backdrop { background: rgb(0 0 0 / 0.5); }
854
- .drawer-head { display: flex; align-items: center; gap: 12px; height: var(--toolbar-height); padding-left: 24px; font-size: 22px; font-weight: 500; letter-spacing: 0.25px; }
875
+ .drawer-head { display: flex; align-items: center; gap: 12px; height: var(--toolbar-height); padding-inline-start: 24px; font-size: 22px; font-weight: 500; letter-spacing: 0.25px; }
855
876
 
856
877
  @keyframes cr-drawer-in { from { transform: translateX(-100%); } }
878
+ @keyframes cr-drawer-in-rtl { from { transform: translateX(100%); } }
879
+ .drawer[open]:dir(rtl) { animation-name: cr-drawer-in-rtl; }
857
880
 
858
881
  @media (prefers-reduced-motion: reduce) {
859
882
  .drawer[open] { animation: none; }
@@ -942,7 +965,8 @@ label.row, .row[actionable] { cursor: pointer; }
942
965
 
943
966
  /* .sp-card: rows on a 12px-rounded card, 8px from the panel's edge. */
944
967
  .sp-card {
945
- margin: 0 0 0 8px;
968
+ margin: 0;
969
+ margin-inline-start: 8px;
946
970
  padding: 8px 0;
947
971
  border-radius: 12px;
948
972
  background: var(--sp-card);
@@ -1027,7 +1051,7 @@ label.row, .row[actionable] { cursor: pointer; }
1027
1051
  place-items: center;
1028
1052
  width: 40px;
1029
1053
  height: 40px;
1030
- margin-right: 16px;
1054
+ margin-inline-end: 16px;
1031
1055
  border-radius: 8px;
1032
1056
  background: var(--sp-icon-bg);
1033
1057
  color: var(--text-secondary);
@@ -1057,12 +1081,20 @@ label.row, .row[actionable] { cursor: pointer; }
1057
1081
  display: none;
1058
1082
  align-items: center;
1059
1083
  gap: 4px;
1060
- margin: 0 -4px 0 8px;
1084
+ margin-block: 0;
1085
+ margin-inline: 8px -4px;
1061
1086
  }
1062
1087
 
1063
1088
  .sp-item:is(:hover, :focus-within) .sp-suffix,
1064
1089
  .sp-suffix.always { display: flex; }
1065
1090
 
1091
+ /* .sp-notice (ui/notice.js): a card saying what a list leaves out or can't do; a warning's icon in
1092
+ the error colour, an information's in the secondary text colour. */
1093
+ .sp-notice .sp-heading > .icon { flex: none; width: 18px; height: 18px; color: var(--error); }
1094
+ .sp-notice.info .sp-heading > .icon { color: var(--text-secondary); }
1095
+ .sp-notice-text { margin: 0; padding: 2px 16px 8px; color: var(--text-secondary); font-size: 12px; line-height: 16px; }
1096
+ .sp-notice-actions { display: flex; justify-content: flex-end; padding: 0 16px 8px; }
1097
+
1066
1098
  /* sp-empty-state: a centred heading and a line of help. */
1067
1099
  .sp-empty {
1068
1100
  display: flex;
@@ -1089,7 +1121,8 @@ button.floating {
1089
1121
  align-items: center;
1090
1122
  gap: 8px;
1091
1123
  height: 40px;
1092
- padding: 8px 16px 8px 12px;
1124
+ padding-block: 8px;
1125
+ padding-inline: 12px 16px;
1093
1126
  border: 0;
1094
1127
  border-radius: 8px;
1095
1128
  background: var(--tonal-bg);
@@ -1111,6 +1144,6 @@ button.floating .icon { width: 20px; height: 20px; }
1111
1144
  /* ------------------------------------------------------------ Narrow */
1112
1145
 
1113
1146
  @media (max-width: 720px) {
1114
- .toolbar { grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px; padding-right: 8px; }
1115
- .toolbar-start { padding-left: 16px; }
1147
+ .toolbar { grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px; padding-inline-end: 8px; }
1148
+ .toolbar-start { padding-inline-start: 16px; }
1116
1149
  }
@@ -125,6 +125,33 @@ export declare function extensionProfile(chromium: BrowserType, dir: string, { p
125
125
  * await page.goto(url('options/options.html'));
126
126
  */
127
127
  export declare function launchExtension(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension>;
128
+ /**
129
+ * Stop the extension's service worker, as Chrome does after 30 seconds idle: its globals,
130
+ * timers and anything else kept in memory are gone; storage, alarms and registered listeners
131
+ * stay. Chrome never does this by itself in an e2e run (Playwright's debugger keeps the worker
132
+ * alive), so a check that state survives a restart has to ask for it. Done through the
133
+ * DevTools protocol from a page of the extension (`ServiceWorker.stopWorker`), and, unless
134
+ * `start` is false, the worker is started again with no event (`ServiceWorker.startWorker`).
135
+ *
136
+ * `launched.worker` stays the handle to use: Playwright keeps it across the restart. Anything a
137
+ * test put in the old worker (`recordIconCalls`, a patched `chrome.*` function) is gone with it.
138
+ *
139
+ * @param {Pick<LaunchedExtension, 'context' | 'worker' | 'extensionId' | 'url'>} launched - From {@link launchExtension}.
140
+ * @param {{start?: boolean, page?: string}} [options] - `start: false` leaves the worker stopped, for
141
+ * the next event (a message, an alarm, a tab) to start it as on a person's computer; `page`: a file
142
+ * of the extension to open for the protocol session (default `manifest.json`).
143
+ * @returns {Promise<void>} Once the worker has stopped (and, with `start`, is running again).
144
+ * @throws {Error} When the worker isn't running, or doesn't stop or start in 10 s.
145
+ *
146
+ * @example
147
+ * await worker.evaluate(() => { globalThis.cache = { tab: 7 }; });
148
+ * await stopWorker(launched);
149
+ * await worker.evaluate(() => globalThis.cache); // undefined: what wasn't stored is gone
150
+ */
151
+ export declare function stopWorker({ context, worker, extensionId, url }: Pick<LaunchedExtension, 'context' | 'worker' | 'extensionId' | 'url'>, { start, page: file }?: {
152
+ start?: boolean;
153
+ page?: string;
154
+ }): Promise<void>;
128
155
  /**
129
156
  * Emulate colour schemes in the offscreen document: one CDP session attached
130
157
  * to it, reused for every change.
@@ -137,10 +164,12 @@ export declare function launchExtension(chromium: BrowserType, dir: string, opti
137
164
  export declare function offscreenEmulator(cdp: CDPSession, suffix?: string): Promise<(scheme: 'light' | 'dark') => Promise<unknown>>;
138
165
  /**
139
166
  * Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
140
- * Chrome has taken it (a path Chrome can't load fails, and isn't recorded).
167
+ * Chrome has taken it (a path Chrome can't load fails, and isn't recorded):
168
+ * its `path`, or `'imageData'` for an icon given as pixels. The record lives
169
+ * in the worker: it's gone when the worker restarts ({@link stopWorker}).
141
170
  *
142
171
  * @param {Worker} worker - The extension's service worker.
143
- * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
172
+ * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded icons, oldest first.
144
173
  */
145
174
  export declare function recordIconCalls(worker: Worker): Promise<() => Promise<unknown[]>>;
146
175
  /** How long one check may take before it's failed, in ms: long enough for a slow real-browser check, short of a hang. */
@@ -159,7 +188,8 @@ export type CheckRunner = {
159
188
  */
160
189
  fail: (name: string) => void;
161
190
  /**
162
- * Print the tally, and exit with 1 if anything failed.
191
+ * Print the tally, and exit with 1 if anything failed or no check ran at all
192
+ * (a script that returned early, or whose filter matched nothing, hasn't passed).
163
193
  */
164
194
  finish: () => void;
165
195
  };
@@ -173,7 +203,8 @@ export type CheckRunner = {
173
203
  * whether it passed.
174
204
  * @property {(name: string) => void} fail Count a failure found outside any check (a page's
175
205
  * uncaught error, say): printed now, listed and counted at the end.
176
- * @property {() => void} finish Print the tally, and exit with 1 if anything failed.
206
+ * @property {() => void} finish Print the tally, and exit with 1 if anything failed or no check ran at all
207
+ * (a script that returned early, or whose filter matched nothing, hasn't passed).
177
208
  */
178
209
  /**
179
210
  * Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's