@arraypress/extension-kit 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ /**
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.
9
+ *
10
+ * Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
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).
15
+ *
16
+ * @module frame/window
17
+ */
18
+ /** The tab strip's and the toolbar's heights (px): the content starts below both. */
19
+ export declare const WINDOW_BARS: Readonly<{
20
+ strip: 42;
21
+ toolbar: 46;
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
+ }>;
28
+ /** The window's default width (px): the store screenshot's. */
29
+ export declare const WINDOW_W = 1152;
30
+ /**
31
+ * A letter favicon for made-up sites (no real brands).
32
+ *
33
+ * @param {string} letter - The letter.
34
+ * @param {string} bg - Background colour.
35
+ * @param {string} [fg] - Letter colour. Default white.
36
+ * @returns {string} A data: URL of the SVG.
37
+ */
38
+ export declare const letterIcon: (letter: string, bg: string, fg?: string) => string;
39
+ export type WindowShadow = {
40
+ /**
41
+ * How far down it falls.
42
+ */
43
+ y: number;
44
+ /**
45
+ * Its blur.
46
+ */
47
+ blur: number;
48
+ /**
49
+ * Its colour.
50
+ */
51
+ color: string;
52
+ };
53
+ /**
54
+ * Chrome's light and dark window colours, a macOS title bar's (`titleBar`, and `titleLine` below
55
+ * it, as AppKit draws them), the canvas behind, and the window's shadow (`windowShadow` for a
56
+ * canvas's `shadow*`, `shadow` for CSS).
57
+ */
58
+ export declare const THEMES: {
59
+ light: {
60
+ bg: string;
61
+ caption: string;
62
+ sub: string;
63
+ frame: string;
64
+ tabText: string;
65
+ tabTextInactive: string;
66
+ tabSep: string;
67
+ activeTab: string;
68
+ toolbar: string;
69
+ toolbarIcon: string;
70
+ omnibox: string;
71
+ url: string;
72
+ urlDim: string;
73
+ contentLine: string;
74
+ titleBar: string;
75
+ titleLine: string;
76
+ windowShadow: WindowShadow;
77
+ shadow: string;
78
+ };
79
+ dark: {
80
+ bg: string;
81
+ caption: string;
82
+ sub: string;
83
+ frame: string;
84
+ tabText: string;
85
+ tabTextInactive: string;
86
+ tabSep: string;
87
+ activeTab: string;
88
+ toolbar: string;
89
+ toolbarIcon: string;
90
+ omnibox: string;
91
+ url: string;
92
+ urlDim: string;
93
+ contentLine: string;
94
+ titleBar: string;
95
+ titleLine: string;
96
+ windowShadow: WindowShadow;
97
+ shadow: string;
98
+ };
99
+ };
100
+ export type Theme = keyof typeof THEMES;
101
+ export type Tab = {
102
+ /**
103
+ * Its title.
104
+ */
105
+ title: string;
106
+ /**
107
+ * Its favicon's URL.
108
+ */
109
+ icon: string;
110
+ /**
111
+ * The selected tab.
112
+ */
113
+ active?: boolean;
114
+ /**
115
+ * Playing sound.
116
+ */
117
+ audible?: boolean;
118
+ /**
119
+ * Muted.
120
+ */
121
+ muted?: boolean;
122
+ };
123
+ /**
124
+ * The window's tab strip and toolbar, as HTML. Positions are window-relative.
125
+ *
126
+ * @param {object} options - What to draw.
127
+ * @param {readonly Tab[]} options.tabs - The tabs, left to right.
128
+ * @param {string} options.url - The address bar's text.
129
+ * @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
130
+ * @param {Theme} [options.theme] - Default light.
131
+ * @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
132
+ * @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
133
+ * @returns {string} The HTML, with its own styles.
134
+ */
135
+ export declare function windowChrome({ tabs, url, urlHost, theme, w, ext }: {
136
+ tabs: readonly Tab[];
137
+ url: string;
138
+ urlHost?: string;
139
+ theme?: Theme;
140
+ w?: number;
141
+ ext?: readonly string[];
142
+ }): string;
143
+ /**
144
+ * The window's tab strip and toolbar as a standalone SVG image, `w` wide and the bars' height
145
+ * high (scale it when drawing), for an extension page to draw on a canvas: `new Image()` with a
146
+ * `data:image/svg+xml` URL of it, then `drawImage`. Images in it (the tabs' icons, `ext`) must be
147
+ * `data:` URLs: an SVG image loads nothing else.
148
+ *
149
+ * @param {Parameters<typeof windowChrome>[0]} options - As {@link windowChrome}.
150
+ * @returns {string} The SVG markup.
151
+ * @example
152
+ * const svg = windowChromeSvg({ tabs: [{ title: 'Example', icon: letterIcon('E', '#1a73e8'), active: true }], url: 'example.com', w: 1280 });
153
+ */
154
+ export declare function windowChromeSvg(options: Parameters<typeof windowChrome>[0]): string;
155
+ /**
156
+ * A plain macOS window's title bar, as HTML: the traffic lights at the left, the title after
157
+ * them (macOS 26 on), a hairline below.
158
+ *
159
+ * @param {object} options - What to draw.
160
+ * @param {string} options.title - The window's title.
161
+ * @param {Theme} [options.theme] - Default light.
162
+ * @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
163
+ * @returns {string} The HTML, with its own styles.
164
+ */
165
+ declare function macTitleBar({ title, theme, w }: {
166
+ title: string;
167
+ theme?: Theme;
168
+ w?: number;
169
+ }): string;
170
+ /**
171
+ * A plain macOS window's title bar as a standalone SVG image, `w` wide and `MAC_WINDOW.titleBar`
172
+ * high (scale it when drawing), for an extension page to draw on a canvas as
173
+ * {@link windowChromeSvg}'s bars are drawn. The window's corners (`MAC_WINDOW.radius`) and its
174
+ * shadow (`THEMES[theme].windowShadow`) are the canvas's to draw.
175
+ *
176
+ * @param {Parameters<typeof macTitleBar>[0]} options - As {@link macTitleBar}.
177
+ * @returns {string} The SVG markup.
178
+ * @example
179
+ * const svg = macWindowSvg({ title: 'Example', theme: 'dark', w: 1280 });
180
+ */
181
+ export declare function macWindowSvg(options: Parameters<typeof macTitleBar>[0]): string;
182
+ export {};
@@ -0,0 +1,299 @@
1
+ /**
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.
9
+ *
10
+ * Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
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).
15
+ *
16
+ * @module frame/window
17
+ */
18
+
19
+ /** The tab strip's and the toolbar's heights (px): the content starts below both. */
20
+ export const WINDOW_BARS = Object.freeze({ strip: 42, toolbar: 46 });
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
+
25
+ /** The window's default width (px): the store screenshot's. */
26
+ export const WINDOW_W = 1152;
27
+
28
+ /**
29
+ * @param {unknown} s - Text.
30
+ * @returns {string} It, escaped for HTML text and attributes.
31
+ */
32
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
33
+
34
+ /** Material icon paths (24×24). */
35
+ const P = {
36
+ back: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
37
+ forward: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z',
38
+ 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',
39
+ 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',
40
+ add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
41
+ 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',
42
+ 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',
43
+ 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',
44
+ tune: 'M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z',
45
+ 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',
46
+ 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',
47
+ 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',
48
+ };
49
+
50
+ /**
51
+ * A Material icon as inline SVG, in the text colour.
52
+ *
53
+ * @param {keyof typeof P} name - Which icon.
54
+ * @param {number} [size] - Width and height in px. Default 20.
55
+ * @returns {string} The SVG markup.
56
+ */
57
+ const icon = (name, size = 20) =>
58
+ `<svg viewBox="0 0 24 24" width="${size}" height="${size}"><path fill="currentColor" d="${P[name]}"/></svg>`;
59
+
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 const letterIcon = (letter, bg, fg = '#fff') =>
69
+ `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>`)}`;
70
+
71
+ /**
72
+ * A window's shadow: soft and dropped below it, as macOS draws one (px; `blur` as CSS's and a
73
+ * canvas's `shadowBlur`, twice the blur's standard deviation).
74
+ *
75
+ * @typedef {object} WindowShadow
76
+ * @property {number} y How far down it falls.
77
+ * @property {number} blur Its blur.
78
+ * @property {string} color Its colour.
79
+ */
80
+
81
+ /** @type {{light: WindowShadow, dark: WindowShadow}} */
82
+ const SHADOWS = {
83
+ light: { y: 24, blur: 60, color: 'rgba(31,45,70,.20)' },
84
+ dark: { y: 24, blur: 60, color: 'rgba(0,0,0,.55)' },
85
+ };
86
+
87
+ /**
88
+ * A window's shadow and its hairline rim as a CSS `box-shadow`.
89
+ *
90
+ * @param {WindowShadow} shadow - The shadow.
91
+ * @param {string} rim - The rim's colour.
92
+ * @returns {string} The `box-shadow` value.
93
+ */
94
+ const boxShadow = (shadow, rim) => `0 ${shadow.y}px ${shadow.blur}px ${shadow.color}, 0 0 0 .5px ${rim}`;
95
+
96
+ /**
97
+ * Chrome's light and dark window colours, a macOS title bar's (`titleBar`, and `titleLine` below
98
+ * it, as AppKit draws them), the canvas behind, and the window's shadow (`windowShadow` for a
99
+ * canvas's `shadow*`, `shadow` for CSS).
100
+ */
101
+ export const THEMES = {
102
+ light: {
103
+ bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
104
+ frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
105
+ toolbar: '#ffffff', toolbarIcon: '#474747', omnibox: '#e9eef6', url: '#1f1f1f', urlDim: '#5e5e5e',
106
+ contentLine: '#d3d7dc', titleBar: '#ffffff', titleLine: '#eaeaea',
107
+ windowShadow: SHADOWS.light, shadow: boxShadow(SHADOWS.light, 'rgba(0,0,0,.18)'),
108
+ },
109
+ dark: {
110
+ bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
111
+ frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
112
+ toolbar: '#3c3c3c', toolbarIcon: '#c7c7c7', omnibox: '#282828', url: '#e3e3e3', urlDim: '#a8a8a8',
113
+ contentLine: '#2a2a2a', titleBar: '#282828', titleLine: '#3c3c3c',
114
+ windowShadow: SHADOWS.dark, shadow: boxShadow(SHADOWS.dark, 'rgba(255,255,255,.14)'),
115
+ },
116
+ };
117
+
118
+ /** The traffic lights' colours: close, minimise, zoom. */
119
+ const LIGHTS = ['#ff5f57', '#febc2e', '#28c840'];
120
+
121
+ /** The traffic lights' styles: circles with a faint edge, their size and spacing set where they're placed. */
122
+ const LIGHTS_CSS = `.lights { position: absolute; display: flex; }
123
+ .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); }`;
124
+
125
+ /**
126
+ * macOS's traffic lights (styled by {@link LIGHTS_CSS}).
127
+ *
128
+ * @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).
129
+ * @returns {string} The HTML.
130
+ */
131
+ const trafficLights = ({ left, top, size, gap }) =>
132
+ `<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>`;
133
+
134
+ /** @typedef {keyof typeof THEMES} Theme */
135
+
136
+ /**
137
+ * One tab in the drawn window.
138
+ *
139
+ * @typedef {object} Tab
140
+ * @property {string} title Its title.
141
+ * @property {string} icon Its favicon's URL.
142
+ * @property {boolean} [active] The selected tab.
143
+ * @property {boolean} [audible] Playing sound.
144
+ * @property {boolean} [muted] Muted.
145
+ */
146
+
147
+ const TAB_H = 34;
148
+
149
+ /**
150
+ * Lay out tabs like Chrome: equal widths up to 240px, shrinking to fit.
151
+ *
152
+ * @param {readonly Tab[]} tabs - The tabs.
153
+ * @param {number} w - The window's width.
154
+ * @returns {{ x: number, width: number }[]} Each tab's window-relative x and width.
155
+ */
156
+ function tabLayout(tabs, w) {
157
+ const left = 84;
158
+ const right = 60;
159
+ const max = 240;
160
+ const avail = w - left - right - 36;
161
+ const width = Math.min(max, Math.floor(avail / tabs.length));
162
+ return tabs.map((_, i) => ({ x: left + i * width, width }));
163
+ }
164
+
165
+ /**
166
+ * The window's tab strip and toolbar, as HTML. Positions are window-relative.
167
+ *
168
+ * @param {object} options - What to draw.
169
+ * @param {readonly Tab[]} options.tabs - The tabs, left to right.
170
+ * @param {string} options.url - The address bar's text.
171
+ * @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
172
+ * @param {Theme} [options.theme] - Default light.
173
+ * @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
174
+ * @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
175
+ * @returns {string} The HTML, with its own styles.
176
+ */
177
+ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W, ext = [] }) {
178
+ const t = THEMES[theme];
179
+ const layout = tabLayout(tabs, w);
180
+ const tabHtml = tabs.map((tab, i) => {
181
+ const { x, width } = layout[i];
182
+ const sepAfter = i < tabs.length - 1 && !tab.active && !tabs[i + 1].active;
183
+ const alert = tab.muted ? icon('muted', 16) : tab.audible ? icon('sound', 16) : '';
184
+ const showClose = tab.active || width >= 120;
185
+ return `<div class="tab${tab.active ? ' active' : ''}" style="left:${x}px;width:${width}px">
186
+ <img class="fav" src="${tab.icon}" width="16" height="16" alt=""/>
187
+ <span class="title">${esc(tab.title)}</span>
188
+ ${alert ? `<span class="alert">${alert}</span>` : ''}
189
+ ${showClose ? `<span class="x">${icon('close', 14)}</span>` : ''}
190
+ </div>${sepAfter ? `<span class="sep" style="left:${x + width - 1}px"></span>` : ''}`;
191
+ }).join('');
192
+ const last = layout[layout.length - 1];
193
+ const plusX = last.x + last.width + 4;
194
+ const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px"/>`).join('');
195
+ return `
196
+ <div class="strip">
197
+ ${trafficLights({ left: 16, top: 15, size: 12, gap: 8 })}
198
+ ${tabHtml}
199
+ <span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
200
+ </div>
201
+ <div class="toolbar">
202
+ <span class="tb">${icon('back')}</span><span class="tb dim">${icon('forward')}</span><span class="tb">${icon('reload')}</span>
203
+ <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>
204
+ ${extIcons}<span class="tb">${icon('puzzle')}</span>
205
+ <span class="avatar">${icon('person', 18)}</span><span class="tb">${icon('more')}</span>
206
+ </div>
207
+ <style>
208
+ .strip { position: relative; height: ${WINDOW_BARS.strip}px; background: ${t.frame}; }
209
+ ${LIGHTS_CSS}
210
+ .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; }
211
+ .tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
212
+ .tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
213
+ .tab.active::before { left: -10px; background: radial-gradient(circle at 0 0, transparent 10px, ${t.activeTab} 10.5px); }
214
+ .tab.active::after { right: -10px; background: radial-gradient(circle at 100% 0, transparent 10px, ${t.activeTab} 10.5px); }
215
+ .tab .fav { flex: none; }
216
+ .tab .title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
217
+ .tab .alert, .tab .x { flex: none; display: flex; color: ${t.toolbarIcon}; }
218
+ .sep { position: absolute; bottom: 9px; width: 1px; height: 18px; background: ${t.tabSep}; }
219
+ .plus { position: absolute; top: 11px; display: flex; color: ${t.toolbarIcon}; padding: 2px; }
220
+ .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}; }
221
+ .tb { width: 34px; height: 34px; display: grid; place-items: center; color: ${t.toolbarIcon}; flex: none; }
222
+ .tb.dim { opacity: .45; }
223
+ .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; }
224
+ .omni .site { display: flex; color: ${t.toolbarIcon}; }
225
+ .omni .url { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
226
+ .omni .dim { color: ${t.urlDim}; }
227
+ .omni .star { display: flex; color: ${t.toolbarIcon}; }
228
+ .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; }
229
+ </style>`;
230
+ }
231
+
232
+
233
+ /**
234
+ * The window's tab strip and toolbar as a standalone SVG image, `w` wide and the bars' height
235
+ * high (scale it when drawing), for an extension page to draw on a canvas: `new Image()` with a
236
+ * `data:image/svg+xml` URL of it, then `drawImage`. Images in it (the tabs' icons, `ext`) must be
237
+ * `data:` URLs: an SVG image loads nothing else.
238
+ *
239
+ * @param {Parameters<typeof windowChrome>[0]} options - As {@link windowChrome}.
240
+ * @returns {string} The SVG markup.
241
+ * @example
242
+ * const svg = windowChromeSvg({ tabs: [{ title: 'Example', icon: letterIcon('E', '#1a73e8'), active: true }], url: 'example.com', w: 1280 });
243
+ */
244
+ export function windowChromeSvg(options) {
245
+ const w = options.w ?? WINDOW_W;
246
+ return svgImage(windowChrome({ ...options, w }), w, WINDOW_BARS.strip + WINDOW_BARS.toolbar);
247
+ }
248
+
249
+ /**
250
+ * A plain macOS window's title bar, as HTML: the traffic lights at the left, the title after
251
+ * them (macOS 26 on), a hairline below.
252
+ *
253
+ * @param {object} options - What to draw.
254
+ * @param {string} options.title - The window's title.
255
+ * @param {Theme} [options.theme] - Default light.
256
+ * @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
257
+ * @returns {string} The HTML, with its own styles.
258
+ */
259
+ function macTitleBar({ title, theme = 'light', w = WINDOW_W }) {
260
+ const t = THEMES[theme];
261
+ return `
262
+ <div class="bar" style="width:${w}px">${trafficLights({ left: 9, top: 9, size: 14, gap: 9 })}<span class="title">${esc(title)}</span></div>
263
+ <style>
264
+ .bar { position: relative; height: ${MAC_WINDOW.titleBar}px; background: ${t.titleBar}; border-bottom: 1px solid ${t.titleLine}; box-sizing: border-box; }
265
+ ${LIGHTS_CSS}
266
+ .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; }
267
+ </style>`;
268
+ }
269
+
270
+ /**
271
+ * A plain macOS window's title bar as a standalone SVG image, `w` wide and `MAC_WINDOW.titleBar`
272
+ * high (scale it when drawing), for an extension page to draw on a canvas as
273
+ * {@link windowChromeSvg}'s bars are drawn. The window's corners (`MAC_WINDOW.radius`) and its
274
+ * shadow (`THEMES[theme].windowShadow`) are the canvas's to draw.
275
+ *
276
+ * @param {Parameters<typeof macTitleBar>[0]} options - As {@link macTitleBar}.
277
+ * @returns {string} The SVG markup.
278
+ * @example
279
+ * const svg = macWindowSvg({ title: 'Example', theme: 'dark', w: 1280 });
280
+ */
281
+ export function macWindowSvg(options) {
282
+ const w = options.w ?? WINDOW_W;
283
+ return svgImage(macTitleBar({ ...options, w }), w, MAC_WINDOW.titleBar);
284
+ }
285
+
286
+ /**
287
+ * HTML as a standalone SVG image, `w` × `h`.
288
+ *
289
+ * @param {string} html - The HTML (XML-clean).
290
+ * @param {number} w - Its width (px).
291
+ * @param {number} h - Its height.
292
+ * @returns {string} The SVG markup.
293
+ */
294
+ function svgImage(html, w, h) {
295
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
296
+ + `<foreignObject x="0" y="0" width="${w}" height="${h}">`
297
+ + `<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;margin:0;-webkit-font-smoothing:antialiased">${html}</div>`
298
+ + '</foreignObject></svg>';
299
+ }
package/src/index.d.ts CHANGED
@@ -2,8 +2,9 @@
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
6
- * a content script's slices and change watcher included. Every function takes the `chrome`
5
+ * the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model,
6
+ * a content script's slices, change watcher and overlay, a module's function called in a page,
7
+ * and a tab's side panel opened from a click. Every function takes the `chrome`
7
8
  * APIs it uses as arguments, so tests run the real logic against fakes.
