@arraypress/extension-kit 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +88 -2
  2. package/package.json +1 -1
  3. package/src/build/index.d.ts +2 -1
  4. package/src/build/index.js +2 -1
  5. package/src/build/store-shots.d.ts +177 -0
  6. package/src/build/store-shots.js +173 -0
  7. package/src/build/test-build.d.ts +13 -1
  8. package/src/build/test-build.js +41 -2
  9. package/src/build/vendor.js +1 -1
  10. package/src/content/elements.d.ts +50 -0
  11. package/src/content/elements.js +88 -0
  12. package/src/content/highlight.d.ts +54 -0
  13. package/src/content/highlight.js +92 -0
  14. package/src/content/overlay.d.ts +57 -0
  15. package/src/content/overlay.js +52 -0
  16. package/src/content/page-toast.d.ts +26 -0
  17. package/src/content/page-toast.js +72 -0
  18. package/src/content/slices.d.ts +16 -1
  19. package/src/content/slices.js +29 -10
  20. package/src/dom/copy.d.ts +36 -0
  21. package/src/dom/copy.js +84 -0
  22. package/src/dom/i18n.d.ts +17 -0
  23. package/src/dom/i18n.js +15 -0
  24. package/src/dom/make.d.ts +53 -0
  25. package/src/dom/make.js +71 -0
  26. package/src/frame/window.d.ts +76 -7
  27. package/src/frame/window.js +119 -15
  28. package/src/index.d.ts +33 -5
  29. package/src/index.js +25 -5
  30. package/src/panel/side-panel.d.ts +35 -0
  31. package/src/panel/side-panel.js +41 -0
  32. package/src/scripting/call.d.ts +63 -0
  33. package/src/scripting/call.js +57 -0
  34. package/src/scripting/tab.d.ts +68 -0
  35. package/src/scripting/tab.js +81 -0
  36. package/src/styles/chrome-ui.css +7 -0
  37. package/src/testing/index.d.ts +6 -0
  38. package/src/testing/index.js +4 -0
  39. package/src/testing/live-run.d.ts +130 -0
  40. package/src/testing/live-run.js +160 -0
  41. package/src/testing/pages.d.ts +114 -0
  42. package/src/testing/pages.js +109 -0
  43. package/src/ui/notice.d.ts +58 -0
  44. package/src/ui/notice.js +43 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Copying in several formats at once: plain text, and HTML for apps that paste it rich (a link
3
+ * with its title in Google Docs, Slack or Notion), from a page, a popup or a content script.
4
+ * Says whether it worked, so a caller can offer another way when the page won't let it.
5
+ *
6
+ * Chrome lets the focused page write without a click (it grants clipboard-write to the active
7
+ * tab), so a content script can copy from a context-menu click or a keyboard shortcut; a page
8
+ * served over plain HTTP has no clipboard API, and one with `Permissions-Policy:
9
+ * clipboard-write=()` refuses it: there the copy command is tried, which needs a click in the page.
10
+ *
11
+ * @module dom/copy
12
+ */
13
+
14
+ /**
15
+ * What to put on the clipboard.
16
+ *
17
+ * @typedef {object} ClipboardFormats
18
+ * @property {string} text Plain text, for every app.
19
+ * @property {string} [html] HTML, for apps that paste formatted text.
20
+ */
21
+
22
+ /**
23
+ * The copy command, with a copy listener that writes the formats: it runs first (capture, on the
24
+ * window) and stops the page's own listeners from changing what's copied.
25
+ *
26
+ * @param {Document} doc - The page.
27
+ * @param {ClipboardFormats} formats - What to copy.
28
+ * @returns {boolean} Whether the command ran and wrote them.
29
+ */
30
+ function copyCommand(doc, { text, html }) {
31
+ const view = doc.defaultView;
32
+ if (!view) return false;
33
+ let wrote = false;
34
+ /** @param {ClipboardEvent} event */
35
+ const onCopy = (event) => {
36
+ if (!event.clipboardData) return;
37
+ event.clipboardData.setData('text/plain', text);
38
+ if (html !== undefined) event.clipboardData.setData('text/html', html);
39
+ event.preventDefault();
40
+ event.stopImmediatePropagation();
41
+ wrote = true;
42
+ };
43
+ view.addEventListener('copy', onCopy, true);
44
+ try {
45
+ return doc.execCommand('copy') && wrote;
46
+ } catch {
47
+ return false;
48
+ } finally {
49
+ view.removeEventListener('copy', onCopy, true);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Put text, and HTML when given, on the clipboard: the clipboard API (one item holding both
55
+ * formats), else the copy command.
56
+ *
57
+ * @param {Document} doc - The page, popup or content script's document.
58
+ * @param {ClipboardFormats} formats - Plain text, and HTML for rich pasting.
59
+ * @returns {Promise<boolean>} Whether it's on the clipboard.
60
+ * @example
61
+ * const link = document.createElement('a');
62
+ * link.href = url;
63
+ * link.textContent = title;
64
+ * if (!(await copyFormats(document, { text: `${title} – ${url}`, html: link.outerHTML }))) offerCopyButton();
65
+ */
66
+ export async function copyFormats(doc, { text, html }) {
67
+ const view = doc.defaultView;
68
+ const clipboard = view?.navigator.clipboard;
69
+ if (view && clipboard) {
70
+ try {
71
+ if (html === undefined) await clipboard.writeText(text);
72
+ else {
73
+ await clipboard.write([new view.ClipboardItem({
74
+ 'text/plain': new view.Blob([text], { type: 'text/plain' }),
75
+ 'text/html': new view.Blob([html], { type: 'text/html' }),
76
+ })]);
77
+ }
78
+ return true;
79
+ } catch {
80
+ // No focus, or the page's permissions policy: the copy command below.
81
+ }
82
+ }
83
+ return copyCommand(doc, { text, html });
84
+ }
package/src/dom/i18n.d.ts CHANGED
@@ -45,3 +45,20 @@ export declare function translator(i18n: Pick<I18nApi, 'getMessage'>): (key: str
45
45
  * @returns {void}
46
46
  */
47
47
  export declare function localize(root: ParentNode, i18n: I18nApi): void;
48
+ /**
49
+ * A count in words, its number written as the person's language writes numbers: one message for
50
+ * one, another (with the number as `$1`) for any other count.
51
+ *
52
+ * @param {number} count - How many.
53
+ * @param {string} one - The message for one (`"1 colour"`).
54
+ * @param {string} many - The message for others (`"$COUNT$ colours"`, its placeholder `$1`).
55
+ * @param {{t: (key: string, substitutions?: string | string[]) => string, lang: string}} words - The
56
+ * page's message lookup and the UI language.
57
+ * @returns {string} The words.
58
+ * @example
59
+ * counted(1200, 'panelColoursOne', 'panelColours', { t, lang: 'en-GB' }); // '1,200 colours'
60
+ */
61
+ export declare const counted: (count: number, one: string, many: string, { t, lang }: {
62
+ t: (key: string, substitutions?: string | string[]) => string;
63
+ lang: string;
64
+ }) => string;
package/src/dom/i18n.js CHANGED
@@ -60,3 +60,18 @@ export function localize(root, i18n) {
60
60
  }
61
61
  if ('documentElement' in root) /** @type {Document} */ (root).documentElement.lang = i18n.getUILanguage();
62
62
  }
63
+
64
+ /**
65
+ * A count in words, its number written as the person's language writes numbers: one message for
66
+ * one, another (with the number as `$1`) for any other count.
67
+ *
68
+ * @param {number} count - How many.
69
+ * @param {string} one - The message for one (`"1 colour"`).
70
+ * @param {string} many - The message for others (`"$COUNT$ colours"`, its placeholder `$1`).
71
+ * @param {{t: (key: string, substitutions?: string | string[]) => string, lang: string}} words - The
72
+ * page's message lookup and the UI language.
73
+ * @returns {string} The words.
74
+ * @example
75
+ * counted(1200, 'panelColoursOne', 'panelColours', { t, lang: 'en-GB' }); // '1,200 colours'
76
+ */
77
+ export const counted = (count, one, many, { t, lang }) => (count === 1 ? t(one) : t(many, [count.toLocaleString(lang)]));
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Making a page's elements in a line: an element with its class and text, an
3
+ * icon from the page's SVG sprite, and Chrome's 24px icon button with its
4
+ * tooltip. For extension pages and content-script overlays alike (pass the
5
+ * document the element belongs to).
6
+ *
7
+ * @module dom/make
8
+ */
9
+ /**
10
+ * An element with a class and text.
11
+ *
12
+ * @template {keyof HTMLElementTagNameMap} K
13
+ * @param {K} tag - Its tag.
14
+ * @param {string} [className] - Its class.
15
+ * @param {string} [text] - Its text.
16
+ * @param {Document} [doc] - Its document. Default `document`.
17
+ * @returns {HTMLElementTagNameMap[K]} It.
18
+ * @example
19
+ * make('span', 'meta', '12 colours');
20
+ */
21
+ export declare function make<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string, doc?: Document): HTMLElementTagNameMap[K];
22
+ /**
23
+ * An icon from the page's SVG sprite (`<symbol id="i-copy">`), hidden from screen readers.
24
+ *
25
+ * @param {string} id - The symbol's id, without `#`.
26
+ * @param {string} [className] - More classes beside `icon`.
27
+ * @param {Document} [doc] - Its document. Default `document`.
28
+ * @returns {SVGSVGElement} The icon.
29
+ * @example
30
+ * button.append(spriteIcon('i-copy'));
31
+ */
32
+ export declare function spriteIcon(id: string, className?: string, doc?: Document): SVGSVGElement;
33
+ /**
34
+ * Chrome's icon button (chrome-ui.css `.icon-button`): an icon, its label as tooltip and for
35
+ * screen readers.
36
+ *
37
+ * @param {object} options
38
+ * @param {string} options.icon - The sprite symbol's id.
39
+ * @param {string} options.label - What it does.
40
+ * @param {() => void} options.onClick - Doing it.
41
+ * @param {string} [options.className] - More classes.
42
+ * @param {Document} [options.doc] - Its document. Default `document`.
43
+ * @returns {HTMLButtonElement} The button.
44
+ * @example
45
+ * iconButton({ icon: 'i-copy', label: 'Copy', onClick: () => copy(hex) });
46
+ */
47
+ export declare function iconButton({ icon, label, onClick, className, doc }: {
48
+ icon: string;
49
+ label: string;
50
+ onClick: () => void;
51
+ className?: string;
52
+ doc?: Document;
53
+ }): HTMLButtonElement;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Making a page's elements in a line: an element with its class and text, an
3
+ * icon from the page's SVG sprite, and Chrome's 24px icon button with its
4
+ * tooltip. For extension pages and content-script overlays alike (pass the
5
+ * document the element belongs to).
6
+ *
7
+ * @module dom/make
8
+ */
9
+
10
+ /**
11
+ * An element with a class and text.
12
+ *
13
+ * @template {keyof HTMLElementTagNameMap} K
14
+ * @param {K} tag - Its tag.
15
+ * @param {string} [className] - Its class.
16
+ * @param {string} [text] - Its text.
17
+ * @param {Document} [doc] - Its document. Default `document`.
18
+ * @returns {HTMLElementTagNameMap[K]} It.
19
+ * @example
20
+ * make('span', 'meta', '12 colours');
21
+ */
22
+ export function make(tag, className = '', text = '', doc = document) {
23
+ const element = doc.createElement(tag);
24
+ if (className) element.className = className;
25
+ if (text) element.textContent = text;
26
+ return element;
27
+ }
28
+
29
+ /**
30
+ * An icon from the page's SVG sprite (`<symbol id="i-copy">`), hidden from screen readers.
31
+ *
32
+ * @param {string} id - The symbol's id, without `#`.
33
+ * @param {string} [className] - More classes beside `icon`.
34
+ * @param {Document} [doc] - Its document. Default `document`.
35
+ * @returns {SVGSVGElement} The icon.
36
+ * @example
37
+ * button.append(spriteIcon('i-copy'));
38
+ */
39
+ export function spriteIcon(id, className = '', doc = document) {
40
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
41
+ svg.setAttribute('class', `icon ${className}`.trim());
42
+ svg.setAttribute('aria-hidden', 'true');
43
+ const use = doc.createElementNS('http://www.w3.org/2000/svg', 'use');
44
+ use.setAttribute('href', `#${id}`);
45
+ svg.append(use);
46
+ return svg;
47
+ }
48
+
49
+ /**
50
+ * Chrome's icon button (chrome-ui.css `.icon-button`): an icon, its label as tooltip and for
51
+ * screen readers.
52
+ *
53
+ * @param {object} options
54
+ * @param {string} options.icon - The sprite symbol's id.
55
+ * @param {string} options.label - What it does.
56
+ * @param {() => void} options.onClick - Doing it.
57
+ * @param {string} [options.className] - More classes.
58
+ * @param {Document} [options.doc] - Its document. Default `document`.
59
+ * @returns {HTMLButtonElement} The button.
60
+ * @example
61
+ * iconButton({ icon: 'i-copy', label: 'Copy', onClick: () => copy(hex) });
62
+ */
63
+ export function iconButton({ icon, label, onClick, className = '', doc = document }) {
64
+ const button = make('button', `icon-button ${className}`.trim(), '', doc);
65
+ button.type = 'button';
66
+ button.title = label;
67
+ button.setAttribute('aria-label', label);
68
+ button.append(spriteIcon(icon, '', doc));
69
+ button.addEventListener('click', onClick);
70
+ return button;
71
+ }
@@ -1,12 +1,17 @@
1
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.
2
+ * Windows around a page, drawn as HTML: Chrome's (the macOS tab strip and toolbar) and a plain
3
+ * macOS window's title bar, light or dark, sharing the traffic lights, the colours and the
4
+ * shadow. Chrome's is shared by the store screenshots (`build/store-frame`, which lays it on a
5
+ * 1280×800 canvas in Playwright) and by extension pages, which draw either on a canvas around a
6
+ * capture through {@link windowChromeSvg} or {@link macWindowSvg} (an SVG image with the HTML
7
+ * inside, which Chrome draws without tainting the canvas). The markup is XML-clean so both can
8
+ * use it.
7
9
  *
