@arraypress/extension-kit 1.7.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
@@ -13,6 +13,8 @@ The pieces ArrayPress's Chrome extensions share, so each is written once:
13
13
  - **Chrome's page components**: the ⋮ action menu and its row button, the text dialog
14
14
  ("Rename folder", or several fields as "Add site search"), the toast with Undo and a settings card's site list, built on the
15
15
  stylesheet's classes; they work in a content script's shadow root too.
16
+ - **Chrome's window around a capture**: the tab strip and toolbar, light or dark, as HTML for store
17
+ screenshots and as an SVG image an extension page draws on a canvas.
16
18
  - **Sites**: what people type into a per-site list, and whether a page is on one.
17
19
  - **Chrome's on-device model** (Gemini Nano, the Prompt API): availability, a download from the
18
20
  person's click with progress, one base session with a `clone()` per question, a time limit on
@@ -108,6 +110,8 @@ chrome.storage.onChanged.addListener((changes, area) => {
108
110
  const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
109
111
  saver.save({ menuStyle: 'full' });
110
112
  addEventListener('pagehide', () => saver.flush());
113
+ // Changed in another tab or on another computer: shown, with this page's unwritten changes over it.
114
+ saver.follow((settings) => show(settings), chrome.storage.onChanged);
111
115
  ```
112
116
 
113
117
  | Store member | What it does |
@@ -183,6 +187,38 @@ takes it as a placeholder whose content is `$1`, and every lookup without substi
183
187
  <h2 class="section-title" data-i18n="optionsAbout"></h2> <!-- About Upright -->
184
188
  ```
185
189
 
190
+ ## Chrome's window around a capture
191
+
192
+ ```js
193
+ import { WINDOW_BARS, letterIcon, windowChromeSvg } from '../vendor/extension-kit/index.js';
194
+
195
+ const svg = windowChromeSvg({ tabs: [{ title: 'Example', icon: letterIcon('E', '#1a73e8'), active: true }], url: 'example.com/a', theme: 'dark', w: 1280 });
196
+ const bars = new Image();
197
+ bars.src = `data:image/svg+xml,${encodeURIComponent(svg)}`;
198
+ await bars.decode();
199
+ context.drawImage(bars, 0, 0, 1280 * scale, (WINDOW_BARS.strip + WINDOW_BARS.toolbar) * scale); // the capture goes below
200
+ ```
201
+
202
+ The same macOS window the store screenshots use (`storeFrame.windowChrome`, now `frame/window.js`
203
+ in the runtime half): `windowChrome(options)` is its HTML, `windowChromeSvg(options)` that HTML in
204
+ an SVG image `w` wide and `WINDOW_BARS.strip + WINDOW_BARS.toolbar` (88px) high, drawn sharp at any
205
+ scale. Chrome draws an SVG image with HTML inside (`foreignObject`) without tainting the canvas
206
+ (checked in Chromium, `tests/frame-chromium.test.js`). Images in it must be `data:` URLs
207
+ (`letterIcon` makes one). `THEMES` holds the light and dark colours.
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
+
186
222
  ## Sites
187
223
 
188
224
  ```js
@@ -198,6 +234,28 @@ withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com'], what a list
198
234
  A site is http or https with a dot in its host (or `localhost`); text without a scheme is https;
199
235
  `localhost:8080` is a host and port. Pure, so a content script can load `sites/sites.js` alone.
200
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
+
201
259
  ## A content script on every site
202
260
 
203
261
  ```js
@@ -286,7 +344,8 @@ const stop = watchChanges(document.body, {
286
344
 
287
345
  `inSlices(root, work, {blockChars, sliceMs, pause})` cuts a page into blocks (`blocksOf`) and
288
346
  hands the main thread back whenever a slice has run its time, so a 2,000-item listing never
289
- 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
290
349
  changing (at most `maxWaitMs` while it keeps on); an element with a few children (`regroupChildren`,
291
350
  default 6) whose children changed is handed over whole. Both are pure DOM, so a content script
292
351
  imports them directly (list them in `web_accessible_resources`).
@@ -456,7 +515,7 @@ A list with an Add button needs every key but `optionsRemoveLabel`; one with an
456
515
  | `renderIcons`, `renderToolbarIcons({chromium, dir})` | See above. |
457
516
  | `buildTestCopy({from, to, hook, manifest})` | A test build: the extension, `hook` as its service worker (`e2e-hook.js`), the manifest changed. |
458
517
  | `testBuildDir(root, name)` | `.test-builds/<name>`, or under `$EXTENSION_TEST_BUILDS` (to test another build, or run two side by side). |
459
- | `storeFrame` | Store screenshots: `canvas`, `windowChrome`, `geometry`, `letterIcon`, `cursor`, `THEMES`, `W`×`H`. |
518
+ | `storeFrame` | Store screenshots: `canvas`, `windowChrome` (and `windowChromeSvg`, from `frame/window.js`), `geometry`, `letterIcon`, `cursor`, `THEMES`, `W`×`H`. |
460
519
  | `placeholderWarning`, `repositoryReminder`, `printReleaseChecks` | See About links. |
461
520
  | `findDisallowedClones({runJscpd})` | Run jscpd and return clones `.jscpd-allowlist.json` doesn't cover (the `extension-kit duplication` command). |
462
521
 
@@ -480,6 +539,8 @@ except those listed, each with a reason:
480
539
  | `extensionProfile(chromium, dir, options)` | A profile to start the browser on more than once (a restart, a first start with a changed manifest): `{dir, launch(), remove()}`. |
481
540
  | `runChecks(runner, body, cleanUps)` | A script's checks: an error outside any check fails, the clean-ups always run (each within 30 s), then the tally. |
482
541
  | `messageLookup(extensionDir, locale)`, `readMessages(extensionDir, locale)` | `chrome.i18n.getMessage` over the real `_locales` (a locale over en, named placeholders filled), for unit tests. |
542
+ | `heartbeat(page)` | A timer beating in a page: `stop()` gives the longest the main thread was held (ms), for performance checks. |
543
+ | `playPermissionPrompt(answers)` | Chrome's permission prompt played in a page (`page.evaluate` or `addInitScript`): each request recorded on `window.asked` with whether a click was behind it, answered from `answers`. |
483
544
  | `checkRunner({timeout})` | Named checks that report and go on: `check(name, fn, {timeout})`, `fail(name)` for an error found outside a check, `finish()`. A check that runs past its limit (`CHECK_MS`, 60 s) fails, and the run goes on. |
484
545
  | `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)` (also in the main entry). |
485
546
  | `realisticBrowser(options)`, `chromeUserAgent(browser)` | Launch options and a user agent for a browser that doesn't say it's automated, for live checks. |
@@ -543,7 +604,7 @@ chrome://bookmarks in Chromium 156 / Chrome 157; the sources are listed at the t
543
604
  ## Development
544
605
 
545
606
  `npm test` runs ESLint, a strict TypeScript check of the JSDoc (`checkJs`), the tests (the menu,
546
- the dialog, the toast, the dialog site list, icon rendering and the Chrome readers in Playwright's
607
+ the dialog, the toast, the dialog site list, the window frame, icon rendering and the Chrome readers in Playwright's
547
608
  Chromium, the rest in Node and jsdom), and the
548
609
  copied-code guard. The `.d.ts` files are generated from the JSDoc: `npm run types` writes them, and
549
610
  the type check fails while one is stale (it regenerates them into `.types/` and compares).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/extension-kit",
3
- "version": "1.7.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",
@@ -10,19 +10,27 @@
10
10
  * Playwright at `geometry(zoom).content`, then screenshot `canvas({caption, chrome:
11
11
  * windowChrome({tabs, url}), content})` at {@link W}×{@link H}.
12
12
  *
13
+ * The window itself is `frame/window.js`, in the runtime half, so an extension page can draw it
14
+ * around a capture too; it's re-exported here.
15
+ *
13
16
  * @module build/store-frame
14
17
  */
18
+ export { THEMES, letterIcon, windowChrome, windowChromeSvg } from '../frame/window.js';
19
+ export type Theme = import('../frame/window.js').Theme;
20
+ export type Tab = import('../frame/window.js').Tab;
21
+ /** @typedef {import('../frame/window.js').Theme} Theme */
22
+ /** @typedef {import('../frame/window.js').Tab} Tab */
15
23
  /** The store screenshot's width. */
16
24
  export declare const W = 1280;
17
25
  /** The store screenshot's height. */
18
26
  export declare const H = 800;
19
27
  /** Where the window sits: its left edge, top edge, full width, and tab strip and toolbar heights. */
20
28
  export declare const WIN: {
29
+ strip: 42;
30
+ toolbar: 46;
21
31
  x: number;
22
32
  y: number;
23
33
  w: number;
24
- strip: number;
25
- toolbar: number;
26
34
  };
27
35
  export type Geometry = {
28
36
  /**
@@ -57,95 +65,6 @@ export type Geometry = {
57
65
  * @returns {Geometry} The window's unscaled width and the size to capture page content at.
58
66
  */
59
67
  export declare function geometry(zoom?: number): Geometry;
60
- /**
61
- * A letter favicon for made-up sites (no real brands).
62
- *
63
- * @param {string} letter - The letter.
64
- * @param {string} bg - Background colour.
65
- * @param {string} [fg] - Letter colour. Default white.
66
- * @returns {string} A data: URL of the SVG.
67
- */
68
- export declare const letterIcon: (letter: string, bg: string, fg?: string) => string;
69
- /** Chrome's light and dark window colours, and the canvas behind. */
70
- export declare const THEMES: {
71
- light: {
72
- bg: string;
73
- caption: string;
74
- sub: string;
75
- frame: string;
76
- tabText: string;
77
- tabTextInactive: string;
78
- tabSep: string;
79
- activeTab: string;
80
- toolbar: string;
81
- toolbarIcon: string;
82
- omnibox: string;
83
- url: string;
84
- urlDim: string;
85
- contentLine: string;
86
- shadow: string;
87
- };
88
- dark: {
89
- bg: string;
90
- caption: string;
91
- sub: string;
92
- frame: string;
93
- tabText: string;
94
- tabTextInactive: string;
95
- tabSep: string;
96
- activeTab: string;
97
- toolbar: string;
98
- toolbarIcon: string;
99
- omnibox: string;
100
- url: string;
101
- urlDim: string;
102
- contentLine: string;
103
- shadow: string;
104
- };
105
- };
106
- export type Theme = keyof typeof THEMES;
107
- export type Tab = {
108
- /**
109
- * Its title.
110
- */
111
- title: string;
112
- /**
113
- * Its favicon's URL.
114
- */
115
- icon: string;
116
- /**
117
- * The selected tab.
118
- */
119
- active?: boolean;
120
- /**
121
- * Playing sound.
122
- */
123
- audible?: boolean;
124
- /**
125
- * Muted.
126
- */
127
- muted?: boolean;
128
- };
129
- /**
130
- * The window's tab strip and toolbar, as HTML. Positions are window-relative.
131
- *
132
- * @param {object} options - What to draw.
133
- * @param {readonly Tab[]} options.tabs - The tabs, left to right.
134
- * @param {string} options.url - The address bar's text.
135
- * @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
136
- * @param {Theme} [options.theme] - Default light.
137
- * @param {number} [options.w] - The window's width. Default the full window.
138
- * @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
139
- * @returns {string} The HTML, with its own styles.
140
- */
141
- export declare function windowChrome({ tabs, url, urlHost, theme, w, ext }: {
142
- tabs: readonly Tab[];
143
- url: string;
144
- urlHost?: string;
145
- theme?: Theme;
146
- w?: number;
147
- ext?: readonly string[];
148
- }): string;
149
68
  /**
150
69
  * The macOS arrow pointer.
151
70
  *
@@ -10,16 +10,26 @@
10
10
  * Playwright at `geometry(zoom).content`, then screenshot `canvas({caption, chrome:
11
11
  * windowChrome({tabs, url}), content})` at {@link W}×{@link H}.
12
12
  *
13
+ * The window itself is `frame/window.js`, in the runtime half, so an extension page can draw it
14
+ * around a capture too; it's re-exported here.
15
+ *
13
16
  * @module build/store-frame
14
17
  */
15
18
 
19
+ import { THEMES, WINDOW_BARS, WINDOW_W } from '../frame/window.js';
20
+
21
+ export { THEMES, letterIcon, windowChrome, windowChromeSvg } from '../frame/window.js';
22
+
23
+ /** @typedef {import('../frame/window.js').Theme} Theme */
24
+ /** @typedef {import('../frame/window.js').Tab} Tab */
25
+
16
26
  /** The store screenshot's width. */
17
27
  export const W = 1280;
18
28
  /** The store screenshot's height. */
19
29
  export const H = 800;
20
30
  const CAPTION_H = 112;
21
31
  /** Where the window sits: its left edge, top edge, full width, and tab strip and toolbar heights. */
22
- export const WIN = { x: 64, y: CAPTION_H, w: 1152, strip: 42, toolbar: 46 };
32
+ export const WIN = { x: 64, y: CAPTION_H, w: WINDOW_W, ...WINDOW_BARS };
23
33
 
24
34
  /**
25
35
  * How a shot's window is laid out.
@@ -42,164 +52,6 @@ export function geometry(zoom = 1) {
42
52
  return { zoom, w, content: { width: w, height: Math.round((H - WIN.y) / zoom) - WIN.strip - WIN.toolbar } };
43
53
  }
44
54
 
45
- /**
46
- * @param {unknown} s - Text.
47
- * @returns {string} It, escaped for HTML text and attributes.
48
- */
49
- const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
50
-
51
- /** Material icon paths (24×24). */
52
- const P = {
53
- back: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
54
- forward: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z',
55
- reload: 'M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z',
56
- close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
57
- add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
58
- more: 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
59
- puzzle: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z',
60
- star: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z',
61
- tune: 'M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z',
62
- sound: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z',
63
- muted: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z',
64
- person: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
65
- };
66
-
67
- /**
68
- * A Material icon as inline SVG, in the text colour.
69
- *
70
- * @param {keyof typeof P} name - Which icon.
71
- * @param {number} [size] - Width and height in px. Default 20.
72
- * @returns {string} The SVG markup.
73
- */
74
- const icon = (name, size = 20) =>
75
- `<svg viewBox="0 0 24 24" width="${size}" height="${size}"><path fill="currentColor" d="${P[name]}"/></svg>`;
76
-
77
- /**
78
- * A letter favicon for made-up sites (no real brands).
79
- *
80
- * @param {string} letter - The letter.
81
- * @param {string} bg - Background colour.
82
- * @param {string} [fg] - Letter colour. Default white.
83
- * @returns {string} A data: URL of the SVG.
84
- */
85
- export const letterIcon = (letter, bg, fg = '#fff') =>
86
- `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>`)}`;
87
-
88
- /** Chrome's light and dark window colours, and the canvas behind. */
89
- export const THEMES = {
90
- light: {
91
- bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
92
- frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
93
- toolbar: '#ffffff', toolbarIcon: '#474747', omnibox: '#e9eef6', url: '#1f1f1f', urlDim: '#5e5e5e',
94
- contentLine: '#d3d7dc', shadow: '0 24px 60px rgba(31,45,70,.20), 0 0 0 .5px rgba(0,0,0,.18)',
95
- },
96
- dark: {
97
- bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
98
- frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
99
- toolbar: '#3c3c3c', toolbarIcon: '#c7c7c7', omnibox: '#282828', url: '#e3e3e3', urlDim: '#a8a8a8',
100
- contentLine: '#2a2a2a', shadow: '0 24px 60px rgba(0,0,0,.55), 0 0 0 .5px rgba(255,255,255,.14)',
101
- },
102
- };
103
-
104
- /** @typedef {keyof typeof THEMES} Theme */
105
-
106
- /**
107
- * One tab in the drawn window.
108
- *
109
- * @typedef {object} Tab
110
- * @property {string} title Its title.
111
- * @property {string} icon Its favicon's URL.
112
- * @property {boolean} [active] The selected tab.
113
- * @property {boolean} [audible] Playing sound.
114
- * @property {boolean} [muted] Muted.
115
- */
116
-
117
- const TAB_H = 34;
118
-
119
- /**
120
- * Lay out tabs like Chrome: equal widths up to 240px, shrinking to fit.
121
- *
122
- * @param {readonly Tab[]} tabs - The tabs.
123
- * @param {number} w - The window's width.
124
- * @returns {{ x: number, width: number }[]} Each tab's window-relative x and width.
125
- */
126
- function tabLayout(tabs, w) {
127
- const left = 84;
128
- const right = 60;
129
- const max = 240;
130
- const avail = w - left - right - 36;
131
- const width = Math.min(max, Math.floor(avail / tabs.length));
132
- return tabs.map((_, i) => ({ x: left + i * width, width }));
133
- }
134
-
135
- /**
136
- * The window's tab strip and toolbar, as HTML. Positions are window-relative.
137
- *
138
- * @param {object} options - What to draw.
139
- * @param {readonly Tab[]} options.tabs - The tabs, left to right.
140
- * @param {string} options.url - The address bar's text.
141
- * @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
142
- * @param {Theme} [options.theme] - Default light.
143
- * @param {number} [options.w] - The window's width. Default the full window.
144
- * @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
145
- * @returns {string} The HTML, with its own styles.
146
- */
147
- export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WIN.w, ext = [] }) {
148
- const t = THEMES[theme];
149
- const layout = tabLayout(tabs, w);
150
- const tabHtml = tabs.map((tab, i) => {
151
- const { x, width } = layout[i];
152
- const sepAfter = i < tabs.length - 1 && !tab.active && !tabs[i + 1].active;
153
- const alert = tab.muted ? icon('muted', 16) : tab.audible ? icon('sound', 16) : '';
154
- const showClose = tab.active || width >= 120;
155
- return `<div class="tab${tab.active ? ' active' : ''}" style="left:${x}px;width:${width}px">
156
- <img class="fav" src="${tab.icon}" width="16" height="16" alt="">
157
- <span class="title">${esc(tab.title)}</span>
158
- ${alert ? `<span class="alert">${alert}</span>` : ''}
159
- ${showClose ? `<span class="x">${icon('close', 14)}</span>` : ''}
160
- </div>${sepAfter ? `<span class="sep" style="left:${x + width - 1}px"></span>` : ''}`;
161
- }).join('');
162
- const last = layout[layout.length - 1];
163
- const plusX = last.x + last.width + 4;
164
- const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px">`).join('');
165
- return `
166
- <div class="strip">
167
- <span class="lights"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i></span>
168
- ${tabHtml}
169
- <span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
170
- </div>
171
- <div class="toolbar">
172
- <span class="tb">${icon('back')}</span><span class="tb dim">${icon('forward')}</span><span class="tb">${icon('reload')}</span>
173
- <div class="omni"><span class="site">${icon('tune', 16)}</span><span class="url">${urlHost ? `${esc(urlHost)}<span class="dim">${esc(url.slice(urlHost.length))}</span>` : esc(url)}</span><span class="star">${icon('star', 18)}</span></div>
174
- ${extIcons}<span class="tb">${icon('puzzle')}</span>
175
- <span class="avatar">${icon('person', 18)}</span><span class="tb">${icon('more')}</span>
176
- </div>
177
- <style>
178
- .strip { position: relative; height: ${WIN.strip}px; background: ${t.frame}; }
179
- .lights { position: absolute; left: 16px; top: 15px; display: flex; gap: 8px; }
180
- .lights i { width: 12px; height: 12px; border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }
181
- .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; }
182
- .tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
183
- .tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
184
- .tab.active::before { left: -10px; background: radial-gradient(circle at 0 0, transparent 10px, ${t.activeTab} 10.5px); }
185
- .tab.active::after { right: -10px; background: radial-gradient(circle at 100% 0, transparent 10px, ${t.activeTab} 10.5px); }
186
- .tab .fav { flex: none; }
187
- .tab .title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
188
- .tab .alert, .tab .x { flex: none; display: flex; color: ${t.toolbarIcon}; }
189
- .sep { position: absolute; bottom: 9px; width: 1px; height: 18px; background: ${t.tabSep}; }
190
- .plus { position: absolute; top: 11px; display: flex; color: ${t.toolbarIcon}; padding: 2px; }
191
- .toolbar { height: ${WIN.toolbar}px; background: ${t.toolbar}; display: flex; align-items: center; gap: 4px; padding: 0 8px; box-sizing: border-box; border-bottom: 1px solid ${t.contentLine}; }
192
- .tb { width: 34px; height: 34px; display: grid; place-items: center; color: ${t.toolbarIcon}; flex: none; }
193
- .tb.dim { opacity: .45; }
194
- .omni { flex: 1; min-width: 0; height: 34px; margin: 0 8px; border-radius: 17px; background: ${t.omnibox}; display: flex; align-items: center; gap: 10px; padding: 0 12px; color: ${t.url}; font: 14px system-ui; }
195
- .omni .site { display: flex; color: ${t.toolbarIcon}; }
196
- .omni .url { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
197
- .omni .dim { color: ${t.urlDim}; }
198
- .omni .star { display: flex; color: ${t.toolbarIcon}; }
199
- .avatar { flex: none; width: 26px; height: 26px; margin: 0 4px; border-radius: 50%; background: #7cacf8; color: #fff; display: grid; place-items: end center; overflow: hidden; }
200
- </style>`;
201
- }
202
-
203
55
  /**
204
56
  * The macOS arrow pointer.
205
57
  *
@@ -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', '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
  }