8
9
  *
9
10
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -15,12 +16,16 @@
15
16
  export { githubAboutLinks, isPlaceholder } from './about/links.js';
16
17
  export { MODEL_MESSAGES, chromeLanguageModel, createModelSession, describeModel, downloadModel, modelAvailability, } from './ai/model.js';
17
18
  export { setShortcuts } from './commands/shortcuts.js';
18
- export { BLOCK_CHARS, SLICE_MS, blocksOf, inSlices } from './content/slices.js';
19
+ export { BLOCK_CHARS, SLICE_MS, blocksOf, eachInSlices, inSlices } from './content/slices.js';
20
+ export { pageOverlay } from './content/overlay.js';
19
21
  export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
20
22
  export { focusedElement } from './dom/focus.js';
23
+ export { MAC_WINDOW, THEMES, WINDOW_BARS, WINDOW_W, letterIcon, macWindowSvg, windowChrome, windowChromeSvg } from './frame/window.js';
21
24
  export { localize, translator } from './dom/i18n.js';
22
25
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
23
26
  export { setUpPage } from './dom/setup.js';
27
+ export { openSidePanel, sidePanelPath } from './panel/side-panel.js';
28
+ export { callInPage, importAndCall } from './scripting/call.js';
24
29
  export { contentScriptSync } from './scripting/registration.js';
