@arraypress/extension-kit 1.7.0 → 1.8.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 +27 -2
- package/package.json +1 -1
- package/src/build/store-frame.d.ts +10 -91
- package/src/build/store-frame.js +11 -159
- package/src/build/vendor.js +1 -1
- package/src/frame/window.d.ts +120 -0
- package/src/frame/window.js +197 -0
- package/src/index.d.ts +6 -1
- package/src/index.js +4 -1
- package/src/settings/saver.d.ts +13 -0
- package/src/settings/saver.js +11 -0
- package/src/testing/heartbeat.d.ts +23 -0
- package/src/testing/heartbeat.js +43 -0
- package/src/testing/index.d.ts +2 -0
- package/src/testing/index.js +2 -0
- package/src/testing/prompt.d.ts +20 -0
- package/src/testing/prompt.js +29 -0
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,25 @@ 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
|
+
|
|
186
209
|
## Sites
|
|
187
210
|
|
|
188
211
|
```js
|
|
@@ -456,7 +479,7 @@ A list with an Add button needs every key but `optionsRemoveLabel`; one with an
|
|
|
456
479
|
| `renderIcons`, `renderToolbarIcons({chromium, dir})` | See above. |
|
|
457
480
|
| `buildTestCopy({from, to, hook, manifest})` | A test build: the extension, `hook` as its service worker (`e2e-hook.js`), the manifest changed. |
|
|
458
481
|
| `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`. |
|
|
482
|
+
| `storeFrame` | Store screenshots: `canvas`, `windowChrome` (and `windowChromeSvg`, from `frame/window.js`), `geometry`, `letterIcon`, `cursor`, `THEMES`, `W`×`H`. |
|
|
460
483
|
| `placeholderWarning`, `repositoryReminder`, `printReleaseChecks` | See About links. |
|
|
461
484
|
| `findDisallowedClones({runJscpd})` | Run jscpd and return clones `.jscpd-allowlist.json` doesn't cover (the `extension-kit duplication` command). |
|
|
462
485
|
|
|
@@ -480,6 +503,8 @@ except those listed, each with a reason:
|
|
|
480
503
|
| `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
504
|
| `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
505
|
| `messageLookup(extensionDir, locale)`, `readMessages(extensionDir, locale)` | `chrome.i18n.getMessage` over the real `_locales` (a locale over en, named placeholders filled), for unit tests. |
|
|
506
|
+
| `heartbeat(page)` | A timer beating in a page: `stop()` gives the longest the main thread was held (ms), for performance checks. |
|
|
507
|
+
| `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
508
|
| `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
509
|
| `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)` (also in the main entry). |
|
|
485
510
|
| `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 +568,7 @@ chrome://bookmarks in Chromium 156 / Chrome 157; the sources are listed at the t
|
|
|
543
568
|
## Development
|
|
544
569
|
|
|
545
570
|
`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
|
|
571
|
+
the dialog, the toast, the dialog site list, the window frame, icon rendering and the Chrome readers in Playwright's
|
|
547
572
|
Chromium, the rest in Node and jsdom), and the
|
|
548
573
|
copied-code guard. The `.d.ts` files are generated from the JSDoc: `npm run types` writes them, and
|
|
549
574
|
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.
|
|
3
|
+
"version": "1.8.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
|
*
|
package/src/build/store-frame.js
CHANGED
|
@@ -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:
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
*
|
package/src/build/vendor.js
CHANGED
|
@@ -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', '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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's window around a page: the macOS tab strip and toolbar (light or dark, the default
|
|
3
|
+
* theme), drawn as HTML. Shared by the store screenshots (`build/store-frame`, which lays it on a
|
|
4
|
+
* 1280×800 canvas in Playwright) and by extension pages, which draw it on a canvas around a
|
|
5
|
+
* capture through {@link windowChromeSvg} (an SVG image with the HTML inside, which Chrome draws
|
|
6
|
+
* without tainting the canvas). The markup is XML-clean so both can use it.
|
|
7
|
+
*
|
|
8
|
+
* Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
|
|
9
|
+
* toolbar with a pill omnibox).
|
|
10
|
+
*
|
|
11
|
+
* @module frame/window
|
|
12
|
+
*/
|
|
13
|
+
/** The tab strip's and the toolbar's heights (px): the content starts below both. */
|
|
14
|
+
export declare const WINDOW_BARS: Readonly<{
|
|
15
|
+
strip: 42;
|
|
16
|
+
toolbar: 46;
|
|
17
|
+
}>;
|
|
18
|
+
/** The window's default width (px): the store screenshot's. */
|
|
19
|
+
export declare const WINDOW_W = 1152;
|
|
20
|
+
/**
|
|
21
|
+
* A letter favicon for made-up sites (no real brands).
|
|
22
|
+
*
|
|
23
|
+
* @param {string} letter - The letter.
|
|
24
|
+
* @param {string} bg - Background colour.
|
|
25
|
+
* @param {string} [fg] - Letter colour. Default white.
|
|
26
|
+
* @returns {string} A data: URL of the SVG.
|
|
27
|
+
*/
|
|
28
|
+
export declare const letterIcon: (letter: string, bg: string, fg?: string) => string;
|
|
29
|
+
/** Chrome's light and dark window colours, and the canvas behind. */
|
|
30
|
+
export declare const THEMES: {
|
|
31
|
+
light: {
|
|
32
|
+
bg: string;
|
|
33
|
+
caption: string;
|
|
34
|
+
sub: string;
|
|
35
|
+
frame: string;
|
|
36
|
+
tabText: string;
|
|
37
|
+
tabTextInactive: string;
|
|
38
|
+
tabSep: string;
|
|
39
|
+
activeTab: string;
|
|
40
|
+
toolbar: string;
|
|
41
|
+
toolbarIcon: string;
|
|
42
|
+
omnibox: string;
|
|
43
|
+
url: string;
|
|
44
|
+
urlDim: string;
|
|
45
|
+
contentLine: string;
|
|
46
|
+
shadow: string;
|
|
47
|
+
};
|
|
48
|
+
dark: {
|
|
49
|
+
bg: string;
|
|
50
|
+
caption: string;
|
|
51
|
+
sub: string;
|
|
52
|
+
frame: string;
|
|
53
|
+
tabText: string;
|
|
54
|
+
tabTextInactive: string;
|
|
55
|
+
tabSep: string;
|
|
56
|
+
activeTab: string;
|
|
57
|
+
toolbar: string;
|
|
58
|
+
toolbarIcon: string;
|
|
59
|
+
omnibox: string;
|
|
60
|
+
url: string;
|
|
61
|
+
urlDim: string;
|
|
62
|
+
contentLine: string;
|
|
63
|
+
shadow: string;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
export type Theme = keyof typeof THEMES;
|
|
67
|
+
export type Tab = {
|
|
68
|
+
/**
|
|
69
|
+
* Its title.
|
|
70
|
+
*/
|
|
71
|
+
title: string;
|
|
72
|
+
/**
|
|
73
|
+
* Its favicon's URL.
|
|
74
|
+
*/
|
|
75
|
+
icon: string;
|
|
76
|
+
/**
|
|
77
|
+
* The selected tab.
|
|
78
|
+
*/
|
|
79
|
+
active?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Playing sound.
|
|
82
|
+
*/
|
|
83
|
+
audible?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Muted.
|
|
86
|
+
*/
|
|
87
|
+
muted?: boolean;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* The window's tab strip and toolbar, as HTML. Positions are window-relative.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} options - What to draw.
|
|
93
|
+
* @param {readonly Tab[]} options.tabs - The tabs, left to right.
|
|
94
|
+
* @param {string} options.url - The address bar's text.
|
|
95
|
+
* @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
|
|
96
|
+
* @param {Theme} [options.theme] - Default light.
|
|
97
|
+
* @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
|
|
98
|
+
* @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
|
|
99
|
+
* @returns {string} The HTML, with its own styles.
|
|
100
|
+
*/
|
|
101
|
+
export declare function windowChrome({ tabs, url, urlHost, theme, w, ext }: {
|
|
102
|
+
tabs: readonly Tab[];
|
|
103
|
+
url: string;
|
|
104
|
+
urlHost?: string;
|
|
105
|
+
theme?: Theme;
|
|
106
|
+
w?: number;
|
|
107
|
+
ext?: readonly string[];
|
|
108
|
+
}): string;
|
|
109
|
+
/**
|
|
110
|
+
* The window's tab strip and toolbar as a standalone SVG image, `w` wide and the bars' height
|
|
111
|
+
* high (scale it when drawing), for an extension page to draw on a canvas: `new Image()` with a
|
|
112
|
+
* `data:image/svg+xml` URL of it, then `drawImage`. Images in it (the tabs' icons, `ext`) must be
|
|
113
|
+
* `data:` URLs: an SVG image loads nothing else.
|
|
114
|
+
*
|
|
115
|
+
* @param {Parameters<typeof windowChrome>[0]} options - As {@link windowChrome}.
|
|
116
|
+
* @returns {string} The SVG markup.
|
|
117
|
+
* @example
|
|
118
|
+
* const svg = windowChromeSvg({ tabs: [{ title: 'Example', icon: letterIcon('E', '#1a73e8'), active: true }], url: 'example.com', w: 1280 });
|
|
119
|
+
*/
|
|
120
|
+
export declare function windowChromeSvg(options: Parameters<typeof windowChrome>[0]): string;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's window around a page: the macOS tab strip and toolbar (light or dark, the default
|
|
3
|
+
* theme), drawn as HTML. Shared by the store screenshots (`build/store-frame`, which lays it on a
|
|
4
|
+
* 1280×800 canvas in Playwright) and by extension pages, which draw it on a canvas around a
|
|
5
|
+
* capture through {@link windowChromeSvg} (an SVG image with the HTML inside, which Chrome draws
|
|
6
|
+
* without tainting the canvas). The markup is XML-clean so both can use it.
|
|
7
|
+
*
|
|
8
|
+
* Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
|
|
9
|
+
* toolbar with a pill omnibox).
|
|
10
|
+
*
|
|
11
|
+
* @module frame/window
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The tab strip's and the toolbar's heights (px): the content starts below both. */
|
|
15
|
+
export const WINDOW_BARS = Object.freeze({ strip: 42, toolbar: 46 });
|
|
16
|
+
|
|
17
|
+
/** The window's default width (px): the store screenshot's. */
|
|
18
|
+
export const WINDOW_W = 1152;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} s - Text.
|
|
22
|
+
* @returns {string} It, escaped for HTML text and attributes.
|
|
23
|
+
*/
|
|
24
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
25
|
+
|
|
26
|
+
/** Material icon paths (24×24). */
|
|
27
|
+
const P = {
|
|
28
|
+
back: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
|
|
29
|
+
forward: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z',
|
|
30
|
+
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',
|
|
31
|
+
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',
|
|
32
|
+
add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
|
|
33
|
+
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',
|
|
34
|
+
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',
|
|
35
|
+
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',
|
|
36
|
+
tune: 'M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z',
|
|
37
|
+
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',
|
|
38
|
+
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',
|
|
39
|
+
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',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A Material icon as inline SVG, in the text colour.
|
|
44
|
+
*
|
|
45
|
+
* @param {keyof typeof P} name - Which icon.
|
|
46
|
+
* @param {number} [size] - Width and height in px. Default 20.
|
|
47
|
+
* @returns {string} The SVG markup.
|
|
48
|
+
*/
|
|
49
|
+
const icon = (name, size = 20) =>
|
|
50
|
+
`<svg viewBox="0 0 24 24" width="${size}" height="${size}"><path fill="currentColor" d="${P[name]}"/></svg>`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A letter favicon for made-up sites (no real brands).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} letter - The letter.
|
|
56
|
+
* @param {string} bg - Background colour.
|
|
57
|
+
* @param {string} [fg] - Letter colour. Default white.
|
|
58
|
+
* @returns {string} A data: URL of the SVG.
|
|
59
|
+
*/
|
|
60
|
+
export const letterIcon = (letter, bg, fg = '#fff') =>
|
|
61
|
+
`data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="${bg}"/><text x="8" y="12" font-family="-apple-system,Helvetica,Arial" font-size="11" font-weight="700" text-anchor="middle" fill="${fg}">${letter}</text></svg>`)}`;
|
|
62
|
+
|
|
63
|
+
/** Chrome's light and dark window colours, and the canvas behind. */
|
|
64
|
+
export const THEMES = {
|
|
65
|
+
light: {
|
|
66
|
+
bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
|
|
67
|
+
frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
|
|
68
|
+
toolbar: '#ffffff', toolbarIcon: '#474747', omnibox: '#e9eef6', url: '#1f1f1f', urlDim: '#5e5e5e',
|
|
69
|
+
contentLine: '#d3d7dc', shadow: '0 24px 60px rgba(31,45,70,.20), 0 0 0 .5px rgba(0,0,0,.18)',
|
|
70
|
+
},
|
|
71
|
+
dark: {
|
|
72
|
+
bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
|
|
73
|
+
frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
|
|
74
|
+
toolbar: '#3c3c3c', toolbarIcon: '#c7c7c7', omnibox: '#282828', url: '#e3e3e3', urlDim: '#a8a8a8',
|
|
75
|
+
contentLine: '#2a2a2a', shadow: '0 24px 60px rgba(0,0,0,.55), 0 0 0 .5px rgba(255,255,255,.14)',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** @typedef {keyof typeof THEMES} Theme */
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One tab in the drawn window.
|
|
83
|
+
*
|
|
84
|
+
* @typedef {object} Tab
|
|
85
|
+
* @property {string} title Its title.
|
|
86
|
+
* @property {string} icon Its favicon's URL.
|
|
87
|
+
* @property {boolean} [active] The selected tab.
|
|
88
|
+
* @property {boolean} [audible] Playing sound.
|
|
89
|
+
* @property {boolean} [muted] Muted.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
const TAB_H = 34;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Lay out tabs like Chrome: equal widths up to 240px, shrinking to fit.
|
|
96
|
+
*
|
|
97
|
+
* @param {readonly Tab[]} tabs - The tabs.
|
|
98
|
+
* @param {number} w - The window's width.
|
|
99
|
+
* @returns {{ x: number, width: number }[]} Each tab's window-relative x and width.
|
|
100
|
+
*/
|
|
101
|
+
function tabLayout(tabs, w) {
|
|
102
|
+
const left = 84;
|
|
103
|
+
const right = 60;
|
|
104
|
+
const max = 240;
|
|
105
|
+
const avail = w - left - right - 36;
|
|
106
|
+
const width = Math.min(max, Math.floor(avail / tabs.length));
|
|
107
|
+
return tabs.map((_, i) => ({ x: left + i * width, width }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The window's tab strip and toolbar, as HTML. Positions are window-relative.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} options - What to draw.
|
|
114
|
+
* @param {readonly Tab[]} options.tabs - The tabs, left to right.
|
|
115
|
+
* @param {string} options.url - The address bar's text.
|
|
116
|
+
* @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
|
|
117
|
+
* @param {Theme} [options.theme] - Default light.
|
|
118
|
+
* @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
|
|
119
|
+
* @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
|
|
120
|
+
* @returns {string} The HTML, with its own styles.
|
|
121
|
+
*/
|
|
122
|
+
export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W, ext = [] }) {
|
|
123
|
+
const t = THEMES[theme];
|
|
124
|
+
const layout = tabLayout(tabs, w);
|
|
125
|
+
const tabHtml = tabs.map((tab, i) => {
|
|
126
|
+
const { x, width } = layout[i];
|
|
127
|
+
const sepAfter = i < tabs.length - 1 && !tab.active && !tabs[i + 1].active;
|
|
128
|
+
const alert = tab.muted ? icon('muted', 16) : tab.audible ? icon('sound', 16) : '';
|
|
129
|
+
const showClose = tab.active || width >= 120;
|
|
130
|
+
return `<div class="tab${tab.active ? ' active' : ''}" style="left:${x}px;width:${width}px">
|
|
131
|
+
<img class="fav" src="${tab.icon}" width="16" height="16" alt=""/>
|
|
132
|
+
<span class="title">${esc(tab.title)}</span>
|
|
133
|
+
${alert ? `<span class="alert">${alert}</span>` : ''}
|
|
134
|
+
${showClose ? `<span class="x">${icon('close', 14)}</span>` : ''}
|
|
135
|
+
</div>${sepAfter ? `<span class="sep" style="left:${x + width - 1}px"></span>` : ''}`;
|
|
136
|
+
}).join('');
|
|
137
|
+
const last = layout[layout.length - 1];
|
|
138
|
+
const plusX = last.x + last.width + 4;
|
|
139
|
+
const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px"/>`).join('');
|
|
140
|
+
return `
|
|
141
|
+
<div class="strip">
|
|
142
|
+
<span class="lights"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i></span>
|
|
143
|
+
${tabHtml}
|
|
144
|
+
<span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="toolbar">
|
|
147
|
+
<span class="tb">${icon('back')}</span><span class="tb dim">${icon('forward')}</span><span class="tb">${icon('reload')}</span>
|
|
148
|
+
<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>
|
|
149
|
+
${extIcons}<span class="tb">${icon('puzzle')}</span>
|
|
150
|
+
<span class="avatar">${icon('person', 18)}</span><span class="tb">${icon('more')}</span>
|
|
151
|
+
</div>
|
|
152
|
+
<style>
|
|
153
|
+
.strip { position: relative; height: ${WINDOW_BARS.strip}px; background: ${t.frame}; }
|
|
154
|
+
.lights { position: absolute; left: 16px; top: 15px; display: flex; gap: 8px; }
|
|
155
|
+
.lights i { width: 12px; height: 12px; border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }
|
|
156
|
+
.tab { position: absolute; bottom: 0; height: ${TAB_H}px; display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; box-sizing: border-box; color: ${t.tabTextInactive}; font: 12px/16px system-ui; }
|
|
157
|
+
.tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
|
|
158
|
+
.tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
|
|
159
|
+
.tab.active::before { left: -10px; background: radial-gradient(circle at 0 0, transparent 10px, ${t.activeTab} 10.5px); }
|
|
160
|
+
.tab.active::after { right: -10px; background: radial-gradient(circle at 100% 0, transparent 10px, ${t.activeTab} 10.5px); }
|
|
161
|
+
.tab .fav { flex: none; }
|
|
162
|
+
.tab .title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
|
|
163
|
+
.tab .alert, .tab .x { flex: none; display: flex; color: ${t.toolbarIcon}; }
|
|
164
|
+
.sep { position: absolute; bottom: 9px; width: 1px; height: 18px; background: ${t.tabSep}; }
|
|
165
|
+
.plus { position: absolute; top: 11px; display: flex; color: ${t.toolbarIcon}; padding: 2px; }
|
|
166
|
+
.toolbar { height: ${WINDOW_BARS.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}; }
|
|
167
|
+
.tb { width: 34px; height: 34px; display: grid; place-items: center; color: ${t.toolbarIcon}; flex: none; }
|
|
168
|
+
.tb.dim { opacity: .45; }
|
|
169
|
+
.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; }
|
|
170
|
+
.omni .site { display: flex; color: ${t.toolbarIcon}; }
|
|
171
|
+
.omni .url { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
172
|
+
.omni .dim { color: ${t.urlDim}; }
|
|
173
|
+
.omni .star { display: flex; color: ${t.toolbarIcon}; }
|
|
174
|
+
.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; }
|
|
175
|
+
</style>`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The window's tab strip and toolbar as a standalone SVG image, `w` wide and the bars' height
|
|
181
|
+
* high (scale it when drawing), for an extension page to draw on a canvas: `new Image()` with a
|
|
182
|
+
* `data:image/svg+xml` URL of it, then `drawImage`. Images in it (the tabs' icons, `ext`) must be
|
|
183
|
+
* `data:` URLs: an SVG image loads nothing else.
|
|
184
|
+
*
|
|
185
|
+
* @param {Parameters<typeof windowChrome>[0]} options - As {@link windowChrome}.
|
|
186
|
+
* @returns {string} The SVG markup.
|
|
187
|
+
* @example
|
|
188
|
+
* const svg = windowChromeSvg({ tabs: [{ title: 'Example', icon: letterIcon('E', '#1a73e8'), active: true }], url: 'example.com', w: 1280 });
|
|
189
|
+
*/
|
|
190
|
+
export function windowChromeSvg(options) {
|
|
191
|
+
const w = options.w ?? WINDOW_W;
|
|
192
|
+
const h = WINDOW_BARS.strip + WINDOW_BARS.toolbar;
|
|
193
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
|
|
194
|
+
+ `<foreignObject x="0" y="0" width="${w}" height="${h}">`
|
|
195
|
+
+ `<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;margin:0;-webkit-font-smoothing:antialiased">${windowChrome({ ...options, w })}</div>`
|
|
196
|
+
+ '</foreignObject></svg>';
|
|
197
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The kit's browser half: what an extension's pages, offscreen document,
|
|
3
3
|
* service worker and content scripts load, Chrome's page components (the ⋮
|
|
4
4
|
* menu, the text dialog, the toast with Undo, a settings card's site list,
|
|
5
|
-
* the built-in AI row), per-site lists' sites, Chrome's on-device model, and
|
|
5
|
+
* the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model, and
|
|
6
6
|
* a content script's slices and change watcher included. Every function takes the `chrome`
|
|
7
7
|
* APIs it uses as arguments, so tests run the real logic against fakes.
|
|
8
8
|
*
|
|
@@ -18,6 +18,7 @@ export { setShortcuts } from './commands/shortcuts.js';
|
|
|
18
18
|
export { BLOCK_CHARS, SLICE_MS, blocksOf, inSlices } from './content/slices.js';
|
|
19
19
|
export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
|
|
20
20
|
export { focusedElement } from './dom/focus.js';
|
|
21
|
+
export { THEMES, WINDOW_BARS, WINDOW_W, letterIcon, windowChrome, windowChromeSvg } from './frame/window.js';
|
|
21
22
|
export { localize, translator } from './dom/i18n.js';
|
|
22
23
|
export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
|
|
23
24
|
export { setUpPage } from './dom/setup.js';
|
|
@@ -46,6 +47,8 @@ export type ModelSession = import('./ai/model.js').ModelSession;
|
|
|
46
47
|
export type ModelStatus = import('./ai/model.js').ModelStatus;
|
|
47
48
|
export type SliceOptions = import('./content/slices.js').SliceOptions;
|
|
48
49
|
export type WatchHandlers = import('./content/watch.js').WatchHandlers;
|
|
50
|
+
export type WindowTab = import('./frame/window.js').Tab;
|
|
51
|
+
export type WindowTheme = import('./frame/window.js').Theme;
|
|
49
52
|
export type ModelRow = import('./ui/model-row.js').ModelRow;
|
|
50
53
|
export type ContentScript = import('./scripting/registration.js').ContentScript;
|
|
51
54
|
export type ActionMenu = import('./ui/menu.js').ActionMenu;
|
|
@@ -70,6 +73,8 @@ export type Toast = import('./ui/toast.js').Toast;
|
|
|
70
73
|
* @typedef {import('./ai/model.js').ModelStatus} ModelStatus
|
|
71
74
|
* @typedef {import('./content/slices.js').SliceOptions} SliceOptions
|
|
72
75
|
* @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
|
|
76
|
+
* @typedef {import('./frame/window.js').Tab} WindowTab
|
|
77
|
+
* @typedef {import('./frame/window.js').Theme} WindowTheme
|
|
73
78
|
* @typedef {import('./ui/model-row.js').ModelRow} ModelRow
|
|
74
79
|
* @typedef {import('./scripting/registration.js').ContentScript} ContentScript
|
|
75
80
|
* @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The kit's browser half: what an extension's pages, offscreen document,
|
|
3
3
|
* service worker and content scripts load, Chrome's page components (the ⋮
|
|
4
4
|
* menu, the text dialog, the toast with Undo, a settings card's site list,
|
|
5
|
-
* the built-in AI row), per-site lists' sites, Chrome's on-device model, and
|
|
5
|
+
* the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model, and
|
|
6
6
|
* a content script's slices and change watcher included. Every function takes the `chrome`
|
|
7
7
|
* APIs it uses as arguments, so tests run the real logic against fakes.
|
|
8
8
|
*
|
|
@@ -21,6 +21,7 @@ export { setShortcuts } from './commands/shortcuts.js';
|
|
|
21
21
|
export { BLOCK_CHARS, SLICE_MS, blocksOf, inSlices } from './content/slices.js';
|
|
22
22
|
export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
|
|
23
23
|
export { focusedElement } from './dom/focus.js';
|
|
24
|
+
export { THEMES, WINDOW_BARS, WINDOW_W, letterIcon, windowChrome, windowChromeSvg } from './frame/window.js';
|
|
24
25
|
export { localize, translator } from './dom/i18n.js';
|
|
25
26
|
export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
|
|
26
27
|
export { setUpPage } from './dom/setup.js';
|
|
@@ -57,6 +58,8 @@ export { TOAST_MS, createToast } from './ui/toast.js';
|
|
|
57
58
|
* @typedef {import('./ai/model.js').ModelStatus} ModelStatus
|
|
58
59
|
* @typedef {import('./content/slices.js').SliceOptions} SliceOptions
|
|
59
60
|
* @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
|
|
61
|
+
* @typedef {import('./frame/window.js').Tab} WindowTab
|
|
62
|
+
* @typedef {import('./frame/window.js').Theme} WindowTheme
|
|
60
63
|
* @typedef {import('./ui/model-row.js').ModelRow} ModelRow
|
|
61
64
|
* @typedef {import('./scripting/registration.js').ContentScript} ContentScript
|
|
62
65
|
* @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
|
package/src/settings/saver.d.ts
CHANGED
|
@@ -41,6 +41,14 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
|
|
|
41
41
|
* Cancel the timers (tests, or a page being torn down).
|
|
42
42
|
*/
|
|
43
43
|
stop: () => void;
|
|
44
|
+
/**
|
|
45
|
+
* Hear the settings change elsewhere (another tab, another computer through sync):
|
|
46
|
+
* `onChange` gets them all, this page's changes not yet written laid over them. `events` is
|
|
47
|
+
* `chrome.storage.onChanged`.
|
|
48
|
+
*/
|
|
49
|
+
follow: (onChange: (settings: S) => void, events: {
|
|
50
|
+
addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void;
|
|
51
|
+
}) => void;
|
|
44
52
|
};
|
|
45
53
|
/** @import { SettingsStore } from './store.js' */
|
|
46
54
|
/** @import { SyncedStorage } from './storage.js' */
|
|
@@ -59,6 +67,10 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
|
|
|
59
67
|
* @property {() => Promise<unknown>} flush Write what's queued now (call it as the page is hidden);
|
|
60
68
|
* resolves once every write so far has finished.
|
|
61
69
|
* @property {() => void} stop Cancel the timers (tests, or a page being torn down).
|
|
70
|
+
* @property {(onChange: (settings: S) => void, events: {addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void}) => void} follow
|
|
71
|
+
* Hear the settings change elsewhere (another tab, another computer through sync):
|
|
72
|
+
* `onChange` gets them all, this page's changes not yet written laid over them. `events` is
|
|
73
|
+
* `chrome.storage.onChanged`.
|
|
62
74
|
*/
|
|
63
75
|
/**
|
|
64
76
|
* Make a debounced saver over a settings store.
|
|
@@ -72,5 +84,6 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
|
|
|
72
84
|
* const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
|
|
73
85
|
* toggle.addEventListener('change', () => saver.save({ compact: toggle.checked }));
|
|
74
86
|
* addEventListener('pagehide', () => saver.flush());
|
|
87
|
+
* saver.follow((settings) => show(settings), chrome.storage.onChanged);
|
|
75
88
|
*/
|
|
76
89
|
export declare function createSettingsSaver<S extends Record<string, unknown>>(store: SettingsStore<S>, storage: SyncedStorage, { delay, retryAfter, onSaved }?: SaverOptions<S>): SettingsSaver<S>;
|
package/src/settings/saver.js
CHANGED
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
* @property {() => Promise<unknown>} flush Write what's queued now (call it as the page is hidden);
|
|
28
28
|
* resolves once every write so far has finished.
|
|
29
29
|
* @property {() => void} stop Cancel the timers (tests, or a page being torn down).
|
|
30
|
+
* @property {(onChange: (settings: S) => void, events: {addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void}) => void} follow
|
|
31
|
+
* Hear the settings change elsewhere (another tab, another computer through sync):
|
|
32
|
+
* `onChange` gets them all, this page's changes not yet written laid over them. `events` is
|
|
33
|
+
* `chrome.storage.onChanged`.
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
/**
|
|
@@ -41,6 +45,7 @@
|
|
|
41
45
|
* const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
|
|
42
46
|
* toggle.addEventListener('change', () => saver.save({ compact: toggle.checked }));
|
|
43
47
|
* addEventListener('pagehide', () => saver.flush());
|
|
48
|
+
* saver.follow((settings) => show(settings), chrome.storage.onChanged);
|
|
44
49
|
*/
|
|
45
50
|
export function createSettingsSaver(store, storage, { delay = 1000, retryAfter = 60_000, onSaved = () => {} } = {}) {
|
|
46
51
|
/** @type {Partial<S>} */
|
|
@@ -90,6 +95,12 @@ export function createSettingsSaver(store, storage, { delay = 1000, retryAfter =
|
|
|
90
95
|
timer = setTimeout(flush, delay);
|
|
91
96
|
},
|
|
92
97
|
flush,
|
|
98
|
+
follow(onChange, events) {
|
|
99
|
+
events.addListener(async (changes, area) => {
|
|
100
|
+
if (!store.changed(changes, area).length) return;
|
|
101
|
+
onChange(store.normalize({ ...(await store.load(storage)), ...pending }));
|
|
102
|
+
});
|
|
103
|
+
},
|
|
93
104
|
stop() {
|
|
94
105
|
clearTimeout(timer);
|
|
95
106
|
clearTimeout(retryTimer);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a page's main thread is held, for performance checks: a heartbeat in the page. From
|
|
3
|
+
* Topicless' and Better Screenshots' e2e helpers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/heartbeat
|
|
6
|
+
*/
|
|
7
|
+
import type { Page } from 'playwright';
|
|
8
|
+
/** @import { Page } from 'playwright' */
|
|
9
|
+
/**
|
|
10
|
+
* Start a heartbeat in a page: a timer that fires again and again, costing nothing between
|
|
11
|
+
* beats; the longest gap between two is the longest the main thread was held (the Long Tasks
|
|
12
|
+
* API misses an extension's isolated world's work, and a `MessageChannel` heartbeat counts as
|
|
13
|
+
* task time itself). Gaps while the tab is hidden (another tab in front) don't count: Chrome
|
|
14
|
+
* throttles a hidden tab's timers.
|
|
15
|
+
*
|
|
16
|
+
* @param {Page} page - The page.
|
|
17
|
+
* @returns {Promise<() => Promise<number>>} Stops it and gives the longest gap (ms).
|
|
18
|
+
* @example
|
|
19
|
+
* const stop = await heartbeat(page);
|
|
20
|
+
* await doTheWork();
|
|
21
|
+
* const held = await stop(); // e.g. 17
|
|
22
|
+
*/
|
|
23
|
+
export declare function heartbeat(page: Page): Promise<() => Promise<number>>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a page's main thread is held, for performance checks: a heartbeat in the page. From
|
|
3
|
+
* Topicless' and Better Screenshots' e2e helpers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/heartbeat
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @import { Page } from 'playwright' */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Start a heartbeat in a page: a timer that fires again and again, costing nothing between
|
|
12
|
+
* beats; the longest gap between two is the longest the main thread was held (the Long Tasks
|
|
13
|
+
* API misses an extension's isolated world's work, and a `MessageChannel` heartbeat counts as
|
|
14
|
+
* task time itself). Gaps while the tab is hidden (another tab in front) don't count: Chrome
|
|
15
|
+
* throttles a hidden tab's timers.
|
|
16
|
+
*
|
|
17
|
+
* @param {Page} page - The page.
|
|
18
|
+
* @returns {Promise<() => Promise<number>>} Stops it and gives the longest gap (ms).
|
|
19
|
+
* @example
|
|
20
|
+
* const stop = await heartbeat(page);
|
|
21
|
+
* await doTheWork();
|
|
22
|
+
* const held = await stop(); // e.g. 17
|
|
23
|
+
*/
|
|
24
|
+
export async function heartbeat(page) {
|
|
25
|
+
await page.evaluate(() => {
|
|
26
|
+
const beat = { longest: 0, running: true };
|
|
27
|
+
Object.assign(window, { extensionKitBeat: beat });
|
|
28
|
+
let last = performance.now();
|
|
29
|
+
const tick = () => {
|
|
30
|
+
const now = performance.now();
|
|
31
|
+
// A hidden tab's timers are throttled by Chrome: those gaps aren't the page being held.
|
|
32
|
+
if (!document.hidden) beat.longest = Math.max(beat.longest, now - last);
|
|
33
|
+
last = now;
|
|
34
|
+
if (beat.running) setTimeout(tick, 0);
|
|
35
|
+
};
|
|
36
|
+
setTimeout(tick, 0);
|
|
37
|
+
});
|
|
38
|
+
return () => page.evaluate(() => {
|
|
39
|
+
const beat = /** @type {{longest: number, running: boolean}} */ (/** @type {any} */ (window).extensionKitBeat);
|
|
40
|
+
beat.running = false;
|
|
41
|
+
return beat.longest;
|
|
42
|
+
});
|
|
43
|
+
}
|
package/src/testing/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { eachLimited } from '../time/pool.js';
|
|
|
14
14
|
export { filesIn, launchDownloading } from './downloads.js';
|
|
15
15
|
export { fakeLanguageModel } from './fake-model.js';
|
|
16
16
|
export { messageLookup, readMessages } from './messages.js';
|
|
17
|
+
export { heartbeat } from './heartbeat.js';
|
|
18
|
+
export { playPermissionPrompt } from './prompt.js';
|
|
17
19
|
export { serve, testCertificate } from './server.js';
|
|
18
20
|
export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
19
21
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
package/src/testing/index.js
CHANGED
|
@@ -17,6 +17,8 @@ export { eachLimited } from '../time/pool.js';
|
|
|
17
17
|
export { filesIn, launchDownloading } from './downloads.js';
|
|
18
18
|
export { fakeLanguageModel } from './fake-model.js';
|
|
19
19
|
export { messageLookup, readMessages } from './messages.js';
|
|
20
|
+
export { heartbeat } from './heartbeat.js';
|
|
21
|
+
export { playPermissionPrompt } from './prompt.js';
|
|
20
22
|
export { serve, testCertificate } from './server.js';
|
|
21
23
|
export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
22
24
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's permission prompt, played in an extension page for an e2e test: Playwright can't
|
|
3
|
+
* click Chrome's own prompt. From Topicless' and Better Screenshots' e2e helpers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/prompt
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
|
|
9
|
+
* recorded on `window.asked` (what was asked, and whether a click was behind it,
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* @param {boolean[]} answers - The answers, in order.
|
|
15
|
+
* @example
|
|
16
|
+
* await page.evaluate(playPermissionPrompt, [true]);
|
|
17
|
+
* await page.click('#download');
|
|
18
|
+
* const asked = await page.evaluate(() => window.asked); // [{ permissions: ['downloads'], origins: undefined, gesture: true }]
|
|
19
|
+
*/
|
|
20
|
+
export declare function playPermissionPrompt(answers: boolean[]): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's permission prompt, played in an extension page for an e2e test: Playwright can't
|
|
3
|
+
* click Chrome's own prompt. From Topicless' and Better Screenshots' e2e helpers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/prompt
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
|
|
10
|
+
* recorded on `window.asked` (what was asked, and whether a click was behind it,
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* @param {boolean[]} answers - The answers, in order.
|
|
16
|
+
* @example
|
|
17
|
+
* await page.evaluate(playPermissionPrompt, [true]);
|
|
18
|
+
* await page.click('#download');
|
|
19
|
+
* const asked = await page.evaluate(() => window.asked); // [{ permissions: ['downloads'], origins: undefined, gesture: true }]
|
|
20
|
+
*/
|
|
21
|
+
export function playPermissionPrompt(answers) {
|
|
22
|
+
/** @type {{permissions?: string[], origins?: string[], gesture: boolean}[]} */
|
|
23
|
+
const asked = [];
|
|
24
|
+
Object.assign(window, { asked });
|
|
25
|
+
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 });
|
|
27
|
+
return answers.length > 1 ? answers.shift() : answers[0];
|
|
28
|
+
}));
|
|
29
|
+
}
|