@arraypress/extension-kit 1.6.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.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A content script's first look at a big page, in slices: the page is cut
3
+ * into blocks of at most `blockChars` characters of text (a long listing's
4
+ * cards, a long article's paragraphs), each handed whole to the caller's
5
+ * work, and the main thread is handed back to the page every `sliceMs`, so
6
+ * a 2,000-item page never freezes. From Better Prices (`content/slices.js`),
7
+ * shared with Topicless.
8
+ *
9
+ * @module content/slices
10
+ */
11
+ /** The most text one block holds (characters), by default. */
12
+ export declare const BLOCK_CHARS = 4000;
13
+ /** How long one slice may run before the page gets the main thread back (ms), by default. Shorter slices cost more layout in all. */
14
+ export declare const SLICE_MS = 40;
15
+ /**
16
+ * The blocks of a part of the page: itself if small enough, else its
17
+ * children's blocks (its own text nodes as blocks of their own).
18
+ *
19
+ * @param {Node} root - The part.
20
+ * @param {number} [blockChars] - The most text a block holds. Default {@link BLOCK_CHARS}.
21
+ * @returns {Node[]} The blocks, in page order.
22
+ * @example
23
+ * blocksOf(document.body).length; // 1 for a short page, one per card for a long listing
24
+ */
25
+ export declare function blocksOf(root: Node, blockChars?: number): Node[];
26
+ export type SliceOptions = {
27
+ /**
28
+ * The most text a block holds. Default {@link BLOCK_CHARS}.
29
+ */
30
+ blockChars?: number;
31
+ /**
32
+ * How long a slice may run. Default {@link SLICE_MS}.
33
+ */
34
+ sliceMs?: number;
35
+ /**
36
+ * Hands the main thread back. Default: a macrotask.
37
+ */
38
+ pause?: () => Promise<void>;
39
+ /**
40
+ * The clock. Default `performance.now`.
41
+ */
42
+ now?: () => number;
43
+ };
44
+ /**
45
+ * @typedef {object} SliceOptions
46
+ * @property {number} [blockChars] The most text a block holds. Default {@link BLOCK_CHARS}.
47
+ * @property {number} [sliceMs] How long a slice may run. Default {@link SLICE_MS}.
48
+ * @property {() => Promise<void>} [pause] Hands the main thread back. Default: a macrotask.
49
+ * @property {() => number} [now] The clock. Default `performance.now`.
50
+ */
51
+ /**
52
+ * Work through a part of the page in slices: `work(block)` on each block
53
+ * still in the page, the main thread handed back whenever a slice has run
54
+ * `sliceMs`.
55
+ *
56
+ * @param {Node} root - The part (the body).
57
+ * @param {(block: Node) => void} work - What to do with one block.
58
+ * @param {SliceOptions} [options] - Block size, slice length, how to pause.
59
+ * @returns {Promise<void>} Once every block is done.
60
+ * @example
61
+ * await inSlices(document.body, (block) => scanner.scan(block));
62
+ */
63
+ export declare function inSlices(root: Node, work: (block: Node) => void, { blockChars, sliceMs, pause, now }?: SliceOptions): Promise<void>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A content script's first look at a big page, in slices: the page is cut
3
+ * into blocks of at most `blockChars` characters of text (a long listing's
4
+ * cards, a long article's paragraphs), each handed whole to the caller's
5
+ * work, and the main thread is handed back to the page every `sliceMs`, so
6
+ * a 2,000-item page never freezes. From Better Prices (`content/slices.js`),
7
+ * shared with Topicless.
8
+ *
9
+ * @module content/slices
10
+ */
11
+
12
+ /** The most text one block holds (characters), by default. */
13
+ export const BLOCK_CHARS = 4000;
14
+
15
+ /** How long one slice may run before the page gets the main thread back (ms), by default. Shorter slices cost more layout in all. */
16
+ export const SLICE_MS = 40;
17
+
18
+ /**
19
+ * The blocks of a part of the page: itself if small enough, else its
20
+ * children's blocks (its own text nodes as blocks of their own).
21
+ *
22
+ * @param {Node} root - The part.
23
+ * @param {number} [blockChars] - The most text a block holds. Default {@link BLOCK_CHARS}.
24
+ * @returns {Node[]} The blocks, in page order.
25
+ * @example
26
+ * blocksOf(document.body).length; // 1 for a short page, one per card for a long listing
27
+ */
28
+ export function blocksOf(root, blockChars = BLOCK_CHARS) {
29
+ /** @type {Node[]} */
30
+ const blocks = [];
31
+ /** @param {Node} node */
32
+ const cut = (node) => {
33
+ if (node.nodeType !== 1 || (node.textContent ?? '').length <= blockChars || !node.firstChild) {
34
+ blocks.push(node);
35
+ return;
36
+ }
37
+ for (const child of node.childNodes) cut(child);
38
+ };
39
+ cut(root);
40
+ return blocks;
41
+ }
42
+
43
+ /**
44
+ * Hand the main thread back to the page: a macrotask later.
45
+ *
46
+ * @returns {Promise<void>}
47
+ */
48
+ const nextTask = () => new Promise((resolve) => { setTimeout(resolve, 0); });
49
+
50
+ /**
51
+ * @typedef {object} SliceOptions
52
+ * @property {number} [blockChars] The most text a block holds. Default {@link BLOCK_CHARS}.
53
+ * @property {number} [sliceMs] How long a slice may run. Default {@link SLICE_MS}.
54
+ * @property {() => Promise<void>} [pause] Hands the main thread back. Default: a macrotask.
55
+ * @property {() => number} [now] The clock. Default `performance.now`.
56
+ */
57
+
58
+ /**
59
+ * Work through a part of the page in slices: `work(block)` on each block
60
+ * still in the page, the main thread handed back whenever a slice has run
61
+ * `sliceMs`.
62
+ *
63
+ * @param {Node} root - The part (the body).
64
+ * @param {(block: Node) => void} work - What to do with one block.
65
+ * @param {SliceOptions} [options] - Block size, slice length, how to pause.
66
+ * @returns {Promise<void>} Once every block is done.
67
+ * @example
68
+ * await inSlices(document.body, (block) => scanner.scan(block));
69
+ */
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
+ }
80
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A content script keeping up with a page that changes (a single-page app,
3
+ * a list that loads more as you scroll): a MutationObserver that only
4
+ * collects what changed, and hands it over in one batch a little later,
5
+ * never touching the page itself. The extension's own elements are ignored
6
+ * (`ignore`), so what it adds never sets it off again. From Better Prices
7
+ * (`content/watch.js`), shared with Topicless.
8
+ *
9
+ * @module content/watch
10
+ */
11
+ /** How long after a change the batch waits for more (ms), by default. */
12
+ export declare const SETTLE_MS = 120;
13
+ /** The longest a batch waits while a page keeps changing (ms), by default. */
14
+ export declare const MAX_WAIT_MS = 600;
15
+ export type WatchHandlers = {
16
+ /**
17
+ * The nodes added or changed since the last batch, each once,
18
+ * none inside another.
19
+ */
20
+ changed: (nodes: Node[]) => void;
21
+ /**
22
+ * A node that left (at once, so what the extension added for it
23
+ * can go with it).
24
+ */
25
+ removed?: (node: Node) => void;
26
+ /**
27
+ * Whether a node added or removed is the extension's own.
28
+ */
29
+ ignore?: (node: Node) => boolean;
30
+ /**
31
+ * How long a batch waits for more. Default {@link SETTLE_MS}.
32
+ */
33
+ settleMs?: number;
34
+ /**
35
+ * The longest a batch waits. Default {@link MAX_WAIT_MS}.
36
+ */
37
+ maxWaitMs?: number;
38
+ /**
39
+ * An element whose children changed is handed over again whole
40
+ * when it has at most this many element children (a split price's pieces; a list's new items
41
+ * are handed over themselves). Default 6; 0 never.
42
+ */
43
+ regroupChildren?: number;
44
+ };
45
+ /**
46
+ * @typedef {object} WatchHandlers
47
+ * @property {(nodes: Node[]) => void} changed The nodes added or changed since the last batch, each once,
48
+ * none inside another.
49
+ * @property {(node: Node) => void} [removed] A node that left (at once, so what the extension added for it
50
+ * can go with it).
51
+ * @property {(node: Node) => boolean} [ignore] Whether a node added or removed is the extension's own.
52
+ * @property {number} [settleMs] How long a batch waits for more. Default {@link SETTLE_MS}.
53
+ * @property {number} [maxWaitMs] The longest a batch waits. Default {@link MAX_WAIT_MS}.
54
+ * @property {number} [regroupChildren] An element whose children changed is handed over again whole
55
+ * when it has at most this many element children (a split price's pieces; a list's new items
56
+ * are handed over themselves). Default 6; 0 never.
57
+ */
58
+ /**
59
+ * Watch `root` for changes.
60
+ *
61
+ * @param {Node} root - What to watch (the body, or the document element from `document_start`).
62
+ * @param {WatchHandlers} handlers - What to call, and how long to wait.
63
+ * @returns {() => void} Stops watching.
64
+ * @example
65
+ * const stop = watchChanges(document.body, {
66
+ * changed: (nodes) => nodes.forEach((node) => scanner.scan(node)),
67
+ * removed: (node) => scanner.forget(node),
68
+ * ignore: (node) => node instanceof Element && node.localName === 'better-prices-note',
69
+ * });
70
+ */
71
+ export declare function watchChanges(root: Node, { changed, removed, ignore, settleMs, maxWaitMs, regroupChildren, }: WatchHandlers): () => void;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * A content script keeping up with a page that changes (a single-page app,
3
+ * a list that loads more as you scroll): a MutationObserver that only
4
+ * collects what changed, and hands it over in one batch a little later,
5
+ * never touching the page itself. The extension's own elements are ignored
6
+ * (`ignore`), so what it adds never sets it off again. From Better Prices
7
+ * (`content/watch.js`), shared with Topicless.
8
+ *
9
+ * @module content/watch
10
+ */
11
+
12
+ /** How long after a change the batch waits for more (ms), by default. */
13
+ export const SETTLE_MS = 120;
14
+
15
+ /** The longest a batch waits while a page keeps changing (ms), by default. */
16
+ export const MAX_WAIT_MS = 600;
17
+
18
+ /**
19
+ * @typedef {object} WatchHandlers
20
+ * @property {(nodes: Node[]) => void} changed The nodes added or changed since the last batch, each once,
21
+ * none inside another.
22
+ * @property {(node: Node) => void} [removed] A node that left (at once, so what the extension added for it
23
+ * can go with it).
24
+ * @property {(node: Node) => boolean} [ignore] Whether a node added or removed is the extension's own.
25
+ * @property {number} [settleMs] How long a batch waits for more. Default {@link SETTLE_MS}.
26
+ * @property {number} [maxWaitMs] The longest a batch waits. Default {@link MAX_WAIT_MS}.
27
+ * @property {number} [regroupChildren] An element whose children changed is handed over again whole
28
+ * when it has at most this many element children (a split price's pieces; a list's new items
29
+ * are handed over themselves). Default 6; 0 never.
30
+ */
31
+
32
+ /**
33
+ * Watch `root` for changes.
34
+ *
35
+ * @param {Node} root - What to watch (the body, or the document element from `document_start`).
36
+ * @param {WatchHandlers} handlers - What to call, and how long to wait.
37
+ * @returns {() => void} Stops watching.
38
+ * @example
39
+ * const stop = watchChanges(document.body, {
40
+ * changed: (nodes) => nodes.forEach((node) => scanner.scan(node)),
41
+ * removed: (node) => scanner.forget(node),
42
+ * ignore: (node) => node instanceof Element && node.localName === 'better-prices-note',
43
+ * });
44
+ */
45
+ export function watchChanges(root, {
46
+ changed, removed = () => {}, ignore = () => false, settleMs = SETTLE_MS, maxWaitMs = MAX_WAIT_MS, regroupChildren = 6,
47
+ }) {
48
+ /** @type {Set<Node>} */
49
+ let pending = new Set();
50
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
51
+ let timer;
52
+ let firstAt = 0;
53
+
54
+ const flush = () => {
55
+ timer = undefined;
56
+ firstAt = 0;
57
+ const nodes = [...pending].filter((node) => node.isConnected);
58
+ pending = new Set();
59
+ // Each subtree once: a node inside another changed node goes with it.
60
+ const tops = nodes.filter((node) => !nodes.some((other) => other !== node && other.contains(node)));
61
+ if (tops.length) changed(tops);
62
+ };
63
+
64
+ const observer = new MutationObserver((mutations) => {
65
+ for (const mutation of mutations) {
66
+ const target = mutation.target;
67
+ if (mutation.type === 'characterData') {
68
+ pending.add(target);
69
+ continue;
70
+ }
71
+ let ours = true;
72
+ for (const node of mutation.addedNodes) {
73
+ if (ignore(node)) continue;
74
+ ours = false;
75
+ pending.add(node);
76
+ }
77
+ for (const node of mutation.removedNodes) {
78
+ if (ignore(node)) continue;
79
+ ours = false;
80
+ removed(node);
81
+ }
82
+ if (!ours && target.nodeType === 1 && /** @type {Element} */ (target).childElementCount <= regroupChildren) pending.add(target);
83
+ }
84
+ if (!pending.size) return;
85
+ const now = Date.now();
86
+ firstAt ||= now;
87
+ clearTimeout(timer);
88
+ timer = setTimeout(flush, Math.max(0, Math.min(settleMs, firstAt + maxWaitMs - now)));
89
+ });
90
+ observer.observe(root, { childList: true, subtree: true, characterData: true });
91
+ return () => {
92
+ observer.disconnect();
93
+ clearTimeout(timer);
94
+ };
95
+ }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
+ }