25
30
  export { createSettingsSaver } from './settings/saver.js';
26
31
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
@@ -45,9 +50,14 @@ export type ModelAnswer = import('./ai/model.js').ModelAnswer;
45
50
  export type ModelSession = import('./ai/model.js').ModelSession;
46
51
  export type ModelStatus = import('./ai/model.js').ModelStatus;
47
52
  export type SliceOptions = import('./content/slices.js').SliceOptions;
53
+ export type PageOverlay = import('./content/overlay.js').PageOverlay;
48
54
  export type WatchHandlers = import('./content/watch.js').WatchHandlers;
55
+ export type WindowTab = import('./frame/window.js').Tab;
56
+ export type WindowTheme = import('./frame/window.js').Theme;
57
+ export type WindowShadow = import('./frame/window.js').WindowShadow;
49
58
  export type ModelRow = import('./ui/model-row.js').ModelRow;
50
59
  export type ContentScript = import('./scripting/registration.js').ContentScript;
60
+ export type FrameResult = import('./scripting/call.js').FrameResult<unknown>;
51
61
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
52
62
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
53
63
  export type MenuItem = import('./ui/menu.js').MenuItem;
@@ -69,9 +79,14 @@ export type Toast = import('./ui/toast.js').Toast;
69
79
  * @typedef {import('./ai/model.js').ModelSession} ModelSession