8
10
  * Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
9
- * toolbar with a pill omnibox).
11
+ * toolbar with a pill omnibox) and AppKit's titled window as macOS 26 and later draw it, measured
12
+ * on macOS 27.2 (`NSThemeFrame`'s `cornerRadius` 16, a 32pt title bar, the window buttons 14pt
13
+ * at 9, 32 and 55 from the left and 9 from the top, the title in the 13pt headline font at 85,
14
+ * after the buttons).
10
15
  *
11
16
  * @module frame/window
12
17
  */
@@ -15,8 +20,20 @@ export declare const WINDOW_BARS: Readonly<{
15
20
  strip: 42;
16
21
  toolbar: 46;
17
22
  }>;
23
+ /** A plain macOS window (macOS 26 and later): its title bar's height and its corners' radius (px). */
24
+ export declare const MAC_WINDOW: Readonly<{
25
+ titleBar: 32;
26
+ radius: 16;
27
+ }>;
18
28
  /** The window's default width (px): the store screenshot's. */
19
29
  export declare const WINDOW_W = 1152;
30
+ /**
31
+ * Text made safe for the drawn windows' HTML (and the store screenshots').
32
+ *
33
+ * @param {unknown} s - Text.
34
+ * @returns {string} It, escaped for HTML text and attributes.
35
+ */
36
+ export declare const esc: (s: unknown) => string;
20
37
  /**
21
38
  * A letter favicon for made-up sites (no real brands).
22
39
  *
@@ -26,7 +43,25 @@ export declare const WINDOW_W = 1152;
26
43
  * @returns {string} A data: URL of the SVG.
27
44
  */