70
80
  * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
71
81
  * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
82
+ * @typedef {import('./content/overlay.js').PageOverlay} PageOverlay
72
83
  * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
84
+ * @typedef {import('./frame/window.js').Tab} WindowTab
85
+ * @typedef {import('./frame/window.js').Theme} WindowTheme
86
+ * @typedef {import('./frame/window.js').WindowShadow} WindowShadow
73
87
  * @typedef {import('./ui/model-row.js').ModelRow} ModelRow
74
88
  * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
89
+ * @typedef {import('./scripting/call.js').FrameResult<unknown>} FrameResult
75
90
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
76
91
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
77
92
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
package/src/index.js CHANGED
@@ -2,8 +2,9 @@
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
6
- * a content script's slices and change watcher included. Every function takes the `chrome`
5
+ * the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model,
6
+ * a content script's slices, change watcher and overlay, a module's function called in a page,
7
+ * and a tab's side panel opened from a click. Every function takes the `chrome`
7
8
  * APIs it uses as arguments, so tests run the real logic against fakes.
8
9
  *
9
10
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -18,12 +19,16 @@ export {
18
19
  MODEL_MESSAGES, chromeLanguageModel, createModelSession, describeModel, downloadModel, modelAvailability,
19
20
  } from './ai/model.js';
20
21
  export { setShortcuts } from './commands/shortcuts.js';
21
- export { BLOCK_CHARS, SLICE_MS, blocksOf, inSlices } from './content/slices.js';
22
+ export { BLOCK_CHARS, SLICE_MS, blocksOf, eachInSlices, inSlices } from './content/slices.js';
23
+ export { pageOverlay } from './content/overlay.js';
22
24
  export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
23
25
  export { focusedElement } from './dom/focus.js';
26
+ export { MAC_WINDOW, THEMES, WINDOW_BARS, WINDOW_W, letterIcon, macWindowSvg, windowChrome, windowChromeSvg } from './frame/window.js';
24
27
  export { localize, translator } from './dom/i18n.js';
25
28
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
26
29
  export { setUpPage } from './dom/setup.js';
30
+ export { openSidePanel, sidePanelPath } from './panel/side-panel.js';
31
+ export { callInPage, importAndCall } from './scripting/call.js';
27
32
  export { contentScriptSync } from './scripting/registration.js';
28
33
  export { createSettingsSaver } from './settings/saver.js';
29
34
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
@@ -56,9 +61,14 @@ export { TOAST_MS, createToast } from './ui/toast.js';
56
61
  * @typedef {import('./ai/model.js').ModelSession} ModelSession
57
62
  * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