28
45
  export declare const letterIcon: (letter: string, bg: string, fg?: string) => string;
29
- /** Chrome's light and dark window colours, and the canvas behind. */
46
+ export type WindowShadow = {
47
+ /**
48
+ * How far down it falls.
49
+ */
50
+ y: number;
51
+ /**
52
+ * Its blur.
53
+ */
54
+ blur: number;
55
+ /**
56
+ * Its colour.
57
+ */
58
+ color: string;
59
+ };
60
+ /**
61
+ * Chrome's light and dark window colours, a macOS title bar's (`titleBar`, and `titleLine` below
62
+ * it, as AppKit draws them), the canvas behind, and the window's shadow (`windowShadow` for a
63
+ * canvas's `shadow*`, `shadow` for CSS).
64
+ */
30
65
  export declare const THEMES: {
31
66
  light: {
32
67
  bg: string;
@@ -43,6 +78,9 @@ export declare const THEMES: {
43
78
  url: string;
44
79
  urlDim: string;
45
80
  contentLine: string;
81
+ titleBar: string;
82
+ titleLine: string;
83
+ windowShadow: WindowShadow;
46
84
  shadow: string;
47
85
  };
48
86
  dark: {
@@ -60,6 +98,9 @@ export declare const THEMES: {
60
98
  url: string;
61
99
  urlDim: string;
62
100
  contentLine: string;
101
+ titleBar: string;
102
+ titleLine: string;
103
+ windowShadow: WindowShadow;
63
104
  shadow: string;
64
105
  };
65
106
  };
@@ -118,3 +159,31 @@ export declare function windowChrome({ tabs, url, urlHost, theme, w, ext }: {
118
159
  * const svg = windowChromeSvg({ tabs: [{ title: 'Example', icon: letterIcon('E', '#1a73e8'), active: true }], url: 'example.com', w: 1280 });
119
160
  */
120
161
  export declare function windowChromeSvg(options: Parameters<typeof windowChrome>[0]): string;
162
+ /**
163
+ * A plain macOS window's title bar, as HTML: the traffic lights at the left, the title after
164
+ * them (macOS 26 on), a hairline below.
165
+ *
166
+ * @param {object} options - What to draw.
167
+ * @param {string} options.title - The window's title.
168
+ * @param {Theme} [options.theme] - Default light.
169
+ * @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
170
+ * @returns {string} The HTML, with its own styles.
171
+ */
172
+ declare function macTitleBar({ title, theme, w }: {
173
+ title: string;
174
+ theme?: Theme;
175
+ w?: number;
176
+ }): string;
177
+ /**
178
+ * A plain macOS window's title bar as a standalone SVG image, `w` wide and `MAC_WINDOW.titleBar`
179
+ * high (scale it when drawing), for an extension page to draw on a canvas as
180
+ * {@link windowChromeSvg}'s bars are drawn. The window's corners (`MAC_WINDOW.radius`) and its
181
+ * shadow (`THEMES[theme].windowShadow`) are the canvas's to draw.
182
+ *
183
+ * @param {Parameters<typeof macTitleBar>[0]} options - As {@link macTitleBar}.
184
+ * @returns {string} The SVG markup.
185
+ * @example
186
+ * const svg = macWindowSvg({ title: 'Example', theme: 'dark', w: 1280 });
187
+ */
188
+ export declare function macWindowSvg(options: Parameters<typeof macTitleBar>[0]): string;
189
+ export {};
@@ -1,12 +1,17 @@
1
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.
2
+ * Windows around a page, drawn as HTML: Chrome's (the macOS tab strip and toolbar) and a plain
3
+ * macOS window's title bar, light or dark, sharing the traffic lights, the colours and the
4
+ * shadow. Chrome's is shared by the store screenshots (`build/store-frame`, which lays it on a
5
+ * 1280×800 canvas in Playwright) and by extension pages, which draw either on a canvas around a
6
+ * capture through {@link windowChromeSvg} or {@link macWindowSvg} (an SVG image with the HTML
7
+ * inside, which Chrome draws without tainting the canvas). The markup is XML-clean so both can
8
+ * use it.
7
9
  *
8
10
  * Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
9
- * toolbar with a pill omnibox).
11
+ * toolbar with a pill omnibox) and AppKit's titled window as macOS 26 and later draw it, measured
12
+ * on macOS 27.2 (`NSThemeFrame`'s `cornerRadius` 16, a 32pt title bar, the window buttons 14pt
13
+ * at 9, 32 and 55 from the left and 9 from the top, the title in the 13pt headline font at 85,
14
+ * after the buttons).
10
15
  *
11
16
  * @module frame/window
12
17
  */
@@ -14,14 +19,19 @@
14
19
  /** The tab strip's and the toolbar's heights (px): the content starts below both. */
15
20
  export const WINDOW_BARS = Object.freeze({ strip: 42, toolbar: 46 });
16
21
 
22
+ /** A plain macOS window (macOS 26 and later): its title bar's height and its corners' radius (px). */
23
+ export const MAC_WINDOW = Object.freeze({ titleBar: 32, radius: 16 });
24
+
17
25
  /** The window's default width (px): the store screenshot's. */
18
26
  export const WINDOW_W = 1152;
19
27
 
20
28
  /**
29
+ * Text made safe for the drawn windows' HTML (and the store screenshots').
30
+ *
21
31
  * @param {unknown} s - Text.
22
32
  * @returns {string} It, escaped for HTML text and attributes.
23
33
  */
24
- const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
34
+ export const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
25
35
 
26
36
  /** Material icon paths (24×24). */
27
37
  const P = {
@@ -60,22 +70,69 @@ const icon = (name, size = 20) =>
60
70
  export const letterIcon = (letter, bg, fg = '#fff') =>
61
71
  `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
72
 
63
- /** Chrome's light and dark window colours, and the canvas behind. */
73
+ /**
74
+ * A window's shadow: soft and dropped below it, as macOS draws one (px; `blur` as CSS's and a
75
+ * canvas's `shadowBlur`, twice the blur's standard deviation).
76
+ *
77
+ * @typedef {object} WindowShadow
78
+ * @property {number} y How far down it falls.
79
+ * @property {number} blur Its blur.
80
+ * @property {string} color Its colour.
81
+ */
82
+
83
+ /** @type {{light: WindowShadow, dark: WindowShadow}} */
84
+ const SHADOWS = {
85
+ light: { y: 24, blur: 60, color: 'rgba(31,45,70,.20)' },
86
+ dark: { y: 24, blur: 60, color: 'rgba(0,0,0,.55)' },
87
+ };
88
+
89
+ /**
90
+ * A window's shadow and its hairline rim as a CSS `box-shadow`.
91
+ *
92
+ * @param {WindowShadow} shadow - The shadow.
93
+ * @param {string} rim - The rim's colour.
94
+ * @returns {string} The `box-shadow` value.
95
+ */
96
+ const boxShadow = (shadow, rim) => `0 ${shadow.y}px ${shadow.blur}px ${shadow.color}, 0 0 0 .5px ${rim}`;
97
+
98
+ /**
99
+ * Chrome's light and dark window colours, a macOS title bar's (`titleBar`, and `titleLine` below
100
+ * it, as AppKit draws them), the canvas behind, and the window's shadow (`windowShadow` for a
101
+ * canvas's `shadow*`, `shadow` for CSS).
102
+ */
64
103
  export const THEMES = {
65
104
  light: {
66
105
  bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
67
106
  frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
68
107
  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)',
108
+ contentLine: '#d3d7dc', titleBar: '#ffffff', titleLine: '#eaeaea',
109
+ windowShadow: SHADOWS.light, shadow: boxShadow(SHADOWS.light, 'rgba(0,0,0,.18)'),
70
110
  },
71
111
  dark: {
72
112
  bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
73
113
  frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
74
114
  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)',
115
+ contentLine: '#2a2a2a', titleBar: '#282828', titleLine: '#3c3c3c',
116
+ windowShadow: SHADOWS.dark, shadow: boxShadow(SHADOWS.dark, 'rgba(255,255,255,.14)'),
76
117
  },
77
118
  };
78
119
 
120
+ /** The traffic lights' colours: close, minimise, zoom. */
121
+ const LIGHTS = ['#ff5f57', '#febc2e', '#28c840'];
122
+
123
+ /** The traffic lights' styles: circles with a faint edge, their size and spacing set where they're placed. */
124
+ const LIGHTS_CSS = `.lights { position: absolute; display: flex; }
125
+ .lights i { width: var(--light); height: var(--light); border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }`;
126
+
127
+ /**
128
+ * macOS's traffic lights (styled by {@link LIGHTS_CSS}).
129
+ *
130
+ * @param {{left: number, top: number, size: number, gap: number}} place - The first one's left edge, their top edge, their size and the space between them (px).
131
+ * @returns {string} The HTML.
132
+ */
133
+ const trafficLights = ({ left, top, size, gap }) =>
134
+ `<span class="lights" style="left:${left}px;top:${top}px;gap:${gap}px;--light:${size}px">${LIGHTS.map((color) => `<i style="background:${color}"></i>`).join('')}</span>`;
135
+
79
136
  /** @typedef {keyof typeof THEMES} Theme */
80
137
 
81
138
  /**
@@ -139,7 +196,7 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
139
196
  const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px"/>`).join('');
140
197
  return `
141
198
  <div class="strip">
142
- <span class="lights"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i></span>
199
+ ${trafficLights({ left: 16, top: 15, size: 12, gap: 8 })}
143
200
  ${tabHtml}
144
201
  <span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
145
202
  </div>
@@ -151,8 +208,7 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
151
208
  </div>
152
209
  <style>
153
210
  .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); }
211
+ ${LIGHTS_CSS}
156
212
  .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
213
  .tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
158
214
  .tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
@@ -189,9 +245,57 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
189
245
  */
190
246
  export function windowChromeSvg(options) {
191
247
  const w = options.w ?? WINDOW_W;
192
- const h = WINDOW_BARS.strip + WINDOW_BARS.toolbar;
248
+ return svgImage(windowChrome({ ...options, w }), w, WINDOW_BARS.strip + WINDOW_BARS.toolbar);
249
+ }
250
+
251
+ /**
252
+ * A plain macOS window's title bar, as HTML: the traffic lights at the left, the title after
253
+ * them (macOS 26 on), a hairline below.
254
+ *
255
+ * @param {object} options - What to draw.
256
+ * @param {string} options.title - The window's title.
257
+ * @param {Theme} [options.theme] - Default light.
258
+ * @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
259
+ * @returns {string} The HTML, with its own styles.
260
+ */
261
+ function macTitleBar({ title, theme = 'light', w = WINDOW_W }) {
262
+ const t = THEMES[theme];
263
+ return `
264
+ <div class="bar" style="width:${w}px">${trafficLights({ left: 9, top: 9, size: 14, gap: 9 })}<span class="title">${esc(title)}</span></div>
265
+ <style>
266
+ .bar { position: relative; height: ${MAC_WINDOW.titleBar}px; background: ${t.titleBar}; border-bottom: 1px solid ${t.titleLine}; box-sizing: border-box; }
267
+ ${LIGHTS_CSS}
268
+ .title { position: absolute; left: 85px; right: 16px; top: 0; color: ${t.tabText}; font: 600 13px/${MAC_WINDOW.titleBar - 1}px system-ui; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
269
+ </style>`;
270
+ }
271
+
272
+ /**
273
+ * A plain macOS window's title bar as a standalone SVG image, `w` wide and `MAC_WINDOW.titleBar`
274
+ * high (scale it when drawing), for an extension page to draw on a canvas as
275
+ * {@link windowChromeSvg}'s bars are drawn. The window's corners (`MAC_WINDOW.radius`) and its
276
+ * shadow (`THEMES[theme].windowShadow`) are the canvas's to draw.
277
+ *
278
+ * @param {Parameters<typeof macTitleBar>[0]} options - As {@link macTitleBar}.
279
+ * @returns {string} The SVG markup.
280
+ * @example
281
+ * const svg = macWindowSvg({ title: 'Example', theme: 'dark', w: 1280 });
282
+ */
283
+ export function macWindowSvg(options) {
284
+ const w = options.w ?? WINDOW_W;
285
+ return svgImage(macTitleBar({ ...options, w }), w, MAC_WINDOW.titleBar);
286
+ }
287
+
288
+ /**
289
+ * HTML as a standalone SVG image, `w` × `h`.
290
+ *
291
+ * @param {string} html - The HTML (XML-clean).
292
+ * @param {number} w - Its width (px).
293
+ * @param {number} h - Its height.
294
+ * @returns {string} The SVG markup.
295
+ */
296
+ function svgImage(html, w, h) {
193
297
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
194
298
  + `<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>`
299
+ + `<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;margin:0;-webkit-font-smoothing:antialiased">${html}</div>`
196
300
  + '</foreignObject></svg>';
197
301
  }