58
63
  * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
64
+ * @typedef {import('./content/overlay.js').PageOverlay} PageOverlay
59
65
  * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
66
+ * @typedef {import('./frame/window.js').Tab} WindowTab
67
+ * @typedef {import('./frame/window.js').Theme} WindowTheme
68
+ * @typedef {import('./frame/window.js').WindowShadow} WindowShadow
60
69
  * @typedef {import('./ui/model-row.js').ModelRow} ModelRow
61
70
  * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
71
+ * @typedef {import('./scripting/call.js').FrameResult<unknown>} FrameResult
62
72
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
63
73
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
64
74
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Opening Chrome's side panel for one tab, from a click: the panel is the
3
+ * tab's own (no `side_panel` in the manifest, no global panel), so it shows
4
+ * only beside the page it's about and goes when the person switches tabs, as
5
+ * Chrome does for a tab's panel. Chrome opens a panel only while the click
6
+ * still counts, so nothing is awaited before `open`.
7
+ *
8
+ * @module panel/side-panel
9
+ */
10
+ export type SidePanelApi = Pick<typeof chrome.sidePanel, 'setOptions' | 'open'>;
11
+ /**
12
+ * The parts of `chrome.sidePanel` opening one uses.
13
+ *
14
+ * @typedef {Pick<typeof chrome.sidePanel, 'setOptions' | 'open'>} SidePanelApi
15
+ */
16
+ /**
17
+ * The panel page's address for a tab: `path?tab=<id>`, so the page knows its tab.
18
+ *
19
+ * @param {string} path - The panel page, `panel/panel.html`.
20
+ * @param {number} tabId - The tab.
21
+ * @returns {string} `panel/panel.html?tab=12`.
22
+ */
23
+ export declare const sidePanelPath: (path: string, tabId: number) => string;
24
+ /**
25
+ * Open the side panel beside a tab, from a click (the toolbar button's, a
26
+ * shortcut's, or a button on an extension page).
27
+ *
28
+ * @param {SidePanelApi} sidePanel - `chrome.sidePanel` (or a fake in tests).
29
+ * @param {number} tabId - The tab.
30
+ * @param {string} path - The panel page; it gets `?tab=<id>`.
31
+ * @returns {Promise<void>} Once it's open; rejects when Chrome refuses (no click behind it).
32
+ * @example
33
+ * chrome.action.onClicked.addListener((tab) => openSidePanel(chrome.sidePanel, tab.id, 'panel/panel.html'));
34
+ */
35
+ export declare function openSidePanel(sidePanel: SidePanelApi, tabId: number, path: string): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Opening Chrome's side panel for one tab, from a click: the panel is the
3
+ * tab's own (no `side_panel` in the manifest, no global panel), so it shows
4
+ * only beside the page it's about and goes when the person switches tabs, as
5
+ * Chrome does for a tab's panel. Chrome opens a panel only while the click
6
+ * still counts, so nothing is awaited before `open`.
7
+ *
8
+ * @module panel/side-panel
9
+ */
10
+
11
+ /**
12
+ * The parts of `chrome.sidePanel` opening one uses.
13
+ *
14
+ * @typedef {Pick<typeof chrome.sidePanel, 'setOptions' | 'open'>} SidePanelApi
15
+ */
16
+
17
+ /**
18
+ * The panel page's address for a tab: `path?tab=<id>`, so the page knows its tab.
19
+ *
20
+ * @param {string} path - The panel page, `panel/panel.html`.
21
+ * @param {number} tabId - The tab.
22
+ * @returns {string} `panel/panel.html?tab=12`.
23
+ */
24
+ export const sidePanelPath = (path, tabId) => `${path}?tab=${tabId}`;
25
+
26
+ /**
27
+ * Open the side panel beside a tab, from a click (the toolbar button's, a
28
+ * shortcut's, or a button on an extension page).
29
+ *
30
+ * @param {SidePanelApi} sidePanel - `chrome.sidePanel` (or a fake in tests).
31
+ * @param {number} tabId - The tab.
32
+ * @param {string} path - The panel page; it gets `?tab=<id>`.
33
+ * @returns {Promise<void>} Once it's open; rejects when Chrome refuses (no click behind it).
34
+ * @example
35
+ * chrome.action.onClicked.addListener((tab) => openSidePanel(chrome.sidePanel, tab.id, 'panel/panel.html'));
36
+ */
37
+ export function openSidePanel(sidePanel, tabId, path) {
38
+ // Not awaited: open() must be called while the click still counts.
39
+ sidePanel.setOptions({ tabId, path: sidePanelPath(path, tabId), enabled: true }).catch((error) => console.error('Setting the side panel up:', error));
40
+ return sidePanel.open({ tabId });
41
+ }